Changelog
Scythe follows Keep a Changelog and Semantic Versioning.
For the latest changes, see the CHANGELOG.md in the repository root.
[0.18.1] - 2026-08-22
Section titled “[0.18.1] - 2026-08-22”- A GitHub Marketplace setup action installs checksum-verified release binaries through the moving
Goldziher/scythe@v0tag, with version pinning, optional caching, and installation metadata outputs. - A native Poly producer catalog exposes all six Scythe hooks through guarded system and pinned Cargo execution paths.
- The Go integration fixture and harness now exercise nullable PostgreSQL composite fields with the generated pointer encoder instead of leaving that path uncompiled.
- Kysely camel-case generation now matches the plugin’s recursively transformed nested JSON keys.
[0.18.0] - 2026-08-22
Section titled “[0.18.0] - 2026-08-22”Upgrading: pass the field nullability as the third argument to
CompositeFieldDefinition::new, add nullable to CompositeFieldInfo values and serialized
metadata, and initialize the new SchemaDescription.composites field. Prefer
SchemaDescription::new() or ..SchemaDescription::new() for forward-compatible construction.
- Schema-defined SQL functions participate in catalog analysis, overload resolution, fingerprints, placeholder typing, and set-returning function expansion.
- PostgreSQL live catalogs include standalone composite types and report composite schema drift via
SC-DRF08throughSC-DRF13. typescript-pg,typescript-postgres, and PostgreSQL Kysely generate typed nested JSON result interfaces for whole-row JSON aggregates.- Release automation verifies every platform archive and checksum before advancing the floating
v0tag.
Changed
Section titled “Changed”- Composite fields carry required nullability metadata through catalogs, analyzed queries, generated
models, and provenance fingerprints. Serialized metadata without
nullableis rejected. - Repository builds use the pinned Rust 1.97.1 toolchain across all five release targets.
- Removed dormant vulnerable Rust dependency chains from MSSQL conformance without changing its TLS coverage.
[0.17.0] - 2026-08-20
Section titled “[0.17.0] - 2026-08-20”This release adds execution-backed catalogs for embedded databases, expands typed PostgreSQL code generation, and closes the composite-parameter runtime gap across every PostgreSQL backend.
php-pdoandphp-amphpdecode PostgreSQL nested JSON rows into readonly generated objects. JSONnumericfields use PHPfloatvalues and therefore follow binary floating-point precision.- Schema blocks can select execution-backed catalog construction for SQLite and DuckDB. SQLite DDL
runs through
rusqlite; DuckDB DDL runs with external access and extension loading disabled. - A validated public catalog builder preserves inspected names, raw types, relation and generated column kinds, and produces deterministic metadata-aware fingerprints.
- PostgreSQL composite parameters are encoded and bound correctly across the generated backends.
- DuckDB schema execution preserves valid same-label enums conservatively and redacts SQL text from execution errors.
- Java, Kotlin, and C# composite casts use token-aware placeholder rewriting.
- The documentation site uses
nanoid3.3.18, resolving its high-severity advisory.
- A live Kysely project verifies
CamelCasePluginresult-key conversion. - The PostgreSQL integration matrix covers composite parameter round trips.
- Release CI builds and size-checks bundled-DuckDB binaries on five supported targets.
[0.16.1] - 2026-08-20
Section titled “[0.16.1] - 2026-08-20”This stabilization release fixes three generated TypeScript/JavaScript failures, accepts PostgreSQL trigger definitions that sqlparser cannot represent correctly, and closes gaps in generated-code validation. Integration harnesses now apply the same schema files used by code generation, while the Java and Kotlin harness templates share canonical lifecycle and test-program macros without changing their generated output.
- PostgreSQL schemas containing
EXECUTE FUNCTIONorEXECUTE PROCEDUREtrigger arguments no longer block catalog construction. Trigger definitions are skipped because they do not contribute catalog state; surrounding tables and functions remain available. (#238) javascript-duckdbqualifiesDuckDBBlobValuethrough the DuckDB Node API in JSDoc output. (#230)typescript-postgresguards nullable composite parameters, binds JSON throughsql.json, and uses the driver’sJSONValuetype. The backend now passes the torture-project TypeScript check. (#231)- TypeScript and C# integration harnesses execute their selected schema fixture instead of maintaining inline copies of its DDL. (#234)
Changed
Section titled “Changed”- Java and Kotlin integration templates now centralize lifecycle and test programs in parameterized macros. Regenerated JVM harnesses remain byte-identical. (#235)
- Generated output from
rust-tiberiusandrust-sibylnow receives the samesynsyntax gate as the other Rust backends. (#229) javascript-mssqlreconstructs explicit result objects so strict JSDoc checking validates row shapes; a mutation test proves incorrect emitted keys are rejected. (#233)
[0.16.0] - 2026-08-15
Section titled “[0.16.0] - 2026-08-15”0.15.0 shipped fixes only and moved everything that was coverage debt or a feature into this release, so 0.16.0 is the mixed one: four bugs, six gates, and the two features outside users asked for.
Three of the bugs turned out to be one. The analyzer used the string "unknown" as an in-band
sentinel for “nothing resolved this”, and when it escaped into codegen the user got
INTERNAL_ERROR: unknown neutral type: unknown — which reads as “file a bug” for input scythe had
diagnosed perfectly well. A set-returning function in the SELECT list, array_agg(ROW(a, b)) and a
UNION whose arms widened in the wrong order were three symptoms of the same thing. The sentinel now
leaves the analyzer as a real UNRESOLVED_TYPE diagnostic naming the column or the parameter and,
where one exists, the form that works instead.
The gates are the shape 0.15.0 spent itself on, one layer further out: a check whose failure path is
unreachable. A fixture suite that skipped every assertion when codegen errored. A compile-check
script that exits 0 when project discovery finds nothing. A schema comparison that degrades to
comparing two empty lists. A generator that warned and succeeded on zero fixtures. Two suites that
run a real interpreter over generated code, and reported success when the interpreter was missing.
A dependency audit that could not see an optional-feature-only dependency, because cargo deny check uses the default feature set. And field_case, honoured by sixteen backends, whose every
assertion was on a generated string rather than on a row a database returned.
On the feature side, json_agg(json_build_object(...)) now infers a struct from the call’s own keys
instead of degrading to flat json, and FILTER (WHERE ... IS NOT NULL) — the idiom for
suppressing the [null] a LEFT JOIN miss produces — is recognized rather than ignored. Six more
TypeScript backends gained a javascript-* JSDoc emit mode.
Upgrading: two nested-aggregate changes move types in code that already worked. A query using
json_agg(json_build_object('id', o.id, ...)) previously generated a flat JSON value and now
generates a struct, so anything hand-typed on the receiving end needs updating. A query using
json_agg(o.*) FILTER (WHERE o.id IS NOT NULL) previously produced an optional element and now
produces a non-optional one — Vec<Option<T>> becomes Vec<T>, and the equivalent in every other
language. Separately, a query whose result column or parameter has no nameable type now fails at
analyze time with UNRESOLVED_TYPE instead of reaching codegen; such queries never generated
working code, but the error now arrives earlier and from a different layer. Fixture authors: the
config.naming and config.type_overrides keys are now load errors, and
testing_data/00-FIXTURE-SCHEMA.json is gone — tools/test-generator/src/fixture.rs is the schema.
Security
Section titled “Security”- Every PHP integration harness created an order and never checked it was the one returned.
The same defect fixed for all 13 Python harnesses in 0.15.0 was left live in all 9 PHP ones:
test_create_orderreturns the new row’s id andtest_get_orders_by_userignored it, asserting only the first result’snotes, so a query returning someone else’s order still passed.test_get_orders_by_usernow takes the createdorder_idand asserts it is among the returned rows. (#112)
-
The
python-psycopg3-msgspecharness never checked its rows were msgspec structs. The project exists to prove therow_type = "msgspec"codegen option works, and its Pydantic twin carries seven assertions — a dedicated row-type test plus fiveisinstancechecks — while the msgspec harness had none;import msgspecwas the only trace of it, unused. It now mirrors the Pydantic assertions. Three unconditional imports (asyncio,Decimal,msgspec) that were unused on some engine branches are now emitted only where used, so the generated Python harnesses areF401- andI001-clean. (#112) -
Every codegen assertion in the fixture-generated test suite was skipped when codegen errored. One line in the generator wrapped each backend loop in
if let Ok(generated) = …, producing 273 skip-guards across 13 files that between them discarded the result of 4993generate_with_backendcalls. Backend construction failure already panicked; generation failure one line later did not — andgenerate_generated_code_assertions, added in 0.15.0 specifically to stop assertions being dropped, was emitted inside that guard, so the fix for dropped assertions was itself dropped. A codegen error now fails the test naming the backend, engine, fixture and error. A fixture may declare an expected failure viaexpected.codegen_errors, which requires a written reason and fails in both directions: an undeclared failure fails, and a declared failure that now succeeds fails as stale. No fixture currently declares one — measured across all 4993 combinations, none fail. (#222) -
A set-returning function in the select list, and a multi-field
ROW(...), passed analysis and then failed every backend withINTERNAL_ERROR: unknown neutral type: unknown.SELECT jsonb_each(data) FROM documentsandSELECT array_agg(ROW(o.id, o.total)) FROM orders oboth reported an internal error — “file a bug against scythe” — for input scythe had diagnosed perfectly well. PostgreSQL’s anonymousrecordand a bare multi-field row genuinely have no neutral type, but that is a fact to report, not an internal fault. Both now fail at analyze time withUNRESOLVED_TYPE, naming the column and the construct; the set-returning-function message points at theFROM-clause form (FROM documents, jsonb_each(data) AS kv), which already resolves to realkeyandvaluecolumns. The same treatment coversjson_each_text, thejson_populate_recordfamily,unnestover a non-array, and nine other expression shapes that previously reached codegen as a bare"unknown". (#223) -
An unresolved marker wrapped in a container leaked its internal spelling to the user.
SELECT array_agg(bogus_fn(id)) FROM treportedINTERNAL_ERROR: unknown neutral type: __unknown_func__:bogus_fn— scythe’s own internal marker, verbatim. The markers that stand for “ambiguous column”, “unknown column” and “unknown function” were matched only at the start of a neutral type, soarray_aggwrapping one asarray<__unknown_func__:…>slipped past every check. This is the #173 failure mode the marker family’s own doc comment warns about, still live for the container case. It now reportsUNKNOWN_FUNCTION: function "bogus_fn" does not exist. -
Two CLI integration tests gated their generated output on its byte count.
test_generate_pagila_writes_file’s entire body, after checking the file existed, wascontent.len() > 500— pagila generates 7016 bytes, so the check permitted losing 93% of it, andtest_generate_writes_file’s> 100was no better. Both now assert that every query in the fixture produced a named function, and that the file defines exactly that many and no more; the count is what catches two queries collapsing onto one name, which a presence check cannot see. (#161) -
A composite-typed query parameter was bound to postgres.js as a whole object, so
typescript-postgresoutput did not type-check (TS2345: 'TortureAddress | null' is not assignable to 'ParameterOrFragment<never>'). The codegen that rendersROW(a, b)::type_namefor a bound composite was already correct but never ran: it looks the composite up inanalyzed.composites, and the analyzer’s composite worklist seeded itself from a query’s columns and nested-struct fields but never from its params. A composite bound only as a parameter — anINSERTwhose composite column never appears inRETURNING— therefore never reached that list at all, and the emitter took its silent whole-object fallback. The worklist now chains params the way the enum scan beside it always did, andtypescript-pgcompiles the torture project as a result.typescript-postgresstill does not: with the composite reaching the emitter, two further defects in that backend became visible — a nullable composite param dereferences null, and ajsonbparam binds as a raw object postgres.js rejects. Itsscripts/torture-expected-failures.txtentry has been restored with those reasons and now points at #231. (#225) -
A
UNIONwhoseNULL-projecting arm came first failed type resolution instead of widening.SELECT id AS tag FROM accounts UNION SELECT NULL AS tag FROM userscompiled; swapping the arms producedINTERNAL_ERROR: type resolution failed for column 'tag': unknown neutral type: unknown.widen_union_arm_type’s non-nested fallthrough calledwiden_typedirectly, andwiden_typereturned its left argument for any pair its numeric ladder does not handle — so anunknownarm on the left won over the other arm’s real type.widen_typenow absorbsunknownfrom either position, and the call site routes throughwiden_neutral_type, the helper whose own doc comment names it the single rule every widening call site must use (#121) and which every other call site already used.UNIONis commutative, so both spellings now agree. Reported and fixed by @snowyukitty in #227. (#224) -
An internal type marker still reached the user, through query parameters. The fix for this defect covered result columns only. A placeholder inside a function whose result has no nameable type adopts that result as its own type, and nothing rejected it afterwards, so
SELECT id FROM documents WHERE data::text = COALESCE($1, ROW(id, data))::textreportedINTERNAL_ERROR: type resolution failed for param 'row': unknown neutral type: __untypeable_row__:– scythe’s internal marker spelling, verbatim, in front of a user. It now reports the same actionableUNRESOLVED_TYPEdiagnostic the column path does, naming the parameter.GREATEST/LEAST,NULLIFandCASEreached it the same way. (#223) -
field_casehad no runtime assertion anywhere. Sixteen backends honour the option (apply_field_case_option), but every existing assertion was on a generated string, never on a row a database actually returned. The newtypescript-pg-camelintegration project drives a realpgquery withfield_case = "camelCase"and asserts on the live row object: the remappeduserIdkey is present with the expected value, and the originaluser_idkey is gone — the negative check is the point, since a backend that adds the camelCase key while leaving the snake_case one in place would pass a positive-only check.typescript-kyselyis not covered: it deliberately does not remap, relying on the caller registeringCamelCasePlugin, so its camelCase keys come from the driver rather than from generated code and the harness has to register the plugin to assert anything — tracked as #228. (#92) -
Three gates reported success for having checked nothing.
check-generated-backends.py, which compile-checks every backend against the torture schema, exits 0 when project discovery returns nothing: its pass/fail flag is only cleared by lists derived from the discovered set. An allowlist entry naming a vanished project used to catch that by accident, and that backstop went inert when the last active entry was deleted.schema_variant_consistencycompares two schema files through a parser that recognises only a literalCREATE TABLEline, so any restyling it stops recognising hits both files at once and reduces the comparison to two empty lists. Andtest-generatorwarned and exited 0 on zero fixtures, leaving the committed tree untouched so CI’s freshness check reported everything fresh — its own vacuity guard counts committed files, which are still there. All three now fail. (#196) -
A missing interpreter silently deleted two suites’ only real check.
composite_text_escaping_regression.rsruns the emitted composite parser against the exact text PostgreSQL 16 produces, andsql_literal_injection_regression.rscompiles every backend’s escaped literal with that language’s real compiler — both precisely because the string match they sit next to cannot tell a correct branch from a plausible-looking one. When the interpreter or compiler was absent, both returned early and reported success, degrading to the string match they exist to supersede. UnderSCYTHE_VALIDATE_STRICT(set for CI’scargo test --workspace) the skip is now a failure naming the missing tool; locally it still skips, since nobody has all ten toolchains. (#127) -
A per-query codegen error in the real-tool harness was swallowed, and the harness kept going.
tool_validation.rsgenerates three queries per backend and then ran every structural and real-compiler check over whatever subset happened to succeed, logging the rest to a captured stderr. A backend that broke only onQUERY_ONE– the query carrying the array, enum-in-array, composite,uuidandjsonbcolumns, i.e. the one worth checking – still produced a struct and a function from the other two and passed. The nominal backstop,assert!(!code.trim().is_empty()), cannot fail:provenance::assemble_filealways prepends a non-empty provenance header no matter how many query bodies survived. A codegen error now panics, naming the backend and the failing query. The same file’s exemption list also claimed all four Rust backends were “still syntax-checked bysyn::parse_fileelsewhere”; only two are, since the generated suite gates that call onrust-sqlxandrust-tokio-postgresby name andcompile_check.rsreachesrust-sqlxalone. The comment now states the real coverage. (#229)
-
javascript-node-sqlite: a JSDoc emit mode fortypescript-node-sqlite. The fifthjavascript-*backend (alongsidejavascript-postgres,javascript-pg,javascript-mysql2,javascript-better-sqlite3): plain, JSDoc-annotated.jsoutput for Node’s built-innode:sqlitemodule, checked against realnode --checkandtsc --checkJs --strictin CI.node:sqliteis synchronous, likebetter-sqlite3, so this mirrorsjavascript-better-sqlite3’s emit shape rather than theasyncpg/postgres.js/mysql2 one, except for:batch:DatabaseSynchas no.transaction()helper, so the generated code wraps explicitBEGIN/COMMIT/ROLLBACKstatements, matchingtypescript-node-sqlite’s own TS-mode:batchshape. -
The
javascript-*backends’:manyoutput is now type-checked by realtsc. The JS-mode tool-validation fixture built only a:oneand a:groupedquery, so the one command whose JSDoc cast is not a plain one-step assertion was pinned by hand-written string matching alone, on all five backends. It now builds a:manyquery too. (#93) -
javascript-wasm-sqliteandjavascript-snowflake: two more JSDoc emit modes. The sixth and seventhjavascript-*backends.javascript-wasm-sqlitemirrorsjavascript-better-sqlite3’s synchronous shape (@sqlite.org/sqlite-wasm’s OO1 API is sync, and the driver’s one-time asyncsqlite3InitModule()stays entirely outside generated code); unlike the sync sqlite backends already shipped, its single-row and array casts are both genuinetscTS2352s as a TypeScriptas, but the JSDoc/** @type {T} */ (...)spelling of both is accepted, verified against realtsc --checkJs --strict, so it never needs the TS path’sunknownhop.javascript-snowflakeis the firstjavascript-*backend whosefile_headeris not empty:normalizeRow, the runtime helper the generated query bodies call to lowercase Snowflake’s uppercase column names, has to stay in JSDoc mode too (re-typed via a JSDoc block), and only the TS-onlyimport type { Binds, Connection }line drops. Itsbindscast is the one JSDoc cast across all seven backends that still needs theunknownhop –Binds’s declared type does not admit every parameter type this backend can emit, and that failure reproduces identically whether the cast is writtenasor/** @type */. (#93) -
javascript-duckdb,javascript-oracledbandjavascript-mssql: the last three JSDoc emit modes. Ten of the eleven TypeScript backends now have ajavascript-*counterpart.typescript-kyselyis the one left out, and deliberately: JSDoc has no way to spellKysely<DB>. Two of the three break the family’s “no driver import” rule for a real reason –mssqlandoracledbgenerated bodies read driver constants (sql.Int,oracledb.BIND_OUT) as runtime values and not merely as types, so the import stays live in JS mode and the handle types aresql.ConnectionPool/oracledb.Connectionrather than the inlineimport("...")spelling the other eight use.javascript-oracledbcarries its TypeScript counterpart’s identifier case-folding (#218) into every JS-mode row read, not just into the type annotations.javascript-mssqlis the one backend in the family whose row read carries no JSDoc cast at all:mssql’s typedquery<Entity>()needs an explicit type argument that JSDoc cannot supply at a call site, and the untyped overload returnsany, sotscchecks nothing there and the string-content assertions are the only guard – recorded here because it is a real weakness in this backend’s verification, not an oversight. (#93) -
json_agg(json_build_object(...))/jsonb_agg(jsonb_build_object(...))now infer an inline nested field list. The relation-argument form (json_agg(o.*)) already synthesized a struct; the inline-object form — what people actually write when the columns they want are not a whole table — fell back to flatjson. Field names come from the call’s own string-literal keys, and field nullability follows each value expression’s real type, including outer-join widening. Confirmed against PostgreSQL 16:json_build_objectis not strict, so the built object is never itself SQL NULL — not even for the phantom all-NULL row a LEFT JOIN miss produces — so unlike the relation-argument form, elements here are never wrappednullable<>; only individual fields are. Also recognizes the standardFILTER (WHERE {alias}.{col} IS NOT NULL)idiom (optionallyAND-conjoined) on the relation-argument form: it provably excludes that phantom row regardless of which column of the relation it names, so the array element is no longer marked nullable when the filter is present. (#78)
Changed
Section titled “Changed”-
Every integration harness now applies the same schema file its
scythe.tomlgenerated from.schema_filereachedscythe.toml.jinjaand one Kotlin branch; the other ~16 sites across 11 harness templates hardcoded a filename per engine, andSCHEMA_FILE_OVERRIDESwas keyed by backend name so it covered only the 2 backends that happened to already agree. 7 of 9 Oracle projects and all 14 Redshift projects generated code fromschema.sqlwhile their harness created the database fromschema_full.sql/schema_pg_compat.sql. The override table is now keyed by engine, which is the thing that actually determines the answer, so it cannot go stale when a backend is added for an engine already listed. No inferred type changes — the two schema variants agree today, which is exactly why this stayed invisible; only the recorded schema hash moves. (#196) -
The generated-type check now covers 10 of 13 Python and 10 of 10 PHP integration projects, up from 5 and 2. The three Python projects still excluded use
aiomysqlorpyodbc, neither of which ships type information, and are documented in place rather than wired up to a checker that cannot fail on them. Every expected-error count is measured, not assumed, and the eight PHP projects that newly runcomposer installin CI have committed lock files so the install is pinned.python-duckdbjoins the loop: the comment excluding it claimed it had nointegration_tests/project directory, which stopped being true several releases ago. (#127)
Removed
Section titled “Removed”-
testing_data/00-FIXTURE-SCHEMA.json, the JSON SchemaCONTRIBUTING.mdpointed contributors at. It had drifted far enough to reject every current fixture: it requiredrust_typewhere the loader readstype, namedgenerated_rustwhere the loader readsgenerated_code, listed 3 engines against the loader’s 9, and defined nolintkey underadditionalProperties: false. A hand-maintained mirror of the loader is exactly what drifted, so it is gone rather than re-synchronised —tools/test-generator/src/fixture.rsis the schema, and its#[serde(deny_unknown_fields)]structs already fail a bad fixture by naming the offending key. -
The
config.namingandconfig.type_overridesfixture keys. Both were deserialized and never read. Zero fixtures declarednaming. One declaredtype_overrides— withlang_typeandjsonfields that do not exist in the real[[type_overrides]]shapescythe-clireads (column,db_type,type), so it was never a silently-ignored feature, just a shape that had never been wired to anything. What that fixture actually proves — a JSON column mapped to a typed struct — is asserted independently through its@jsonannotation. Both keys are now load errors. (#156)
[0.15.0] - 2026-08-15
Section titled “[0.15.0] - 2026-08-15”This release is mostly about checks that could not fail. A validator whose only callers were its own
tests, an allowlist nobody reconciled, a CI step whose assertion was vacuous, a fixture that asserted
only that something was generated — each one reported success while measuring nothing, and several
had done so since the feature they guarded shipped. Auditing them found real defects underneath:
generated Rust whose bytes depended on whether rustfmt happened to be on PATH, two queries whose
names collapsed onto one function, a nested aggregate quietly degraded to an opaque string, and a
ruby-pg signature promising a Hash for a value that was the driver’s raw wire text.
A second shape recurred often enough to name: the test that pins the bug. Several tests asserted the defective output verbatim, so they failed when someone fixed the thing they were named after. Those are inverted here, each with a doc comment stating what it now guards.
Nullability, JVM enum round-trips, ? placeholder counting and Oracle’s LOB reads were all measured
against live PostgreSQL, MySQL, MariaDB and Oracle rather than against scythe’s own model. Four
backends that had never executed anywhere — kotlin-exposed among them — now run in CI, and running
them found seven defects no string-matching test could reach.
Upgrading: three changes can turn a config that used to be accepted into an error. [lint.sqruff]
is now actually read, so a table that was previously inert may now fail the run; keys in a [[sql]]
block that scythe does not define are now rejected instead of ignored; and Ruby .rbs output changes,
so committed signatures need regenerating. scythe-codegen’s public generate_from_catalog stub is
also removed (#132) — a breaking change for any direct caller, though it had none in this repository.
Two lint-crate suppression and audit APIs also changed shape (see Fixed): SuppressionSet is now
keyed by statement index instead of source line, and LintRule gained cwe() / is_applicable_to()
methods with safe defaults. scythe-lint also drops four sqruff_adapter free functions in favour of
building a SqruffLinter once (see Removed). scythe-core’s public CustomAnnotation struct
gained a suggested_keyword: Option<String> field (see Fixed, #152) — a breaking change for any
direct caller that builds one by struct literal rather than through the parser. Details below.
Security
Section titled “Security”-
SC-RLS02(policy-always-permissive) reported a deny-all RLS policy as granting unconditional access.WITH CHECK (NULL)/USING (NULL)reject every row — NULL is not TRUE — but the rule’s tautology check foldedNULLin alongsidetrueand1=1, so the most restrictive policy possible was flagged aterrorseverity with remediation advice (“replace the tautology with an actual predicate”) that would have loosened security in response to a security finding.NULLis no longer treated as a tautology by this rule;SC-CHK01(check-constraint-always-true), whereNULLgenuinely does satisfy a CHECK constraint, is unaffected. (#139) -
Every Python integration harness created an order and never checked it was the one returned.
test_create_orderreturns the new row’s id, buttest_get_orders_by_userignored it and only asserted the first result’snotes, so a query that returned someone else’s order (or the wrong row) would still pass.test_get_orders_by_usernow takes the createdorder_idand asserts it is present in the returned rows, in all 13 Python harnesses. (#112) -
java.java.jinjaandkotlin.kt.jinja’s non-postgresql engine branches were missing tests for queries their own fixtures already defined. GH #195/#196’s parity gate (10066723) made the drift visible viatest-parity-exemptions.txtbut left the 44 “never wired up” gaps open;UpdateUserEmailandSearchUsersare now called from every engine branch in both templates,GetOrderTotalfrom every branch that didn’t already have it (duckdb, mssql, redshift, snowflake, sqlite), andListActiveUsersfrom redshift’s. Redshift’sSearchUsersandListActiveUsersqueries filter bystatus, not a nameLIKEpattern like every other engine — its ported tests call them with a status value rather than"%Alice%", matching whatqueries/users.sqlactually defines for that engine. The 44 closed exemption lines are deleted; the 48 remaining entries are all structural (aUserStatusenum parameter or the nullable composite-column read from board #197 with no per-engine equivalent) and are unchanged.
-
kotlin-exposedhas a running integration project, and running it found seven defects. The backend had shipped since 0.6.0 with nothing ever executing its output, and none of the seven was reachable by a string-matching test: the generated file declared nopackage generated, so any caller importing it failed outright; an enum parameter was bound as the Kotlin enum object rather than its SQL spelling; those parameters then needed an explicit::<enum type>cast, because Exposed sends a typedcharacter varyingthat PostgreSQL will not coerce to a user enum;:exec_rowsand:exec_resultread a row count offTransaction.exec, which returnsUnit, so they never compiled; aRETURNINGquery ran as anINSERTand the driver raised “A result was returned when none was expected”, now fixed with an explicitStatementType.SELECT; the bind list was an unannotatedlistOf(...)whose type inference collapsed on a heterogeneous parameter set; andUUIDColumnTypewas emitted but never imported. The project runs 14 assertions in CI, including the composite-escaping and nullable-enum reads. (#213, #214) -
java-r2dbcandkotlin-r2dbchave running integration projects on PostgreSQL. Both backends shipped with nothing executing their output, and running them found defects no string-matching test could see: an enum parameter was bound as the Java/Kotlin enum object, which r2dbc-postgresql cannot encode, and once bound as its SQL spelling the server rejected the untypedcharacter varyingagainst auser_statuscolumn. Enum placeholders now carry an explicit::<enum type>cast on PostgreSQL, so the generated code needs noEnumCodecregistration from the caller. The MySQL, MariaDB and SQLite pairs stay uncovered, each with a measured reason recorded intools/integration-test-generator/coverage-exemptions.txt. -
php-amphpon MySQL andtypescript-kyselyon Redshift now have integration projects that actually run in CI. Both manifests shipped with nothing exercising them.php-amphp-mysqlimmediately found two real defects — the harness’sMysqlConfig::fromArray()does not exist, and the generated pool type madeLAST_INSERT_ID()unreliable (see Fixed) — which is the whole point of the exemption list these two came off.typescript-kysely-redshiftgates the queries Redshift’s fixture does not define and readsstatusas a varchar rather than an enum, matching what thepgandpostgresdrivers already did for that engine. -
rust-tokio-postgrescan read and write range columns. The manifest previously declared norangemapping at all, becausepostgres-typesships noRange<T>with aFromSql/ToSqlimpl the waysqlx-postgresdoes, and the mapping it used to carry (String) could not decode:String’saccepts()matches no range OID, sorow.getpanicked beforefrom_sqlran. The backend now emits a hand-rolledPgRange<T>built onpostgres_protocol::types::range_from_sql/range_to_sql— the same wire-format primitivespostgres-typesuses internally for arrays — gated on a generated fragment actually namingPgRange<, so a file with no range column does not carry it.Emptyis a distinct variant from a fully-unbounded range rather than collapsed into it, because the two are different values on the wire and collapsing them would make an empty range decode as if it contained everything. Verified against live PostgreSQL across bounded, empty, unbounded and both binding directions; note that no schema in this repository has a range column yet, so no CI job compiles the emitted wrapper. (unfiled) -
Integration coverage for nullable enum and nullable composite columns. No integration project had ever selected a composite column, so the entire runtime read path was unexercised while codegen compiled green. The PostgreSQL schema gains a
user_addresscomposite and two nullable columns, and aGetUserProfilequery asserts both a present value and a SQL NULL — the shape that catches a reader which decodes NULL as a zero-valued variant or an all-default struct.Running it revealed that composite decoding is implemented in only four of the fifteen PostgreSQL backends:
rust-sqlxandrust-tokio-postgres, which get it from their drivers’ derive macros, andjava-jdbcandkotlin-jdbc, which parse the composite text form. In the other eleven the generated row type declares the composite struct while the driver’s raw value is assigned straight through, so the annotation is wrong at runtime —php-pdo,php-amphpandcsharp-npgsqlthrow, andpython-psycopg3,python-asyncpg, thetypescript-pgfamily,ruby-pg,elixir-postgrex,elixir-ectoandgo-pgxreturn a raw string, a driver record, orundefinedwith no error at all. The new assertions are therefore scoped to the four backends that work, with each excluded language’s template carrying a note to restore them once that backend learns to parse a composite, so the gap is explicit rather than a green suite that proves nothing. (unfiled) -
scythe inspectnow has a real MySQL/MariaDB driver. Live inspection was PostgreSQL-only; every other engine fell through to a stub that reported itself asmysqlregardless of what the user asked for.MySqlDriver(backed bymysql_async) ships four checks driven by its ownmysql/checks.toml, merged into the canonical registry alongside PostgreSQL’s:SC-INS-MY01(no primary key),SC-INS-MY02(duplicate index),SC-INS-MY03(AUTO_INCREMENTpast 70% of its type range),SC-INS-MY04(MEMORYstorage engine). The two check sets are deliberately not symmetric — PostgreSQL’s row-level-security, extension andSECURITY DEFINERsearch-path checks have no MySQL equivalent and are not approximated — andverify_queriesstays PostgreSQL-only because it depends on the extended-query protocol’s describe response. SQLite, MSSQL, Oracle, Snowflake and Redshift still getUnsupportedDriver, which names the engine the user actually asked for and refuses rather than returning an empty finding set. (#131, partial) -
scythe generate --validate-outputruns the generated code through the real compiler or linter for its language and reports, per target, whether it wasVALIDATED,SKIPPED, orFAILED.validate_generated_codepreviously had no production caller at all — every call site outsidevalidation.rswas a test — sogeneratenever checked its own output. Off by default because it shells out to toolchains that may not be installed. A run where the validator found no tool to invoke is reported asSKIPPED, never as success: reporting it as validated would recreate the unfalsifiable gate the flag exists to close. AFAILEDtarget exits 2, matching the exit-code contractcheck/lint/fmt --checkfollow, where exit 1 stays reserved for operational failure. (unfiled) -
DuckDB integration coverage.
python-duckdb,typescript-duckdb,java-jdbc-duckdbandkotlin-jdbc-duckdbnow run in a newintegration-duckdbCI job, against the schema and query set added earlier in this release. DuckDB is embedded, so the job needs no service container.go-database-sql-duckdbexists and its harness is written, but stays exempt:go-duckdbcannot bind a nil pointer, and the backend emits*Tfor a nullable parameter, so any NULL argument fails at runtime — measured against the driver, tracked as board #228. (#126) -
scythe-inspectcan read a SQLite or MySQL catalog. A newSchemaCatalogDrivertrait gives catalog reading the engine seam it never had —fetch_live_schemawas a bare function hardcoded totokio_postgres::Client.SqliteCatalogSourcereadssqlite_masterplusPRAGMA table_infoand needs no server, so it is tested in-process;MySqlCatalogSourcereadsinformation_schema, with live tests gated the way the PostgreSQL ones already are.ColumnDescriptiongainedprimary_key. At the time this landed, theSC-INShealth checks were still PostgreSQL-only hand-writtenpg_catalogSQL and the CLI was not wired to either source, soscythe inspect’s user-facing behaviour was unchanged — MySQL/MariaDB got a realSC-INSdriver and honest CLI dispatch separately (see the “scythe inspectnow has a real MySQL/MariaDB driver” entry above). What this entry’sSchemaCatalogDriversources still are not wired into is schema drift: nothing yet feedsSqliteCatalogSourceorMySqlCatalogSourceoutput intodiff_schemasthe wayfetch_live_schemais for PostgreSQL, so drift detection stays PostgreSQL-only. -
Generated Python and PHP are type-checked in CI. A new
validate-generated-typesjob installs each project’s real driver, then runspyrefly check -p strictover five Python backends and PHPStan over both PHP ones. Every step was proven able to fail by injecting a defect first. Thestrictpreset is load-bearing — pyrefly’s defaultbasicpreset misses a wrong return-type annotation entirely — and the job already catches one real pre-existing bug. Ruby and Elixir were investigated and deliberately left out rather than given a step that cannot fail: no Ruby driver has signatures ingem_rbs_collection(andrbs validatereturns 0 even for a nonexistent class), and Dialyzer needsdialyxir’s translation layer, which no integration project depends on.
-
The java/kotlin engine-test-parity gate never measured five of its twelve branches, and one measured branch silently overwrote another’s count.
branch_test_namesonly recognised a top-levelif/elif engine == "..."line, sodriver == "r2dbc"-conditioned branches andbackend == "kotlin-exposed"were invisible to it entirely — 21 java and 35 kotlin test functions sat outside every window it built and were excluded from every comparison without a trace, leavingintegration_tests/java-r2dbc,kotlin-r2dbc, andkotlin-exposedwith zero parity coverage. Separately, a nested column-0{% if engine == "mariadb" %}insidekotlin.kt.jinja’s r2dbc branch was mistaken for a real top-level branch, and its measured test set was overwritten by the realmariadbbranch’s via a plainBTreeMap::insertwith no warning. Branch discovery now derives a key from every quoted literal a top-level condition compares against (r2dbc-postgresql,r2dbc-mysql-mariadb,kotlin-exposed, alongside the existing per-engine keys), a duplicate derived key is now a hardpanic!naming both branch starts instead of a silent overwrite, and a new assertion fails if any test function falls outside every measured branch range. The newly measuredkotlin-exposedgap was closed by renaming a test to match its postgresql counterpart; the remaining genuine gaps (all in the twor2dbcbranches, one of which — mysql/mariadb — has no generated project to run it) are recorded intest-parity-exemptions.txtwith reasons specific to why porting isn’t safe or possible yet, taking that file from 48 entries to 60. (#195) -
ruby-pgdeclared ajson/jsonbcolumnHashin its.rbsbut never decoded it.ruby-pg.tomlmapsjson = "Hash", butruby_coercionhad no arm for it, so the generated.rbcode read the barerow["col"]— thepggem does no client-side JSON decoding, so that value was the raw wire-formatString, contradicting theHash[String, untyped]its own.rbssignature promised. This is thejsonsibling of #198’sdecimalbug, left open when that fix only coveredBigDecimal.ruby_coercionnow wraps ajson/json_arraycolumn’s value inJSON.parse(...), gated behind a conditionalrequire "json"the same way.to_dgatesrequire "bigdecimal/util".json_array(thejson_aggarray shape) is a new manifest scalar mapped toArray, so a degraded nested aggregate keeps declaring anArrayinstead of falsely claimingHash. (#147) -
php-amphptyped its handle asSqlConnectionPool, which made MySQL’s generatedLAST_INSERT_ID()lookups unreliable. Every generated function took\Amp\Sql\SqlConnectionPool, so a singleMysqlConnectionwas rejected outright — but a pool is the wrong thing to pass on MySQL:GetLastInsertUserresolvesLAST_INSERT_ID(), which is scoped to the connection that ran theINSERT, so the pool routes the follow-upSELECTto a different connection and it finds no row. The parameter is now\Amp\Sql\SqlExecutor, the narrowest interface carrying theprepare()the generated code actually calls; both the pool and the bare connection implement it on PostgreSQL and MySQL alike. This is a widening — callers already passing a pool are unaffected. Found by running the newphp-amphp-mysqlintegration project. -
go-database-sqlon DuckDB failed at runtime on every nullable parameter. The manifest maps a nullable parameter to*{T}, andgo-duckdbcannot bind a typed pointer at all: measured against v2.3.3, both a nil and a non-nil*stringfail withcould not bind parameter / unsupported data type: unknown type, while an untyped nil and a bare value both bind. So this was never limited to NULL arguments — any query with a nullable parameter was unusable. Pointer-typed arguments are now dereferenced at the bind site (nil becoming an untyped nil) by a generated helper, leaving the public function signature unchanged. The otherdatabase/sqlengines bind pointers natively and are untouched.go-database-sql-duckdbnow runs in theintegration-duckdbCI job, which is what surfaced this. (#228) -
ruby-oci8handed back a LOB locator where the generated row type declared aString. OCI8 returns a lazyOCI8::CLOB/NCLOB/BLOB/BFILEhandle rather than a materialized value, so a LOB-backed field held the locator instead of its contents. Both CLOB and VARCHAR2 resolve to the neutral typestring(and BLOB and RAW tobytes), so nothing at the neutral level could tell them apart — the fix dispatches on the column’s rawsql_type, matching whatrust_sibyl.rsalready does for the same problem on the same engine. Applied at all seven column-read sites, deliberately not to the grouped-query grouping key: a LOB’s read position hits EOF after the first#read, so wrapping the key would blank the field read afterwards. Found byruby-oci8-oracle’s first CI run that got far enough to execute queries. Its step is restored and now runs last in the Oracle job, so a failure there costs only its own coverage. Applied to thecursor.fetchreads only: aRETURNING ... INTOoutput bind is declared to OCI8 up front asbind_param(n, nil, String)and OCI8 materializes the value into that class, socursor[n]there is already aStringand wrapping it raisedundefined method 'read' for an instance of Stringincreate_order. The test covering that path had asserted the wrapped spelling, so it guarded the defect instead of against it; it is inverted. (#225) -
ruby-oci8called a cursor method on an integer for:exec_rowsand:exec_result.OCI8#execis polymorphic in its return: anOCI8::Cursorfor aSELECT, but the number of rows processed — a plainInteger— forINSERT/UPDATE/DELETE. The generated code bound the result and called.row_counton it, which is aCursormethod, sodelete_orders_by_userraisedundefined method 'row_count' for an instance of Integer. For DML the count is already the return value. Surfaced by the Oracle CI job only after the LOB fix above let it run that far. (#225) -
A harness executing the shared schema could send several statements as one. Every generated harness splits
schema.sqlon;and runs the fragments; the split was not SQL-aware, so a semicolon inside a string literal or a$$-quoted body split mid-statement. All eight templates now split with a small state machine that tracks'...',"...",$$...$$and--line comments — the last of those is not optional: without it an apostrophe in a comment (schema.sql's) opens a phantom literal and swallows every following semicolon, which is strictly worse than the naive split it replaced. Block comments are not handled, and each splitter says so; no schema underintegration_tests/sql/uses them. (#224) -
csharpandelixirharnesses printedPASSfor a test whose assertions had just failed. Same defect fixed for typescript in this release.python,phpandrubyturned out not to share it — their assertion helpers raise or throw, so thePASSline after a failure is unreachable — andgo,javaandkotlinalready use a passed/failed counter. In every case the run still exited non-zero; the damage was to whoever reads the log. (#227) -
elixir-jamdbgenerated code could not run at all. Four independent defects, each hidden behind the last, found by givingelixir-jamdb-oracleits first CI step and then fixed and verified against a local Oracle 21c:- Every generated function called
Jamdb.Oracle.query/3on the valueJamdb.Oracle.start_link/1returns. That is aDBConnectionpool, andquery/3sends a{:sql_query, ...}GenServercall only a raw connection process answers, so the first call raisedFunctionClauseError. The backend’s own@specalready saidDBConnection.conn(). Queries now execute throughDBConnection.execute/3. DBConnection.execute/3returns{:ok, query, result}, so every result match gained the middle element.RETURNING ... INTOreturns rows column-wise — one single-element list per OUT parameter.INSERT ... RETURNING id, name INTO :2, :3yields%{rows: [[1], ["Alice"]]}, not[[1, "Alice"]], so the old[row | _]match bound only the first column and destructured it across every field. A plainSELECTis row-wise, so only theRETURNINGpath transposes.- jamdb returns an Oracle
NUMBERas an Elixir float whatever its scale —1.0for an integer key,99.99for aNUMBER(10,2)— while the manifest declares those columnsinteger()andDecimal.t().Decimal.equal?/2rejects a float outright, which is how this surfaced; the integer case was quieter and merely wrong. Numeric columns are now converted to the type the struct declares.
elixir-postgrexandelixir-ectowere unaffected throughout. The CI step is restored, andelixir-jamdb-oraclenow passes end-to-end. (#223) - Every generated function called
-
A composite value containing a double quote came back truncated, with every field after it shifted. PostgreSQL’s
record_outescapes a literal"inside a quoted composite field by doubling it (ROW('he said "hi"', 'back\slash')renders as("he said ""hi""","back\\slash")), but every composite text parser scythe emits recognized only the backslash spelling. On a doubled quote each one took the first"for the field’s closing quote — truncating that field’s value and then resynchronizing on the wrong character, so unrelated later fields silently received wrong values. Fixed in all nine emitted parsers (java-jdbc,java-r2dbc,kotlin-jdbc,kotlin-r2dbc,kotlin-exposed,python-psycopg3,typescript-pg,typescript-postgres,typescript-kysely); the five JVM ones shipped with the defect, the rest inherited it fromjava_jdbc.rsas the model. Covered bycomposite_text_escaping_regression.rs, which runs the emitted python parser against the exact text PostgreSQL 16 produced. (#204) -
A nullable composite column decoded to the driver’s raw value while the generated type claimed otherwise.
python-psycopg3andpython-asyncpgdeclaredaddress: UserAddress | Noneand the three typescript backends declared the composite interface, but all five assigned the driver’s raw value straight through — astrfor psycopg3, anasyncpg.Recordfor asyncpg,undefinedfields for the typescript drivers. psycopg3 and typescript now parse PostgreSQL’s composite text form through a generated_from_text/parse{Name}; asyncpg reads theRecordit already decodes through_from_record. A nullable enum column likewise now reads asNone if raw is None else T(raw)rather than calling the enum constructor onNone. Verified live against PostgreSQL 16, which is how the defect was found in the first place.The remaining seven PostgreSQL backends are now fixed too, each according to what its driver actually does — established by reading the vendored driver source rather than assuming:
elixir-postgrexandelixir-ectoget afrom_tupleconversion, because Postgrex already decodes a composite into a natively-typed positional tuple and never hands back text;ruby-pg,php-pdo,php-amphp,csharp-npgsqlandgo-pgxget a text parser, because their drivers do hand backrecord_outtext.csharp-npgsqladditionally setsUnknownResultTypeListfor the composite column, since Npgsql’s nativeMapComposite<T>needs a registration the generated code cannot perform on the caller’s behalf;go-pgxis the same story for pgx’s type map. PHP’s parser is emitted once per file as a shared class rather than copied into each composite. (#204)All seven integration harnesses now select a composite column and assert on it — a present value, a SQL NULL, a nullable enum, and a field containing
"and,. That last case is the one that matters: an assertion using only plain values passes identically against the pre-fix parser, which is how the doubled-quote defect survived in nine backends. Confirmed falsifiable by reverting the fix inruby-pgand watching the new assertion catch it —expected "12 \"Main\", Apt 3", got "12 ". (#204, #226) -
The generated python composite parser did not type-check, and cast a NULL sub-field away.
_from_textfed_parse_composite_fields’str | Nonetokens straight into fields declaredstr, which pyrefly rejects — and PostgreSQL does permit a NULL sub-field, so the value really can arrive. Silencing the checker with a cast would have traded a type error for a value that lies at runtime, so the str-typed fields now route through a_require_composite_fieldguard that raises naming the field that was NULL. asyncpg’s_from_recordalso gained anAnyannotation on itsrecordparameter, which pyrefly rejected outright as unannotated. (#204) -
A composite whose field named another composite emitted the two definitions in the wrong order. The analyzer discovers composites breadth-first, so a type reached only through another composite’s field list landed after the type that references it. Languages whose declarations hoist never noticed; python evaluates
@dataclassannotations when the class body runs, so the generated module raisedNameErroron import. Definitions are now emitted in dependency order. (#204) -
Four integration projects had their generated output checked for freshness but never executed.
elixir-jamdb-oracleandruby-oci8-oraclenow run inintegration-oracle,kotlin-jdbc-extandphp-pdo-namespaceinintegration-pg. Each already had atest:*Taskfile target and needed no new infrastructure — only the missing workflow step.php-pdo-snowflakestays exempt, with its reason corrected:pdo_snowflakeships as neither a PECL nor an apt package and must be built from source against Snowflake’s C driver, which is infrastructure work rather than a missing step. This is the gap that let the csharp-snowflake parameter-binding bug survive from v0.6.0 to 0.14.0. (#118) -
A semicolon inside a SQL comment broke every harness that executes the shared schema. Each generated harness runs
sql/<engine>/schema.sqlby splitting it into single statements on the semicolon character, and that split is not comment-aware — so a semicolon inside a--comment ended the fragment there and left the rest of the comment line to be sent as bare SQL.elixir-postgrexfailed withERROR 42601 syntax error at or near "this". Only elixir surfaced it, because the postgres job fails fast and the harnesses ahead of it happen not to split that schema. The comment is rewritten and aschema_sql_comments_contain_no_semicolongenerator test now enforces the fixture side of the contract; the naive splitting itself is tracked separately. -
ruby-oci8’s teardown called a method that does not exist. The generated harness ended withconn&.close, butOCI8disconnects via#logoff— so theensureblock raisedNoMethodErrorand masked whatever the real failure had been. -
Generated Ruby raised
LoadErroron Ruby 3.4+.bigdecimalstopped shipping as a default gem in Ruby 3.4.0, andruby-pg,ruby-mysql2andruby-trilogyemitrequire "bigdecimal/util"whenever a query’s generated code applies.to_dto adecimalcolumn — so on 3.4 thatrequirefailed unless something else in the bundle happened to depend on the gem. The generatedGemfilefor those three drivers now declaresbigdecimalexplicitly.ruby-oci8declares it too, for a second and independent reason: ruby-oci8’s ownlib/oci8/bindtype.rbrequiresbigdecimallazily when it decodes an OracleNUMBERcolumn and does not declare that dependency in its gemspec, so reading any numeric column raisesLoadErrorregardless of what scythe emits.ruby-sqlite3andruby-tiny-tdsneed neither and are unaffected. CI pinned Ruby 3.3 — a version predating the change — so it structurally could not observe any of this; the integration workflow now pins 3.4. -
An enum reachable only through an array column generated with no variants. The analyzer’s enum-discovery loop matched the bare
enum::xneutral type, so a column typedmood[]— neutral typearray<enum::mood>— was never recognized as referencingmood.scythe-codegen, which unwraps containers on its own, then found the type reachable but had noEnumInfofor it and fell back to a stub with an empty variant list, emitting an enum declaration with no variants. (#165) -
An explicit but empty
[sql.gen]table silently generated arust-sqlxtarget. A legacy[sql.gen]block naming none ofrust/python/typescript/go/kotlinresolved to a defaultrust-sqlxtarget rather than an error — the same silent-fallback shape #97 removed for an unresolvable target, left open for a block that resolves to nothing. Omitting thegenkey entirely still defaults torust-sqlx, which is documented and intended. (#165) -
A
derivebackend option repeating a base derive produced code that would not compile.SqlxBackend::derive_lineappended everyextra_derivesentry unconditionally, so namingDebug(always in the base set) orserde::Serializealongsideserde = trueemitted a duplicate derive token —E0119, conflicting trait implementations, in the generated file. (#165) -
A typo’d case name in a
[naming]manifest overlay installed silently.apply_casepasses an unrecognized case name through unchanged, which is safe for the compiled-in manifests but not for an overlay, the one path a case name reaches it from outside.struct_case = "PascalCse"was accepted and then emitted every affected identifier uncased. Overlays are now validated against the four real case names. (#165) -
scythe lint <file>ignored[lint.sqruff]. Explicit-file mode built its sqruff linter withNonein place of the config’s rule table, unconditionally — the same gap #206 closed forfmt, left open inlint. A[lint.sqruff]that config-modelintrejects was silently accepted when the same config was paired with a file argument. (#206) -
A
column = "table.col"override that could only ever match a parameter was never flagged. The unmatched-override preflight built its known-references set from columns alone, so a qualified override naming a real parameter reference but no column passed silently — as did a typo’d one, since neither could be distinguished from an override that simply never fires.resolve::param_referencesis now chained into the same set, feeding the existing diagnostic rather than adding a second. (#189) -
SC-PRV09,SC-PRV10,SC-PARSE01andSC-PARSE02could not be configured, counted, or discovered. All four were ad hocError-severity findingsscythe-cliconstructed directly at the point of failure (an unconstructable[[sql.gen]]target, a query file with zero recognized blocks, a query that fails to parse, a query that fails semantic analysis), never as registeredLintRules — so[lint.rules]and[lint.categories]had no effect on any of the four, and the documented “8 provenance rules” undercounted the 11 that actually exist by two.SC-PRV09(gen-target-invalid) andSC-PRV10(empty-query-file) now joinSC-PRV01-08/SC-PRV11inscythe_lint::provenance_registry;SC-PARSE01(unparseable-query) andSC-PARSE02(unanalyzable-query) get a newscythe_lint::parse_registryandRuleCategory::Parse, since they fire fromcheck,lint, andauditalike rather than a single check-time command. All four are zero-behaviorLintRules exactly like the rest of the provenance family: the finding itself is still built where the failure is detected, but its severity is now resolved from the registry instead of hardcoded. (#216) -
A schema-qualified enum or composite generated two different names for the same type. The declaration side spelled the type through
enum_type_name/composite_type_name, which strip characters an identifier cannot hold; the reference side — the type as it appears in a column, parameter or composite-field annotation — calledto_pascal_casedirectly. SoCREATE TYPE app.pointwas declared asAppPointand referred to asApp.point, a.inside a type position that no target language parses, and the reference never matched the declaration it named. Both paths now share one helper. The same call also hardcoded PascalCase instead of honouring the manifest’sstruct_case; that half was latent only because all manifests currently set PascalCase, and is fixed alongside. Separately, the composite declaration itself inlinedto_pascal_case(&composite.sql_name)in roughly sixty backend call sites rather than sharing one place, including five nested-composite reference sites that disagreed with their own declaration. (unfiled) -
A composite reachable only as another composite’s field was never emitted. The analyzer collected composites by scanning selected columns and nested field types, and never looked inside a composite’s own fields, so selecting a column whose type nests another composite produced code referring to a type that was never defined. Codegen gated emission on the same incomplete check, so collecting it in the analyzer alone would not have been enough. Both now walk the full reachability closure, with a visited set that also serves as the diamond and cycle guard. This was documented as a known gap when the JVM composite reader landed; it is now closed. (unfiled)
-
A qualified
column = "table.col"type override was silently ignored for parameters outsideSELECT *. The per-parameter match key was built from a query-level table name that only ever exists for a single-tableSELECT *, so on any explicit select list the override matched nothing and was dropped without a word — the parameter half of the defect whose column half was fixed earlier. Parameters bound by a directcol op $Ncomparison now carry their own owning relation, taken from the real table name rather than an alias. Parameters with no single owning column (anINlist, aLIKEpattern, a literal comparison) deliberately carry none and keep their previous behaviour rather than guessing. (#189) -
Every JVM backend read a composite column through
getObject(col, T.class), which throws at runtime. pgjdbc registers no type map for a user-defined composite, soPSQLException: conversion to class T ... not supportedwas raised the first time any generated JVM reader touched one — code that compiled and then failed on first use. Composites now read as text and parse through a generatedfromTextfactory implementing PostgreSQL’s composite text-form rules: an empty unquoted field is NULL, a field needing quoting is wrapped in"with"and\backslash-escaped inside, and a nested composite arrives quoted and recurses. Five assertions that pinned the brokengetObjectshape as correct were inverted. Still unhandled and documented rather than dropped: array-typed composite fields, per-field NULL into a primitive-typed field, and a composite reachable only as another composite’s field, which the analyzer never collects. (unfiled) -
rust-tokio-postgrescould not bind or read a composite column at all. The generated struct derived neitherToSqlnorFromSql, sorow.getand the bind path both failed to resolve. Composites now derivepostgres_types::ToSql/FromSqlwith#[postgres(name = "...")], since postgres-derive matches the Postgres type name exactly while scythe PascalCases the identifier.postgres-typesis a transitive dependency oftokio-postgres, but itsderivefeature is not forwarded, so it is now declared directly in the integration scaffolding. (unfiled) -
A
column = "table.col"type override was a silent no-op unless the query wasSELECT *. Column resolution built one qualified name from a query-level source table populated only for a star expansion, so an explicit select list had nothing to qualify against. Columns now carry their own source relation — the real table name, not the alias — andNonewhere there genuinely is one (a computed expression, literal, or function result). Two silent halves went with it: a combinedcolumn+db_typeentry returnedfalsethe momentcolumnmissed instead of falling through todb_type, and an override matching nothing produced no diagnostic whatsoever. It is now a hard error before generation starts. A qualified override on a parameter is still inert outsideSELECT *; that needs analyzer work and is tracked, not quietly half-fixed. (#189) -
A schema-qualified table emitted a
.inside its model struct name.SELECT * FROM app.widgetsproducedpub struct App.widget, the same defect fixed for enums earlier. Row struct names are unaffected —@nameis already restricted to ASCII identifier characters, verified rather than assumed. Separately, two different queries in one output file whose generated types collapse onto one identifier emitted two declarations of that name; collisions are now keyed by name and rendered body, so the identical-body case remains the intended dedupe. Composite struct names still carry the dot bug across 56 inlined call sites and are tracked separately. (#136) -
scythe migratepassed a malformed annotation straight through and still reported success. A wrong-case return-type keyword, a missing return type, or whitespace insidesqlc.arg( name )missed the strict pattern and was emitted unconverted. Malformed input is now reported, and the final output is scanned for residualsqlc.arg(/sqlc.narg(. (#152, partial) -
scythe lintandscythe auditaccepted an unknown[[sql]] engineand silently analyzed it as PostgreSQL.SqlDialect::from_str(&engine).unwrap_or(SqlDialect::PostgreSQL)in bothlint_cmd.rs(config mode and explicit-file mode, two separate call sites) andaudit.rs(config mode) turned a typo likemysql8into a silent PostgreSQL run — wrong catalog parsing, wrong dialect-gated rule set, no diagnostic — whilescythe generatealready rejected the same config outright. A newscythe_lint::parse_engine_dialect, sharing one alias list withaudit --dialect’s existing validation, now errors naming the offending value and the accepted aliases. (#165, item 3) -
scythe checkpassed on stale output after a[[sql.gen]]option changed. The provenance header fingerprinted the schema and the queries but not the options that decide what is generated from them, so switchingrow_typefrompydantictomsgspec, or editing the contents of a manifest overlay, left the header byte-identical andcheckreported the artifact fresh. A sixthoptions=field now covers the target’s resolved[[sql.gen]]options together with the contents of its manifest overlay, andSC-PRV11reports a mismatch as its own finding rather than folding into the existing header rules. A header written before this field existed is still read as complete — absence means “generated by an older scythe”, not “drifted” — and a target with no options and no overlay produces bytes identical to the old five-field header. All 111 committed integration artifacts are regenerated to carry it. Fingerprinting uses FNV-1a rather than theahashused elsewhere: ahash’s “fixed” keys are regenerated per process from OS randomness, so it cannot produce a value that is stable across the write and the later verify. (#155) -
Two queries selecting the same composite column emitted its model struct twice. Enum definitions were already deduplicated when assembling an output file; the composite/model structs beside them were not, so a second query selecting the same composite produced a duplicate type declaration — a compile error in every target with a one-definition rule. Deduplicated on the rendered struct text, the same way enums already are, and not scoped to the JVM. (unfiled)
-
A schema-qualified enum emitted a
.inside the generated type name, and two names colliding in one file went undetected.CREATE TYPE app.status AS ENUM (...)carries its qualifier intoEnumInfo::sql_name, and case conversion alone does not remove it, soapp.statusbecameApp.status—pub enum App.status, a syntax error in every target that shares this path. Enum type names now go through the samesanitize_for_identifierthe variant labels already used. Separately,to_pascal_casereturned the empty string when every_-delimited part was empty (a bare"_", or the underscore run a symbols-only label sanitizes to), emitting a type with no name; it now falls back to its sanitized input, matching whatto_camel_casealready did. Two generated type names that collapse onto one identifier — two enums, or an enum and the query’s own row type — are now rejected withDuplicateAliasinstead of emitting two declarations of the same name. (#136) -
Parameters were bound by declaration order rather than by where they appear in the SQL, so a repeated or out-of-order placeholder bound the wrong argument.
java-jdbc,kotlin-jdbc,kotlin-exposedandphp-amphpemitted one?per declared parameter and then set them1..nin declaration order. A query writing$2before$1therefore bound the caller’s first argument to the second slot — silently wrong results, no error — and a query repeating$1emitted fewer binds than the rewritten SQL contained, which the driver rejects at execute time. Placeholder rewriting now returns the sequence of parameter positions it actually emitted, and each backend binds from that sequence.preprocess_oracle_sqlandpreprocess_mssql_sqlwere also collapsing:N/@pNto a bare?before parsing, discarding which N each referred to; they now emit$N, which sqlparser’sOracleDialectandMsSqlDialectboth tokenize asToken::Placeholderthrough the defaultsupports_dollar_placeholderimpl. (#149) -
SQL-text cleanup and placeholder rewriting were dialect-blind, corrupting MySQL and MSSQL identifiers. The comment stripper and placeholder rewriter knew only PostgreSQL quoting, so a MySQL backtick-quoted identifier containing
--had the rest of the query deleted, a MySQL#line comment was left in place, and an MSSQL[bracketed]identifier containing a comment marker was truncated the same way.SqlDialectis now threaded through the whole SQL-text pipeline, and backtick and bracket spans are recognised as quoted regions alongside PostgreSQL’s. Bare?under PostgreSQL was previously governed by a heuristic — rewrite?only if the query contains no$<digit>anywhere — which corrupted a zero-parameter query using the JSONB?operator; the decision is now made from the dialect instead of from a scan of the text. This is the last of #186’s items; the JSONB?operator, dollar-quoted strings andNOT LIKEwere fixed earlier. (#186) -
:oneand:optrendered identical code on 53 backends, so one of the two contracts was always silently wrong.:onemeans “exactly one row, error if absent”;:optmeans “zero or one”. Every affected backend matchedQueryCommand::One | QueryCommand::Optin a single arm, so whichever behaviour that arm happened to implement won for both. Each language now gets an error path built from its own idiom — a raisedScytheNoRowsError/RecordNotFound/RecordNotFoundException, a thrownNoSuchElementExceptionorInvalidOperationException,Mono.erroron the reactive backends, the driver’s ownErrNoRowsin Go,{:error, :not_found}in Elixir, andErrin Rust — while:optkeeps its existing shape everywhere. Ruby.rbssignatures and PHP return-type declarations were narrowed to match, so signatures no longer over-promise nullability. (#197)The direction was not uniform, and the earlier census recorded it wrongly for 10 of the 53. On the
go-*andelixir-*backends:onewas already correct —sql.ErrNoRowspropagates throughrow.Scan, and Elixir already returned{:error, :not_found}— and it was:optthat wrongly errored on a legitimately absent row.go-godrorfolded the permissive way while its three Go siblings did not, so even same-family behaviour was not safe to assume. -
python-snowflakedeclared:execrowsas-> intwhile returningcur.rowcount, which the DB-API typesint | None. Narrowed at the call site rather than widening the annotation: psycopg, aiosqlite, aiomysql and oracledb all typerowcountas plainint, so snowflake was the lone outlier and widening would have spread the imprecision to seven backends. (unfiled) -
typescript-postgrescould not bind a composite-typed parameter. postgres.js serialises only values it recognises, and a plain object standing for a PostgreSQL composite is not one, so the generated tagged template failed to type-check. Composite parameters are now expanded toROW(${field}, ...)::type_name— one binding per scalar field, recursing through nested composites — instead of being interpolated whole. (unfiled) -
A
rust-sqlx:groupedquery selecting a non-identifier column produced code that could not compile. The grouped path reads its flat rows through the untypedsqlx::query!macro, whose row field names come from sqlx’s own expansion of the raw column names rather than from this backend’ssanitize_field_namesconvention. sqlx’sparse_identrequires the driver-reported name to be a valid Rust identifier and otherwise fails macro expansion outright, so a quoted"my col"was a hard compile error, not a silent mismatch. Such columns now get an explicitAS "field_name"so the macro sees a name scythe chose. Note this is a different mechanism from the#[sqlx(rename)]attribute added earlier forFromRow: bothquery!andquery_as!build their row type directly and never consultFromRow, so that attribute has no effect on either macro path. (unfiled) -
The same
rust-sqlxdefect was live on the plain:one/:many/:optpath, and its enum aliasing emitted a stray backslash into the SQL.generate_query_fnselected a non-identifier column unaliased, soparse_identfailed macro expansion there too; and a column whose name is a valid identifier but differs in shape from this backend’sfield_name(case, orsanitize_field_namesreshaping) failed against a struct-literal field spelled differently, sincequote_query_asbuilds#out_ty { #ident: #var_name }from the driver-reported name. Separately,rewrite_sql_for_enumshand-wrote its alias as\"…\"in Rust source — a literal backslash and quote — and then passed it throughescape_rust_string, which escaped both again, so the SQL sqlx saw at compile time contained a backslash nobody asked for. Both paths now share onerewrite_sql_for_row_columns, which aliases wheneverfield_namediffers from the column name or an enum override applies, writes the alias as a single plain"…", and is escaped exactly once. (unfiled) -
checkprinted “All queries valid.” for a query file it had not checked at all. A file whose annotations were never recognised — a mistyped--name:, or every statement commented out — yields zero query blocks, andhas_unannotated_sqldeliberately ignores it, so the run reported success having examined nothing. A non-empty file that produces no query blocks is now anSC-PRV10error naming the file. A genuinely empty or whitespace-only file is still accepted: there is nothing there that could have been misrecognised. (unfiled) -
VARBINARY(MAX)resolved to the invalid neutral typevarbinary(max), which no manifest maps. SQL Server’s unbounded binary type parses toDataType::Varbinary(Some(BinaryLength::Max)), for whichnormalize_data_typehad no arm at all, so it fell to the catch-all that stringifies throughDisplay.strip_precisiononly strips a trailing(<digits>), somaxsurvived, never matched the barevarbinaryarm, and the column resolved to a type name rather thanbytes. The siblingVARCHAR(MAX)/NVARCHAR(MAX)spellings were already correct — their arms routeCharacterLength::Maxthrough a_ => "text"fallback — and all ten mssql-capable manifests already mappedbytes, so no manifest changed.BINARYneeds no equivalent arm: sqlparser types itOption<u64>, makingBINARY(MAX)unrepresentable. (unfiled) -
A literal
%in SQL broke every%-paramstyle Python driver at execute time.WHERE name LIKE 'a%'reaches psycopg3 and aiomysql as a format string, and%'is not a valid placeholder, so the driver raised before the statement was ever sent. The%is now doubled — but only for a query that actually binds parameters. psycopg3 and PyMySQL run%-formatting exclusively fromexecute(query, params); a parameterlessexecute(query)passes the string through untouched, so doubling it there would have replaced a driver-side error with a silently wrongLIKE 'a%%'that matches nothing.python-snowflakeadditionally emitssnowflake.connector.paramstyle = "qmark"to match the?it generates — previously the onlyparamstyleassignment in the tree was a hand-written compensation inside the integration harness, so every consumer of the generated module got none. (#201) -
python-aiomysqlrewrote a?inside a SQL string literal. A blind.replace('?', "%s")ran after the literal-awarerewrite_pg_placeholders, soWHERE note = 'really?'became'really%s'— a silent wrong answer, not an error. GH #153 was closed with this half unfixed. -
scythe lint <file>ran no scythe rules at all. Explicit-file mode built a sqruff linter and never constructed aLintEngine, so everySC-*rule was skipped —scythe lint queries.sqlsilently checked far less thanscythe lintwith the same config, and the code said so in a comment. It now builds a catalog from the config’s first[[sql]]block and runs the native rules with suppressions honoured, falling back to sqruff-only when there is genuinely no schema to build from.scythe fmt <file>likewise dropped[lint.sqruff]entirely, honouring only the dialect half of #206. -
scythe checkgreen-lit anoutputpath thatscythe generaterefuses.checknever applied #207’s containment rule, so a config could pass the check and then fail the thing the check exists to predict. (#206, #207) -
A
CREATE VIEWwith an explicit column list got no types at all. The branch handlingCREATE VIEW v (a, b) AS SELECT …never ran the analyzer:sql_typefell back to the literal string"unknown"andnullablewas hardcodedtrue, so the same view declared with and without a column list produced different — and wrong — columns. It now analyzes the body and overlays the declared names. A declared list whose arity disagrees with the body is now an error rather than silently mismatched output, mirroring how aWITH t(a,b) AS …alias list is already handled. -
ALTER TABLE … RENAME TOon an unknown table did nothing, silently. Every sibling operation —AddColumn,DropColumn,RenameColumn,AlterColumn,AddConstraint— errors on a missing table;RenameTablealone fell through with noelse, so a typo’d migration was indistinguishable from a correct one. It now follows the same precedent. -
json_eachand friends were typedstringin select-list position. They returnSETOF record, not text. The neutral type vocabulary cannot name an anonymous record —composite::{name}needs a catalog entry and thejson_nestedmachinery assumes the value on the wire is JSON text, which a native composite is not — so they now resolve tounknown, following the precedentjson_populate_recordalready set, rather than to a confidently wrong scalar.json_array_elements/jsonb_array_elementspreviously hit the unknown-function error path and now resolve tojson, matching what the FROM-position handling already assigned. -
rust-sqlx’s:optoutput never compiled. The return type said{Struct}while the body’shas_row_structguard excludedOpt, so it emitted the anonymous-recordsqlx::query!instead ofsqlx::query_as!— the declared type and the produced type disagreed on every:optquery the backend has ever generated.:optnow returnsOption<{Struct}>and fetches with.fetch_optional, which is what the command means.rust-tiberius’s:optlikewise stopped emitting.expect("expected one row"), a panic in generated code on exactly the absent row:optexists to handle. (#197) -
rust-sqlxmapped a mangled field back to the wrong column. The backend derivessqlx::FromRow, which looks a column up by the Rust field name, and #215’ssanitize_field_namesrenames any non-identifier column — somy colbecame a fieldmy_colthatFromRowthen searched for under that name and could not find. A compile fix bought at the cost of a runtime one. Fields whose generated name differs from the SQL column now carry#[sqlx(rename = "…")]. The other Rust backends were checked and are unaffected: tokio-postgres and tiberius look up by the raw SQL name, sibyl reads positionally. -
typescript-duckdbtyped abytescolumn as something the driver never returns. The manifest declaredUint8Array;@duckdb/node-apihands a BLOB back asDuckDBBlobValue. Verified against the published package rather than inferred — 1.5.5-r.4 shipsclass DuckDBBlobValue { readonly bytes: Uint8Array }and lists it in theDuckDBValueunion. The read direction had no test at all, which is why this survived. Note the manifest has no read/bind split, so the bind-position type changed too: construct one with the driver’sblobValue(Uint8Array | string). -
The tool-validation schemas contained no container or user-defined type. The ~20 PostgreSQL backend tests that compile generated code with a real compiler — the strongest gate in the project — never asked one to accept an array, an enum, an array of enums, a composite, a
uuidor ajsonbcolumn, which is why the JSDoc and JVM enum defects above survived. The schemas now carry all of them, and every added column is selected by the query each test runs; a widened schema with an unwidened query would have added columns no generated file reaches. MySQL gains an inlineENUM(...)column for the same reason. (#146) -
The two
SC-INS09live tests raced each other. Both trustedCREATE EXTENSION IF NOT EXISTSto tell them where an extension landed; they now verify againstpg_extension/pg_namespace. (#144) -
SC-N02(table-naming) could not see a CamelCase table name. The catalog stores tables under a lowercased lookup key, so by the time the rule read the name every table looked snake_case andCREATE TABLE "UserProfile"passed.Tablenow keeps the DDL’s own spelling inraw_namealongside the lookup key, and the rule reads that. The existing test asserted the miss verbatim — it guarded the bug rather than against it — and is inverted here. (#145) -
A placeholder inside a
LIKEpattern or anIS NULLoperand was dropped from the generated signature.WHERE name LIKE '%' || $1 || '%'andWHERE $1 IS NULLboth bind a parameter, but the analyzer only collected one from aLIKEwhose pattern was a bare literal and never descended intoIS NULL/IS NOT NULLat all — so the generated function took fewer arguments than the statement needs. Placeholder positions are now memoised by source span, which keeps a parameter repeated across several expressions from being counted more than once. (#171) -
go-pgxemitted a static import block that omitted imports its own types needed. A query selecting ajsonoruuidcolumn produced*json.RawMessageanduuid.UUIDwith neither import, so the file did not compile; the generated header conceded as much by advisinggoimports -w .. #100 fixed the opposite direction — an import emitted but unused. Imports are now derived from the types actually emitted, via the[imports.rules]table every Go manifest already declared and nothing read.go-pgxconsequently passes the torture gate and has been removed from the expected-failure allowlist. The PHP casts likewise come from the manifest instead of a hardcoded table that contradicted it. (#198) -
A JVM enum whose SQL spelling was not the uppercase of its variant threw on every read. Binding emitted
.getValue()/.value— the SQL value — while reading emittedvalueOf(rs.getString(col).toUpperCase())— the variant name. For a value likein-activewith variantIN_ACTIVE,toUpperCase()yieldsIN-ACTIVE, whichvalueOfrejects withIllegalArgumentException; case-folding is not the same operation as sanitising. The generatedvalue/getValue()accessor that makes this exact was emitted and consulted by no reader. Reads now match on the declared SQL value. The existing tests asserted thetoUpperCase()spelling verbatim, so they pinned the defect and changed with the fix. (#213) -
Every
javascript-*file containing an enum failedtsc --checkJs. The generated/** @type {const} */sat on the declaration, where it isTS2304: Cannot find name 'const'. The valid position is the initializer expression —= /** @type {const} */ ({…})— which also narrows to literal types as intended. The same spelling existed as three byte-identical copies acrosstypescript-pg,typescript-postgresandtypescript-mysql2; they now share onegenerate_js_enum_def, so the next fix here lands once instead of three times. -
A non-identifier column name was spliced raw into a JSDoc
@property.@property {string} my colisTS1003: Identifier expected, and unlike the TypeScript emit path the JSDoc row typedef cannot mangle the name — a generated row type is cast onto the driver’s rows, so its key must stay the column’s own spelling. The typedef now switches to JSDoc’s quoted type-literal form (@typedef {{ "my col": string }}) when any key is not a bare name, and keeps the@propertyform otherwise.@parammangling is unaffected and remains correct: a binding is a JavaScript parameter, which has no quoted form. -
javascript-better-sqlite3’s:batchpath never type-checked.db.transaction((items) => …)with an unannotated parameter makes TypeScript inferneverfrom better-sqlite3’s variadic signature, giving TS2488 and TS2345. The TypeScript emit path annotates it as(items: T[]); the js_mode path now carries the equivalent inline@param. This was invisible until the enum fix above stoppedtscshort-circuiting on an earlier error. -
typescript-postgres’s single-parameter:batchrewrote${field}inside a SQL string literal. A blindString::replacematched the tail of an escaped\${field}. #219 covered the$Nform only. -
A
:groupedquery’s.rbsdescribed a class the.rbfile never defines. The RBS producer resolved the flat column list where the Ruby producer splits parent from child, so the signature declared one class with neither thechildrenreader nor the child classData.defineactually emits —steep checkagainst a correct.rbfailed on the signature, not the code. The RBS path now performs the same split.RbsQueryInfocarries the child columns in their own field; an earlier revision smuggled them through a sentinel inResolvedColumn.full_type, which is the shape that leaked__unknown_col__into user-visible output in #173. (#203) -
Eight
.rbsfiles were rejected outright byrbs parse. The Ruby backend emittedlibrary "bigdecimal"ahead of any signature referencingBigDecimal, butlibraryis Steepfile and CLI syntax, not an RBS declaration, so the parser failed at the first token with “cannot start a declaration”. A signature namingBigDecimalneeds no directive at all. The.rbside keeps itsrequire "bigdecimal/util", which is genuinely needed for.to_d. -
elixir-exqlitereleases its prepared statement on every exit path, not just the success one;elixir-tdstypesbytesandtime/time_tzparameters instead of falling through to:string; and a:groupedquery with no parent columns no longer emitsdefstruct [, :children], which is not valid Elixir. (#202) -
A column named
my col,with-dashor2fareached a field declaration verbatim in every language but TypeScript.pub my col: String,my col: str,My col string,String my col— none of them parse, and no gate caught it because the torture schema has no such column. #215 fixed this for TypeScript by quoting ("my col": string,row["my col"]), which is the right answer there and only there: a generated TypeScript row type is cast onto the driver’s rows, so its key has to stay the column’s own spelling. The other nine targets have no quoted form for a field and never read a column back by the generated name — they use the position or the raw SQL name (rs.getString("my col")) — so their 85 manifests now set[naming] sanitize_field_names, andfield_namereplaces the characters an identifier cannot hold. A leading digit takes acol_prefix rather than a bare_, becauseto_pascal_casedrops a leading underscore and go-pgx and the C# backends case the field name a second time, which handed the digit straight back. The SQL text is untouched. (#215) -
IDENTITYpreprocessing ate the whitespace after the keyword and rewrote its case. The catalog stripsIDENTITY(seed, step)before parsing; when the keyword was not followed by a clause, the branch that put it back pushed a literal uppercase"IDENTITY"and resumed from the position it had already advanced past the whitespace, soGENERATED ALWAYS AS IDENTITY PRIMARY KEYbecameIDENTITYPRIMARY KEYand a column namedidentitybecameIDENTITYTEXT NOT NULL. The original characters are now copied through unchanged. Thanks to @fzlzjerry. (#154) -
An unsupported nested
json_aggdegraded to a single JSON object even where the driver could describe the array. The degradation pass rewrote every column referencing a nested struct the backend did not implement to plainjson. A distinctjson_arrayscalar marker now carries “one JSON document whose top level is an array”, andtypescript-pg,python-asyncpg,elixir-postgrexandphp-pdoopt into it by declaring it in their manifests. It is deliberately not thearray<json>container, which means a SQLjson[]column and can select a typed array reader:csharp-npgsqlwould have declaredList<string>while reading through the untypedGetValueaccessor. Backends that declare nojson_arraymapping keep the plain-jsonfallback unchanged. Thanks to @fzlzjerry. -
A parameter named after a column like
my col,with-dashor2fawas emitted verbatim into a binding.export async function findWeird(client: PoolClient, my col: string)does not parse, and neither does its equivalent in the other nine target languages. #215 fixed the two positions that have a quoted form — the declared property key and the property read — and left this one pinned as a known gap, because a binding has no quoted form anywhere and mangling is a cross-language naming decision.scythe_backend::naming::param_namenow makes it: characters an identifier cannot hold become_, and a leading digit takes a_prefix. Only parameters are mangled. A column’s field name is a contract with whatever the driver returns, so it keeps its raw spelling and its quoting; the SQL text is untouched, and any collision the mangling introduces (my colagainst a realmy_col) is reported by the existing duplicate-field check rather than silently resolved. (#215) -
python-psycopg3bound a reserved-word parameter to a name it never passed. It is the one backend that binds by name rather than position, and it derived the two halves of that contract separately — theexecutedict from the resolved param’sfield_name, the%(...)splaceholder from a secondto_snake_caseof the raw SQL name. The spellings matched until anything else touchedfield_name: a param namedclassbecameclass_in the signature and the dict while the SQL still asked for%(class)s, so every call raisedquery parameter missing: classat execute time. Nothing earlier could catch it — the module imports, type-checks and passes the generated-code gate, which compiles generated code and never runs it. Both halves now come from the resolved param, the waytypescript-postgresalready did it.crates/scythe-codegen/tests/python_named_placeholder_regression.rsasserts the invariant (every placeholder is a dict key) rather than the single keyword that exposed it. -
A column named after a TypeScript keyword produced a file that would not parse. Every generated TypeScript query function takes its parameter names from the columns they are compared against, so a
classcolumn emittedexport async function q(client: PoolClient, class: string)—TS1390, and the syntax error stoppedtscbefore it type-checked anything else in the file. The seventeen TypeScript manifests now declare[naming] reserved_bindings, consulted by a newscythe_backend::naming::param_name, which mangles a keyword toclass_where it lands in a binding. Deliberately not the existing[naming] reservedlist: that is applied to columns too, and a generated TypeScript row type is cast straight onto the driver’s rows (client.query<FindByClassRow>(...)), so renaming the key would have described an objectpgnever returns — a compile error traded for a silent wrong answer.classtherefore staysclassin the row type and inrow.class, both of which are legal TypeScript. Five of the six TypeScript entries inscripts/torture-expected-failures.txtare gone;typescript-postgresstill fails, on a composite-typed parameter postgres.js cannot bind, which was invisible behind the syntax error. (#180) -
scripts/check-generated-backends.pyranruby -coverqueries.rbs. The script globs every file in a backend’s output directory and picked the syntax checker by backend, but RBS is a signature language, not Ruby, so it choked onACTIVE: Stringand reported aruby-pgfailure that no change to the generated code could ever have cleared. The entry sat inscripts/torture-expected-failures.txtblamed on unescaped SQL, which it never had anything to do with. Syntax checkers are now selected by file extension first, since a file’s language is a property of the file and not of the backend that emitted it — the distinctionscripts/check-generated-syntax.shalready made, now with one derivation instead of two.ruby-pgbuilds clean against the torture schema and is out of the allowlist. -
Every remaining reason in
scripts/torture-expected-failures.txtwas re-derived from the compiler’s actual output rather than carried forward. All five non-TypeScript entries had been grouped under “unescaped quoted identifier (#179)”, written mid-rollout and wrong for every one of them by the time 740cc99 finished the escaping layer: the three Rust projects fail because their scaffolding declares neitherserde_jsonnoruuid,go-pgxfails on a static import block that omits what its own emitted types reference (#198), andruby-pgwas the harness bug above. A gate that checks only pass/fail cannot check why, so the file now carries an instruction to re-derive before editing. -
SC-SEC01(dangerous-function) missed set-returning functions called inFROMposition (FROM dblink(...),FROM pg_ls_dir('/etc'),FROM openrowset(...)) — the idiomatic way these particular functions are written — because the matcher only inspectedExpr::Functionnodes andpre_visit_relationwas a no-op. It now also matches the relation name. (#138) -
SC-SEC06(weak-hash-in-auth) missed salted and wrapped hash arguments:md5(password || salt)andmd5(lower(password))produced no finding, only the baremd5(password)form did.extract_sensitive_columnnow recurses throughBinaryOp,Nested,FunctionandCast. (#138) -
SC-A03(or-in-join-condition) only fired when theORin a JOIN’sONclause was unparenthesised, and only inspected the ON clause’s root expression instead of descending it — soON (a OR b)andON x AND (a OR b), both real occurrences of the same antipattern, produced no finding. It now unwraps parentheses and descends throughANDconjuncts, counting each top-level disjunction once. (#145) -
engine.rs’s cross-query duplicate-name check (SC-C03) hardcodedSeverity::Errorregardless of[lint.rules], so"SC-C03" = "warn"had no effect, and it fired even whenDuplicateQueryNameswas not registered in the calling registry at all. It now resolves severity through the registry, same as every other rule, and produces no finding when the rule isn’t active. (#137) -
SC-A02(implicit-type-coercion) implements no check and is off by default with no way to ever produce a finding if enabled; its description now says so explicitly, matchingSC-C01. (#137) -
Inline suppression comments (
-- scythe-audit: ignore[...]) were keyed by source line, so two statements sharing one physical line (DROP TABLE a; DROP TABLE b;) resolved to the same key and a suppression meant only for the first silently covered the second too.SuppressionSetis now keyed by 0-based statement index instead — callers must pass a statement index, not a computed source line. The module doc’s claim that a blank line between an annotation and its statement still attaches was also wrong; the code discarded it then and still does, so the doc was corrected instead of the (intentional) behavior. (#140) -
A user-supplied
[[audit.rule]]’s declaredcwearray had no way to reach a caller throughLintRule— onlyMatcherRule’s privateRuleSpecheld it, so every consumer fell back to scanningdescriptionforCWE-\d+text and a declaredcwewith no such text in its description was silently dropped.LintRulegained acwe()method (default: empty;MatcherRuleprefers the declaredcwe, falling back to the description scan only when it’s empty). (#140) -
MatcherRule::check_query’s dialect gate (spec.dialects) was invisible from outside — a rule scoped to Postgres just silently returned nothing on every other engine, indistinguishable from a rule that ran and found nothing.LintRulegained anis_applicable_to(dialect)method (default: every dialect;MatcherRuleexposes itsspec.dialectsgate) so a caller can count and report skipped, not-applicable rules instead of an engine’sscythe auditreading as a clean pass when most rules never ran. (#167) -
[lint.sqruff] enabledwas declared and never read:enabled = falsedid not disable sqruff. It now does. Separately,[lint.sqruff.rules]wrote any non-"off"value into sqruff’sruleskey, which sqruff treats as an allowlist — so"LT02" = "warn"silently disabled every other sqruff rule, the opposite of what it reads like and of what the docs claimed. sqruff has no per-rule severity at all, so only"off"can be honoured and any other value is now rejected with a message naming the offending key. An unknown rule code, previously swallowed, is also reported. (#113, #114) -
A rejected
[lint.sqruff]table aborted the entire lint run and discarded every scythe-native finding along with it, because the sqruff call sits ahead of the rule engine in the per-file loop. A single typo could silently switch off the security rules,SC-SEC07PII detection included. The configuration is now validated once per[[sql]]block before any query file is read, so a config mistake is reported as one and the blast radius is visible rather than silent. Note validation lints a trivial statement: sqruff checks rule codes when a string is linted, not when the linter is built. -
SqruffConfig::default()returnedenabled: false, the opposite of an absent[lint.sqruff]table, because#[serde(default)]only applies to absent TOML input and not to a derivedDefault. No call site hit it, butenabledonly recently became load-bearing. -
Ruby
.rbssignatures were emitted from a hardcoded scalar table rather than the backend manifest, so they could disagree with the.rbcode generated beside them.ruby-oci8declaredcreated_at: Datewhile the query bound aTime. Every RBS scalar now comes from the manifest. Regenerate to pick this up. (#106) -
A
[[sql.gen]]entry missing its requiredoutputkey produced a generic untagged-enum deserialization error that named neither the field nor the block. The error now names both, and unknown keys in a[[sql]]block are rejected rather than silently ignored. (#116) -
Every PHP manifest declared its
arraycontainer asarray<{T}>, which reached the generated file in a native type position where PHP has no generics:public array<string> $tagsis a parse error. Two routes hit it — a PostgreSQL array column, and an= ANY(...)parameter, which the analyzer synthesises asarray<T>in every dialect, so the broken type landed in function signatures even on engines with no array type of their own. (#200) -
All five JVM backends resolved a column’s reader from a table maintained in parallel with the manifest, and every type outside that table fell through to an untyped accessor —
rs.getObject(col)on the JDBC family,row.get(col, Object.class)/Any::class.javaon the R2DBC pair. The declared field type came from the manifest, nothing compared the two, and the result did not compile:incompatible types: Object cannot be converted to WidgetAddress. Readers now derive from the declared type itself, so the two cannot drift. Three defects fell out of the same tables: the R2DBC arms matchedLocalDatebeforeLocalDateTimeand read every datetime column as a date;kotlin-exposedcalledwasNull()nowhere, so a SQL NULL in a nullableInt?arrived as0; and all three JDBC backends read a nullable enum asvalueOf(getString(col).toUpperCase()), an NPE on exactly the value the column exists to hold. (#191, #192, #213, #214) -
java-r2dbcemitted top-level records, a top-level enum and bare static methods into one compilation unit, and closed its:groupedrow buffer with});while.flatMap(was still open. Neither had ever compiled. (#191) -
TypeScript emitted raw column names into positions that require an identifier.
ts_property_keyexisted and was correct but only the row-struct emitters used it, so batch-params interface members, per-item binds, dot-access row reads and oracledb object-literal keys spliced the name verbatim —first name: string;,[item.first name], androw['it's']closing its own quote. Property positions are now quoted; a scalar parameter named after a non-identifier column is still broken, because quoting is not available in a binding position, and is pinned by a failing-when-fixed test. (#215) -
row_type = "zod"derived its types from a table maintained beside the manifest, so the two disagreed on four of six columns in the same query:activewasnumberunderinterfaceandbooleanunderz.infer,pricenumbervsstring,created_atstringvsDate. Zod types now derive from the resolved TypeScript type, soz.inferequals the manifest type by construction. Enum variants also went through rawto_pascal_caseand had their values spliced unescaped —In-active: "in-active",is not a valid key. (#216) -
typescript-duckdbimportedConnection, which@duckdb/node-apidoes not export, and calledstmt.run(args), which takes no arguments. Every file this backend has ever produced failed to compile. Values now bind throughstmt.bind(). (#217) -
typescript-oracledbbound the driver result toconst resultinside the block where the grouped fold declares its own (Cannot redeclare block-scoped variable), uppercased row keys unconditionally so a quoted lower-case column read asundefinedwith no compile error, and ignoredrow_type = "zod"entirely — there was a test certifying that no-op. (#218) -
The postgres.js
:batchpath rewrote$Nwith a raw string replace while every other command path used the literal-aware rewriter, soVALUES ($1, $2, 'lit $1 $2 end')turned an inert SQL string literal into two extra live bindings. It compiled, ran, and stored the wrong text. (#219) -
Six JSON functions were split across arms whose behaviour followed from which arm they landed in rather than from their semantics:
jsonb_agglost the nested-struct inferencejson_agggot,to_json/to_jsonbover a whole-row reference returned flatjsonand were hardcoded non-nullable despite being strict, andjson_strip_nullswas likewise fixed non-nullable. -
The JSON function table is now derived from
pg_proc.proisstrictand measured behaviour rather than assumption. Four functions had no arm at all, so legal PostgreSQL failed with a hardunknown functionerror:array_to_json,json_object/jsonb_object,jsonb_set/jsonb_insertandjsonb_pretty.jsonb_set_laxreportsproisstrict = fbut still returns NULL for a NULL target or path — only its replacement argument is exempt — so it gets its own arm.json_typeof,jsonb_typeofandjson_array_length/jsonb_array_lengthwere unconditionally nullable where the database is strict, and now follow their argument. -
A bare MySQL
?placeholder used as a plain arithmetic operand in the SELECT list (SELECT age + ? AS x FROM users) was dropped entirely:infer_expr_type’sExpr::Valuearm only resolved a placeholder’s position viaparse_placeholder, which parses$Nbut returnsNonefor?, so the occurrence never reachedresolve_placeholder_positionand the generated function signature was missing an argument. Separately,analyze_selectvisitedWHERE/HAVINGbefore the projection, so a?textually first in the SELECT list was numbered after one appearing later inWHERE—SELECT CAST(? AS CHAR) AS tag, name FROM users WHERE age = ?bound the WHERE placeholder first. Projection is now analyzed beforeWHERE/HAVING, and theExpr::Valueplaceholder arm resolves throughresolve_placeholder_positionfor both$Nand?. (#170) -
java-r2dbcandkotlin-r2dbcthrewIllegalArgumentExceptionon any null argument. R2DBC’sStatement.bind(index, value)rejects null outright —bindNull(index, Class<?>)is the only way to send SQL NULL — and both backends emittedbindfor every parameter regardless of nullability, so a nullable parameter failed at the bind call rather than reaching the database. Ordinary nullable parameters now route through a generatedbindNullablehelper; a nullable enum gets an inline null check instead, because its bind expression calls.getValue()/.valueon the field and would throw before any helper could test it. Both PostgreSQL harnesses gained a call that passes a real null, which is what makes the regression catchable: reverting the fix now fails them with the driver’s own “value must not be null”. TheBatchbind sites are untouched and still have a separate pre-existing gap — a batch enum parameter binds the raw enum object with no.getValue()/.valuecall. -
java-r2dbcandkotlin-r2dbc‘s:batchbind sites never got either fix above. They bound every parameter unconditionally (the sameIllegalArgumentExceptionon a null batch argument that ordinary bind sites had) and, for an enum, bound the raw Java/Kotlin enum object instead of its SQL spelling (the same “no codec for a user enum type” failure). Both backends’ bind-site logic is now shared between the ordinary and:batchcode paths through awrite_r2dbc_bind_for/r2dbc_bind_expr_forpair that takes an explicit receiver expression (a loop variable or a batch-params record/data-class accessor) instead of always reading the parameter’s own field. The PostgreSQL enum placeholder cast (add_pg_enum_casts) already reached:batchSQL before this fix, since it operates on the onesqllocal shared by every command shape — that part needed a test, not a fix. Covered by new backend unit tests only: the PostgreSQL fixture schema both harnesses build from (integration_tests/sql/pg/queries/) has no:batchquery at all, so neither harness can yet exercise this path end to end — still an unfalsifiable gate at the integration level until a:batchfixture query exists. -
A misspelled annotation (
@nullible,@optionall,@nonull, …) was captured and silently discarded. Any-- @<name> <value>line scythe does not natively recognise is deliberately kept as an opaqueCustomAnnotation— that escape hatch is how consumers layer their own annotation vocabulary (@http,@http_auth, …) on top of scythe — but nothing ever inspected it, so a typo’d override behaved identically to one with no override at all whilescythe generate,scythe checkandscythe lintall reported success.CustomAnnotationnow carries asuggested_keywordwhen the unrecognised name is within edit distance 2 of a known keyword (name,returns,param,nullable,nonnull,json,deprecated,group_by,optional), for a caller to turn into a warning. Left as a signal rather than a hard parse error: rejecting every unrecognised annotation would break the same consumer-defined vocabulary the escape hatch exists for. (#152) -
scythe migratereported everysqlc.arg/sqlc.nargname as “renamed” while discarding it. It emitted-- @param {name}, whichscythe_core::parserstores as a docs-onlyParamDocthat the analyzer never reads for naming — only the positional-- @param $N {name}form becomes aPositionalParamDocand actually renames the generated parameter. A migratedsqlc.arg(needle)/sqlc.arg(mailbox)query silently fell back to inferred orpNparameter names on the very nextscythe generate, even thoughmigrateprinted “2 param(s) renamed”.migratenow emits the positional form, with the same sequential numbering it already assigns to the placeholder. (#152) -
Two SQL values of the same enum could collide on the generated variant name and
scythe generatewrote both anyway.'gpt-3.5-turbo'and'gpt_3_5_turbo'both sanitize and case-convert toGpt35Turbounderenum_variant_case = "PascalCase"(Rust, C#, Go, TypeScript); nothing compared the rendered variant names beforegenerate_enum_defs_via_backendhanded them to a backend, so the file came out withpub enum Model { Gpt35Turbo, Gpt35Turbo, }—E0428under a realrustc, a redeclaration in every other target — while the command exited 0. A newresolve::check_enum_variant_collisionsruns once per enum, the variant counterpart of the existing enum/query-type-name check, and rejects the query withDUPLICATE_ALIASbefore any backend renders it. (#136) -
Four more PostgreSQL manifests lost a nested aggregate’s list-ness on degrade, the same way
java-jdbc.toml’sjson = "String"collapsesjson_aggdown to one opaque string.elixir-ecto,php-amphp,typescript-kyselyandtypescript-postgresnow declarejson_arrayfor an array-shapedjson_agg/row_to_jsonresult their backend does not construct into a typed struct, each verified against the exact decode path an already-declared sibling manifest relies on:elixir-postgrex’s Postgrex/Jason pipeline forelixir-ecto(both run raw SQL through the same Postgrex binary protocol, verified live against PostgreSQL 16);php-pdo’s generatedjson_decode($value, true)forphp-amphp(the same call, independently emitted);typescript-pg’spgauto-parsing fortypescript-kysely’s PostgreSQL dialect (documented as running overpgunchanged) and fortypescript-postgres’spostgres.jsdriver (which parsesjson/jsonbthe same way). Scope, precisely:catalog_has_nested_aggregatesonly infers a nested aggregate for the PostgreSQL dialect on a postgresql-family engine — Redshift and DuckDB are excluded by name — so only the 19 postgresql-engine manifests can reach this path at all, and after this change 4 of them build a real struct, 8 keep the array shape, and 7 still collapse to plainjson(csharp-npgsql,java-jdbc,java-r2dbc,kotlin-exposed,kotlin-jdbc,kotlin-r2dbc,ruby-pg). Those 7 mapjsonto a raw string with no driver- or codegen-level array decoding to point to, so a distinctjson_arraymarker would carry no real information overjsonitself;ruby-pgis the one worth revisiting, since thepggem can decode JSON but nothing in the generated code configures it.json_nested(a typed struct, not just array-shape) requires a backend-side decoder —generate_nested_struct_def— that a manifest alone cannot add, so it stays at its existing four (rust-sqlx,rust-tokio-postgres,go-pgx,python-psycopg3). (#147) -
A
json_agg/row_to_jsoncolumn degraded to plainjson(orjson_array) on the 15 of 19 PostgreSQL backends that do not implementgenerate_nested_struct_def, and nothing said so.degrade_unsupported_nested_structsrewrote the column’s neutral type andscythe generateexited 0, so a user asking for a structured nested row from, say,java-jdbc(json = "String", read back viars.getString) got an opaque string with no indication a struct was ever requested. The function now also returns oneNestedStructDegradationper rewritten column — the SQL column name, the struct that could not be built, the fallback type it got instead, and the backend — threaded ontoGeneratedCode::degraded_nested_structs. This is a library-side signal only:scythe-clistill needs to turn each entry into a reported finding (scythe-codegencannot install a subscriber or depend onscythe-cli/scythe-lint). Not a hard error by default — failing every degrading backend outright would break working setups. (#147) -
A bare
?placeholder or literalNULLprojected with noCAST/comparison/COALESCEto borrow a type from reachedanalyze()’sOkresult typedneutral_type: "unknown", then surfaced two layers down as the backend’sINTERNAL_ERROR: unknown neutral type: unknown— the part of #170 the counting/ordering fix (c288fce1) left open.analyze()now rejects the shape withTYPE_MISMATCH, naming the query and column and suggesting an explicitCAST. The rejection is origin-based, not a blanket check onneutral_type == "unknown": it only fires on a column whose projected expression is itself a bare placeholder/NULL, so a UNION arm’sNULLthat a sibling arm resolves, and ajsonb_each/json_eachrecord column (legitimately"unknown"— PostgreSQL’srecordpseudo-type has no neutral-type representation), are both unaffected. (#170) -
A UNION with both arms projecting a bare
NULL, or a bareNULL/placeholder projected out of a derived table or CTE, still reached codegen asINTERNAL_ERROR: unknown neutral type: unknowninstead of the cleanTYPE_MISMATCHabove. Two places stripped theuntyped_literaltaint beforeanalyze()’s final check ever saw it: a UNION arm’s widened column was rebuilt with..Default::default(), always dropping tofalseregardless of whether either side actually resolved a type; and a derived table’s or CTE’s output columns were folded back into scope through a constructor that hardcodeduntyped_literal: false, resetting the flag the moment a column crossed a subquery boundary. The UNION case now survives the taint only when both arms are untyped — either side supplying a real type still clears it, so aNULLarm a sibling resolves is unaffected — andScopeColumnnow carries the flag from an already-analyzed output column through a derived-table/CTE boundary via a newfrom_analyzed_columnconstructor, while a genuine catalog column or a function’s synthetic result (jsonb_eachincluded) is untouched and stays untainted no matter how many subquery or UNION layers it passes through. (#170) -
The Java and Kotlin integration-test generators had no way to notice their per-engine harness branches drifting apart.
java.java.jinjaandkotlin.kt.jinjaduplicate a whole test program per SQL engine, and nothing compared what one branch tests against another — the redshift branches quietly ended up with fewer than half the postgresql branch’s test functions. A new parity gate (tests/engine_test_parity.rs) now fails the build when an engine branch is missing a test function the postgresql branch of the same template has, unless it is named in the newtest-parity-exemptions.txtratcheting allowlist with a reason; the allowlist also fails on stale entries, so it can only shrink. Separately,oracle/schema_full.sqlandredshift/schema_pg_compat.sqlare runtime-only schema variants that harness templates apply independently of the (possibly different) filescythe.tomlgenerated queries from — safe only as long as both files agree on table/column shape, which nothing checked; a newtests/schema_variant_consistency.rsnow checks it. (#196, #195) -
Two queries in one
[[sql]]block whose@namevalues differed only in case could render the same function name into one file, andgenerateexited 0.CreateAPIKeyandCreateApiKeybothsnake_casetocreate_api_key;check_file_level_type_name_collisionsalready caught this shape for row/model structs and enums but never compared query function names, andassemble_bodyhas no dedup pass at all forquery_fn(unlike the struct/enum lists, it pushes every result’s function unconditionally), so the collision always reached the output file as two function definitions. The check now also comparesfn_nameacross every query destined for the same file. (#136) -
scythe generatesilently produced different bytes for the same input depending on whetherrustfmthappened to be onPATH, and said nothing either way.format_rust_code_if_possiblepipedrustfmt’sstderrto/dev/nulland fell back to the unformatted code on a failed spawn, a non-zero exit, or a broken pipe, all indistinguishably. A missingrustfmtis now reported as a warning (and still never fails the run — a missing toolchain says nothing about whether the generated code is correct);rustfmtspawning and then rejecting the input is reported with its own stderr and, since Rust has no other tool-based validator, now counts as a--validate-outputfinding the same way every other backend’s real-compiler check already does. (#167) -
A misspelled annotation still reported success — nothing consumed the
suggested_keywordsignal #152 added toCustomAnnotation. A typo like@nullibleparsed clean, analyzed clean, andscythe generate/check/lintall exited 0 while the nullability override it named silently never took effect. New ruleSC-PARSE03(misspelled-annotation) fires whensuggested_keywordis set, naming both the annotation as written and the suggested keyword (e.g.@nullible→ did you mean@nullable?).Warnby default, notError: the same escape hatch the signal rides on is a deliberate extension point with legitimate shipping usage (@http,@http_auth), andsuggested_keywordis a heuristic, not proof the annotation is wrong. Lives indefault_registryrather thanparse_registry(unlikeSC-PARSE01/SC-PARSE02): it has a real, already-analyzedLintContextto inspect, so it needs no additionalscythe checkwiring beyond registration. The default registry now holds 59 built-in rules, up from 58. (#152, #167) -
scythe migrateparsed sqlc’s top-levelplugins:array and eachgen.<lang>.packagefield and discarded both with no diagnostic. Neither has ascythe.tomlequivalent — scythe has no wasm/process plugin system to receiveplugins:, and no backend supports overriding the generated-code package/module name (every scythe Go file hardcodespackage queries) — so there is no config keymigratecould fill in for either. Both are now awarning:on stderr naming what was dropped, rather than silence. Left as warnings, notinvalid_configerrors like an unsupportedgen.<lang>target: a hard error on the mere presence ofplugins:would fail ordinary, fully-convertible v2 configs that declare it only to satisfy sqlc’s own plugin resolution alongside an otherwise-unremarkablegen.goblock. (#152) -
scythe-backend’s type tests ran against a stale private copy of the manifests, not what scythe actually ships.crates/scythe-backend/test-manifests/{rust-sqlx,rust-tokio-postgres}.tomlhad drifted fromcrates/scythe-codegen/manifests/: the private copies were missingjson_nested,sanitize_field_namesand the ~50-entryreservedkeyword list entirely, and the tokio-postgres copy declaredrange = "String"where the shipped manifest declaresPgRange<{T}>. Worse, a test asserted that stale"String"value directly, so it wasn’t merely blind to drift — it actively pinned the bug, and would have broken the moment someone pointed it at the real file.4ef83676had already provenPgRange<{T}>correct by compiling the emitted wrapper withrustc. The private copies are deleted; bothtypes.rsandmanifest.rstests nowinclude_str!the manifestsscythe-codegenships, and assertions coverreserved,sanitize_field_names,json_nestedand the correctedrangevalue. (#157) -
scythe-conformancenever checked that a fixture’sexpected.query.columnsmatched what the analyzer actually produced forquery_sql. The four nullability assertions only ever iterateanalyzed.columns, so a declared column absent from that list — a rename, a droppedSELECTitem, a typo — was never fed into any of them: not a failure, not a skip, just silently never examined, even though every row still named it.crate::runner::evaluate_fixturenow rejects a declared column the analyzer produced no match for, via a newRunnerError::DeclaredColumnNotAnalyzed. (#160) -
A typo in a live-fixture’s
liveblock, a run, a row expectation, or anengine_expectationsentry parsed clean and silently dropped the whole thing.LiveBlock,Run,RowExpectationandEngineExpectationhad no#[serde(deny_unknown_fields)], andnull_together/engine_expectationsare#[serde(default)], so a misspelled key evaporated instead of failing to parse. All four now reject unknown fields. (#160) -
DIVERGENCES.toml’senginefield was a bare, unvalidatedString. A typo’d engine name (e.g."postgres"instead of"postgresql") loaded successfully and then matched noVerdict, forever, with no diagnostic — the entry would sit in the registry looking active while suppressing nothing.DivergenceEntry::engineis now typed asEngine, so an unrecognized engine name fails to deserialize instead. (#160) -
The three unit tests in
scythe-conformance/src/executors/mssql.rsran in no CI job at all.ci.yml’stestjob runscargo test --workspacewith this crate’s default (empty) feature set, which never compiles themssql-gated module;nullability-conformance.yml’s mssql job passes--test live, which restrictscargo testto that one integration binary and excludes lib unit tests.ci.ymlnow runscargo test -p scythe-conformance --features mssql --libas its own step, on every push and pull request. (#160)
Changed
Section titled “Changed”-
Six of the surviving
rangemappings named a type their driver does not produce, and are corrected; a seventh is removed. Verified by running each real client against a live PostgreSQL instance rather than by reading the manifests.csharp-npgsqlsaidstring, butGetStringthrowsInvalidCastException— nowNpgsqlTypes.NpgsqlRange<{T}>.go-pgxsaidstring, but pgx v5 refuses to scan a range into*stringat all — nowpgtype.Range[{T}]. Both Elixir manifests saidString.t()where Postgrex returns%Postgrex.Range{}and rejects a plain string as a bind parameter. Both Python manifests saidtuple[{T}, {T}], and neither driver returns a tuple; they now nameasyncpg.Range[{T}]andpsycopg.types.range.Range[{T}]respectively, which are genuinely different classes, so the family spelling legitimately diverges.rust-tokio-postgreshas no usable mapping —postgres-typesexcludes every range OID fromFromSql for Stringand ships no range decoder — so its declaration is removed and recorded as a capability exception that fails the gate if anyone re-adds one without justification. Any query selecting a range column on these backends generated code that did not work; it now does, but the host type it names has changed. (#190) -
The
rangecontainer is now declared only on PostgreSQL manifests — 84 declarations become 19.rangewas mapped in 84 of 102 manifests in seven mutually incompatible spellings, and nothing asserted anything about it. Presence tracked no engine capability in either direction: it was declared for MySQL, MariaDB, SQLite, MSSQL, Oracle, DuckDB and Redshift, none of which has a PostgreSQL-style range column type, and omitted from manifests whose siblings declared it. The spellings contradicted each other inside single language families and, in two cases, inside one file —python-asyncpgsaidtuple[{T}, {T}]whilepython-asyncpg.redshiftsaidstr;elixir-postgrexsaidstring(), which is not the Elixir typespec for a binary at all. Dropping the key on an engine with no range type is a degradation only in the sense that a query can no longer silently resolverange<T>to a wrong host type there — it now falls through the unknown- container path like any other unmapped container.range_container_consistency.rsgates presence against engine and spelling against each family’s ownstringscalar, in both directions. (#190) -
scythe fmt --checkexits 2 rather than 1 when files need formatting. #212 reserves exit 1 for operational failure — an unreadable file, an invalid config — and a distinct code for “the thing you asked about is not satisfied”, which is whatlintandcheckalready do.fmt --checkused a plain error for both, so a CI step could not tell a formatting difference from a broken run. Scripts branching on exit 1 fromfmt --checkneed updating. -
Breaking (
elixir-ecto): the backend emits Ecto instead of a Postgrex clone. Generated functions take areporather than aconn, specs change fromPostgrex.conn()toEcto.Repo.t(), queries run throughEcto.Adapters.SQL.query(repo, sql, args, []), and:batchusesrepo.transaction/1withrepo.rollback/1. Struct definitions also move to top level instead of nesting underScythe.Queries.*. A backend named after Ecto that generated raw Postgrex calls was misnamed rather than merely limited. Every caller of a generatedelixir-ectofunction must now pass a repo module. (#202) -
The PHP backends now render a type twice: the native position (property, parameter, return) keeps the bare
arrayPHP’s syntax requires, and@var/@paramdocblocks getarray<T>back. A barearrayisarray<mixed, mixed>to PHPStan, so the fix that made the output parse cost every array column and every= ANY(...)parameter its element type at level 9. Manifests gained an optional[types.docblock_containers]table, which falls back per container name to[types.containers]; only the ninephp-*.tomlmanifests declare it, and every other language’s output is byte-identical. Measured on the torture schema at PHPStan level 9: 15 findings to 9 (php-pdo), 24 to 18 (php-amphp), all six removed beingmissingType.iterableValue. -
Dependency pins for
integration_tests/**are managed by Renovate against the jinja templates intools/integration-test-generator/templates/, which is where they actually live. Dependabot cannot target generated files, so its PRs against them were dead on arrival. (#115) -
snowflake-jdbcis unified on 4.0.2 across both JVM templates, which previously disagreed. -
The JVM backends have a real array reader, and array columns are
List<T>again. An earlier revision of this release degraded the JVM manifests to declare array columns asString, because no JVM backend could read one andint[]/bool[]additionally renderedjava.util.List<int>, which is not valid Java. That entry said the degradation was not the destination; the reader now exists, soarraymaps to a boxedList<T>and theStringfallback is gone. Regenerated JVM output changes shape for every array column. (#192) -
scythe lintandscythe fmtbuild one sqruff linter per[[sql]]block instead of one per file. Construction compiles the dialect’s lexer, so a run over N files paid N+1 constructions where one suffices; measured on 200 single-query files in one block,scythe lintgoes from 0.547s to 0.047s. Construction is also validation, which moves an invalid[lint.sqruff]table from “error against whichever file was read first” to an error about the config itself. (#130)
Removed
Section titled “Removed”- Breaking (
scythe-codegen): removed the publicgenerate_from_catalogstub. It ignored its argument and always returnedOk(GeneratedCode::default()), so a caller could not distinguish “nothing to generate” from “this function does nothing” — reporting success while doing nothing is worse than not existing. It had no caller besides its own tautological test, which asserted the stub’s behavior matched the stub’s behavior and could never fail. If catalog-level codegen is implemented later it should land as a real implementation, not a reserved name. (#132) - Breaking (
scythe-backend): removedBackendRenderer, its jinja fixtures, and theBackendError::TemplateErrorvariant, along with the crate’sminijinjadependency. Code generation is done by the per-language emitters inscythe-codegen; the template renderer was a parallel mechanism with no production caller, so it read as a supported extension point that did not exist. Breaking only for a caller matching directly on that error variant. - Four
r2dbcmanifests for engines the r2dbc backends never accepted. They were unreachable. - Breaking (
scythe-lint): removed the free functionssqruff_adapter::validate_config,lint_sql,lint_and_fix_sqlandformat_sql. Each built aSqruffLinterper call, and building one compiles the dialect’s lexer — the cost that dominatedscythe lintuntil #130 hoisted construction out of the per-file loop. Leaving them in left a second way to do the same thing where the obvious use (a loop over files) silently reintroduced that cost. UseSqruffLinter::for_linting(returnsNonefor[lint.sqruff] enabled = false) withlint/lint_and_fix, orSqruffLinter::newwithformat, building one linter per run instead of per file;validate_configisfor_lintingwith the linter discarded, so keep the linter. None were re-exported from the crate root, so only a caller namingscythe_lint::sqruff_adapter::directly is affected. (#130)
[0.14.0] - 2026-08-09
Section titled “[0.14.0] - 2026-08-09”This release checks scythe’s output against something other than scythe. Nullability inference is
measured against what live database engines actually return across all six of them. Generated files
now record the schema they came from, and scythe check can diff your DDL against the database the
code will actually run against. A Snowflake backend that had never executed anywhere runs in CI for
the first time.
Three of those checks found real bugs the model-only tests could not: Oracle returns NULL for an
empty string literal, typescript-duckdb had been reading rows positionally while indexing them by
name, and typescript-oracledb cast nullable columns to their non-null type. All three are fixed
below.
Upgrading: scythe check now exits 2 for findings and 1 for operational failures, so a CI
script keying on 1 needs updating. Unrecognized options on any [[sql.gen]] target — not just
TypeScript — are now an error rather than silently ignored. Identifiers derived from names with
consecutive capitals change spelling (CreateAPIKeyRow becomes CreateApiKeyRow). And every
generated file gains a provenance header, so the first regeneration after upgrading touches every
artifact. See Changed for the full list.
-
A per-target manifest override:
manifest = "..."on a[[sql.gen]]target names a partial manifest merged over the backend’s compiled-in one, so a project can retarget a few type mappings, naming fields or import rules without vendoring a whole manifest. The path resolves against the directory containingscythe.toml— the same rule every other path in the config follows since 0.13.0 — so generated output does not depend on where the command was run. The override is keyed per target rather than per backend name:rust-sqlxcovers five engines andjava-jdbcnine, each with its own type mappings, and a[[sql.gen]]target inherits its engine from the enclosing[[sql]]block, so two targets naming the same backend under different engines each get their own file.[types.scalars]and[types.containers]may only replace mappings the backend already defines — neutral type names are a fixed vocabulary, so a key outside it is a typo, and the error suggests the near miss;[imports.rules]does accept new keys, because retargeting a scalar requires an import rule keyed on the new type’s prefix. There is no[backend]section: manifest selection stays a pure function of(backend, engine). Every failure — unknown section, unknown key, missing file — failsscythe generatenaming the backend, the resolved absolute path and the offending key, and nothing falls back to the compiled-in manifest silently (#82) -
A live nullability conformance suite (
scythe-conformance), a dev-only workspace member that compares inferred nullability against what engines actually return rather than against scythe’s own model. Per (fixture, engine, column) it holds three facts side by side: the analyzer’s verdict, whether the generated code actually renders the column as optional (parsed out of the resolved type against the backend manifest, deliberately not copied from the analyzer, so the two can genuinely disagree), and the engine’s observed per-row nullness from a real query run. Four assertions relate them: fidelity (analyzer and generated code agree), soundness (an observed NULL implies the generated code renders the column optional), anti-vacuity (a column called nullable must be demonstrated NULL by some run, or the suite is satisfied by marking everything nullable), and join-group coherence (columns widened by the same outer join go NULL together). Accepted over-pessimism — the analyzer is stricter than an engine turns out to be — goes in a capped registry with a tracking issue per entry, and an entry that stops reproducing fails the build, so fixing the gap forces deleting the entry that excused it. A soundness failure is never suppressible by any registry entry. The crate is unpublished, has no CLI surface, and nothing inscythe generatetouches it -
Live drivers in that suite for all six engines — PostgreSQL, MySQL, MariaDB, SQLite, SQL Server and Oracle — each behind its own Cargo feature plus a
live-testsgate, with one CI job per engine. No driver is linked by default, socargo test --workspaceexercises the pure comparison logic without a container. Selecting an engine whose feature was not compiled in is a hard error naming the feature to enable, never a silent skip. Each engine’s isolation is its own problem: SQL Server gets a fresh database per connection, because T-SQL’s default schema belongs to the database principal rather than the session andUSEdoes not survive tiberius routing statements throughsp_executesql, so tables would otherwise land inmaster; Oracle gets a user per connection, because in Oracle a schema is a user. Fixtures are now analyzed under their own engine’s dialect rather than PostgreSQL’s, which is what made the Oracle empty-string bug below observable (#71) -
A provenance header in every generated file, recording the scythe version, backend, engine, a fingerprint of the schema and a fingerprint of the query set the file was generated from:
// scythe:provenance v=0.14.0 backend=go-pgx engine=postgresql schema=sch1:2e813606acee8b51 queries=q1:9c4e1f77a0b3d582The comment token follows the target language (
#for Python, Ruby and Elixir), and where a language requires particular first bytes the header goes second — after<?php, after Ruby’s# frozen_string_literal: true. Python’s variant carries a trailing# noqa: E501, since the line exceeds ruff’s default 88-column limit and would otherwise failruff checkon line 1 of every generated Python file. Ruby’squeries.rbssignature file gets one too.The schema fingerprint is a SHA-256 over a canonical rendering of the resolved catalog — tables, columns, enums, composites, domains, dialect — rather than over DDL text, so reformatting a migration or reordering statements does not move it while a real schema change does. It excludes column defaults (free-form AST text that churns on dependency bumps) and scythe’s own version, which would otherwise report every artifact as drifted on every release.
The query fingerprint covers the analyzed query set — each query’s name, command, parameter names and types, and resolved column names, types and nullability — rather than the SQL text, so reformatting a
.sqlfile or editing a comment does not report drift while a change that moves a generated signature does. Parameter names participate because every backend emits them as the generated function’s argument names: swappingWHERE name = $1forWHERE email = $1leaves both parameters typedstringbut rewrites the signature fromname: &strtoemail: &str, breaking every caller. Schema drift and query drift are reported as distinct findings,SC-PRV01andSC-PRV08.Generated code is only as correct as the schema snapshot scythe read, and nothing in the artifact recorded which snapshot that was. Code generated against a drifted local schema compiles, reviews as an ordinary diff, and meets a different migration state in production. Raised by Mads Hansen.
scythe checkverifies it through seven rules:SC-PRV01schema drift,SC-PRV03backend drift andSC-PRV04engine drift as errors;SC-PRV02scythe-version drift,SC-PRV05missing header,SC-PRV06malformed header andSC-PRV07unverifiable header as warnings. They are ordinary registry rules, so[lint.rules]and[lint.categories]can downgrade or disable any of them. A scythe upgrade alone is a warning by design — upgrading the tool must never fail a consumer’s CI before they have had a chance to regenerate. A missing artifact produces no finding at all, since the default.gitignoreexcludes**/generated/.It answers “was this generated from this schema?”, not “from these queries?” — editing a query file without touching the schema produces no mismatch (#68)
-
scythe check --database-urlnow also diffs your DDL against the live database. PostgreSQL only; blocks on other engines are skipped with a warning naming the block. SevenSC-DRFrules cover tables and columns missing from either side, type mismatches, nullability mismatches and enum value mismatches — all errors exceptSC-DRF02, a table present in the database but not in your DDL, which is a warning because every real database has aschema_migrationstable scythe knows nothing about.SC-DRF06is the rule that justifies the feature. Query verification cannot check nullability: preparing a statement makes PostgreSQL report type OIDs and nothing about NULL-ness. Readingpg_attribute.attnotnullis the only way scythe can tell you aNOT NULLin your DDL is not true in production, where the generated non-optional field fails to decode the first NULL row it meets.The catalog is read from
pg_catalog, notinformation_schema, which reportsUSER-DEFINEDfor every enum column and never names the type — the enum and type-mismatch rules would be undetectable through it. Type comparison is exact equality rather than the tolerant predicate query verification uses, which forgives string widening and so reported nothing when atextcolumn becameuuid. Types neither side can express in scythe’s neutral vocabulary are skipped rather than reported as mismatches. Views and materialized views are excluded from the nullability check, since PostgreSQL storesattnotnull = falsefor every view column.Opt-in via the flag: with no
--database-urlnothing connects, and unlikescythe inspect,checknever falls back to$DATABASE_URL— it cannot start requiring a database because that variable happens to be set, which is what keeps it usable in a pre-commit hook.This is the cheaper intermediate the issue proposes, not its headline
schema_source = "execute"design: scythe still builds its catalog by parsing DDL and does not execute your migrations against an ephemeral database, so DDL that only a real engine can resolve is still out of reach. Raised by u/Character-Forever-91 (#79) -
Nested struct types inferred from
json_agg(alias.*)androw_to_json(alias.*). A column that aggregates a whole relation now resolves to a generated struct with that relation’s fields instead of an opaque JSON scalar. PostgreSQL and CockroachDB only, and only onrust-sqlx,rust-tokio-postgres,go-pgxandpython-psycopg3— every other backend degrades to exactly the plainjsonmapping it produced before, including on Redshift, wherejson_aggdoes not exist.Element nullability is modelled on the element, not the fields.
json_aggover a LEFT JOIN emits a JSONnullelement, never an object of null fields, so an inner join yieldsVec<GetUserOrdersRowOrders>and an outer joinVec<Option<GetUserOrdersOuterRowOrders>>. Widening the fields instead would model a value PostgreSQL never produces while leaving the type unable to hold the one it does.JSON keys are the raw SQL column names, so a quoted
"createdAt"column gets#[serde(rename)]in Rust, ajson:"createdAt"struct tag in Go, and an explicit_from_jsonclassmethod in Python —Cls(**item)would passcreatedAtas an unexpected keyword argument. Enums reachable only through a nested struct are emitted with per-variant renames, since the driver’s ownrename_alltells serde nothing.jsonb_aggis deliberately not covered, nor isjson_aggover a scalar or a bare*. Two queries in one file deriving the same struct name deduplicate if their shapes match and are a hard error naming both if they do not. Your own@jsonannotations are untouched (#78) -
A
field_caseoption on a[[sql.gen]]target, acceptingsnake_case(the default) orcamelCase, honored by the 11 TypeScript backends and byjava-jdbc,java-r2dbc,kotlin-jdbc,kotlin-r2dbcandkotlin-exposed. It renames generated field and parameter names only; every backend still reads the driver row by the raw SQL column name, so the rename cannot break decoding.On TypeScript the naive version of this would ship a type that lies: 10 of the 11 backends return the driver’s row through a blind
rows[0] as StructNamecast, so renaming the declared field alone type-checks green and returnsundefinedfor every field at runtime —tsccertifies the bug. UndercamelCasethe function body therefore reconstructs the row field by field, reading raw keys and writing renamed ones (#87) -
Four JSDoc
javascript-*backends:javascript-pg,javascript-postgres,javascript-mysql2andjavascript-better-sqlite3. They emit plain ESM.jscarrying its types entirely in JSDoc comments —@typedef/@propertyfor row types,@param/@returnsfor functions — with no driver import statement, referencing driver types inline asimport("pg").PoolClient. Nullability is alwaysT | null, never the optional-property form. These are an emit mode on the existing TypeScript backends rather than new manifests, so the manifest count is unchanged.row_type = "zod",outer_join_unionsandfield_case = "camelCase"are rejected with an error naming the TypeScript backend to use instead: each needs syntax a plain.jsfile cannot carry. Output is validated in CI with realnode --checkandtsc --checkJs --strict(#81) -
--exit-zeroonscythe check, matching the flagauditandinspectalready had -
A
queries=fingerprint in the provenance header, covering the analyzed query set alongside the schema. The schema fingerprint said nothing about the.sqlfiles, so editing a query and forgetting to regenerate left an artifact thatscythe checkcalled clean. Computed over each query’s name, command, parameter names and types, and resolved column names, types and nullability — not the SQL text — so reformatting a query or editing a comment stays silent while a change that moves a generated signature does not. Parameter names participate because every backend emits them as the generated function’s argument name: swappingWHERE name = $1forWHERE email = $1leaves both typedstringbut rewrites the signature fromname: &strtoemail: &str, breaking every caller. Reported asSC-PRV08(query-drift, Error), distinct fromSC-PRV01, so a drift report names which of the two moved. An artifact whose header predates this field is not reported as malformed (#94) -
ErrorCode::InvalidConfig, so a mistake inscythe.tomlno longer surfaces underINTERNAL_ERRORand read as a scythe bug rather than something the user can fix.ErrorCodeis now#[non_exhaustive], so adding a future variant is no longer a breaking change (#102) -
Live nullability coverage for seven more rules:
MAX/AVGover an empty set,NULLIFwith equal operands,CASEwith noELSE, a scalar subquery matching no row,RIGHT JOINandFULL JOIN. Each is asserted against real engines rather than against the analyzer’s own model.FULL JOINjoins users to tags rather than users to orders, because the foreign key onorders.user_idmeans no order can ever be unmatched and the join would degenerate to aLEFT JOIN— proving half the rule while appearing to prove all of it. It is scoped away from MySQL and MariaDB, which parseFULLas a table alias and reject the query (#71)
Changed
Section titled “Changed”-
The README is a front page again, and its code is checked. It was 412 lines, 227 of them ten hand-written samples of “generated” code that no tool validated — and five of the ten were wrong, including a Go block still showing pre-0.14.0 field casing and typing
NUMERICas*string. It now carries one Rust sample copied verbatim from committed generated output, andREADME.mdis insnippet-runner’s reference set so CI compiles it. The feature list and the language/database matrix moved to the docs pages that own them. -
The documentation was audited against the source and corrected. Seven claims contradicted the implementation: the unknown-option rule was described as TypeScript-only;
[lint.sqruff] enabledwas documented as a working toggle when nothing reads it;[lint.sqruff.rules]was documented as the opposite of the allowlist it actually builds;--dialectonlint/fmtwas described as unset; the crate table omittedauditandinspect;scythe fmtwas described as running sqruff’s full default rule set, whenLT01is excluded there too; and[sql.gen.python|typescript|go|kotlin]were supported but undocumented. Per-language backend pages replace the combined Java/Kotlin page and the “Other” grab-bag, with the old URLs kept as stubs. -
llms.txtgeneration no longer destroys the code samples.minify.collapseCodeBlockswas enabled, which collapses whitespace inside code fences as well as prose — so the abridged file rendered the whole site as 258 lines with every sample unparseable. It is off; the changelog and Starlight’s anchor markup (22% of the corpus between them) are excluded; andllms.txtnow carries the facts a model gets wrong by default, above all that the annotation syntax is not sqlc’s. Backends, databases and guide are addressable as separate sets. -
Generated-code tool validation reports a skipped checker as a skip, not a pass.
validate_with_toolsreturnedOption<Vec<String>>, whereNonemeant “tool not installed” and every call site spelledif let Some(errors)— so a checker that was never installed was indistinguishable from one that ran and found nothing. In practice 31 of 76 backend tests were passing without any tool touching the generated code: 13 becausebiomewas installed nowhere, and 18 because Java, C#, Elixir and Rust have no validator at all.validate_python_toolswas the worst case, returningSome([])whenruffwas absent. The return type is nowToolValidation, reporting each checker separately asRan/Missing/Failed, and CI runs withSCYTHE_VALIDATE_STRICT=1, where a missing tool fails the build instead of being skipped (#98) -
Generated code is checked by
poly, this repository’s linter, rather than by a per-language collection of separately-installed binaries. poly bundles its engines in-process –oxcfor TypeScript,rufffor Python,magofor PHP – so one already-required tool replacesbiome, standaloneruff, thepython3 -m astsyntax pass andphp -l, and CI drops four install steps along with the Python, PHP and JDK toolchains it only needed in order to feed them.nodeandtsc --checkJs --strictremain for thejavascript-*JSDoc backends, since oxc lints JavaScript but does not typecheck JSDoc annotations;gofmtandruby -cremain because poly delegates those languages rather than bundling them. Kotlin loses its tool validation: poly delegates toktlint, and standing up a JVM plus a downloaded jar to lint generated Kotlin is out of proportion to what it catches –validate_structuralstill covers those backends, and an inventory test keeps the gap visible. Validation runs against a dedicatedgenerated-code-poly.tomlpassed explicitly, because poly resolves config by walking up from the file it is handed and a temporary file finds nothing, so what CI enforced would otherwise depend on where the system temp directory sits -
Unknown
[[sql.gen]]keys are rejected on every backend, not just TypeScript. 24 of the 52 backends had noapply_optionsat all and inherited a permissive default that accepted anything (#103) -
scythe checkexits2for findings and1for operational failures. It previously exited1for both, so a CI script could not distinguish “your schema drifted” from “your config file is missing”. Any script or hook keying on1for findings must change to2, or use--exit-zerofor an advisory gate. Warnings have never affected the exit code and still do not -
Unrecognized options on any
[[sql.gen]]target are now a hard error. Every backend previously inherited the defaultapply_options, which silently discarded any key it did not read —row_typ = "zod"parsed as valid TOML and did nothing, with no diagnostic. This was fixed for the 11 TypeScript backends first; the same typo behaving differently depending on target language was itself a trap, so theCodegenBackendtrait default now rejects every key unless a backend declares it known, closing the gap for the other 41 backends in one change (#103). Unknown keys fail generation, with a suggestion when the key is within edit distance 2 of a real one. A config carrying a forward-compatibility key on any target will fail on upgrade -
Identifiers derived from names with consecutive capitals change spelling. PascalCase conversion previously returned mixed-case input containing no underscore unchanged, so a query named
CreateAPIKeygenerated the functioncreateApiKeyreturning the typeCreateAPIKeyRow— the same query, spelled two ways. Both sides now normalize identically:CreateAPIKeyRowbecomesCreateApiKeyRow. This affects row, enum and composite type names on every backend, and function names on the 16 backends whose manifests use PascalCase function naming (allcsharp-*and allgo-*). Names that are snake_case, or PascalCase without consecutive capitals, are unaffected -
Two SQL columns whose names collapse onto one field name are now a hard error.
SELECT "USER_ID", user_id FROM tis legal SQL and passes the analyzer’s case-sensitive duplicate-alias check, which runs on raw names before conversion; it previously emitted a struct with two identically-named fields. This applies on every backend, including under the defaultsnake_case, and covers parameters as well as columns -
The
Auto-generated by scythe. Do not edit.banner is gone, replaced by the provenance header, which carries the same warning plus the version, backend, engine and schema fingerprint — and whichscythe checkverifies rather than merely stating. Removed from 40 of the 52 backends; the Go, Kotlin and Elixir backends never emitted it. Go’s// Code generated by scythe. DO NOT EDIT.line is unchanged, since the Go toolchain matches on it. Together with the header, the first regeneration after upgrading touches every generated file
-
[sql.gen.rust] serdeandderivefailed on three of the four Rust backends. Both keys are documented on the legacy table independently oftarget, and the CLI puts them into the options map whichever backend was selected — but onlyrust-tokio-postgresrecognized them.rust-sqlxacceptedstructs_onlyalone, andrust-tiberiusandrust-sibyldeclared no options at all, so the reject-by-default trait default turned the documented config intounknown option 'serde'. All four now accept both keys. Emitted output is unchanged when neither is set. -
scythe audit --list-rulesunder-reported the rule set by two. It printed 56 rules against a registry of 58, and--explain SC-A02reported no such rule. Both built their catalog from the active-rule set, which drops anything resolving tooff— correct for deciding what runs, wrong for a catalog.SC-A02andSC-C01are off by default but are registered rules a user can enable, and they now appear with theiroffseverity. What actually fires is unchanged. -
The schema fingerprint could report drift that was not real, and miss a change that was. Schema-qualifier stripping was gated on PostgreSQL although
Catalog::get_tableis dialect-blind, and it stripped onlypublic.whereget_tablestrips any prefix, somyschema.usersandusersdiverged. Enum values were emitted unescaped, soENUM ('a|b')andENUM ('a','b')produced an identical canonical line, and a value containing a tab or newline could forge an entire extra record.CREATE DOMAINwas absent from the canonical form. The escaping scheme escapes only when a delimiter is actually present, so every fingerprint in the corpus is unchanged andFINGERPRINT_ALGORITHM_TAGis deliberately not bumped — bumping it would hand every user a spurious drift report on upgrade. Eight fixture fingerprints are now pinned to their released values (#91) -
scythe migratewrote configs that could not generate. A stock sqlc-for-Go config producedbackend = "go-go", since the language and target were concatenated unconditionally. Kotlin was worse: it had no field in the legacy config at all, so a migrated Kotlin project silently emittedrust-sqlxoutput. This is the on-ramp for every sqlc user (#97) -
Generated Go failed to compile against a schema with no temporal or decimal column, because the import block was emitted unconditionally across
go-pgx,go-godrorandgo-gosnowflake.gofmt -eparses but does not typecheck, so it could never have caught an unused import (#100) -
ruby-sqlite3andruby-tiny-tdsemitted an.rbssignature contradicting the.rbbeside it, because the RBS generator hardcoded thejsontype instead of following the manifest. Manifest scalars are Ruby doc names rather than RBS types, so this needed a translation table, not a passthrough (#101) -
WITH t(a, b) AS (...)ignored the column alias list. A CTE’s explicit column names were never applied to the analyzed scope, so the outer query could not reference them:WITH t(a, b) AS (SELECT 1, 2) SELECT a, b FROM tfailed with “column a does not exist”, because a body projection carrying no names of its own labels its columnsunknown. The alias list is now applied at all three registration points – the recursive anchor’s seed, the widened recursive union shape, and the plain fall-through path – and an alias count that disagrees with the body’s column count is rejected the way PostgreSQL rejects it, rather than being matched positionally into mislabelled columns. Contributed by Znie (#107) -
LEAD/LAGwere always inferred nullable, even when both the argument and the three-argument default are non-null.IGNORE NULLSbails out, since it can return NULL regardless (#89) -
typescript-kyselyoutput now carries a note recording that it requiresCamelCasePlugin(#96) -
The grouped-fold row reads in
typescript-pgandtypescript-mysql2are cast in TypeScript mode, matching every other read site. The twojs_modesites stay uncast, sinceas Tis not valid JavaScript (#95) -
csharp-snowflakegenerated parameter bindings that Snowflake rejects. Generated code named each positional bindingp1,p2,p3, but Snowflake’s REST protocol keys?placeholders by bare ordinal, so the server read them as named bindings and the query failed. Bindings are now named"1","2","3". The backend had shipped since 0.6.0 without ever running anywhere: it was held out of the Snowflake integration job on the diagnosis that fakesnow’s binding-name heuristic was at fault, and 0.12.0 recorded that as a fakesnow limitation rather than a codegen one. The diagnosis was backwards — fakesnow was right to rejectp1, and real Snowflake would have rejected it too. The backend now runs against the shared fakesnow server in CI -
scythe migratesilently converted nothing when the project directory contained a glob metacharacter. The pattern used to find.sqlquery files was built by joining the base directory onto thequeriesentry, and the “is this already a glob, or a bare directory needing/*.sql” decision was then made on the joined string. Neither step escaped the base directory, so a project in a directory named e.g.a[b]had its[b]compiled as a glob character class, matched no files, and reportedMigration complete: 0 file(s) convertedinstead of converting anything — the same silent-zero failure mode #84 fixed forgenerate/check/lint/audit/fmtin 0.13.0. The base directory is now escaped withglob::Pattern::escapeand joined with/on every platform, and the directory-vs-glob decision is made on the raw pattern, never on the string after the base directory has been prefixed onto it (#88) -
task version:checkasserted that every crate’s own version matchedscythe-cli’s, including crates markedpublish = false. An unpublished crate never reaches crates.io, so its version carries no meaning andversion:syncdeliberately leaves it alone — which made the two steps contradict each other, with no version that could satisfy both. Unpublished crates are now exempt from the own-version check; their inter-crate pins are still checked -
Oracle’s empty string literal is NULL, and the analyzer did not know it. Oracle is alone in treating
''as NULL, soSELECT COALESCE(email, '') AS email_or_emptywas typed non-optional —Stringrather thanOption<String>— and the driver could not decode the first row where Oracle returned NULL. The literal now carries the right nullability under the Oracle dialect, which propagates throughCOALESCE,CASE ... ELSE ''and concatenation. The other five dialects are unaffected.NVLis not covered, though it was already optional by a different route. Found by the live Oracle conformance leg; a model-only fixture could never have caught it, since it compares the analyzer against the same model it is built from -
typescript-duckdbread every row positionally while indexing it by name. The generated code calledgetRows(), which returns positional arrays, then read fields by property name — so every field came backundefinedat runtime, with notscerror because the row cast is unchecked. It now callsgetRowObjects(). Present since the backend shipped in 0.6.0; there is notypescript-duckdbintegration project, which is why nothing caught it -
typescript-oracledbcast nullable columns to their non-null type. Three sites — both read paths and theRETURNINGout-binds path — cast to the column’s base type while the declared interface said| null, so a null column was typed as though it could never be null -
The TypeScript discriminated-union row type omitted some columns entirely. With
outer_join_unions = true, a column belonging to a join group that carries no discriminant — one where every projected column was already nullable in the schema — matched neither the base-field loop nor any union variant, so it was declared nowhere. A query selecting five columns produced a row type declaring three. The Zod form of the same function had the identical defect, despite its contract that the two shapes cannot drift apart -
Provenance verification could discard every lint finding. A target whose backend and engine pair failed to construct — a config with no
[[sql.gen]]block synthesizes arust-sqlxtarget, which does not support every enginecheckaccepts — aborted the run before findings were emitted, socheckexited with an error and an empty SARIF report while real findings existed. Verification now cannot unwind past emission. Related: the header was compared against the raw backend alias from the config rather than the canonical name, so a target written assqlxreported backend drift against its own output forever -
task version:syncinvalidated every generated artifact. The provenance header embeds the scythe version, so bumping it made all committed artifacts stale and failed the generated-freshness gate on the release commit itself — on every future release, not just this one.version:syncnow regenerates after bumping and rewrites the version in documented examples, and CI carries a guard comparing every committed header against the workspace version
[0.13.0] - 2026-08-07
Section titled “[0.13.0] - 2026-08-07”This release makes generation depend on committed inputs rather than on where the command was run from, and widens distribution to Node and Python.
- npm and PyPI wrapper packages for the CLI, so Node and Python teams can pin scythe as a dev
dependency without installing a Rust toolchain. The npm package is
scythe-cliand the PyPI package isscythe-sql; both expose the binary asscythe. Each resolves the host platform to a release target triple, downloads the matching asset, verifies its SHA-256 against the release checksums file, and unpacks it. They are shims over assets the release already produced, so the build matrix is unchanged (#80) - The two platform gaps are handled deliberately rather than silently. musl Linux has no published asset and the gnu binary dies at exec with an opaque loader error, so it fails at install time naming the platform. Windows on ARM also has no asset, but the x64 binary runs under emulation, so it falls back with a warning rather than blocking a configuration that works
- Both wrappers work behind a corporate proxy and a TLS-intercepting one: they honour
HTTPS_PROXY,NO_PROXYand npm’s or pip’s own proxy settings, and read a CA bundle fromnpm_config_cafileorNODE_EXTRA_CA_CERTS. The cached binary is written through a temp file and renamed into place, so an interrupted install leaves no truncated binary for the next run to trust task version:checkfails on either a crate whose own version disagrees withscythe-cli’s or an inter-crate pin that does.version:syncnow runs it, so a partial version bump can no longer reach a tag
scythe.tomlpaths resolved against the working directory, so--configwas close to unusable from outside the project.scythe generate --config /path/to/project/scythe.tomlrun from anywhere else silently found no schema and no queries, and ascythe.tomlcould not describe its own project independently of where it was invoked from. Paths now resolve against the directory containing the config file — see Breaking Changes below (#84)task generate:allregenerated with whateverscythewas first on$PATH. With an older release installed viacargo install, it silently rewrote committed output backwards, printedDone.per backend and exited 0. During the 0.12.0 release this reverted four files from the SQLiteint64fix back toint32, and the damage was indistinguishable from legitimate regeneration. The task now buildsscythe-clifrom the workspace and invokes it by absolute path, matching what CI’s freshness job already did (#85)task version:syncsilently skippedscythe-inspect, which was absent from bothsedfile lists and missing from the secondsed’s crate alternation. Becausepublish_cratestolerates an “already exists” error from crates.io, a release would publish five crates, skip the sixth and report success — while the publishedscythe-clideclared a dependency on the previousscythe-inspect, which resolves fine and therefore never surfaces- Snowflake typed the same underlying storage three different widths depending on spelling. Snowflake
aliases every integer spelling —
INT,INTEGER,BIGINT,SMALLINT,TINYINT— toNUMBER(38,0), but onlyBIGINTresolved toint64;SMALLINTandTINYINTfell through toint16andINT/INTEGERtoint32.REALandFLOAT4likewise reportedfloat32for what Snowflake stores as an 8-byte double. See Breaking Changes (#83) NUMBER(p,s)with a non-zero scale is now scale-aware. Through the DDL path this was already handled upstream bynormalize_data_type, but the AST path —CASTand parameter inference — reached the barenumberarm and typed a decimal asint64.NUMBER(p,0)and bareNUMBERkeepint64- Types that had no arm at all and fell through as unknown, erroring only later at the backend
boundary:
INT1,HUGEINT/UHUGEINT(DuckDB),MONEY/SMALLMONEY(MSSQL and PostgreSQL),XML(MSSQL and PostgreSQL),BYTEINT(Snowflake’s synonym forTINYINT) andGEOGRAPHY/GEOMETRY. The 128-bit DuckDB integers map todecimalrather than a silently truncatingint64— no integer neutral type is wide enough.GEOGRAPHY/GEOMETRYhad been documented asstringsince the Snowflake page was written, with no code behind it - An explicit
NUMBER(p,0)resolved todecimalthrough the schema path, because catalog normalization rewrote every two-tokenNUMBER(p,s)tonumeric(p,s)regardless of the scale, so a zero scale reached the decimal arm.NUMBER(38,0)is exactly what Snowflake’sDESCRIBE TABLEreports forINT, so a schema reverse-engineered from a live table typed its keysdecimalwhile the same table written withINTtyped themint64— the spelling-dependent inconsistency #83 set out to end, still reachable by a different route.oracle.mdhad documented the correct behaviour (NUMBER(*, 0)→int64) all along (#86) - The unit test guarding zero-scale
NUMBERpassed a hand-built string straight to the conversion function, which is not the spelling the schema path produces — so it stayed green while real schemas resolved the other way. The type-mapping regressions now drive the whole pipeline verify-binstallnever ran on a release re-run, becauserelease_assetsis skipped rather than successful when the assets already exist- The release workflow’s “already published” probe against crates.io sent no
User-Agent, which their data-access policy answers with403. The check therefore always reported “not published”, so a re-run replayed the full publish chain and its 30-second inter-crate sleeps - The documented pre-commit
rev:pins in the guide were bumped by nothing and validated by nothing, so they still pointed at the previous release.version:syncnow rewrites them andversion:checkfails on drift
Changed
Section titled “Changed”UNSIGNEDis now documented on the MariaDB page, which inherits it from the MySQL dialect but never mentioned it
Breaking Changes
Section titled “Breaking Changes”- Paths in
scythe.tomlresolve against the config file’s directory, not the process working directory. This coversschemaandqueriesglob patterns and[[sql.gen]].output. Absolute paths and patterns are unchanged, and a config invoked from its own directory — the overwhelmingly common case, including every project in this repository — behaves identically. Output moves with the inputs: leaving it working-directory-relative would turn a silent wrong read into a silent wrong write, scattering generated trees into whatever directory the command ran from. A pattern matching nothing is now a hard error naming the pattern, the config directory and the resolved pattern, since after this change an empty match is the most common symptom of a stale path (#84) - Snowflake
SMALLINT,TINYINT,INTandINTEGERnow generate 64-bit integers, andREALandFLOAT4now generate 64-bit floats. Regenerate; in the statically typed targets this changes field types and driver accessors (int→long,setInt→setLong). Languages that map both widths to one native type — Python, TypeScript, PHP — are unaffected (#83)
[0.12.0] - 2026-08-07
Section titled “[0.12.0] - 2026-08-07”typescript-kyselybackend targeting Kysely’ssqltemplate tag instead of a specific driver, so one generated file runs against any Kysely dialect. Covered end to end against all four built-in dialects —PostgresDialect,MysqlDialect(mysql2),SqliteDialect(better-sqlite3) andMssqlDialect(tedious + tarn) — plus a MariaDB project, each running against a live container. Aredshiftmanifest also ships, but has no integration project of its own — Redshift coverage is inherited from the wire-compatible PostgreSQL path, not directly tested. Supports the samerow_type(interface/zod) andouter_join_unionsoptions as the other TypeScript backends; the latter makes scythe’s Kysely output strictly more precise than a hand-written Kysely query can express, since Kysely has no way to know a joined column’s nullability is correlated with its schemaNOT NULLconstraint (#66)typescript-node-sqlitebackend targeting Node’s built-innode:sqlitemodule (DatabaseSync), with zero npm dependencies. Requires Node 23.4+ to run unflagged (--experimental-sqliteon Node 22).DatabaseSynchas notransaction()helper, so:batchqueries emit explicitBEGIN/COMMITwith a rethrowingROLLBACK(#66)typescript-wasm-sqlitebackend targeting the official@sqlite.org/sqlite-wasmbuild via its synchronous OO1 API (#66)- Both new SQLite backends generate fully synchronous code — no
async,awaitorPromiseanywhere, asserted in tests. This was the explicit ask in #66: these clients are synchronous, and routing them through Kysely “introduces async promise thrashing” structs_onlynow applies to every TypeScript backend, not justrust-sqlx. It suppresses the query functions and the driver import while still emitting interfaces, Zod schemas, enums and composites. Combined withrow_type = "zod"this produces the types-only output requested in #66scythe check --database-urlverifies inferred query types against a live PostgreSQL database using the extended query protocol (Parse/Describe), reporting mismatches as rules SC-VER01 through SC-VER05 (#65)- Opt-in
outer_join_unionsfor the TypeScript backends: outer-join nullability is expressed as a discriminated union rather than independent per-column optionals, so a shape like{ total: null, notes: "gift" }— unreachable whenorders.totalisNOT NULL— is no longer admitted by the generated type. Now supported forrow_type = "zod"as well, emitting a realz.union([...])(#64) - Generated TypeScript is now type-checked with
tsc --noEmitin every TypeScript integration project and in CI. The stricttsconfig.jsonin those projects was previously decorative:tsxonly transpiles, and the validation harness ran biome alone. This gate immediately caught several codegen defects listed under Fixed - SQLite coverage in the tool-validation suite via a new
sqlite_backend_test!macro. That suite previously exercised only PostgreSQL, MySQL and DuckDB, sotypescript-better-sqlite3had never been checked against the real TypeScript toolchain cargo binstallsupporttypescript-snowflakeandgo-gosnowflakeintegration coverage against the shared fakesnow server (#27, #61)- End-to-end coverage for
outer_join_unions(interface and Zod forms) andstructs_only, which previously had none — every assertion was a unit test over hand-built columns, so no generated project set either option,tscnever checked their output and no database ever ran it.typescript-kysely’s test now calls all of its generated functions; six were imported and never invoked - A CI job that regenerates all integration code and fails if the tree moved. The integration jobs test the committed files and never regenerate, so drift meant CI was validating output no current build produces — which is exactly how the
ruby-trilogyandgo-godrordefects above stayed hidden - Criterion benchmarks over the analyzer and codegen path (
task bench). The repo previously had none, so allocation changes in the hot path were unmeasurable - fakesnow now serves snowflake-jdbc its native Arrow result format, dispatching per session on the login request’s
CLIENT_APP_IDso the Node, Go and .NET drivers keep the JSON path unchanged
- The integration-test generator’s templates had been silently corrupted since July. The commit that migrated linting to poly ran its prose formatter over
tools/integration-test-generator/templates/*.jinja, because that config carried no exclusion for.jinjafiles — one was added later, but the damage was never repaired. The formatter stripped every newline and re-wrapped all seven language templates at 120 columns, which split string literals across lines and left//comments swallowing the code behind them. Word counts were identical before and after, so nothing was lost; the templates are restored from the last good revision with every subsequent change re-applied. The corruption stayed invisible for a month because it only reaches the tree when someone regenerates, and it surfaced as unrelated-looking compiler errors when they did.scripts/check-generated-syntax.shnow parses every generated harness, and the freshness job regenerates the scaffolding as well as the query code so drift in either is caught (#72) scythe inspectwas unusable against every live database. Check SC-INS13 calledround(float8, integer), which PostgreSQL does not define, raising SQLSTATE 42883 on every server version. Becauserun_all()was fail-fast, that one bad check aborted the entire inspection. The check is repaired andrun_all()now isolates a failing check instead of abandoning the run:batchqueries emitted TypeScript that does not compile. When a generated signature exceeded 80 characters the line-wrapping helper discarded the signature its caller passed and rebuilt one from the query’s own per-column params — so the function declared(db, name, email)while its body referenceditems, an identifier that was never declared. Affected seven TypeScript backends; any:batchquery with two or more params and a long enough name was broken- Oracle
CLOB,BLOB,NCLOBandBFILEcolumns failed at runtime in therust-sibylbackend withInterface("cannot return as a String"). These are now read through a LOB locator, and the read loop no longer discards the returned byte count (a short read previously truncated large LOBs silently) - Generated TypeScript did not escape SQL spliced into template literals. A backtick — idiomatic identifier quoting in MySQL/MariaDB,
`users`.`id`— terminated the literal early, a backslash escaped the following character, and a literal${opened a live interpolation (inside a Kyselysqltag, a parameter binding). Newly reachable once Kysely made MySQL a supported target - Elixir TDS encoded a
nilboolean parameter as0rather than NULL, becausenilis falsy in Elixir — silently writingfalsewhere the caller meant “unknown”. Also corrects the MSSQL type map:float32/float64were mapped to:decimalanddateto:datetime(#28) SUMandAVGresult types now follow engine semantics instead of echoing the argument type:sum(int)widens toint64,sum(bigint)todecimal, andavg(int|numeric)yieldsdecimal, matching PostgreSQLscythe check --database-urlprepared every[[sql]]block against the PostgreSQL connection regardless of the block’s configured engine, so a MySQL or MSSQL block produced a flood of spurious SC-VER01 errors and a non-zero exit. Non-PostgreSQL blocks are now skipped with a warning, as the flag’s own documentation already promisedscythe check --database-urlprinted the connection URL — including the password — into stderr and CI logs on a connection failure. Credentials are now redacted- Type verification treated
string,uuid,jsonandinetas mutually interchangeable, so an inferreduuidagainst a reportedjsonpassed silently — exactly the wrongly mapped catalog type SC-VER03 exists to catch. Only one-directional widening tostringis now accepted, and PostgreSQL domain types resolve to their base type - Boolean codegen options (
outer_join_unions,structs_only, and others) silently coerced any unrecognised value tofalse, so"on","TRUE"or a typo disabled the feature without complaint. They are now parsed strictly and reject invalid values with an error naming the option csharp-oracleemitted code that does not compile:OracleDecimalhas noToDecimal(),bytesparameters used the wrongOracleDbType, and binary columns used a reader method that does not exist forbyte[]- The Zod emitter mapped every
bytescolumn toz.instanceof(Buffer)regardless of backend — wrong for the two new SQLite backends, whose drivers yieldUint8Array, and unresolvable in a browser whereBufferdoes not exist. The grouped-struct Zod emitter additionally bypassed the column-aware path entirely, degrading an enum column to a barez.string()instead of referencing its generated enum schema typescript-better-sqlite3’s integration test was a no-op: the harness template had import and connection branches but no test body, somain()was a bareprocess.exitand the CI step passed while exercising nothing. It now runs a full create/read/update/delete round trip with assertions- The integration-test config templates emitted invalid TOML
- Kysely’s
:batchthrew at runtime when handed an existing transaction.Transaction<DB> extends Kysely<DB>, so the unconditionaldb.transaction()call nested a transaction inside itself; it now reuses the active one viadb.isTransaction - CI’s
poly fmt --checkwent red repeatedly becausecargo-sort(run bypoly lint) rewritesCargo.tomlwith a 4-space indent while poly’s formatter insists on 2, so the two rewrote each other indefinitely.Cargo.tomlis now excluded from poly’s formatter, andtask updatereformats after a dependency bump INSERT INTO t VALUES (...)with no column list registered no parameters at all — the generated function took none while the SQL carried placeholders, so it compiled and then failed at runtime on a bind-count mismatch. Values now bind positionally to the table’s columns in catalog order. Placeholders nested inside function calls orCASEbranches inINSERT ... VALUESandUPDATE ... SETwere swallowed by a catch-all match arm and are now collected, keeping the target column’s type, name and nullability. Contributed by @Zniece (#67)ruby-trilogyquoted onlyenum::*andstringparameters, souuid,date,time,datetime,json,inet,bytesandintervalwere interpolated bare — a UUID rendered asWHERE id = f3373249-..., which MySQL parses as an identifier. It is also the one Ruby backend that builds SQL by interpolation rather than binding, so any value containing an apostrophe broke the statement and was injectable. Trilogy’s client has no bind API (its C extension defines onlyquery,query_with_flagsandescape), so every non-numeric value now goes throughTrilogy#escape, with temporal typesstrftime-formatted andjsonserialised firstgo-godrordeclared OracleRETURNING ... INTOOUT variables from the column’s neutral type alone, ignoring nullability, then assigned them to pointer fields — code that does not compile. Nullable numeric and temporal params now bindsql.NullInt32/NullInt64/NullFloat64/NullTime; string-like columns keep a plain OUT var and take its address, since godror documentssql.NullStringas unsupported (Oracle cannot distinguish''from NULL)java-jdbcandkotlin-jdbcread temporal columns withrs.getObject(col, LocalDateTime.class), which snowflake-jdbc does not implement — itsgetObject(int, Class<T>)dispatches only on the legacyjava.sqltemporal types, so everyTIMESTAMP_NTZorTIMESTAMP_TZread threw against real Snowflake. Snowflake now uses the legacy getters with null-safe conversion; the other eight JDBC engines are unchanged- Oracle schemas written as SQL*Plus scripts failed to parse, freezing two integration projects at output an old build produced. The statement splitter only recognised
;, but such scripts terminate on a lone/and contain no top-level semicolons;CREATE SEQUENCE ... START WITH ... INCREMENT BY ...is also rejected by the parser, and trigger bodies are PL/SQL it cannot read. Sequences and triggers contribute no columns and are now skipped, asCREATE SCHEMAalready was validate_structuralknew 31 of the 52 registered backends; the other 21 reported “unknown backend” instead of being checked. Enrolling them surfaced two real defects:python-oracledb,python-pyodbcandpython-snowflakeemitted lines that violate ruff’s line length for any query with a few columns, andtypescript-snowflakeemittedanyfor row and bind typesscythe checkretained every analyzed query and verifiable block even when--database-urlwas absent, though both are only read when it is set- CI’s generated-code freshness gate had three holes that let real drift through unnoticed.
integration_tests/Taskfile.yamlhand-maintained a list of backends to regenerate that had drifted to 71 of the generator’s ownbuild_backends()99 entries, so every Redshift, MSSQL and Snowflake backend was silently excluded and never regenerated by CI; the list now comes from a new--listflag on the generator instead of a hand-copied one. The gate also compared withgit diff --exit-code, which cannot see untracked files, so a newly added backend with no committed output yet would pass green with nothing to regenerate against — it now usesgit status --porcelain. Andscripts/check-generated-syntax.shprintedSKIPPEDand returned success whenever a language checker binary was missing, while the job that ran it installed no PHP, Ruby, Python or Go toolchain at all, so every one of those checks had been silently doing nothing since it was added. The script now fails on a missing checker in--strictmode (implied byCI=true), and the job installs all four toolchains. Regenerating the 29 backends the gate had never covered turned up no behavioral drift — 13 files changed, all formatting (#72) - Bare
FLOAT(no precision) normalized incorrectly on two dialects, though not the defect #73 reported: the silent narrowing ofFLOAT(53)it described does not exist, sincenormalize_data_typealready maps any precision above 24 todouble precisionon every dialect. The real bugs were in the two unparameterized paths. The neutral-type resolver’s bare"float"string arm defaulted tofloat32unconditionally, wrong on PostgreSQL, MSSQL and Oracle, where bareFLOATis 8-byte double precision; andDataType::Float(None)— sqlparser’s node for the same bareFLOAT— normalized todouble precisionfor every dialect, wrong on MySQL, whose bareFLOATis a genuine 4-byte type. Both paths are now dialect-aware: MySQL’s bareFLOATresolves tofloat32, every other dialect tofloat64 - MySQL
UNSIGNEDinteger columns (INT UNSIGNED,BIGINT UNSIGNED, etc.) failed codegen outright withBackendError::UnknownType("bigint unsigned")— a hard failure on an ordinary MySQL schema, not a silently wrong type.normalize_data_typepreserved the MySQL display width (e.g."bigint(20) unsigned"), which the neutral-type resolver had no matching arm for and which itsstrip_precisionhelper couldn’t rescue, since the parenthesized width isn’t trailing. The normalizer now discards the display width for every unsigned integer type, giving the resolver a clean string to match (#74) BITcolumns lost their declared width during normalization, collapsingBIT(1)andBIT(n>1)to the same bare"bit"string and making them indistinguishable downstream. The width is now preserved ("bit(1)","bit(8)"), so the neutral-type resolver can tell a boolean-ishBIT(1)from a genuine multi-bit value — which PostgreSQL treats as a bit string (bytes) and MySQL treats as an integer bitfield (int64). MSSQL’sBITis untouched: it has no width and was already correctlybool, and none of the 11 MSSQL integration harnesses changed when regenerated, which is the evidence the fix is scoped to PostgreSQL and MySQL (#75)- A scalar subquery’s inferred type inherited only its projected column’s own nullability, ignoring that the subquery itself evaluates to
NULLwhen it matches zero rows:(SELECT name FROM users WHERE id = $1)was typed non-nullable wheneverusers.namewasNOT NULL, even though the subquery is nullable by construction. It’s now nullable unless the query is provably guaranteed to return exactly one row — an ungrouped aggregate — in which case the aggregate’s own nullability already accounts for the empty-input case. That carve-out stops short of windowed aggregates:COUNT(*) OVER ()looks like a single-row aggregate but produces one output row per input row, so it stays subject to the general zero-rows-means-NULLrule (#76) WITH RECURSIVEqueries were typed from the anchor branch alone, discarding the recursive branch’s analysis withlet _ = ...— under-reporting nullability whenever the recursive term introduced aNULLthe anchor didn’t have, such as aLEFT JOINor an explicitNULLliteral filling a column the anchor fills with aNOT NULLvalue. The full anchor-UNION-recursive query is now re-analyzed and kept, taking the sameSetOperationpath as any otherUNION— which also means a column-count mismatch between the anchor and recursive branches now surfaces as an error instead of being silently swallowed (#77)- SQLite integration harnesses are retyped to match the widened
int64/float64neutral types from the dialect-aware fixes above.rust-sqlx-sqlitehad not compiled since93b76b9, the earlier commit in this same release that widened SQLiteREALtofloat64: its harness template still declaredlet total: f32, soorder.total == totalcould not type-check against the now-f64generated field. CI runs this suite on every push, so the job had been red since that commit landed; a concurrent GitHub Actions outage kept the failure from getting the scrutiny a red required job normally gets.go-database-sql-sqlitehad the same defect from theINTEGER→int64change, failing with eight type errors because its harness declaredvar createdUserID int32while the generated code returnedint64; its template grouped SQLite with MySQL, MSSQL and Snowflake, which legitimately use 32-bit ids, and SQLite now has its own arm. The remaining statically typed SQLite harnesses — C#, Java, Kotlin and the four TypeScript projects — were compile-checked and were already correct. Both fixed here - Every generated
Gemfiledeclared no gem source, so all six Ruby integration projects failed to install. The same formatter pass had joined the magic comment and the source directive onto one line —# frozen_string_literal: true source "https://rubygems.org"— and since#opens a Ruby comment, thesourcecall was silently commented out. The file stayed valid Ruby, which is why it survived: Bundler simply reported that gems were missing “in locally installed gems”. CI additionally ran an older Bundler than the committedGemfile.lockfiles were written with, which an older Bundler cannot read, so the Ruby steps now pinbundler: latest - Every generated
mix.exswas a syntax error, failing six integration jobs before any Elixir test ran. Its template had been reflowed as prose and re-wrapped at 120 columns, putting the whole module on one line, which Elixir rejected atmix.exs:1:63. Formatting only — project name, version,elixirc_pathsand every dependency are unchanged. Together withpyproject.tomlbelow, this is the last functionally broken residue of that formatter pass;composer.jsonandtsconfig.jsonwere reflowed by it too but remain valid, since JSON ignores whitespace - Five Kotlin projects had their generated file committed as
Queries.ktwhile the generator writesqueries.kt. On a case-insensitive filesystem those are one path, so generation overwrote in place and git never recorded the rename — meaning those projects shipped generated code that no current build produces, visible only on Linux. All nine Kotlin projects now use the generator’s name. Java keepsQueries.java, which is correct, as Java requires the filename to match the public class go-godror-oracledid not compile: its harness passedint64where the generatedCreateOrderexpectsfloat64. Oracle’sNUMBER(10, 2)is a scaled decimal and correctly maps tofloat64, so the harness was stale, not the type mappinggo-gosnowflakefailed with missinggo.sumentries across the driver’s Azure, AWS and Arrow dependency tree. It was the only one of the eight Go integration steps not runninggo mod tidy, and since the generatedgo.modcarries direct dependencies only while Go 1.17+ module-graph pruning needs the indirect set resolved, that step could never have succeeded- Every generated Python integration project had an unparseable
pyproject.toml. Its template’s first line had been collapsed into[project] name = "..." version = "..." requires-python = "..." dependencies = [, which looks plausible but is invalid TOML, souvfailed withTOML parse error at line 1, column 11before running anything. This broke seven of the eight integration jobs — every one that touches a Python backend — and is unrepaired residue from the same formatter pass that reflowed the Jinja templates as prose ind895d04; the follow-up repair ind4d6694missed this file. The other two TOML templates were checked and are intact - Manifest selection no longer depends on the process working directory. At 56 call sites, one per backend constructor, manifest loading preferred a CWD-relative
backends/<name>/manifest.tomlover the compiled-in manifest, so identical inputs produced different generated code depending on wherescythewas invoked, with nothing in the output recording which manifest was used. The lookup was also engine-blind: everyrust-sqlxengine variant probed the same PostgreSQL file, andjava-jdbccollapsed nine engines onto one path, so a MySQL or SQLite target run from a directory containingbackends/would have silently received PostgreSQL type mappings. Manifests are now compiled in and selected purely from(backend, engine). The lookup was undocumented — no CLI flag, no config key, no log line, discoverable only by reading the source — and fired in no integration project and no CI job; all 13 stub manifests were byte-identical to their compiled-in counterparts, so removing it changes no generated output. Note that this is the determinism half of #82 only: there is no user-facing manifest override yet, and the issue stays open for one, since a global override directory would reintroduce the engine collision above. Also corrects the MSSQL type-mapping docs, which claimedTINYINTmaps to a nonexistentint8neutral type — it maps toint16
Changed
Section titled “Changed”- Breaking (output):
scythe checknow writes its report to stdout instead of stderr, and no longer prints the trailingCheck passed.line or the warnings-only summary. Scripts parsing either will need updating - Breaking (types): SQLite
REALandINTEGERcolumns now resolve tofloat64andint64instead offloat32andint32. Both of SQLite’s storage classes are 8 bytes wide with no narrower variant —REALis defined as an 8-byte IEEE float, andINTEGERholds up to 8 bytes — but the type mapper applied PostgreSQL’s 4-byte widths (real/integergenuinely arefloat4/int4there) to every engine. Statically typed SQLite targets change accordingly (f32→f64,int32→int64,float/int/getFloat/getInt→double/long/getDouble/getLong); becauseINTEGER PRIMARY KEYis SQLite’s rowid alias, every SQLite primary key retypes along with it. PostgreSQL is unaffected (#70) - Breaking (API): generated Kysely functions now take
QueryExecutorProviderrather thanKysely<DB>. The previous<DB = any>generic did nothing useful, and the narrower type rejected callers holding a connection or a controlled transaction — both of which Kysely’sRawBuilder.executeaccepts - The Snowflake
NUMBER(p, s)normalizer now preserves precision and scale inCatalog::Column::sql_type(consumed by therust-sibylbackend). This does not change inferred neutral types:sql_type_to_neutralstrips precision before mapping, sonumeric(10,2)andnumericresolve identically. Theint64→float64shift visible in the Snowflake integration output came from regenerating stale committed files, not from this change. (Supersedes an earlier entry in this section which credited this commit with fixing money-column truncation across all seven Snowflake projects — that truncation was a real bug, but it was fixed for Oracle in 0.11.0 and the Snowflake output was merely out of date.) better-sqlite3moves to^12.11.1in the integration projects, and the SQLite CI job to Node 24.node:sqliteis only unflagged from Node 23.4, and better-sqlite3 11.10 will not load on Node 24+- The documentation site migrated from zensical to Astro Starlight, which incidentally fixed
guide/auditandguide/inspectbeing unreachable on the live site - fakesnow’s shared query-request wrapper (moved to
integration_tests/fakesnow/fakesnow_server.py, see Removed) now always emits the plain Snowflake JSON rowset format — every cell stringified, matching real Snowflake’s wire format — instead of doing so only for the Node driver. Query execution is serialized with anasyncio.Lockso a client’s HTTP retry cannot re-run a statement concurrently with the still-in-flight original. The login handler now advertisesCLIENT_RESULT_COLUMN_CASE_INSENSITIVE, which snowflake-jdbc needs to resolve lowercase generated-code column lookups (getInt("id")) against fakesnow’s uppercase column labels go-gosnowflake’s generated harness pointed atsql/snowflake/schema_emu.sql, a leftover from an abandoned Docker-emulator plan that lackedAUTOINCREMENT; it now usessql/snowflake/schema.sqllike every other Snowflake backendelixir-tds-mssqlis back in CI — its step was added in7b3ec72, removed in5edaaa6while the backend was broken, and restored once the type mapping was fixed. Itsintegration_tests/Taskfile.yamlentry is new (#28)
Removed
Section titled “Removed”integration_tests/typescript-snowflake/fakesnow_server.pymoved tointegration_tests/fakesnow/fakesnow_server.py— it is shared infrastructure for every non-Python Snowflake driver, not a TypeScript-specific fixture- The root-level
backends/directory (23 files): 13 manifests byte-identical to their compiled-in counterparts undercrates/scythe-codegen/manifests/, plus 10 Jinja templates that no code path reads — a vestige of an abandoned template-based architecture, since every backend emits strings directly. It existed only to be picked up by the working-directory-relative manifest lookup removed above.scythe-backend’s renderer tests, the sole remaining reader, now use a fixture undercrates/scythe-backend/tests/fixtures/ naming.field_casemanifest option. It was deserialized from all 106 manifests and never read — field names come fromto_snake_caseinresolve.rsregardless of what’s declared, so the 73 manifests declaringcamelCaseorPascalCasewere silently ignored, whilefn_caseon those same manifests is honored, which is what made the gap easy to miss. Implementing it instead would rename fields in generated code for most backends and break every downstream caller that destructures a row, so the option is deleted rather than wired up — universal snake_case field naming is now intentional instead of accidental. Regenerating all 99 integration backends afterward produced no diff, which is the proof the option was dead (#69)
Unverified / Skipped in CI
Section titled “Unverified / Skipped in CI”These backends have codegen support but are not exercised against a live database. The equivalent list under [0.6.8] describes that release and is now out of date; this one supersedes it.
Snowflake (#27) — python-snowflake,
typescript-snowflake, go-gosnowflake, java-jdbc-snowflake and kotlin-jdbc-snowflake all run
against the shared fakesnow server. The two JDBC suites were
unblocked this release by teaching fakesnow to serve snowflake-jdbc its native Arrow result format.
Still excluded:
csharp-snowflake— the codegen defect was fixed this release, but the harness fails earlier: Snowflake.Data names its bind parametersp1/p2/p3, which fakesnow’s binding-name heuristic treats as named rather than positional. A fakesnow limitation, not a codegen onephp-pdo-snowflake— uncoverable. Snowflake has no PDO driver; access requires the proprietary closed-source ODBC driver preinstalled on the runner
Oracle — elixir-jamdb (DBConnection.ConnectionPool dispatch error with jamdb_oracle) and
ruby-oci8 (native gem needs Oracle Instant Client SDK headers unavailable in CI).
SQLite — php-pdo-sqlite has no CI job. The createUser arity mismatch noted under [0.6.8] is
resolved (generated signature and harness call now agree, and the harness parses), but it has never
been run against a database.
[0.11.0] - 2026-07-04
Section titled “[0.11.0] - 2026-07-04”- Full
:grouped/@group_bynested code generation across every backend. A:groupedquery now emits a child struct plus a parent struct carrying achildrencollection, and a query function that runs the flat SQL and folds rows into an order-preserving list of parents keyed by the grouping column — all client-side, with the SQL unchanged from:many. Previously:groupedsilently degraded to a flat:manyproxy despite the docs promising nesting (#55). Implemented for all Rust, Python, TypeScript, C#, Go, Ruby, PHP, Elixir, and Java/Kotlin backends, each with language-native structs, collection types, and fold idioms. CodegenBackend::generate_grouped_structsandgenerate_grouped_query_fntrait methods (inputs bundled in aGroupedQueryFncontext struct) with default implementations that return a clear “grouped queries are not yet supported by ‘’” error, so future backends opt in incrementally without panicking. - Positional param-naming escape hatch:
-- @param $N <name>[: <description>]overrides the inferred/pNfallback name for a placeholder by position, flowing the chosen name to every language. The existing docs-only-- @param <name>: <description>form is unchanged (#53). - Lint rule SC-S07
unbound-sql-param(error): flags any$Npresent in the SQL body but absent from the generated parameter signature, backstopping the whole class of silent param drops.
- Params inside a FROM-clause derived table (subquery) are no longer discarded — the sub-analyzer’s collected params and positional counter are merged back into the parent scope (#52, Case C).
- Placeholders nested inside an
UPDATE … SETarithmetic expression such asSET credits = credits + $2are now collected instead of silently dropped; param collection recurses throughBinaryOp/UnaryOp/Nestedexpressions (but not subqueries, which own their own param scope). Caught by SC-S07 (#52). - Unsupported inline named placeholders (
:name) now fail fast with a query-pointed error instead of emitting broken codegen (#52, Cases A/B).
Changed
Section titled “Changed”- Workspace crate versions bumped 0.10.0 → 0.11.0 across all six crates, with cross-crate path-dep version pins updated.
sqruff-libupgraded 0.38 → 0.39 (cargo upgrade --incompatible); lockfile refreshed.
[0.10.0] - 2026-06-14
Section titled “[0.10.0] - 2026-06-14”scythe inspect <database-url>subcommand — live-database operational health checks. Connects viatokio-postgresand runs a set ofpg_catalogqueries that detect issues only visible in a running database, then emits findings in the same human / SARIF 2.1.0 / JSON reporter shapes used byscythe audit. URL resolution: positional argument, then$DATABASE_URL, then$SCYTHE_DATABASE_URL. Builds a per-invocationtokio::runtime::Builder::new_current_thread()runtime so the rest of the CLI (lint,audit,generate) stays synchronous.- New
scythe-inspectcrate (crates/scythe-inspect/) carrying aDbDriverasync trait, aPostgresDriverimplementation backed bytokio-postgres, and aMysqlDriverstub that returnsInspectError::Unsupported("mysql")fromconnectandrun_all. The stub exists to keep the trait shape engine-agnostic; a real MySQL driver lands in Phase 3 (v0.13.0). - Three Postgres operational checks at Phase 0, clean-room reimplemented from the equivalent supabase/splinter lints (no source code copied; ATTRIBUTIONS.md updated): SC-INS01 missing-fk-index (warn — foreign-key columns with no covering index force a sequential scan on every join through the constraint; splinter 0001), SC-INS02 policy-exists-rls-disabled (error — table has
CREATE POLICYdefinitions butROW LEVEL SECURITYis disabled, so the policies never apply; splinter 0006), and SC-INS03 duplicate-index (warn — two or more indexes with identical definitions modulo name; splinter 0009). scythe inspect --list-checksprints the check catalog (id, name, severity, description) without connecting, so users can discover the rule set offline.scythe inspect --format <human|sarif|json>,--severity <off|warn|error>,--exit-zero,--output <PATH>,--dialect <postgres|mysql>— mirror the audit subcommand surface for consistency. Exit code 2 on remaining error-severity findings unless--exit-zerois set; exit 0 otherwise. Severity floor filtering applies before emission.- Public
scythe-inspectpre-commit hook published via.pre-commit-hooks.yaml. CI-mode hook:always_run: true,pass_filenames: false, requires$DATABASE_URL(or$SCYTHE_DATABASE_URL) in the hook environment. Local pre-commit runs without the variable fail loudly with the same error as the CLI. Phase 1 (v0.11.0) will addscythe.toml[inspect]URL sourcing so local use becomes natural. - New documentation page
docs/guide/inspect.mdcovering quick-start, check catalog, severity/exit-code semantics, GitHub Actions CI recipe withservices: postgres, pre-commit usage, whatscythe inspectdoes not do (yet), and the phased roadmap through v0.14.0. docs/guide/cli-reference.mdextended with theinspectsubcommand and every flag;docs/guide/pre-commit-hooks.mdadds the newscythe-inspecthook row and section; README addsscythe inspectto the feature list and a Documentation link.- New CI workflow
.github/workflows/inspect-live.ymlspins uppostgres:16-alpineas a service and runscargo test -p scythe-inspect --features live-tests. Triggered on PRs that touchcrates/scythe-inspect/**. Defaultcargo testruns stay DB-free. ATTRIBUTIONS.mdextended with a “Live inspection rules inspired by splinter (scythe-inspect)” subsection citing splinter lints 0001, 0006, 0009 against SC-INS01, SC-INS02, SC-INS03 respectively.
Changed
Section titled “Changed”- Workspace crate versions bumped 0.9.0 → 0.10.0 across all six crates (the five existing crates plus the new
scythe-inspect), with cross-crate path-dep version pins updated. scythe lintandscythe auditare unaffected — Phase 0 adds the inspect surface without touching the static pipeline.
[0.9.0] - 2026-06-14
Section titled “[0.9.0] - 2026-06-14”scythe auditsubcommand — static security analyzer for SQL. Reads.sqlfiles, runs a built-in security rule pack, and emits findings as human-readable text, SARIF 2.1.0 (with CWE tags for code-scanning ingest), or JSON. Exits non-zero when any rule fires, so it slots into CI gates.scythe audit --list-rules— print the rule catalog (id, name, severity, category, description) grouped by category, then exit 0. Reflects user-loaded rules fromscythe.tomlso the catalog is honest.scythe audit --explain <RULE_ID>— print the description and CWE references for a rule by id, then exit 0. Useful for figuring out why a rule fired without going to the docs.scythe audit --severity <off|warn|error>— drop findings below the given level so CI gates can graduate from warnings to errors.scythe audit --exit-zero— always exit 0 after emitting findings, for advisory CI integrations that publish findings but don’t gate the build.scythe audit -o, --output <PATH>— write reporter output to a file instead of stdout. Useful for SARIF/JSON artifacts in CI.scythe audit --ignore-suppressions— disable inline-- scythe-audit: ignore[...]annotations for periodic strict scans.scythe audit --dialect <postgres|mysql|sqlite|mssql|oracle|snowflake>— set the SQL dialect for explicit-file mode (config mode already inherits the dialect from[[sql]].engine).- New docs page
docs/guide/audit.mdcovering quick-start, rule catalog, suppression syntax, user-defined rules, available matchers, and CI integration recipes (GitHub Actions SARIF, GitLab SAST, pre-commit).docs/guide/cli-reference.mdextended with theauditsubcommand and every flag. Severitynow derivesPartialOrd/Ordand gains aSeverity::parse_clihelper so CLI consumers can resolveoff/warn/errorto a typed minimum.- Eleven canonical security rules ship in
scythe-lint’sauditmodule: SC-SEC01 dangerous-function (CWE-78), SC-SEC02 grant-all (CWE-269), SC-SEC03 grant-to-public (CWE-269), SC-SEC04 superuser-role (CWE-269) covering SUPERUSER/CREATEDB/CREATEROLE/REPLICATION/BYPASSRLS, SC-SEC05 literal-password (CWE-798), SC-SEC06 weak-hash-in-auth (CWE-327, CWE-916), SC-SEC07 select-star-pii (CWE-200), SC-SEC08 cartesian-join (CWE-400), SC-SEC09 unbounded-like (CWE-1333), SC-SEC10 security-definer-no-search-path (CWE-426), and SC-SEC11 session-mutation (CWE-269) covering SET ROLE / SET SESSION AUTHORIZATION / RESET ROLE. - Hybrid matcher framework: rule metadata lives in TOML, AST-matching logic lives in named Rust functions registered against a
MatcherRegistry. Adding a rule that reuses an existing matcher is now a TOML stanza, not a Rust file. Canonical rules ship in-tree viainclude_str!so the default registry has zero runtime config dependencies. - User-defined audit rules via
scythe.toml:[[audit.rule]]for inline rules andextra_rules = ["./path.toml"]to load separate files. IDs must start withUSER-; collisions with canonicalSC-SEC*IDs are rejected at load time with the offending ID and source path. - Inline suppressions:
-- scythe-audit: ignore[SC-SEC02,SC-SEC09] reason="vetted"attaches to the next statement and suppresses the listed rule IDs for every line of that statement (terminated by a blank line or;). Reason clauses are parsed and discarded. Malformed annotations are silently ignored. LintContext.dialect: SqlDialectfield, threaded through every rule call site, so matchers can dialect-filter viadialects = [...]in the rule spec.RuleFileTOML schema withschema_version = 1for forward-compatible rule files.- New
migrationrule category and nine canonical migration-safety rules under theSC-MIG*prefix: SC-MIG01 ban-drop-table, SC-MIG02 ban-drop-column, SC-MIG03 require-concurrent-index-creation, SC-MIG04 renaming-column, SC-MIG05 constraint-missing-not-valid, SC-MIG06 ban-drop-database-or-schema, SC-MIG07 renaming-table, SC-MIG08 ban-truncate-cascade, SC-MIG09 ban-alter-column-type. Each rule targets a class of irreversible or lock-prone Postgres DDL change that breaks zero-downtime deployments. All declaredialects = ["postgres"]. Seven matcher functions back them:drop_statement(parameterised bykinds = ["table", "column", "database", "schema"]so a single matcher serves SC-MIG01/SC-MIG02/SC-MIG06),create_index_concurrency,alter_table_rename_column,constraint_missing_not_valid,alter_table_rename_table,truncate_cascade,alter_column_type. The matcher framework is unchanged. - Four additional column-type-preference migration rules backed by a single new
column_type_disallowedmatcher: SC-MIG10 prefer-bigint-over-int (fires onint/integer/int4/smallint/int2— 32-bit keys overflow at 2^31 and widening requires a write-blocking ALTER), SC-MIG11 prefer-text-over-varchar (fires onvarchar(n)/character varying(n)/char(n)— Postgres stores these identically totext; a length bump is write-blocking), SC-MIG12 prefer-timestamptz (fires ontimestamp/timestamp without time zone— naive timestamps silently shift on session timezone changes), SC-MIG13 prefer-identity-over-serial (fires onserial/bigserial/smallserial— SERIAL is legacy implicit-sequence shorthand;GENERATED AS IDENTITYis the SQL-standard replacement). The matcher walksCREATE TABLEcolumns andALTER TABLE … ADD COLUMNoperations, using exact-match and prefix-before-(semantics to avoid false-positives (e.g.bigintdoes not fire whenintis disallowed). Emitstable,column,actual_type, andsuggested_typebindings. - The
scythe auditdispatcher now also runs rules in the newmigrationcategory;--list-rulesgroups SC-MIG* under a separate[migration]heading. - Three additional constraint-lock migration rules covering the next class of Squawk-derived ALTER hazards: SC-MIG14 disallowed-unique-constraint (fires on
ALTER TABLE … ADD CONSTRAINT … UNIQUE (…)— builds the index inline under ACCESS EXCLUSIVE; safe pattern isCREATE UNIQUE INDEX CONCURRENTLYfollowed byADD CONSTRAINT … UNIQUE USING INDEX), SC-MIG15 adding-primary-key-constraint (fires onALTER TABLE … ADD CONSTRAINT … PRIMARY KEY (…)— same lock hazard, sameUSING INDEXworkaround), SC-MIG16 ban-create-domain-with-constraint (fires onCREATE DOMAIN … CHECK (…)— Postgres validates every row of every table using the domain under ACCESS EXCLUSIVE and the constraint cannot be split intoNOT VALID+VALIDATE). Two new matchers back them:add_constraint_without_using_index(parameterised bykinds = ["unique", "primary_key"]so a single matcher serves SC-MIG14/SC-MIG15, and distinguishes the plainUNIQUE/PRIMARY KEYtable constraints from the… USING INDEXvariants) andcreate_domain_with_constraint. - Two NULL-contract-integrity migration rules: SC-MIG17 ban-drop-not-null (error — fires on
ALTER TABLE … ALTER COLUMN … DROP NOT NULL; relaxing a NOT NULL contract breaks deployed application versions and ORM mappings that still treat the column as non-null) and SC-MIG18 adding-not-nullable-field (warn — fires onALTER TABLE … ADD COLUMN … NOT NULLwithout aDEFAULT; rewrites every existing row on Postgres <11 and breaks deployed application versions that insert without the new column). Two new matchers back them:alter_column_drop_not_nullandadd_column_not_null_no_default. Both rules declaredialects = ["postgres"]. - Two splinter-inspired rules covering function search-path hygiene and pg_upgrade-blocking column types: SC-SEC12 function-search-path-mutable (warn — fires on
CREATE FUNCTIONwithoutSET search_path = …and notSECURITY DEFINER; complementary to SC-SEC10 which owns the escalating DEFINER case at error severity, so the two rules never double-count on the same statement) and SC-MIG19 unsupported-reg-types (error — fires when a column type isregcollation/regconfig/regdictionary/regnamespace/regoper/regoperator/regproc/regprocedure; reg* OID types other thanregclassblockpg_upgradeand do not survive logical dump/restore). One new matcher (function_search_path_mutable); SC-MIG19 reuses the existingcolumn_type_disallowedmatcher with an emptysuggestedand a regtypedisallowedlist. Detection patterns inspired by supabase/splinter lints 0011 and 0018 — seeATTRIBUTIONS.md. ATTRIBUTIONS.mdat the repo root listing external projects whose detection patterns informed scythe rules. Initial entry credits supabase/splinter and documents the no-license caveat (clean-room reimplementation only).- Row Level Security rule pack — three rules under the new
SC-RLS*prefix (stillcategory = "security"): SC-RLS01 policy-references-user-metadata (error, CWE-639 — fires onCREATE POLICYwhose USING or WITH CHECK reads fromuser_metadata, an end-user-editable JWT claim; safe path uses server-setapp_metadata), SC-RLS02 policy-always-permissive (error, CWE-285 — fires on a permissive policy whose USING or WITH CHECK is a tautology like(true),(1 = 1), orNULLon a write-side command; SELECT policies and restrictive policies are excluded), SC-RLS03 policy-uses-uncached-auth-function (warn, CWE-405 — fires on a bareauth.uid()/auth.jwt()/auth.role()/auth.email()/current_setting(…)call in the policy expression without wrapping in a scalar subquery; wrapping lets Postgres cache the result as an InitPlan instead of re-evaluating per row). Three new matchers walk the typedCreatePolicy.using/.with_checkExprASTs. SC-RLS03 specifically stops atExpr::Subqueryboundaries — that’s the safe form. Detection patterns inspired by supabase/splinter lints 0015, 0024, 0003 — seeATTRIBUTIONS.md. - CHECK-constraint quality rule SC-CHK01 check-constraint-always-true (warn,
category = "antipattern"): fires when a CHECK constraint expression is a tautology (true,1 = 1,NULL, parenthesised variants). Covers column-level CHECK inCREATE TABLE, table-level CHECK inCREATE TABLE, andALTER TABLE … ADD CONSTRAINT … CHECK. A tautological CHECK enforces nothing — almost always signals a copy-paste mistake or unfinished migration. New matchercheck_constraint_always_true. New canonical TOML filerules/quality.tomlcarrying theSC-CHK*rule namespace. scythe auditnow dispatches rules in theAntipatterncategory alongsideSecurityandMigration, so non-security canonical rules surface in audit output.--list-rulesgroups SC-CHK* under a separate[antipattern]heading.scythe lintnow runs the canonical SC-SEC*, SC-RLS*, SC-MIG*, and SC-CHK* audit packs alongside the existing schema-aware safety/codegen/naming rules and sqruff. Dialect gating: rules whosedialectslist excludes the configured[[sql]].engineare silently skipped, so amysqlproject does not see postgres-onlySC-MIG*findings without explicit opt-in. No CLI flag is required — the rules ship indefault_registry()and respect the same[lint]severity overrides as the rest of the rule set.- Public
scythe-auditpre-commit hook published via.pre-commit-hooks.yaml. Runs the canonical audit rule packs over staged.sqlfiles with noscythe.tomlrequired. Defaults to the postgres dialect; override per-hook viaargs: [--dialect, mysql]. The existingscythe-linthook now also picks up audit rules whenever ascythe.tomlis present. Documented indocs/guide/pre-commit-hooks.mdanddocs/guide/audit.md. - Oracle bindings upgraded to sibyl 0.7. The codegen emitter (
crates/scythe-codegen/src/backends/rust_sibyl.rs) was rewritten for sibyl 0.7’s broken APIs:sibyl::preludeis gone (top-level re-exports used directly),Varchar::as_str()now returns&strinstead ofResult<&str>, andDate::timestamp()was removed (chrono::NaiveDateTime now built from thedate_and_time()tuple). The integration test template selects["tokio", "nonblocking"]; withoutnonblocking, sibyl 0.7’simpl Debug for LOBhas everyfn fmtbody cfg-gated away and the lib fails to build. The Oracle manifest now mapsdecimaltof64because sibyl 0.7 has noToSql/FromSqlforrust_decimal::Decimal— flagged as a precision trade-off for follow-up. - sqlx 0.8 → 0.9 in the Rust integration test crates (
rust-sqlx,rust-sqlx-mysql,rust-sqlx-mariadb,rust-sqlx-sqlite,rust-sqlx-redshift). sqlx 0.9 tightenedraw_sqlandqueryto requireSqlSafeStr; the integration test template now wraps runtime SQL strings withsqlx::AssertSqlSafe.
- Five
test_enginescodegen tests that were failing onmainagainst the previous baseline are green. Three were neutral-type mappings falling through to the unknown-type literal fallback: MSSQLDATETIMEOFFSET→datetime_tz(was"datetimeoffset"), RedshiftSUPER→json(was"super"), OracleNUMBER(p, s)with a non-zero scale →decimal(wasint64becausenormalize_data_typewas ignoring theCustom-token scale parameter). Two were stale fixture expectations: OracleNUMBER(10)correctly maps toint64(10 digits overflows int32), and SnowflakeINTEGERcorrectly maps toint32(sqlparser parses it dialect-agnostically asDataType::Integer(None); dialect-aware widening to int64 is tracked as a separate follow-up).
Changed
Section titled “Changed”- The four Postgres-specific audit rules (SC-SEC04 superuser-role, SC-SEC05 literal-password, SC-SEC10 security-definer-no-search-path, SC-SEC11 session-mutation) now declare
dialects = ["postgres"]and no-op on non-PostgreSQL dialects instead of producing false positives. Behaviour is unchanged for the default PostgreSQL workflow. - Pre-commit hook chain aligned with the polyrepo’s shared
kreuzberg-dev/pre-commit-hooks v2.1.10source. Nine individual hook repos collapsed into a single consolidated source for general file checks, markdown, Rust (fmt/clippy/sort/machete/deny), shell (shfmt/shellcheck), typos, and ai-rulez governance.taplo-formatandbiome-formatstay as separate repos.rustdoc-lint,markdownlint-rumdl-strict, andrust-max-linesare listed in the config but commented out with TODOs — scythe’s current codebase trips each one (~449 missing-doc errors, 35 long-line markdown files, 4 source files over 1,000 LOC); each is its own focused remediation. A new_typos.tomlallowlists SQL aliases (ba), a singularize edge case (statu), the typos default dictionary’s surprise prefix entries (CHEC→CHECK,SELEC→SELECT) that fire on plural SQL keywords, and excludes lockfiles where hex commit hashes routinely trip false matches. - Sibyl-driven Oracle integration test now reads
schema.sqlinstead ofschema_full.sql.schema_full.sqlcontained PL/SQLCREATE SEQUENCE … INCREMENT BY …blocks that sqlparser cannot parse; the trimmedschema.sqlcarries only theCREATE TABLEDDL scythe actually needs for type inference. The test database setup still usesschema_full.sqlseparately.
[0.8.0] - 2026-05-26
Section titled “[0.8.0] - 2026-05-26”- Kotlin
extension_functionsbackend option (opt-in, default off) forkotlin-jdbcandkotlin-r2dbc. When enabled, query functions are generated as idiomatic Kotlin extension functions on the connection receiver (fun Connection.getUser(id: Int)called asconnection.getUser(id)) with expression bodies for value-returning queries.kotlin-r2dbcis reworked into asuspendextension onio.r2dbc.spi.Connection, moving the connection lifecycle to the caller. (#43) - PHP
namespacebackend option forphp-pdoandphp-amphp. Any value emitsnamespace <value>;; an empty string omits the declaration. Default remainsApp\Generated, so existing output is unchanged. Enables PSR-4 framework integration (Laravel, Symfony, etc.). (#46)
- Schema parser no longer crashes on psql client meta-commands.
pg_dump 18+anddbmateemit\restrict/\unrestrictlines that are not SQL; scythe now strips any line whose first non-whitespace character is\before parsing, so plain-format Postgres 18 dumps are consumed as-is. (#49) python-psycopg3,python-asyncpg, andpython-aiomysqlnow emitimport uuidandfrom typing import Anywhen their type mappings useuuid.UUID/dict[str, Any]. Generated modules previously raisedNameErroron import. (#48)
[0.7.0] - 2026-05-20
Section titled “[0.7.0] - 2026-05-20”scythe-corenow captures unknown-- @<name> <value>annotation lines asCustomAnnotation { name, value, line }triples onAnnotations.customandAnalyzedQuery.custom. Lets crate consumers layer their own annotation vocabularies (e.g. HTTP routing metadata) on top of scythe without coupling the SQL compiler to any one domain. Native annotations (@name,@returns,@param,@nullable,@nonnull,@json,@optional,@group_by,@deprecated) are unaffected — only previously-ignored unknowns are captured.scythe-coregained an optionalserdefeature that addsSerialize/Deserializederives to the public IR types (AnalyzedQuery,AnalyzedColumn,AnalyzedParam,EnumInfo,CompositeInfo,CompositeFieldInfo,GroupByConfig,QueryCommand,Annotations,ParamDoc,JsonMapping,CustomAnnotation). Off by default.Catalog::tables_iter()accessor returning(&String, &Table)pairs, complementing the existingtables()(which returns names only).
- sqlparser 0.62 compatibility: handle multi-alias select items, object-name insert targets, and unsupported table-query insert targets so
cargo clippy --workspace -- -D warningsis clean.
[0.6.13] - 2026-05-10
Section titled “[0.6.13] - 2026-05-10”- Generated Rust code is now rustfmt-clean — scythe invokes rustfmt on generated
.rsfiles to ensure long function signatures are properly formatted across multiple lines, eliminating unnecessary diffs when downstream projects runcargo fmt
[0.6.12] - 2026-05-07
Section titled “[0.6.12] - 2026-05-07”- The 0.6.11 ON CONFLICT preprocessor scanned the raw SQL byte string, so text inside
--line comments and'…'literals could trigger the predicate-stripping path and chew into the surrounding INSERT body. The scanner now runs against an ASCII-uppercase mask where comments + string literals are replaced with same-length spaces, so positions still line up but only structural SQL is matched.
[0.6.11] - 2026-05-07
Section titled “[0.6.11] - 2026-05-07”- PostgreSQL: accept
INSERT … ON CONFLICT (cols) WHERE … DO …(the index-inference form for partial unique indexes). sqlparser-rs through 0.61 doesn’t recognise the predicate, so scythe now strips it for the parser pass while keeping the original SQL for codegen and runtime, where Postgres validates and uses the predicate to pick the matching partial index. Mirrors the existing dialect-preprocess pattern used for Oracle and MSSQL.
[0.6.10] - 2026-05-06
Section titled “[0.6.10] - 2026-05-06”- Clippy warnings in
scythe-lintstyle rules (collapsible_match) andtypescript-postgresbackend (unnecessary_sort_by)
Changed
Section titled “Changed”- Fixture data for pending engines (MSSQL, Oracle, Redshift, Snowflake) moved from
engines_pending/totesting_data/engines_pending/— all fixtures now under one directory - Updated pre-commit hooks: ai-rulez v4.1.6, rumdl v0.1.88, cargo-sort v2.1.4
- Bumped integration test dependencies:
rand0.8.5 → 0.8.6,pgx/v55.7.4 → 5.9.2,gosnowflake1.10.1 → 1.13.3,snowflake-sdk1.15.0 → 2.0.4,snowflake-jdbc3.16.1 → 4.0.2
[0.6.9] - 2026-04-15
Section titled “[0.6.9] - 2026-04-15”scythe fmtandscythe lintnow auto-detect SQL dialect fromscythe.tomlwhen files are passed directly (e.g. by pre-commit hooks)- PHP amphp: autoload vendor deps, use
query()instead ofexec() - Ruby SQLite: handle
:execCreateUser/CreateOrder with post-insert fetch - PHP SQLite: pass
statusparam tocreateUser - Oracle CI: install Instant Client SDK headers for ruby-oci8
- Snowflake CI: simplified to Python fakesnow only (no Docker emulator)
- Kotlin SQLite: Float literal types for total values
- Elixir jamdb Oracle: use
DBConnection.executeandschema_full.sql - Elixir Ecto: use Postgrex directly, fix
:oneempty result handling - MariaDB C#:
GetValue().ToString()for UUID columns (wasGetString()) - Oracle Go: EZ Connect format (
//host:port/service) for godror
[0.6.8] - 2026-04-15
Section titled “[0.6.8] - 2026-04-15”- MSSQL integration tests across 10 backends (Rust tiberius, Python pyodbc, Go go-mssqldb, TypeScript mssql, Java JDBC, Kotlin JDBC, C# SqlClient, Elixir TDS, Ruby TinyTds, PHP PDO)
- Redshift integration tests across 13 backends (all PostgreSQL-compatible drivers with Redshift-specific manifests)
- Snowflake integration tests across 7 backends (Python, TypeScript, Go, Java, Kotlin, C#, PHP)
- MSSQL CI job with SQL Server 2022 Docker
- Redshift CI job using PostgreSQL container with PG-compatible schema
- Snowflake CI job with snowflake-emulator Docker + fakesnow for Python
- MSSQL
OUTPUT INSERTEDpreprocessing: converts toRETURNINGfor parser, preserves original SQL in codegen - Redshift
IDENTITY(N,N)schema preprocessing: strips before parsing - Snowflake type mappings:
TIMESTAMP_NTZ,TIMESTAMP_TZ,TIMESTAMP_LTZ,VARIANT - 89 total integration test backends (up from 69)
- CI:
libaio1→libaio1t64for Ubuntu 24.04 (Oracle job) - CI: SQLite
create_if_missing(true)+touchstep - CI: removed committed macOS
.bundle/config - Go codegen:
@pNplaceholder rewriting for MSSQL - Rust tiberius codegen:
Compat<TcpStream>type,&dyn ToSqlparam binding, stringFromSqlhandling - Ruby TinyTds codegen: type-aware param escaping (integers/booleans not escaped)
- TypeScript mssql codegen: explicit
sql.*type bindings for params - Template fixes for Redshift (no enums,
schema_pg_compat.sql, status as string) - Elixir:
elixirc_pathsincludesgenerated/for all backends - TypeScript:
String()coercion for decimal total comparisons
Unverified / Skipped in CI
Section titled “Unverified / Skipped in CI”The following backends have codegen support but are not tested in CI due to driver/infra limitations:
MSSQL:
elixir-tds— Elixirtdslibrary parameter type encoding fails (#28)
Oracle:
elixir-jamdb—DBConnection.ConnectionPooldispatch error withjamdb_oracleruby-oci8— native gem requires Oracle Instant Client SDK headers not available in CI
SQLite:
php-pdo-sqlite— generatedcreateUserparam count mismatch with test template
Snowflake (#27):
python-snowflake, typescript-snowflake, and go-gosnowflake all run in CI against a shared
fakesnow server
(integration_tests/fakesnow/fakesnow_server.py) — gosnowflake connects with protocol=http&insecureMode=true
to skip TLS/OCSP the same way the Node driver does. The remaining three are still excluded:
java-jdbc-snowflake/kotlin-jdbc-snowflake— both use the snowflake-jdbc driver, which fakesnow forces into its JSON result format (fakesnow has no Arrow chunk-download endpoint). snowflake-jdbc’s JSON-formatResultSetdoesn’t implementgetObject(int, LocalDateTime.class), so any query touching aTIMESTAMP_NTZcolumn throws regardless of how the connection is configured. Enabling these needs an Arrow chunk-download endpoint infakesnow_server.py; the JDBC-side wiring (insecure TLS URL parameters) was deliberately not landed, since it can’t be verified end to end until that blocker is lifted.csharp-snowflake— not attempted. The Snowflake.Data driver needs its own TLS/OCSP and result-format investigation, which was not carried out, so no claim is made either way about whether it can work.php-pdo-snowflake— genuinely uncoverable in CI:composer.jsononly declaresext-pdo_odbc, and the proprietarypdo_snowflakePHP extension isn’t installable through Composer, PECL, or any standard CI package manager. It requires Snowflake’s closed-source ODBC driver preinstalled on the runner.
[0.6.7] - 2026-04-12
Section titled “[0.6.7] - 2026-04-12”- Oracle integration tests across 9 backends (Python oracledb, TypeScript oracledb, Go godror, Java JDBC, Kotlin JDBC, C# Oracle, Elixir jamdb, Ruby oci8, Rust sibyl)
- Oracle CI job with Oracle XE 21 and Instant Client
- Oracle SQL support:
:Nplaceholder preprocessing,RETURNING ... INTOoutput bind codegen - Oracle
orders.sqlqueries withRETURNING INTOsupport structs_onlyoption for Rust sqlx backend (skipssqlx::query!()macros that require compile-time DB)
Changed
Section titled “Changed”- Java codegen: emit
package generated;andpublic class Queries { ... }wrapper — eliminates hand-written wrapper files - Kotlin codegen: emit
package generatedheader - Java output path:
src/main/java/generated/Queries.java; Kotlin:src/main/kotlin/generated/queries.kt - Rust sqlx integration tests output to
src/queries.rswithstructs_onlymode - Oracle dialect uses
OracleDialectfrom sqlparser (wasGenericDialect)
- Go database-sql MySQL: fixed connection failure when
MYSQL_URLusesmysql://URL format - Ruby mysql2 MySQL: regenerated code to use
stmt.affected_rows(fixes incorrectDELETErow counts) - Java/Kotlin JDBC: enum columns read via
valueOf(toUpperCase())instead of brokengetObject() - Java/Kotlin JDBC: PostgreSQL enum params use
setObject(Types.OTHER), others usesetString(getValue()) - Java/Kotlin JDBC MariaDB:
RETURNINGqueries useexecute()+getResultSet()(MySQL Connector/J doesn’t supportexecuteQuery()for DML RETURNING) - Rust sqlx MariaDB: UUID columns cast to
CHARin all queries (sqlx can’t decode MariaDB BINARY UUID) - Rust sqlx MariaDB/MySQL: use
last_insert_id()from result instead ofLAST_INSERT_ID()SQL function (pool connection mismatch) - Rust sqlx:
raw_sql()for multi-statement schema loading (PG and SQLite) - MariaDB manifests: UUID mapped to
Stringfor Rust sqlx, Java JDBC, Kotlin JDBC (drivers return String, not UUID object) - Java imports:
java.time.*wildcard for all temporal types
[0.6.6] - 2026-04-12
Section titled “[0.6.6] - 2026-04-12”- MariaDB integration tests across all 11 supported backends (Rust sqlx, Python aiomysql, TypeScript mysql2, Go database/sql, Java JDBC, Kotlin JDBC, C# MySqlConnector, Elixir MyXQL, Ruby mysql2, Ruby trilogy, PHP PDO)
- MariaDB CI job running all 11 backends against MariaDB 11
- MariaDB
orders.sqlqueries withINSERT...RETURNINGsupport
[0.6.5] - 2026-04-12
Section titled “[0.6.5] - 2026-04-12”- Java JDBC and Kotlin JDBC: Oracle backend support
- tokio-postgres: enums now implement
FromSqlandToSqltraits natively, enabling direct use as query parameters and row fields without manual string conversion - Ruby mysql2:
affected_rowsnow called on the statement instead of the client, fixing incorrect return values for exec queries
[0.6.4] - 2026-04-10
Section titled “[0.6.4] - 2026-04-10”- Integration tests now run all generated code against real databases (PostgreSQL, MySQL, SQLite) across all 39 backends and 10 languages
- CI split into 3 parallel jobs (PostgreSQL, MySQL, SQLite) covering all backends
- New MySQL/SQLite SQL queries: GetUserOrders, CountUsersByStatus, GetUserWithTags
- tokio-postgres: enum parameters now use
::text::enum_namecasts for proper PostgreSQL enum handling - tokio-postgres: enum columns in SELECT/RETURNING use
::textcast for correct deserialization - sqlx: RETURNING clauses now include enum type annotations (
"status: UserStatus") - sqlx: aggregate functions (COUNT, SUM) get non-null override annotations (
"column_name!") - C# Npgsql: enum extension methods moved to top-level static classes (fixes CS1109)
- C# Microsoft.Data.Sqlite: fixed type mappings (int32->long, float32->double for SQLite)
- Elixir exqlite: updated to Exqlite 0.36 prepare/bind/step API
- Elixir myxql/exqlite/ecto: generated code now properly wrapped in
defmodule - Python aiomysql:
?placeholders correctly rewritten to%s - Go pgx: added missing
timeanddecimalimports in generated code - Ruby trilogy: parameterized queries use string interpolation (trilogy lacks prepared statement support)
- TypeScript pg-zod: enum columns use correct Zod schema references
[0.6.3] - 2026-04-10
Section titled “[0.6.3] - 2026-04-10”fmtandlintcommands now auto-detect the SQL dialect from the configenginefield (CLI--dialectflag still takes precedence)
- Sqruff rule
LT01excluded by default — it incorrectly splits compound operators (>=,<=,<@) - Compound operators inside CHECK constraints no longer get split by the formatter (e.g.,
>=becoming> =)
[0.6.2] - 2026-04-10
Section titled “[0.6.2] - 2026-04-10”Changed
Section titled “Changed”- tokio-postgres:
from_rowis now infallible (returnsSelfinstead ofResult) matching tokio-postgresrow.get()conventions - tokio-postgres: all query functions uniformly return
Result<T, tokio_postgres::Error>instead of mixed error types - tokio-postgres: extracted
ERROR_TYPEconstant to reduce string duplication in signatures
:optcommand now correctly generates row structs (was missing from struct generation match)
[0.6.1] - 2026-04-10
Section titled “[0.6.1] - 2026-04-10”:optquery command across all backends — returns optional/nullable single row (distinct from:onewhich expects exactly one row)- Serde and custom derive support for tokio-postgres backend via
serdeandderiveoptions apply_options()method on tokio-postgres backend for runtime configurationis_column_nullable()helper on analyzer scope for nullable column lookupscollect_param_from_expr_with_type_nullable()for nullable-aware parameter collectionversion:synctask in Taskfile for updating all crate versions at once
Changed
Section titled “Changed”- tokio-postgres:
clientparameter now accepts&(impl GenericClient + Sync)instead of concrete&Client - tokio-postgres: batch functions no longer wrap operations in implicit transactions
- INSERT parameter analysis now propagates column nullability to parameters
- Changelog retroactively aligned with Cargo.toml version history (0.1.0–0.6.0)
[0.6.0] - 2026-04-08
Section titled “[0.6.0] - 2026-04-08”- Microsoft SQL Server engine (6 backends: tiberius, pyodbc, mssql, sqlclient, tiny_tds, tds)
- Oracle Database engine (6 backends: sibyl, oracledb, godror, oracle, oci8, jamdb)
- MariaDB engine with native UUID support, RETURNING clause, and dedicated manifests
- Amazon Redshift engine (PostgreSQL-based with SUPER type support)
- Snowflake engine with VARIANT/OBJECT/ARRAY types
- 17 new database backends and 51 type mapping manifests
- Pre-commit/prek hooks for scythe users
Changed
Section titled “Changed”- Flattened docs structure for better organization
- Expanded to 10 total databases with 70+ backend drivers across 10 languages
- Extracted shared
rewrite_pg_placeholdersfunction (eliminated 26+ duplicated functions) - Extracted shared
load_or_default_manifestfunction (eliminated 49 duplicated code blocks) - CockroachDB documentation TOML snippet duplicate key issue
- Python DuckDB missing datetime import
- TypeScript DuckDB import type issue
- Go godror PascalCase conversion issue
- Go unconditional imports problem
- SQLx hardcoded PgPool issue
- Tiberius unwrap error handling
- Kotlin wasNull null handling
- Ruby batch operation fix
- Sibyl error swallowing issue
- Go
interface{}updated toanykeyword
[0.5.0] - 2026-04-08
Section titled “[0.5.0] - 2026-04-08”- CockroachDB engine support
- DuckDB engine support
:groupedoperation support- Kotlin Exposed backend
- R2DBC backend support
- Homebrew bottles for distribution
- Integration test generator for all 39 backend test suites
[0.4.0] - 2026-04-08
Section titled “[0.4.0] - 2026-04-08”- Real
:batchoperations across all backends - PHP AMPHP backend
- Custom type overrides feature
@optionalannotation support- Elixir Ecto backend
- Ruby Trilogy backend
- Pydantic/msgspec row types for Python
- Zod v4 schemas for TypeScript
- GenOptions infrastructure for per-backend configuration
Changed
Section titled “Changed”- Extended Quick Start documentation with all 10 languages
[0.3.0] - 2026-04-07
Section titled “[0.3.0] - 2026-04-07”- Snippet-runner tool for validating documentation code snippets across 13 languages
- PHP namespace support and Generator for
:manyqueries - C# SQLite async API
- Ruby module
Queriesencapsulation across all 3 backends
Changed
Section titled “Changed”- C# all backends: Enum.TryParse with descriptive InvalidOperationException
- Python aiosqlite: Decimal maps to
decimal.Decimalinstead of float - Go database-sql MySQL: Decimal maps to float64
- Ruby: SCREAMING_SNAKE_CASE enum variants
- PHP: Final class
Querieswrapper
- 8 backend-specific fixes across PHP, Ruby, C#, Rust, Python, and Go
[0.2.0] - 2026-04-07
Section titled “[0.2.0] - 2026-04-07”- Engine-aware backend architecture —
get_backend(name, engine)loads engine-specific manifests - 12 new language backends for MySQL and SQLite: go-database-sql, python-aiomysql, python-aiosqlite, typescript-mysql2, typescript-better-sqlite3, ruby-mysql2, ruby-sqlite3, csharp-mysqlconnector, csharp-microsoft-sqlite, elixir-myxql, elixir-exqlite
- Multi-backend CLI config via
[[sql.gen]]array syntax in scythe.toml - Full MySQL support across all 10 languages (Rust, Go, Python, TypeScript, Java, Kotlin, C#, Elixir, Ruby, PHP)
- Full SQLite support across all 10 languages
- 33 real integration tests against PostgreSQL, MySQL, and SQLite
supported_engines()method on CodegenBackend trait for engine validationmanifest()method on CodegenBackend trait for direct manifest accessfile_footer()method on CodegenBackend trait for class wrappers (C#)- Engine-specific manifest files for multi-DB backends (java-jdbc, kotlin-jdbc, php-pdo, rust-sqlx)
- Docker Compose setup for integration testing (PostgreSQL + MySQL)
Changed
Section titled “Changed”get_backend()now requires engine parameter for database-aware code generation- Backend constructors accept engine parameter and load appropriate manifests
- PG-only backends reject non-PostgreSQL engines with clear error messages
- Python codegen: multiline SQL now uses triple-quoted strings
- Python codegen: added missing
import decimalto file headers - TypeScript pg codegen: multiline SQL now uses backtick template literals
- C# codegen: generated code now wrapped in
public static class Queries { } - C# codegen: enum parameters use
.ToString().ToLower()with::enum_typeSQL cast - C# codegen: enum columns deserialized via
Enum.Parse<T>(reader.GetString(i), true) - PHP codegen: MySQL
?placeholders use positional arrays instead of named params - PHP codegen: enum params use
->value, enum columns use::from(), DateTimeImmutable for timestamps - Go codegen: added missing
timeanddecimalimports to file header - Java codegen: added import statements to file header
- Ruby mysql2 codegen:
affected_rowscalled on statement instead of client
[0.1.0] - 2026-04-06
Section titled “[0.1.0] - 2026-04-06”- SQL-to-code generation for 13 language backends:
- Rust (sqlx, tokio-postgres)
- Python (psycopg3, asyncpg)
- TypeScript (postgres.js, pg)
- Go (pgx v5)
- Java (JDBC with records)
- Kotlin (JDBC with data classes)
- C# (Npgsql with records)
- Elixir (Postgrex with defstruct)
- Ruby (pg gem with Data.define)
- PHP (PDO with readonly classes)
- Database dialect support: PostgreSQL, MySQL, SQLite
- SQL annotation system (@name, @returns, @param, @nullable, @nonnull, @json, @deprecated)
- Smart type inference with nullability propagation (JOIN, COALESCE, aggregates, CASE)
- Language-neutral type vocabulary with per-backend type mapping via manifest.toml
- 93 SQL lint rules (22 scythe-specific + 71 via sqruff integration)
- SQL formatting via sqruff integration
- CLI commands: generate, check, lint, fmt, migrate
- sqlc migration tool (convert sqlc.yaml to scythe.toml, migrate query annotations)
- 275 JSON test fixtures with auto-generated test code
- Real language tool validation (ruff, biome, gofmt, ktlint, ruby -c, php -l)
- Template-based backend architecture (manifest.toml + MiniJinja templates)
- Trait-based CodegenBackend for extensible language support