Skip to content

feat: emit the schema in the generated module - #22

Open
ilbertt wants to merge 4 commits into
mainfrom
feat/schema-block
Open

feat: emit the schema in the generated module#22
ilbertt wants to merge 4 commits into
mainfrom
feat/schema-block

Conversation

@ilbertt

@ilbertt ilbertt commented Aug 6, 2026

Copy link
Copy Markdown
Owner

The generator already reads the whole schema to type queries — this exposes it, and makes it the source the query types point at.

queries.gen.d.ts now carries a Tables registry alongside Queries: every table and view with its columns, index names and constraint names.

type UserRow = DatabaseTables['users']['columns'];
type UserIndex = DatabaseTables['users']['indexes']; // 'users_pkey' | 'users_email_idx'

Result fields that trace back to a base column now reference it rather than repeating its type, so the origin is visible on hover and in tsc errors:

export interface IListDealDetailsResult {
    deal_id: IDealsColumns["id"];       // `d.id AS deal_id`, traced through a view
    amount: IDealsColumns["amount"];
    status_upper: string | null;        // an expression — no column to point at
}

The nullability pass already resolved each field to its source column (that's how it finds the catalog entry); it just discarded it. Nullability is still composed per query — | null for outer-join widening, NonNullable<…> for a @notNull pragma — because it isn't a property of the column alone. Expressions, per-query @type, and untraceable columns keep their type inline.

On by default; --no-schema / schema: false opts out and restores byte-identical pre-PR output. Views are included but their columns are all nullable (neither engine tracks NOT NULL through a view definition); SQLite lists only explicitly named constraints, since it catalogues none.

Fixes #23.

ilbertt added 3 commits August 6, 2026 09:53
…e generated module

The generated module now carries a `Tables` registry alongside `Queries`: every
table and view with its columns (resolved through the same type/override pipeline
as the query results) and its index and constraint names as literal unions.
Merged into the package's `DatabaseTables` global; `--no-schema` (or
`schema: false`) opts out.
A result field that traces to a base column is now emitted as
`IUsersColumns['email']` rather than a repeated `string`, so the column it came
from is visible on hover and in tsc errors. The nullability step already
resolved each field to its source column; it just discarded it.

Nullability is composed per query — `| null` for outer-join widening,
`NonNullable<…>` for a `@notNull` pragma — since it isn't a property of the
column alone. Expressions, per-query `@type` and `--no-schema` stay inline.
A GENERATED ... VIRTUAL column reaches the plan as its generating expression, so
it has no column provenance and its COMMENT ON COLUMN override was matched by
name across every relation in scope — ambiguous, and silently dropped, as soon as
two joined tables commented the same name (#23).

Postgres qualifies an expression's inputs exactly when more than one relation is
in scope, so the alias it names resolves the owning relation and the override
lands. Outer-join widening now applies to comment-sourced nullability too, so
resolving more comments can't make an outer-joined column confidently non-null.
@ilbertt

ilbertt commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Fixes #23 — pushed in 1e92cb2.

Reproduced it exactly against PGlite 0.5.4 (PG 18.3). The plan output is the key detail:

query plan Output
SELECT u.search_key FROM users u lower(email)
SELECT u.search_key FROM users u JOIN deals d ON … lower(u.email)

Postgres qualifies an expression's inputs exactly when more than one relation is in scope — which is exactly when commentByName goes ambiguous. So the alias is available precisely where it's needed, and analyzePlan already had aliasToRel, as you noted.

Expression provenance now carries the relation its inputs resolve to (only when they all agree on one), and commentByName narrows its candidate list to that relation instead of scanning every relation in scope.

One thing the fix needed beyond the issue: resolving more comments in join queries would otherwise make an outer-joined column confidently non-null, since the comment branch ignored outer-join widening. Expression provenance now carries outerNullable too, so a @notNull generated column pulled through a LEFT JOIN stays nullable.

Covered in the simple example — users and deals both carry a commented search_key VIRTUAL column, with three queries pinning one-table, both-tables-joined, and outer-joined. No pre-existing generated type changed (zero deletions across all three example files).

What I did not do: promote these expressions to full column provenance. lower(u.email) and nullif(u.email,'') are indistinguishable in the plan, so treating them as users.email would mistype the second and make the new IUsersColumns['email'] reference claim an origin that isn't real. A comment override is an explicit declaration about a column name, so matching it by name is sound in a way that inferring the type isn't.

Cut the pitch-toned prose down to what a user needs: how to read the types,
that view columns are nullable, and how to turn the block off.
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.

@notNull column comment is dropped when two joined tables comment the same column name

1 participant