Skip to content

feat(typed-sql): add the PostgreSQL dialect with golden tests - #212

Merged
trakhimenok merged 1 commit into
mainfrom
pg-sql-03-postgres-dialect
Oct 4, 2026
Merged

trakhimenok merged 1 commit into
mainfrom
pg-sql-03-postgres-dialect

Conversation

@trakhimenok

Copy link
Copy Markdown
Contributor

SQL-03 (task pg-sql-03-postgres-dialect): the PostgreSQL dialect for the typed SQL compiler core (#206, v0.23.0), with golden tests, plus the three minors and the follow-ups the core's last review left for this task.

What changes for existing callers

Nothing is wired. DbOptions does not select the dialect, so no caller's statement changes; StructuredQueryDialect: "postgres" is still refused as an unsupported dialect (dispatch is SQL-04). One message changes: a field that is not a column of a known source now reads field "x" is not a column of source "a" (it used to add a clause about how the server would read the name; that reason is now a comment). The error type is unchanged.

The dialect (dialect_postgres.go)

  • Identifiers. Always double-quoted, inner " doubled, NUL / empty / invalid UTF-8 refused. Two modes: exact, and fold to lower case with strings.ToLower (dalgo2postgres's DDL rule). The 63-byte limit is read on the folded name (U+023A is 2 bytes and folds to 3; U+0130 is 2 and folds to 1), so a name PostgreSQL would truncate is refused, never written.
  • Fold is quoteIdent's rule. One method (fold) is both what quoteIdent writes and the Fold the facts carry. TestPostgresDialectFoldIsTheFoldQuoteIdentApplies pins it over mixed-case and non-ASCII names in both modes (mutating either side fails it).
  • Constants, typed by Go type, never by value. Signed ints ?::bigint; unsigned ints and floats ?::numeric bound from decimal text (floats as the shortest text for their own width: float32(0.1) is 0.1; NaN and the infinities as NaN, Infinity, -Infinity); bool ?::boolean; time.Time ?::timestamptz; bytes ?::bytea; strings untyped, so dates, UUIDs and enums work; nil and driver.Valuer untyped.
  • Statements. $n; LIMIT $n / OFFSET $n bound as int64; ASC NULLS FIRST / DESC NULLS LAST, with the NULLS clause dropped for NOT NULL columns; division ((l)::double precision / NULLIF((r)::double precision, 0)); ((SUM(x))::double precision) and the same for AVG; FALSE / TRUE for an empty IN. Capabilities: GroupBy, Having, OrderBy, Count, Sum, Avg with DISTINCT forms, Min, Max; First, Last, GroupKeyOrder, StableRowOrder, aggregate ORDER BY off.
  • Catalog facts. One query over pg_class, pg_attribute, pg_type, pg_collation, with each source's written text bound as a parameter to to_regclass, so the server resolves it as the statement will, search_path included. It lists every column of each table, view, materialized view, foreign table and partitioned table (not system or dropped columns), in table order, with the type name, a type category, NOT NULL, and a non-deterministic flag (non-deterministic collation or citext). Facts are keyed by the query's own spelling of each source, folded, schema included (an unqualified source has an empty schema key); this is written in the dialect's doc and in the catalogFacts doc. Needs PostgreSQL 12 (collisdeterministic).

The review minors and follow-ups (all acceptance lines of the task)

  1. Two accepted join subtests, facts for Album only and for Artist only, with a pair of different types: with one side unknown the key is accepted. leftKnown && rightKnown to || now fails the suite.
  2. The contract sentence now says what the code does (a select-all is refused even when its exclusions leave the unaddressable column out), and AllColumnsExcept("Total") on the colliding facts pins it. Moving the check below the exclusions fails the suite.
  3. The error wording above.
  4. The compiler core's fold tests (bare names, facts lookups, refusals) now run against the real dialect in FoldLower mode as well as the fake: foldingTypedDialects in typed_dialect_fake_test.go, and each folding case in the error tables is rerun against it.
  5. README: the legacy-path section now says which path compiles which engine and that the legacy emitter is not the PostgreSQL path.

Golden files (testdata/postgres)

Each holds the exact statement and arguments (with Go types), or the refusal, for one group in one identifier mode: shapes, constants (every constant type), probes (11 injection probes in every value position; also a test that the statement is byte-identical to the one for a harmless value, so probes appear only in the arguments), datatug-dtql (all six shipped DTQL texts, with and without catalog facts), joins (the testdata/joins fixtures plus the join shapes they do not cover, and the two error fixtures checked against their .error.json), and catalog-query. Rewrite with go test -run TestPostgresGolden -update-postgres-golden; review the diff like code. dtql_datatug_inventory_test.go only hoists its case list and query builder to package level so both dialects compile the same inventory.

Tests and checks

  • Unit tests per dialect method and per constant type in both modes; the value-independence proofs of typed_sql_property_test.go run against the real dialect in both modes; go-sqlmock for the catalog query (rows to facts, key spelling, dedupe, no source, refused names, query / scan / rows errors, a relation nobody asked for) and an end-to-end path from typedQuerySources through the catalog to compileTypedSQL.
  • Statement coverage 100.0%; go vet ./..., golangci-lint run ./..., gofmt -l . clean. No database was started; the SQL below was checked by reading, not by a server, and SQL-08 pins it against one.

Reviewer notes and open points for later tasks

  • Known limit, server side. An untyped string constant with nothing to infer its type from, a lone constant in the select list (SELECT $1 AS x), is a server error (42P18). Typing it needs to know where the constant stands, which bind does not; an alias-and-cast in the select list is a SQL-04/08 decision.
  • FoldLower folds output aliases too. A column aliased Country comes back as country, and an unaliased COUNT(*) as count(*) (DALgo's generic engine names it COUNT(*)). That is the mode as specified; the reader in SQL-04 may need to map names back.
  • SQL-04 (unchanged, as ordered): refuse a source the facts do not know; wildcard exclusions under FoldLower are matched exactly against catalog names (the goldens spell them as the catalog does); duplicate output names under folding.
  • Not checked: how PostgreSQL 18's NOT NULL ... NOT VALID constraints show in attnotnull. If the flag is set while existing rows may still hold NULL, the NULLS clause would be dropped for such a column. SQL-08's server pins are the place to find out.

🤖 Generated with Claude Code

Add the PostgreSQL typedDialect (identifier modes exact and fold to lower
case, 63-byte limit on the folded name, constants typed by Go type, one
catalog query for column facts) with golden files, go-sqlmock tests for the
catalog query and the 100% coverage the package gates on. Close the three
minors and the follow-ups the compiler core review left for this task, run the
core fold tests against the real dialect, and rewrite the README section on
the legacy text path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@trakhimenok

Copy link
Copy Markdown
Contributor Author

Review by claude-opus-5-5@claude-code@wf_b045afb3-c58 (head 10616d91f329)
Reviewed-Head: 10616d9

[review r1 #212] independent adversarial review (Opus), head 10616d9

Review r1 of #212 at 10616d9

Independent review. I read the diff, dialect_postgres.go, typed_dialect.go, all of typed_sql.go, every new test and every golden file. Nothing here was executed against a PostgreSQL server, by the author or by me; every statement about server behaviour is from reading.

What I verified

  • Head and checks. Head is 10616d9, one commit on c3f67fc. wb ci wait passed on that head: Build & test (coverage gate 100%) and Lint green, three jobs skipped by design. Targeted tests pass locally in the author's worktree.
  • Nothing is wired. No exported identifier is added. reader_base.go:95 still refuses dialect "postgres". The one behaviour change is the error text field %q is not a column of source %s, in code no caller reaches.
  • Contract, line by line. bind types by typedKindOf only and writes one marker. limitOffset writes one marker per argument. divide, aggregateResult and orderItem write each operand once, verbatim, in order, with no marker of their own, no string literal and no comment. divide and SUM/AVG are parenthesised. No dialect error echoes a name or a value. The capabilities equal SQLite's declaration in database.go:33-43.
  • Identifiers. Always double-quoted with " doubled; empty, NUL and invalid UTF-8 refused on the name as given. The 63-byte limit is applied to the folded name: 22 x U+023A (44 bytes written, 66 folded) is refused in FoldLower only, and 32 x U+0130 (64 written, 32 folded) is accepted in FoldLower only, so moving the limit to the written name fails two cases. A longer name is refused, never truncated (for a UTF8 server encoding, see finding 4).
  • Fold is quoteIdent's function. Both are the single method postgresDialect.fold (:103, used at :120 and :318). TestPostgresDialectFoldIsTheFoldQuoteIdentApplies compares Fold(name) with the unquoted text quoteIdent wrote for 20 names (Latin-1, Greek, U+0130, the Kelvin sign, U+023A, a titlecase digraph, CJK) in both modes, so an ASCII-only or otherwise different fold on either side fails. strings.ToLower is the rule in dalgo2postgres sql_gen.go:45 (local clone 548e051).
  • Core fold tests on the real dialect. The two golden suites and the three refusal tables that used the folding fake now also run against newPostgresDialect(postgresFoldLower).
  • Constants and shapes, read as PostgreSQL reads them. A value reaches the server only as an argument. A name reaches the text only as one quoted identifier. GROUP BY 1 / ORDER BY 1 are compiler-written ordinals. Bare ORDER BY names in the goldens meet no output of the same written name. Nested joins are parenthesised. An untyped string is typed by the column it meets (so '42' matches integer 42, as the plan states). $n::numeric from decimal text carries a uint64 or a float exactly.
  • Probes. The statement for each of the 11 probes is byte-identical to the one for "harmless", and each probe is found exactly 4 times in the arguments.
  • Catalog query. Its text depends on the number of sources only. Each relation text is a bound $n::text resolved by to_regclass, which parses a quoted name as the FROM clause does. It lists every attnum > 0, non-dropped column of relkinds r, p, v, m, f in attnum order. Rows map to Name (catalog spelling, not folded), DataType, Category, NotNull and the non-deterministic flag. Keys are {fold(schema), fold(name)}, which is what typedCatalogFacts.source looks up. go-sqlmock matches the text exactly and checks the arguments.
  • The lead's acceptance lines. Each has a test that fails without the change: the two one-sided join subtests kill && to || in both directions; AllColumnsExcept("Total") on the colliding facts fails if the addressability check moves below the exclusions; the new error text is pinned by the shapes and joins goldens; the README section is rewritten.
  • Declared deviations. Unsigned as ::numeric, nil as an untyped bound NULL outside == nil, the extra parentheses around SUM/AVG, one text parameter per source, the extra catalog columns, the helper changes and the catalog-spelled exclusions are all sound. chinook-wildcard is refused by planWildcardProjection, which the SQLite reader calls too, so it is not a PostgreSQL regression.

Findings

No blocker and no major. Eight minors.

Recommended in this pull request (no server needed)

  1. minor, dialect_postgres.go:250-259. The catalog query names pg_class, pg_attribute, pg_type, pg_collation, to_regclass and format_type without pg_catalog.. A temporary relation of the same name is searched before pg_catalog by default, and so is any schema listed before pg_catalog in an explicit search_path. The answer is the column list that stops x.to_json. Not reachable through DTQL. Fix: qualify all six and regenerate catalog-query.golden. dalgo2postgres's own helper already does.
  2. minor, dialect_postgres.go:30-33, 46-64. Three doc statements: numeric Infinity needs PostgreSQL 14, not 12; the claim that a lone string constant in the select list is a server error is probably wrong from PostgreSQL 10 on (unknown output columns resolve to text; the remaining 42P18 case is an untyped constant as an aggregate argument) and was not verified; the 63-byte limit assumes a UTF8 server encoding. Fix: reword as in the finding.
  3. minor, undeclared deviation, typed_sql.go:1149 and the "add subtract multiply" golden. The task and plan say arithmetic runs on double precision. Only division does. +, - and * run in the operands' own types, so int4 x int4 past 2^31 is error 22003 where DALgo's generic engine and the SQLite path return a float. The dialect has no hook to change it. Fix here: declare it in the description and the dialect doc. The behaviour belongs to SQL-04 or SQL-08.
  4. minor, dialect_postgres_golden_test.go:196-294. Missing from "every supported shape": the accepted scan-restated leaf read (the statement the generic engine sends for joins and subqueries over PostgreSQL), a dal.ID field, and the probes compiled as identifiers. Fix: add the cases and regenerate.
  5. minor, dialect_postgres_test.go:260-264. Vacuous assertion on the test's own want literal. Delete it or assert on the dialect.
  6. minor, README.md:39-40. "One of three paths" omits NativeStructuredQueryCompiler, which is checked first; the PostgreSQL bullet is in the present tense for a path not selectable yet.

Here or in SQL-08, the lead's choice

  1. minor, dialect_postgres.go:250. On PostgreSQL 18, attnotnull is true for a NOT VALID not-null constraint while NULL rows may exist (my reading of the 18 catalog documentation, not run). The NULLS clause is then dropped and NULLs sort on the wrong side, which changes the rows under LIMIT. Fix: the pg_constraint ... NOT convalidated predicate in the finding, or a PostgreSQL 18 pin in SQL-08.
  2. minor, dialect_postgres.go:251, 257, 280-298. Domains are unwrapped one level, so a domain over a domain over bytea, money, time or citext is miscategorised or not flagged. oid/reg* (category N) and same-type columns with no equality operator (json, xml, point) pass the join check and fail on the server. Fix: resolve the base type to the end or document the limit.

Not for this pull request

SQL-04

  • Refuse a source the facts do not know (unresolved name, sequence, zero-column table); until then a qualified x.f on it is unchecked.
  • Wildcard exclusions under FoldLower fail open (catalog email, exclusion Email, the column is returned). This must be closed before FoldLower can be selected; the goldens here deliberately do not pin it.
  • Duplicate output names under folding, and mapping folded output names back (count(*)).
  • Run catalogFacts and the statement on one connection or transaction; database/sql may hand out two with different search_path.
  • An alias or unaliased expression name over 63 bytes is a plain error from quoteIdent, not ErrNotSupported, so it does not fall back to the generic engine. Decide which it should be.
  • The arithmetic of finding 3: a hook, or a recorded divergence.
  • A whole number that arrives as float64 (JSON-decoded parameters) binds ::numeric, and an integer index is then not used. Callers should pass Go integers. Same cost as the declared one for unsigned values.

SQL-08 (server pins)

  • The catalog query on PostgreSQL 12, 17 and 18 over a view, materialized view, partitioned table, foreign table, domain, citext column, non-deterministic collation and a NOT VALID not-null column.
  • SELECT $1 AS "x" with a string; 'Infinity'::numeric on 12 and 13; int4 overflow in *; a float32 constant against a real column.
  • A server whose server_encoding is not UTF8.

Lead's disposition of the remaining findings. The dialect is not selectable by any caller yet, so all eight minors are DEFERRED, each as an acceptance line: findings 1 to 6 and 8 to the task that wires the dialect into the reader (SQL-04), the next change to these files; finding 7 to the server pins (SQL-08), with the PostgreSQL 18 case. The lists under "Not for this pull request" are written into the texts of SQL-04 and SQL-08. Each is reviewed independently.

VERDICT: blockers=0 majors=0 minors=8 land=yes

@trakhimenok
trakhimenok merged commit f6d7460 into main Oct 4, 2026
5 checks passed
@trakhimenok
trakhimenok deleted the pg-sql-03-postgres-dialect branch October 4, 2026 23:50
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