Skip to content

Add New<Model> attributes records for type-safe record creation - #2725

Open
mpscholten wants to merge 1 commit into
masterfrom
new-model-attributes-records
Open

Add New<Model> attributes records for type-safe record creation#2725
mpscholten wants to merge 1 commit into
masterfrom
new-model-attributes-records

Conversation

@mpscholten

Copy link
Copy Markdown
Member

Motivation

newRecord fills every field via its Default instance, so a NOT NULL foreign key without a database default becomes the null UUID 00000000-0000-0000-0000-000000000000. That means:

newRecord @Comment |> createRecord

…type-checks but fails the foreign key constraint at runtime — there is no compile-time signal that postId was never set, and the eventual error is a cryptic Postgres FK violation rather than "you forgot postId". This is a common footgun, and especially bad for AI coding agents, which is where this idea came from.

What this generates

Per table, a New<Model> attributes record holding only the required columns — writable (no generator), NOT NULL, no database default, and not the primary key — plus a new<Model> smart constructor:

data NewComment = NewComment { postId :: Id Post, body :: Text }

newComment :: NewComment -> Comment
newComment attributes = newRecord @Comment
    |> set #postId attributes.postId
    |> set #body attributes.body

Nullable columns, columns with a DEFAULT/SERIAL, and the primary key are intentionally excluded (they are safe to omit).

Usage

-- required fields enforced, no meta/default boilerplate:
newComment NewComment { postId = post.id, body = "Hi" } |> createRecord

-- optional fields via the familiar `set`:
newComment NewComment { postId = post.id, body = "Hi" }
    |> set #author (Just "Marc")
    |> createRecord

-- forgetting postId is now a compile-time signal:
newComment NewComment { body = "Hi" }
-- warning: [-Wmissing-fields] Fields of 'NewComment' not initialised: postId

Because New<Model> is built with record-construction syntax, a forgotten required field is a -Wmissing-fields warning — a hard compile error under -Werror=missing-fields. Unlike the full model it carries no meta, id, timestamps or defaultable columns, so there is no boilerplate and no leaking of the internal MetaBag.

Design notes

  • The constructor returns an ordinary model, so it composes unchanged with set (optional columns), validation, forms and createRecord. newRecord stays — it is still the right tool for the form workflow (empty record → fill from params → validate).
  • Required columns are always written to the INSERT regardless of the touched-fields bitmask (compileSqlEntry), so the provided values reach the DB; using set additionally marks them touched, keeping dirty-tracking consistent.
  • The New<Model> type lives in Generated.ActualTypes.<Model>; the constructor lives in Generated.<Model> (next to newRecord/set).
  • The type is omitted from the schema-designer preview (like the SetField instances), and the constructor is only emitted when the SetField instances are. This keeps the existing golden-output tests unchanged.
  • requiredColumns treats composite-PK member columns as required (correct for join tables, where the user must supply both FKs).

Tests

  • All 47 existing SchemaCompilerSpec examples still pass.
  • 2 new examples assert the generated New<Model> type and new<Model> constructor for a posts/comments schema.

Verification status / open questions

This is a draft / RFC to discuss the approach:

  • ✅ The generator compiles and SchemaCompilerSpec (49 examples) passes; the generated string is verified to be exactly the snippet above.
  • ⚠️ I have not yet regenerated a real app and compiled its build/Generated end-to-end. The generated code uses only constructs IHP already emits (newRecord @M, set #f, record.field), so it should compile, but this needs confirming — note IHP's own CI may not exercise generated code unless a fixture app does.
  • ❓ Should generated apps enable -Werror=missing-fields so the guarantee actually bites?
  • ❓ Should the scaffolded new/create actions use new<Model> New<Model>{…} instead of newRecord?
  • ❓ Should the New<Model> type also appear in the schema-designer preview?

🤖 Generated with Claude Code

`newRecord` fills every field via `Default`, so a `NOT NULL` foreign key
without a database default becomes the null UUID
`00000000-0000-0000-0000-000000000000`. That means

    newRecord @comment |> createRecord

type-checks but fails the foreign key constraint at runtime — there is no
compile-time signal that `postId` was never set. This is a common footgun,
especially for AI coding agents.

This generates, per table, a `New<Model>` attributes record holding only
the *required* columns (writable, `NOT NULL`, no default, not the primary
key) plus a `new<Model>` smart constructor:

    data NewComment = NewComment { postId :: Id Post, body :: Text }

    newComment :: NewComment -> Comment
    newComment attributes = newRecord @comment
        |> set #postId attributes.postId
        |> set #body attributes.body

Usage:

    newComment NewComment { postId = post.id, body = "Hi" } |> createRecord

Because `New<Model>` is built with record-construction syntax, a forgotten
required field is a `-Wmissing-fields` warning — a hard compile error under
`-Werror=missing-fields`. Unlike the full model it carries no `meta`, `id`,
timestamps or defaultable columns, so there is no boilerplate and no leaking
of the internal `MetaBag`. The constructor returns an ordinary model, so it
composes unchanged with `set` (for optional columns), validation, forms and
`createRecord`. `newRecord` stays for the form workflow.

Required columns are always written to the INSERT regardless of the
touched-fields bitmask, so the provided values reach the database; using
`set` additionally marks them touched, keeping dirty-tracking consistent.

The `New<Model>` type is omitted from the schema-designer preview (like the
SetField instances) and the constructor is only emitted when the SetField
instances are.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Core Size & Compile Allocations Benchmark

Metric Baseline (master) This PR Change
Core size 10699768 bytes 10857799 bytes 1.5%
Compile allocations 27089998440 bytes 27530834392 bytes 1.6%

Core size within threshold
Compile allocations within threshold

HTTP Latency (GET /, 5000 reqs, 10 concurrent)

Metric Baseline (master) This PR Change
Mean 3.05ms 2.91ms -4.6%
p50 2.80ms 2.30ms
p99 6.80ms 14.90ms
Min 0.50ms 0.50ms
Max 83.20ms 166.00ms
Req/s 3168 3192

HTTP latency within threshold

Top 10 modules (this PR)

Module Size (bytes)
Web.Types.thr 547347
Web.Routes.thr 409720
Web.Controller.Comments.thr 305409
Web.View.Threads.Show.thr 303820
Web.Controller.Threads.thr 294977
Admin.Types.thr 264263
build.Generated.User.thr 263784
Admin.Routes.thr 255762
Web.Controller.Users.thr 254614
build.Generated.Enums.thr 244791

@mpscholten
mpscholten marked this pull request as ready for review June 17, 2026 15:41
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant