diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a2322f..b1d03de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,12 +51,9 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # A single-artifact pgn generate peaks at ~6.5 GB RSS on this runner - # (memory goes to normalizing the generator closure, near-independent - # of the query count), which flirts with the 7.8 GiB RAM + 3 GiB stock - # swap and got the VM killed by the memory watchdog mid-suite; extra - # swap lets the GC spill instead. /swapfile is taken by the image's - # stock swap, so the extra file goes on the /mnt ephemeral disk. + # pgn generation has exceeded the hosted runner's stock memory on this + # suite. Keep extra swap as evaluator and GC headroom. /swapfile is taken + # by the image's stock swap, so the extra file goes on /mnt. - name: Add swap headroom for pgn generate shell: bash run: | @@ -77,8 +74,8 @@ jobs: set -euo pipefail version="$(mise x -- pgn --version)" - if [[ "$version" != "0.9.1" ]]; then - echo "::error::Expected pgn 0.9.1 (mise.toml pin), got '$version'." + if [[ "$version" != "0.12.0" ]]; then + echo "::error::Expected pgn 0.12.0 (mise.toml pin), got '$version'." exit 1 fi @@ -124,9 +121,8 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} - # A plain, standard-Dhall evaluator cannot run this because the local - # Project.buildLookup uses Text/equal, a builtin from pgn's forked Dhall. - # The pinned action bundles that same fork. + # Use the pinned directory-tree action for the same reproducible contract + # evaluation path used by release validation. - name: Generate output from Dhall uses: nikita-volkov/dhall-directory-tree.github-action@60a18dc647d6daea805263ea0fed7bb8011f3bcd # v2 with: diff --git a/AGENTS.md b/AGENTS.md index 7da3b7b..79f0a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,3 +36,9 @@ already says. `src/` pins its remote imports by sha256 (`src/Deps/*.dhall`). Bump those deliberately, one at a time, and re-run the harness before committing a pin change. + +Never replace a pin with a local filesystem path (`../gen-contract/...`, +`../gen-sdk/...`) even temporarily for convenience; it only works on a +machine with those sibling repos checked out and breaks CI. Always use a +`https://raw.githubusercontent.com/pgenie-io///...` import with +its `sha256`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7e441..2f0001a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,25 @@ # Upcoming -- Added fail-loud, namespace-wide validation for generated Python names. Query - and custom-type collisions can be resolved with typed, whole-entity mappings; - invalid, reserved, duplicate, and unknown mappings are rejected. Defensive - local-member audits identify both sources when such names reach the generator; - pgn may reject them earlier, and resolution is a SQL or schema rename. The - generator never silently overwrites files or assigns numeric suffixes. - Per-type module audits also reject a mapped class that would shadow an actual - primitive, core, or custom dependency import. +- Bumped the pins to gen-contract v5.0.0 and gen-sdk v3.0.0 (requires `pgn` + v0.12.0). Replaced the hand-rolled `buildLookup` custom-kind resolver and its + fixed-point removal cascade with gen-sdk's `CustomTypes` module: references + now resolve by the contract's `CustomTypeRef.index` and `onUnsupported: Skip` + computes survivorship in a single left-fold over the topologically-sorted + `customTypes`. This retires the generator's last `Text/equal` use, so no live + `Text/equal` call remains anywhere in `src/`. + +- Removed the `queryNameMappings`/`customTypeNameMappings` rename-mapping + config and all generation-time Python namespace-collision detection + (project-wide facade/module, per-query, per-custom-type, and per-type + module-internal audits). `pgn` 0.11.0 dropped the `Text/equal` builtin these + relied on to compare two runtime `Text` values, and there is no + `Text/replace`-based way to reconstruct that decision. A colliding schema is + no longer caught at `pgn generate`. Collisions that remain visible in the + generated tree generally fail the package's `basedpyright --strict` gate + (`reportRedeclaration` or `reportInvalidTypeForm`), but a module-path + collision can overwrite an earlier file before the checker sees it. + [pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn + to guarantee unique custom-type identities at the source. - Finalized one additive package surface. Async functions remain at the package root for every configuration. `emitSync: true` adds `.sync`, a sync @@ -29,22 +41,22 @@ ranks, composite ranks, custom-array fields inside composites, and missing or unsupported custom types fail loudly. -- Retained `buildLookup` as the project-wide custom-kind resolver for identities - preserved in the contract. Its `Text/equal` use is an explicit pgn-fork - constraint. The planned exit must preserve every schema-qualified custom type - entry and expose a stable qualified identifier on `Scalar.Custom`; kind alone - is insufficient. pgn 0.9.1 can collapse same-unqualified-name types across - schemas, so that database shape remains unsupported and cannot be repaired by - a Python mapping. Natural project indexes now provide deterministic - custom-import deduplication and ordering. - Query, parameter, field, and private statement names are collision-safe while - SQL names remain unchanged. +- Resolved custom-type references by their contract-supplied + `CustomTypeRef.index` (gen-contract v5) rather than by name comparison, so a + reference carries a stable schema-qualified identity (`pgSchema`/`pgName`) + directly. Same-unqualified-name types across schemas no longer collapse + during reference resolution. Generated class and module names still come + from the unqualified contract name, so their Python names must remain unique. + Natural project indexes provide deterministic custom-import deduplication and + ordering. Reserved helper and keyword conflicts are escaped while SQL names + remain unchanged. - Kept `onUnsupported: Fail | Skip`. `Fail` aborts generation with the nested - report. `Skip` preserves warnings and computes a bounded fixed point that - removes an unsupported custom type, its dependent custom types and statements, - and all affected type, registration, facade, Row, and statement entries. The - surviving package remains strict-importable. + report. `Skip` preserves warnings and computes survivorship in a single + left-fold over the topologically-sorted `customTypes`, removing an unsupported + custom type, its dependent custom types and statements, and all affected type, + registration, facade, Row, and statement entries. The surviving package + remains strict-importable. - Established the generated tree as a greenfield layout with no compatibility layer or promise for internal generated paths. Every generated Python file now @@ -61,8 +73,8 @@ class-aware adapters, pure models, canonical `args_row` Rows, and no query decoders, model codecs, casts, or bytes SQL. H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. H3 CONFIRM: - Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 73 - passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. + Ruff 0/0, authored long0, SQL long0, raw output/no postformat. Tests: 50 + passed, 0 skipped; pgn v0.12.0; strict basedpyright 0/0. - Migrated the generator to gen-contract v4.0.1 and gen-sdk v2.0.0 using `Sdk.Sigs`. The implementation moved from `gen/` into `src/`, the working-tree @@ -79,9 +91,10 @@ tuples with `dataclasses.fields` and `getattr`, preserving one-field arity. The fixture exercises the type as both a parameter and a result. -- Kept `PyIdent.dhall` independent of fork-only text equality by using its - bounded `Text/replace` marker construction. Keyword fields and parameters gain - a trailing underscore only in Python, while raw database names remain stable. +- Kept `PyIdent.dhall` independent of fork-only text equality by using + `Lude.Text.replaceIfOneOf`'s bounded, sentinel-wrapped `Text/replace` + construction. Keyword fields and parameters gain a trailing underscore only + in Python, while raw database names remain stable. - Preserved the release and license flow. The release job resolves `src/package.dhall` into the `resolved.dhall` release asset, then byte-checks diff --git a/DESIGN.md b/DESIGN.md index a55e017..1496cb6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -157,7 +157,7 @@ directly. Statement SQL is passed as `LiteralString`. No runtime accepts encoded SQL bytes. The generated package has no separately installed support library; its only non-stdlib consumer dependency is `psycopg>=3.3.4,<4`. -## 5. Configuration, mapping, and unsupported shapes +## 5. Configuration and unsupported shapes The public Dhall config is: @@ -165,14 +165,6 @@ The public Dhall config is: { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional < Fail | Skip > -, queryNameMappings : Optional - (List { source : Text, target : { snakeCase : Text, pascalCase : Text } }) -, customTypeNameMappings : Optional - ( List - { source : { schema : Text, name : Text } - , target : { snakeCase : Text, pascalCase : Text } - } - ) } ``` @@ -182,35 +174,27 @@ The public Dhall config is: - `packageName`: project name in kebab case; - `emitSync`: `False`; - `onUnsupported`: `Fail`. -- `queryNameMappings`: `[]`; -- `customTypeNameMappings`: `[]`. An omitted config block, an omitted field, and a `null` field therefore use the same fallback. Async output is present for every value of `emitSync`; only true adds sync output. -Source-derived identifiers first receive lexical and reserved-name escaping. -Typed mappings replace a complete query or custom-type Python identity and must -already contain exact valid targets. After resolution, project and local -namespace audits reject duplicate modules, functions, Row classes, custom -types, facade exports, parameters, fields, and enum members before rendering. -Each custom-type module also audits its class against the primitive, core, and -custom dependency symbols it actually imports. -Fixed core exports remain occupied. The generator never silently overwrites a -file or assigns an order-dependent numeric suffix; see -[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). - -Custom-type mapping is exact only for entities preserved in the input contract. -pgn 0.9.1 can collapse same-unqualified-name types across schemas and expose an -unqualified `Scalar.Custom` reference. The generator cannot reconstruct the -discarded schema identity from SQL and rejects duplicate unqualified contract -names if they do reach it. Until upstream preserves all schema-qualified types -and a stable qualified reference, such database shapes are unsupported. - -Local member audits are defensive for inputs that reach the generator. pgn may -reject a conflicting SQL or schema spelling during analysis first. Local -conflicts are resolved by renaming that SQL or schema source; whole-entity query -and custom-type mappings do not apply to members. +Source-derived identifiers receive lexical and reserved-name escaping only +(`Structures/PyIdent.dhall`); there is no rename-mapping config and no +generation-time namespace-collision detection. `pgn`'s embedded Dhall evaluator +dropped `Text/equal` (pgn 0.11.0), which removed the only mechanism that could +compare two runtime `Text` values to decide a collision or resolve a mapping's +`source` against a query/type name; that decision is not reconstructible from +`Text/replace` alone (see section 10). Instead, the generated package is held +to `basedpyright --strict` returning zero errors and zero warnings. A duplicate +dataclass field name or a name that shadows a needed type surfaces there +(`reportRedeclaration`/`reportInvalidTypeForm`) rather than at `pgn generate` +time, and less precisely attributed (generated Python, not the originating +SQL/schema). A module-path collision can overwrite an earlier generated file +before the type checker runs, so input schemas must still guarantee unique +generated Python names. +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source. `Interpreters/Primitive.dhall` maps pgn union constructors, not signature-file strings. Supported scalars are Boolean, integer and OID, floating point, @@ -275,25 +259,31 @@ postprocessor rewrites generated files. ```text optional config -> resolved config - -> buildLookup(custom types) - -> fixed-point custom-type filtering in Skip mode - -> compile custom types and query checks + -> index-aligned kind classification (kindOf) of every custom type + -> one-pass survivor cascade (Sdk.CustomTypes.supportedCustomTypesReasoned) + masking unsupported indices to Absent in Skip mode + -> compile custom types and query checks against the masked lookup -> dependency-first registration order -> render facades, core, runtimes, types, registration, and statements -> prepend the generated header ``` `onUnsupported: Fail` traverses the original project and propagates the first -compiled failure, so generation aborts without a partial client. - -`onUnsupported: Skip` repeatedly rebuilds `buildLookup` from the surviving -custom types and removes any type that no longer compiles. It then compiles -queries against the final lookup. A removed type therefore also removes its -dependent composites and statements. Facade exports, type initializers, -registration entries, statement files, and Row exports are assembled only from -survivors. Reports for dropped units are preserved as warnings. The bounded -fixed point can only remove candidates, so it terminates after at most the -original custom-type count. +compiled failure, so generation aborts without a partial client. In this mode +nothing is masked: the lookup is the full, unmasked kind classification. + +`onUnsupported: Skip` computes survivorship in a single left-fold via +`Sdk.CustomTypes.supportedCustomTypesReasoned`. Because `Project.customTypes` +is topologically sorted (every referenced index precedes the referencing type), +one pass suffices: the fold marks a type unsupported if its own definition +cannot compile (`ownDefinitionSupported`) or if any custom type it references +was already marked unsupported. Unsupported indices are then masked to `Absent` +in the kind lookup, so a reference to a removed type reads exactly like a +missing type. Queries compile against that same masked lookup, so a removed type +also removes its dependent composites and statements. Facade exports, type +initializers, registration entries, statement files, and Row exports are +assembled only from survivors, and reports for dropped units are preserved as +warnings. Query compilation is deliberately consolidated in `QueryCheck`: keep/drop state and warning data come from one literal `QueryGen.run` call site before the final @@ -344,27 +334,52 @@ element nullability. `Member` and `ParamsMember` apply member nullability, identifier safety, lookup classification, imports, and parameter wrapping. `ResultColumns` builds Row field declarations in analyzed order. `Query` combines SQL, Row, parameters, and surfaces. `Project` owns lookup scope, -fixed-point filtering, registration order, file selection, and facades. +single-pass survivor filtering, registration order, file selection, and +facades. ## 10. The pinned `Text/equal` constraint -`buildLookup` intentionally remains in `Interpreters/Project.dhall`. It compares -a custom reference's snake-case name with the project custom type name using -`Text/equal`. That builtin belongs to pgn's embedded Dhall fork and is not -available in upstream standard Dhall. - -The dependency is pinned and explicit. The complete fixture needs fork-aware -evaluation solely because it invokes this generator and the local `buildLookup` -uses `Text/equal`. The upstream exit must preserve every schema-qualified -`customTypes` entry and put a stable qualified identifier, or a project index -with equivalent identity, on each custom scalar reference. Besides removing -text equality, that prevents pgn 0.9.1 from collapsing same-unqualified-name -types across schemas. Until then, pgn and CI's pinned fork-aware action are the -supported evaluators, and cross-schema duplicate type names are unsupported. - -`PyIdent.dhall` uses its separate `Text/replace` marker construction for keyword -membership. `ImportSet.dhall` uses natural project indexes for equality, -deduplication, and ordering. Neither substitutes for the project lookup. +`pgn` 0.11.0 removed `Text/equal` (along with `Text/length` and `Bool/equal`) +from its embedded Dhall evaluator, to stay in line with the official Dhall +spec. There is no way to reconstruct a `Bool` or a differently-typed decision +from comparing two arbitrary runtime `Text` values using only `Text/replace`; +`Text/replace`-based tricks (this repository's own former keyword-marker +trick, and gen-sdk's `Lude.Text.replaceIfEqual`/`replaceIfOneOf`) only ever +transform text, they cannot branch into a different type. Every piece of this +generator that made a decision this way is gone: the `queryNameMappings`/ +`customTypeNameMappings` rename-mapping config +(`Structures/PythonNameMapping.dhall`, matched a mapping's `source` against a +runtime name), `validateCustomTypeIdentities` (rejected two custom types +collapsing to the same unqualified contract `Name`), and +`Structures/PythonNamespace.dhall`'s `validate` (4 call sites in +`Interpreters/Project.dhall`: per-query and per-custom-type local audits, the +project-wide facade/module audit, and per-type module-internal bindings). +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source instead. The +generated package's `basedpyright --strict` gate (see section 12) catches +collisions that remain visible in the output tree. It cannot detect an earlier +file that was overwritten at the same generated module path. + +`buildLookup`, the last `Text/equal` user, compared a reference's snake-case +name against each project custom type's name and has been removed. +gen-contract v5's `CustomTypeRef` carries an `index` into `Project.customTypes`, +and gen-sdk v3's `CustomTypes` module folds over that topologically-sorted list +to compute survivorship without any text comparison. References now resolve by +index (`Structures/CustomKind.dhall`'s `at`), so a repo-wide `grep -rn +"Text/equal" src/` finds only these explanatory comments. No live use remains. + +The generator no longer needs fork-only text equality anywhere. It does still +rely on gen-contract v5's contract guarantees: every `CustomTypeRef.index` must +address the intended `customTypes` entry, and `customTypes` must be +topologically sorted (every referenced index precedes the referencing type), +which is what makes the single-pass survivor cascade sound. `pgn` remains the +supported evaluator and generation driver; upholding those index/ordering +invariants is the producer's responsibility. + +`PyIdent.dhall` uses `Lude.Text.replaceIfOneOf`'s bounded `Text/replace` +construction for keyword membership. `ImportSet.dhall` uses natural project +indexes for equality, deduplication, and ordering. Neither substitutes for the +project lookup. ## 11. Taking ownership @@ -394,7 +409,7 @@ PostgreSQL with these verdicts: - H2 CONFIRM: 25 Python files / 1467 lines / 11 statement files / 801 statement lines / 11 SQL. - H3 CONFIRM: Ruff 0/0, authored long0, SQL long0, raw output/no postformat. -- Tests: 73 passed, 0 skipped; pgn 0.9.1; strict basedpyright 0/0. +- Tests: 50 passed, 0 skipped; pgn v0.12.0; strict basedpyright 0/0. The H1 round trip covers both connection surfaces and exact cross-facade identities. It covers scalar enum, enum arrays including rank 2, scalar diff --git a/README.md b/README.md index d06900e..258ac26 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,6 @@ freeze key machine-specific; pgn project configuration does not accept a | `packageName` | `Text` | project name in kebab case | | `emitSync` | `Bool` | `False` | | `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | -| `queryNameMappings` | typed query-name mappings | `[]` | -| `customTypeNameMappings` | typed PostgreSQL type-name mappings | `[]` | The `config` block and every field are optional. An omitted or `null` field uses its default. Omitting `emitSync`, setting it to `null`, or setting it to `false` @@ -54,52 +52,26 @@ canonical statement module. `packageName` becomes an import name by replacing `-` with `_`. `onUnsupported: "Fail"` aborts generation with a path-aware report. -`onUnsupported: "Skip"` preserves warnings and repeatedly removes an -unsupported custom type, its dependent custom types and statements, and every -affected facade, registration, and statement entry until the survivors are a -strict-importable fixed point. - -Generated Python namespaces are audited before files are rendered. Collisions -fail loudly instead of overwriting a file or receiving an order-dependent -numeric suffix. An intentional public rename uses one typed mapping for the -entity's snake-case module/function name and Pascal-case Row/type name: - -```yaml -queryNameMappings: - - source: sync_query - target: - snakeCase: synchronize - pascalCase: Synchronize -customTypeNameMappings: - - source: - schema: public - name: json_value - target: - snakeCase: pg_json_value - pascalCase: PgJsonValue -``` - -Mapping sources must identify an existing query or exact PostgreSQL schema/type -pair. A query source is pgn's snake-case query name; a custom-type source is the -exact PostgreSQL schema and type name preserved in the input contract. Targets -are final Python names: invalid or reserved targets are rejected, and -PostgreSQL names remain unchanged. -`PgJsonValue` above is a user-chosen Python alias for `public.json_value`, not a -built-in type or automatic naming rule. - -pgn 0.9.1 does not preserve two custom types that share one unqualified name -across schemas: it can retain only one `customTypes` entry, while -`Scalar.Custom` carries only an unqualified `Name`. A mapping cannot recover a -schema-qualified identity missing from the contract. Avoid that database shape -until upstream preserves every schema-qualified type and a stable qualified -reference; if a future input contains two entries with the same unqualified -contract `Name`, this generator rejects it as ambiguous. - -Local parameter, result-field, composite-field, and enum-member audits are -defensive for names that reach the generator. pgn may reject a conflicting SQL -or schema spelling earlier. Such a conflict is resolved at its SQL or schema -source; the entity mappings above do not apply. See -[ADR 0001](docs/adr/0001-generated-python-name-collisions.md). +`onUnsupported: "Skip"` preserves warnings and, in a single pass over the +topologically-sorted custom types, removes an unsupported custom type, its +dependent custom types and statements, and every affected facade, +registration, and statement entry, leaving a strict-importable survivor set. + +Generated Python names are not audited for collisions at generation time. +Query and custom-type identifiers are derived directly from their PostgreSQL +source names (sanitized for Python syntax and reserved words only); a schema +that maps two different SQL entities onto the same Python identifier will not +be caught by `pgn generate`. Instead, the generated package is held to +`basedpyright --strict` with zero errors and zero warnings. Collisions that +remain visible in the generated tree typically surface there as +`reportRedeclaration` or `reportInvalidTypeForm`, pointing at the generated +Python rather than the original SQL/schema source. That gate cannot detect a +module-path collision after one generated file has overwritten another, so +Python-name uniqueness remains an input-schema requirement. Renaming the +conflicting SQL or schema entity is the fix. +[pgenie-io/pgenie#75](https://github.com/pgenie-io/pgenie/issues/75) asks pgn +to guarantee unique custom-type identities at the source, which would let a +future version of this generator catch such collisions earlier again. ## Generated package @@ -250,7 +222,7 @@ disabled. ## Development -This repository pins pgn 0.9.1 in `mise.toml`. Run all tools through `mise`: +This repository pins pgn v0.12.0 in `mise.toml`. Run all tools through `mise`: ```bash mise run install @@ -260,7 +232,7 @@ mise run test The harness drives real pgn subprocesses against a live PostgreSQL server and only creates and drops its own scratch databases. Set `PGN_TEST_DATABASE_URL` -for a non-default server. The full verified suite is 73 passed and 0 skipped; +for a non-default server. The full verified suite is 50 passed and 0 skipped; it includes byte-for-byte golden comparison, async and sync round trips, basedpyright strict, Ruff, generated-quality budgets, unsupported-type modes, and public identity checks. @@ -280,9 +252,14 @@ at a time, and rerun the harness. The release workflow resolves wheel from those exact bytes. The wheel exposes path, URL, and vendor commands; PyPI publication remains disabled until its explicit release gate is enabled. -`fixtures/Exhaustive.dhall` is the contract fixture. It and `buildLookup` rely -on the pgn fork's `Text/equal` builtin, so the standard upstream Dhall evaluator -cannot run the complete contract path. CI uses the pinned fork-aware action. +`fixtures/Exhaustive.dhall` is the contract fixture. The generator no longer +depends on the pgn fork's `Text/equal` builtin at all: the custom-type removal +cascade that was the last user (`buildLookup`) has been replaced by gen-sdk's +`CustomTypes` module, which resolves references by their contract-supplied +`CustomTypeRef.index` instead of by name comparison. `pgn` itself remains the +supported evaluator and generation driver (CI invokes `pgn generate`), and it +requires the contract's topological `customTypes` ordering that gen-contract +v5 guarantees. ## Provenance and license diff --git a/docs/adr/0001-generated-python-name-collisions.md b/docs/adr/0001-generated-python-name-collisions.md index ee8783b..d3024ee 100644 --- a/docs/adr/0001-generated-python-name-collisions.md +++ b/docs/adr/0001-generated-python-name-collisions.md @@ -1,74 +1,34 @@ -# ADR 0001: Fail loudly on generated Python name collisions +# ADR 0001: Generated Python name collisions -Status: Accepted +Status: Superseded ## Context -Different PostgreSQL names can resolve to the same Python name. For example, -the query names `sync` and `sync_query` both resolved to the module and function -`sync_query`, so one generated file silently replaced the other. Similar -collisions can affect Row classes, custom types, facade exports, and members -after Python keyword escaping. +Different PostgreSQL names can resolve to the same Python name. The original +implementation collected every generated binding and compared it with a growing +list of prior bindings. It did this for project exports and modules, query +members, custom-type members, and imports inside each custom-type module. -Silent overwrite loses code. Order-based suffixes such as `name2` also make a -public API change when an unrelated declaration is added or reordered. +Those scans were quadratic and expensive under Dhall normalization. They ran on +every generation, including projects that did not configure any name mappings. -## Decision +## Superseding decision -Lexical handling and uniqueness are separate stages: +The namespace-wide and local audits are removed. The typed query and custom-type +rename-mapping API and its runtime identity validators are removed with them. -1. Source-derived defaults are escaped for Python keywords and generator-owned - implementation names. -2. Optional typed mappings resolve a declaration's complete Python identity. -3. Explicit targets must already be exact, valid, non-reserved Python names. - Snake-case targets start with a lowercase ASCII letter, and Pascal-case - targets start with an uppercase ASCII letter. Facade and module metadata - dunder names cannot be overwritten. Invalid targets are rejected rather - than silently rewritten. -4. The generator audits each Python namespace selected for emission after name - resolution and before rendering files. A collision stops generation and - identifies both owners and the final name. +Lexical handling remains. Source-derived defaults are escaped for Python +keywords and generator-owned implementation names. -The greenfield configuration uses entity-typed mappings: - -```dhall -let PythonName = { snakeCase : Text, pascalCase : Text } - -let QueryNameMapping = { source : Text, target : PythonName } - -let CustomTypeNameMapping = - { source : { schema : Text, name : Text }, target : PythonName } -``` - -For a query, `snakeCase` owns the statement module and async/root public name; -the sync facade exports that same public name while its adjacent implementation -function adds `_sync`. `pascalCase` owns `${pascalCase}Row`. For a custom type, -`snakeCase` owns the type module and imports, while `pascalCase` owns the class, -facade export, and adapter registration. PostgreSQL and SQL names never change. -Duplicate or unknown mapping sources, invalid targets, and mapped collisions all -fail before files are emitted. Fixed package names such as `JsonValue`, -`NoRowError`, `register_types`, and `sync` cannot be remapped. `PgJsonValue` is -an example explicit user alias, not an automatic naming policy. - -Local parameter, result-field, composite-field, and enum-member audits are -defensive for inputs that reach the generator; pgn may reject the conflicting -source spelling earlier. They are resolved by renaming the SQL placeholder, SQL -alias, or schema member. A future local override, if needed, must use a typed, -parent-scoped mapping instead of a string path grammar. - -The structured custom-type source is exact only when the input contract -preserves the entity. pgn 0.9.1 can collapse types with the same unqualified -name across schemas, and `Scalar.Custom` has no qualified identity. A mapping -cannot recover that lost distinction. Such database shapes remain unsupported -until upstream preserves all qualified custom types and references. +Final uniqueness is now the project's responsibility. Two distinct entities can +resolve to the same module, class, function, field, or export. Static analysis +can catch many collisions in the surviving generated Python, but a module-path +collision can overwrite an earlier file before static analysis sees it. ## Consequences -- Generation cannot silently overwrite a file or export. -- Adding or reordering a declaration cannot renumber existing public names. -- Intentional renames are deterministic and propagate through all references. -- A conflicting source requires either a source-level rename or an explicit - typed mapping. -- Mapping configuration is more verbose than automatic suffixing, but it is - type-checked, and raw identifiers represented in the contract remain - unambiguous. +- Normal generation uses less time and peak memory. +- Projects must keep final generated names unique. +- Resolve a conflict by renaming its SQL or schema source. +- A future collision check should avoid growing-list normalization and should + preferably be enforced by pgn before generator evaluation. diff --git a/docs/upstream-asks.md b/docs/upstream-asks.md index 430fd50..1bac627 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -1,9 +1,10 @@ # Upstream resolution status: pgn and gen-sdk This brief records the current status of three upstream integration points. -The pgn annotation-metadata and warning-surfacing issues are closed and shipped. -Only the custom-type identity request remains actionable. Architecture details -stay in `DESIGN.md`. +The first two issues are closed and shipped. The qualified custom-type identity +gap is addressed by gen-contract v5, while the broader normalized-name +guarantee remains open in pgenie issue 75. Architecture details stay in +`DESIGN.md`. ## 1. pgn annotation metadata: resolved @@ -18,13 +19,13 @@ generator-side annotation parsing remains. Issue [#67](https://github.com/pgenie-io/pgenie/issues/67) is closed. Since pgn 0.7.2, successful generation surfaces reports from `Compiled.warnings`; this -repository pins pgn 0.9.1. +repository pins pgn v0.12.0. With `onUnsupported: Skip`, the generator retains a report for every dropped -unit while fixed-point filtering removes unsupported custom types, their +unit while a single-pass survivor fold removes unsupported custom types, their dependents, and affected statements. See `DESIGN.md`, section 8. -## 3. Preserve qualified custom-type identity: actionable +## 3. Preserve qualified custom-type identity: contract support shipped Project-wide custom-type lookup classifies every custom reference as an enum, composite, or absent. Shipped models are pure declarations with no class @@ -32,16 +33,19 @@ codecs. The lookup classification instead drives support-shape validation, custom imports and dependencies, and dependency-first psycopg adapter registration. See `DESIGN.md`, sections 3, 5, and 8. -`Interpreters/Project.dhall` currently implements `buildLookup` by comparing a -custom reference's snake-case name with each project custom type through -`Text/equal`. This local lookup is the generator's sole need for that -pgn-specific builtin, but the unqualified comparison also exposes an upstream -identity gap. +This ask has since been addressed by gen-contract v5.0.0 (see below). For +history: `Interpreters/Project.dhall` formerly implemented `buildLookup` by +comparing a custom reference's snake-case name with each project custom type +through `Text/equal`, which was the generator's sole need for that pgn-specific +builtin; the unqualified comparison also exposed an upstream identity gap. That +lookup has been removed, references now resolve by `CustomTypeRef.index`. +Generated Python class and module names still use the unqualified contract +name, so gen-contract v5 does not itself prevent normalized-name collisions. -With pgn 0.9.1, a project containing `alpha.status` and `beta.status` can arrive -with only one `customTypes` entry, while both uses are represented by the same -unqualified `Scalar.Custom Name`. A Python mapping cannot recover the discarded -schema or safely repair annotations and adapter registration. +Before gen-contract v5, a project containing `alpha.status` and `beta.status` +could arrive with only one `customTypes` entry, while both uses were represented +by the same unqualified `Scalar.Custom Name`. A Python mapping cannot recover a +discarded schema or safely repair annotations and adapter registration. The upstream ask has two inseparable parts: @@ -54,6 +58,6 @@ not enough. The qualified reference lets lookup use an equality-free structural match and removes the local `Text/equal` dependency described in `DESIGN.md`, section 10. -Changing `Scalar.Custom` affects gen-sdk consumers. This repository pins its -gen-sdk import by sha256, so adopting such a change would require an explicit -pin update and a full harness run. +Changing `Scalar.Custom` affects gen-sdk consumers. This repository adopted the +new shape through explicit sha256 pin updates to gen-contract v5 and gen-sdk v3 +and verified it with the full harness. diff --git a/fixtures/Exhaustive.dhall b/fixtures/Exhaustive.dhall index 6bb6fb9..fd1030b 100644 --- a/fixtures/Exhaustive.dhall +++ b/fixtures/Exhaustive.dhall @@ -9,17 +9,13 @@ -- those statements/types are dropped with a warning instead of aborting the -- whole compile. -- --- CI evaluates this fixture with the pinned fork-aware directory-tree action. --- Standard `dhall to-directory-tree` cannot evaluate the pgn fork's Text/equal --- builtin used by the generator. +-- CI evaluates this fixture with its pinned directory-tree action. let Sdk = ../src/Deps/Sdk.dhall let Gen = ../src/package.dhall let OnUnsupported = ../src/Structures/OnUnsupported.dhall -let PythonNameMapping = ../src/Structures/PythonNameMapping.dhall - let project = Sdk.Fixtures.Exhaustive let config = @@ -27,8 +23,6 @@ let config = { packageName = None Text , emitSync = Some False , onUnsupported = Some OnUnsupported.Mode.Skip - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Output.toFileMap (Gen.compile config project) diff --git a/mise.lock b/mise.lock index 2f2ab47..78f3772 100644 --- a/mise.lock +++ b/mise.lock @@ -1,18 +1,18 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html [[tools."github:pgenie-io/pgenie"]] -version = "0.9.1" +version = "0.12.0" backend = "github:pgenie-io/pgenie" [tools."github:pgenie-io/pgenie"."platforms.linux-x64"] -checksum = "sha256:6fd7d9852b43083e29e6b81311cad526773ee58fb34e3e2916c599a477828ae3" -url = "https://github.com/pgenie-io/pgenie/releases/download/v0.9.1/pgn-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/472372948" +checksum = "sha256:27aab711ba18b76ce3daeb3e1f204ce404d0ad97c7952ad08b8f1c13c41703e3" +url = "https://github.com/pgenie-io/pgenie/releases/download/v0.12.0/pgn-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/476896947" [tools."github:pgenie-io/pgenie"."platforms.macos-arm64"] -checksum = "sha256:17469d67bbb540af197df9b1abc30b097c4f222cb59bad53f8a3458929a0e5bd" -url = "https://github.com/pgenie-io/pgenie/releases/download/v0.9.1/pgn-macos-arm64.tar.gz" -url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/472372949" +checksum = "sha256:9f95485d1607e3c1c3eebec9b4cde99163981c517d31b836b456228af0164206" +url = "https://github.com/pgenie-io/pgenie/releases/download/v0.12.0/pgn-macos-arm64.tar.gz" +url_api = "https://api.github.com/repos/pgenie-io/pgenie/releases/assets/476896949" [[tools.python]] version = "3.12.12" diff --git a/mise.toml b/mise.toml index 00c0cfb..b185c3b 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ # This is a standalone repo (unlike the monorepo copy this was extracted from, # which inherits pgn from its repo-root mise.toml), so pgn is pinned here. [tools] -"github:pgenie-io/pgenie" = { version = "v0.9.1", exe = "pgn" } +"github:pgenie-io/pgenie" = { version = "v0.12.0", exe = "pgn" } uv = "latest" python = "3.12" diff --git a/src/Deps/Contract.dhall b/src/Deps/Contract.dhall index ea9e805..6fdb9e8 100644 --- a/src/Deps/Contract.dhall +++ b/src/Deps/Contract.dhall @@ -1,2 +1,2 @@ -https://raw.githubusercontent.com/pgenie-io/gen-contract/v4.0.1/src/package.dhall - sha256:4a130ba7fbaa152a776babbb1bf2994a4833931ca76bde9bf6930d354225651e +https://raw.githubusercontent.com/pgenie-io/gen-contract/v5.0.0/src/package.dhall + sha256:a1b48fe025c5536b13907bcd2db307cd438f3ae0f67a59222b3aa39e4bdac9ef diff --git a/src/Deps/Sdk.dhall b/src/Deps/Sdk.dhall index 18f9fce..95de509 100644 --- a/src/Deps/Sdk.dhall +++ b/src/Deps/Sdk.dhall @@ -1,2 +1,2 @@ -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v2.0.0/src/package.dhall - sha256:b9def6ab1179bc4aaae7fc6e91977f094f75934cd5755175c294a9e97ca71b15 +https://raw.githubusercontent.com/pgenie-io/gen-sdk/v3.0.0/src/package.dhall + sha256:368e4ee1f7557e8a1713a0ac53db6bbfb476027b322d06a660842e5e22e18662 diff --git a/src/Interpreters/CustomType.dhall b/src/Interpreters/CustomType.dhall index 1b59985..b158fce 100644 --- a/src/Interpreters/CustomType.dhall +++ b/src/Interpreters/CustomType.dhall @@ -8,10 +8,6 @@ let ImportSet = ../Structures/ImportSet.dhall let CustomKind = ../Structures/CustomKind.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - -let PythonNamespace = ../Structures/PythonNamespace.dhall - let PyIdent = ../Structures/PyIdent.dhall let MemberGen = ./Member.dhall @@ -20,9 +16,7 @@ let EnumModule = ../Templates/EnumModule.dhall let CompositeModule = ../Templates/CompositeModule.dhall -let Config = - { customTypeNameMappings : List PythonNameMapping.CustomType - } +let Config = {} let Input = Model.CustomType @@ -38,7 +32,6 @@ let Output = , kind : TypeKind , order : Natural , dependencies : List Natural - , moduleBindings : List PythonNamespace.Binding } let renderExtraImports = @@ -77,100 +70,19 @@ let renderExtraImports = ) # customLines -let moduleNamespace = - \(input : Input) -> - "custom type module for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - -let moduleBinding = - \(namespace : Text) -> - \(owner : Text) -> - \(name : Text) -> - { namespace - , owner - , name - , remediation = "Choose a different custom type name mapping target" - } - -let enumNamespaceBindings = - \(input : Input) -> - \(typeName : Text) -> - let namespace = moduleNamespace input - - in [ moduleBinding - namespace - "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - typeName - , moduleBinding namespace "enum base import" "StrEnum" - ] - -let compositeNamespaceBindings = - \(input : Input) -> - \(typeName : Text) -> - \(imports : ImportSet.Type) -> - let namespace = moduleNamespace input - - let imported = - ( if imports.uuid - then [ moduleBinding namespace "UUID primitive import" "UUID" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.datetime - then [ moduleBinding namespace "datetime primitive import" "datetime" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.date - then [ moduleBinding namespace "date primitive import" "date" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.time - then [ moduleBinding namespace "time primitive import" "time" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.timedelta - then [ moduleBinding namespace "timedelta primitive import" "timedelta" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.decimal - then [ moduleBinding namespace "Decimal primitive import" "Decimal" ] - else [] : List PythonNamespace.Binding - ) - # ( if imports.jsonValue - then [ moduleBinding namespace "generated core import" "JsonValue" ] - else [] : List PythonNamespace.Binding - ) - - let customImports = - Prelude.List.map - ImportSet.CustomImport - PythonNamespace.Binding - ( \(custom : ImportSet.CustomImport) -> - moduleBinding - namespace - "custom dependency import \".${custom.moduleName}.${custom.className}\"" - custom.className - ) - imports.customTypes - - in [ moduleBinding - namespace - "custom type class for schema ${Text/show input.pgSchema}, type ${Text/show input.pgName}" - typeName - , moduleBinding namespace "dataclass decorator import" "dataclass" - ] - # imported - # customImports - let run = \(config : Config) -> \(lookup : CustomKind.Lookup) -> + -- The custom type's own position in the project's `customTypes` list. + -- Unlike a `CustomTypeRef`, a `Model.CustomType` does not carry its own + -- index, so the caller (Interpreters/Project.dhall) threads it in for the + -- self-lookup kind-consistency check in each merge arm below. + \(index : Natural) -> \(input : Input) -> let pythonName = - PythonNameMapping.resolveCustomType - config.customTypeNameMappings - { schema = input.pgSchema, name = input.pgName } - { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase - , pascalCase = PyIdent.pySafeName input.name.inPascalCase - } + { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase + , pascalCase = PyIdent.pySafeName input.name.inPascalCase + } let typeName = pythonName.pascalCase @@ -210,8 +122,6 @@ let run = , kind = TypeKind.Enum , order = identity.order , dependencies = [] : List Natural - , moduleBindings = - enumNamespaceBindings input typeName } , Composite = \(_ : CustomKind.Identity) -> @@ -225,7 +135,7 @@ let run = [ input.pgName ] "Custom type not found in project customTypes" } - (lookup input.name) + (CustomKind.at lookup index) , Composite = \(members : List Model.Member) -> let compiledMembers @@ -239,18 +149,14 @@ let run = \(_ : Model.Primitive) -> MemberGen.run {=} lookup m , Custom = - \(name : Model.Name) -> - Prelude.Optional.fold - Model.ArraySettings - m.value.arraySettings - (Lude.Compiled.Type MemberGen.Output) - ( \(_ : Model.ArraySettings) -> - Lude.Compiled.report + \(ref : Model.CustomTypeRef) -> + if Prelude.Bool.not + (Natural/isZero m.value.dimensionality) + then Lude.Compiled.report MemberGen.Output - [ m.pgName, name.inSnakeCase ] + [ m.pgName, ref.name.inSnakeCase ] "Custom array fields inside a composite type are not supported" - ) - (MemberGen.run {=} lookup m) + else MemberGen.run {=} lookup m } m.value.scalar ) @@ -310,11 +216,6 @@ let run = , kind = TypeKind.Composite , order = identity.order , dependencies - , moduleBindings = - compositeNamespaceBindings - input - typeName - combinedImports } , Enum = \(_ : CustomKind.Identity) -> @@ -328,7 +229,7 @@ let run = [ input.pgName ] "Custom type not found in project customTypes" } - (lookup input.name) + (CustomKind.at lookup index) in Lude.Compiled.flatMap (List MemberGen.Output) diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall index c621e6d..bffb7bb 100644 --- a/src/Interpreters/Member.dhall +++ b/src/Interpreters/Member.dhall @@ -49,10 +49,10 @@ let runWithPrefix = } , Custom = Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> let mkOutput = \(identity : CustomKind.Identity) -> \(customImports : ImportSet.Type) -> @@ -87,7 +87,7 @@ let runWithPrefix = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of an enum with dimensionality > 2 is not supported" , Composite = @@ -104,16 +104,16 @@ let runWithPrefix = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of a composite type with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output - [ name.inSnakeCase ] + [ ref.name.inSnakeCase ] "Custom type not found in project customTypes" } - (lookup name) + (CustomKind.at lookup ref.index) ) ( Lude.Compiled.report Output diff --git a/src/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall index b48c1dd..d2fb6d3 100644 --- a/src/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -166,23 +166,21 @@ let primitiveIsJsonb = let scalarIsJson = \(value : Model.Value) -> merge - { Primitive = primitiveIsJson, Custom = \(_ : Model.Name) -> False } + { Primitive = primitiveIsJson + , Custom = \(_ : Model.CustomTypeRef) -> False + } value.scalar let scalarIsJsonb = \(value : Model.Value) -> merge - { Primitive = primitiveIsJsonb, Custom = \(_ : Model.Name) -> False } + { Primitive = primitiveIsJsonb + , Custom = \(_ : Model.CustomTypeRef) -> False + } value.scalar let valueIsArray = - \(value : Model.Value) -> - Prelude.Optional.fold - Model.ArraySettings - value.arraySettings - Bool - (\(_ : Model.ArraySettings) -> True) - False + \(value : Model.Value) -> Prelude.Bool.not (Natural/isZero value.dimensionality) -- A bare (non-array) jsonb scalar binds via psycopg Jsonb(); a bare json scalar -- binds via Json(). json preserves the document verbatim, jsonb normalizes it, so @@ -253,10 +251,10 @@ let run = } in Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> let dimsAtMostTwo = Natural/isZero (Natural/subtract 2 value.dims) @@ -289,7 +287,7 @@ let run = else Lude.Compiled.report Output [ input.pgName - , name.inSnakeCase + , ref.name.inSnakeCase ] "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = @@ -314,15 +312,15 @@ let run = ) else Lude.Compiled.report Output - [ input.pgName, name.inSnakeCase ] + [ input.pgName, ref.name.inSnakeCase ] "Array of a composite type parameter with dimensionality > 1 is not supported" , Absent = Lude.Compiled.report Output - [ name.inSnakeCase ] + [ ref.name.inSnakeCase ] "Custom type not found in project customTypes" } - (lookup name) + (CustomKind.at lookup ref.index) ) ( if isJsonArrayParam then Lude.Compiled.report diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall index b4bf60d..68b62f4 100644 --- a/src/Interpreters/Project.dhall +++ b/src/Interpreters/Project.dhall @@ -14,6 +14,8 @@ let QueryGen = ./Query.dhall let CustomTypeGen = ./CustomType.dhall +let PrimitiveGen = ./Primitive.dhall + let CoreModule = ../Templates/CoreModule.dhall let RuntimeModule = ../Templates/RuntimeModule.dhall @@ -28,10 +30,6 @@ let FacadeModule = ../Templates/FacadeModule.dhall let OnUnsupported = ../Structures/OnUnsupported.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - -let PythonNamespace = ../Structures/PythonNamespace.dhall - let Report = { path : List Text, message : Text } -- The generator's public Config: every field is independently Optional, so a @@ -43,471 +41,21 @@ let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode - , queryNameMappings : Optional (List PythonNameMapping.Query) - , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } --- The root resolves every public option once. Query receives only emitSync and --- its mappings, while CustomType receives only its mappings. Lower interpreters --- use empty configs except for Result's caller-supplied rowClassName. +-- The root resolves every public option once. Lower interpreters use empty +-- configs except for Query's emitSync and Result's caller-supplied rowClassName. let ResolvedConfig = { packageName : Text , importName : Text , emitSync : Bool , onUnsupported : OnUnsupported.Mode - , queryNameMappings : List PythonNameMapping.Query - , customTypeNameMappings : List PythonNameMapping.CustomType } let Input = Model.Project let Output = Lude.Files.Type -let QueryMappingState = - { seen : List Text, error : Optional Report } - -let CustomTypeMappingState = - { seen : List PythonNameMapping.CustomTypeSource - , error : Optional Report - } - -let CustomTypeIdentity = - { contractName : Text, source : PythonNameMapping.CustomTypeSource } - -let CustomTypeIdentityState = - { seen : List CustomTypeIdentity, error : Optional Report } - -let finishMappingValidation = - \(stateError : Optional Report) -> - Prelude.Optional.fold - Report - stateError - (Lude.Compiled.Type {}) - (\(report : Report) -> (Lude.Compiled.Type {}).Err report) - (Lude.Compiled.ok {} {=}) - -let validateCustomTypeIdentities = - \(customTypes : List Model.CustomType) -> - let step = - \(customType : Model.CustomType) -> - \(state : CustomTypeIdentityState) -> - Prelude.Optional.fold - Report - state.error - CustomTypeIdentityState - (\(_ : Report) -> state) - ( let contractName = customType.name.inSnakeCase - - let previous = - List/fold - CustomTypeIdentity - state.seen - (Optional PythonNameMapping.CustomTypeSource) - ( \(identity : CustomTypeIdentity) -> - \(found : Optional PythonNameMapping.CustomTypeSource) -> - if Text/equal - identity.contractName - contractName - then Some identity.source - else found - ) - (None PythonNameMapping.CustomTypeSource) - - let error = - Prelude.Optional.fold - PythonNameMapping.CustomTypeSource - previous - (Optional Report) - ( \(source : PythonNameMapping.CustomTypeSource) -> - Some - { path = [ "customTypes", contractName ] - , message = - "Ambiguous unqualified custom type identity " - ++ Text/show contractName - ++ ": schema " - ++ Text/show source.schema - ++ ", type " - ++ Text/show source.name - ++ " and schema " - ++ Text/show customType.pgSchema - ++ ", type " - ++ Text/show customType.pgName - ++ " share one contract Name. The upstream contract must preserve a distinct schema-qualified custom identifier; Python mappings cannot recover it" - } - ) - (None Report) - - let identity = - { contractName - , source = - { schema = customType.pgSchema - , name = customType.pgName - } - } - - in { seen = state.seen # [ identity ], error } - ) - - let state = - List/fold - Model.CustomType - customTypes - CustomTypeIdentityState - step - { seen = [] : List CustomTypeIdentity - , error = None Report - } - - in finishMappingValidation state.error - -let validateQueryMappings = - \(mappings : List PythonNameMapping.Query) -> - \(queries : List Model.Query) -> - let step = - \(mapping : PythonNameMapping.Query) -> - \(state : QueryMappingState) -> - Prelude.Optional.fold - Report - state.error - QueryMappingState - (\(_ : Report) -> state) - ( let duplicate = - Prelude.List.any - Text - (\(source : Text) -> Text/equal source mapping.source) - state.seen - - let known = - Prelude.List.any - Model.Query - ( \(query : Model.Query) -> - Text/equal query.name.inSnakeCase mapping.source - ) - queries - - let snakeIsExact = - PyIdent.isSnakeIdentifier mapping.target.snakeCase - && Text/equal - (PyIdent.querySafeName mapping.target.snakeCase) - mapping.target.snakeCase - - let pascalIsExact = - PyIdent.isPascalIdentifier mapping.target.pascalCase - && Text/equal - (PyIdent.pySafeName mapping.target.pascalCase) - mapping.target.pascalCase - - let error = - if duplicate - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - ] - , message = - "Duplicate query name mapping source \"${mapping.source}\"" - } - else if Prelude.Bool.not known - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - ] - , message = - "Unknown query name mapping source \"${mapping.source}\"" - } - else if Prelude.Bool.not snakeIsExact - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - , "target" - , "snakeCase" - ] - , message = - "Mapped query snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" - } - else if Prelude.Bool.not pascalIsExact - then Some - { path = - [ "config" - , "queryNameMappings" - , mapping.source - , "target" - , "pascalCase" - ] - , message = - "Mapped query pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" - } - else None Report - - in { seen = state.seen # [ mapping.source ], error } - ) - - let state = - List/fold - PythonNameMapping.Query - mappings - QueryMappingState - step - { seen = [] : List Text, error = None Report } - - in finishMappingValidation state.error - -let validateCustomTypeMappings = - \(mappings : List PythonNameMapping.CustomType) -> - \(customTypes : List Model.CustomType) -> - let sameSource = - \(left : PythonNameMapping.CustomTypeSource) -> - \(right : PythonNameMapping.CustomTypeSource) -> - Text/equal left.schema right.schema - && Text/equal left.name right.name - - let step = - \(mapping : PythonNameMapping.CustomType) -> - \(state : CustomTypeMappingState) -> - Prelude.Optional.fold - Report - state.error - CustomTypeMappingState - (\(_ : Report) -> state) - ( let duplicate = - Prelude.List.any - PythonNameMapping.CustomTypeSource - (sameSource mapping.source) - state.seen - - let known = - Prelude.List.any - Model.CustomType - ( \(customType : Model.CustomType) -> - Text/equal - customType.pgSchema - mapping.source.schema - && Text/equal - customType.pgName - mapping.source.name - ) - customTypes - - let snakeIsExact = - PyIdent.isSnakeIdentifier mapping.target.snakeCase - && Text/equal - ( PyIdent.typeModuleSafeName - mapping.target.snakeCase - ) - mapping.target.snakeCase - - let pascalIsExact = - PyIdent.isPascalIdentifier mapping.target.pascalCase - && Text/equal - (PyIdent.pySafeName mapping.target.pascalCase) - mapping.target.pascalCase - - let source = - "${mapping.source.schema}.${mapping.source.name}" - - let error = - if duplicate - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - ] - , message = - "Duplicate custom type name mapping source \"${source}\"" - } - else if Prelude.Bool.not known - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - ] - , message = - "Unknown custom type name mapping source \"${source}\"" - } - else if Prelude.Bool.not snakeIsExact - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - , "target" - , "snakeCase" - ] - , message = - "Mapped custom type snakeCase must start with a lowercase ASCII letter, then contain only lowercase ASCII letters, digits, or underscores, and must not be reserved" - } - else if Prelude.Bool.not pascalIsExact - then Some - { path = - [ "config" - , "customTypeNameMappings" - , source - , "target" - , "pascalCase" - ] - , message = - "Mapped custom type pascalCase must start with an uppercase ASCII letter, then contain only ASCII letters or digits, and must not be reserved" - } - else None Report - - in { seen = state.seen # [ mapping.source ], error } - ) - - let state = - List/fold - PythonNameMapping.CustomType - mappings - CustomTypeMappingState - step - { seen = [] : List PythonNameMapping.CustomTypeSource - , error = None Report - } - - in finishMappingValidation state.error - -let validateLocalNamespaces = - \(queries : List Model.Query) -> - \(customTypes : List Model.CustomType) -> - let validateQuery = - \(query : Model.Query) -> - let querySource = Text/show query.name.inSnakeCase - - let parameterBindings = - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(parameter : Model.Member) -> - { namespace = - "parameters for query ${querySource}" - , owner = - "query parameter ${Text/show parameter.pgName}" - , name = - PyIdent.parameterSafeName - parameter.name.inSnakeCase - , remediation = "Rename one SQL placeholder" - } - ) - query.params - - let resultColumns = - merge - { Void = [] : List Model.Member - , RowsAffected = [] : List Model.Member - , Rows = - \(rows : Model.ResultRows) -> - Prelude.NonEmpty.toList - Model.Member - rows.columns - } - query.result - - let resultBindings = - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(column : Model.Member) -> - { namespace = - "result fields for query ${querySource}" - , owner = - "result column ${Text/show column.pgName}" - , name = - PyIdent.pySafeName column.name.inSnakeCase - , remediation = "Rename one SQL result alias" - } - ) - resultColumns - - in PythonNamespace.validate - (parameterBindings # resultBindings) - - let validateCustomType = - \(customType : Model.CustomType) -> - let customSource = - "schema ${Text/show customType.pgSchema}, type ${Text/show customType.pgName}" - - let bindings = - merge - { Composite = - \(members : List Model.Member) -> - Prelude.List.map - Model.Member - PythonNamespace.Binding - ( \(member : Model.Member) -> - { namespace = - "fields for custom type ${customSource}" - , owner = - "composite field ${Text/show member.pgName}" - , name = - PyIdent.pySafeName - member.name.inSnakeCase - , remediation = - "Rename one PostgreSQL composite field" - } - ) - members - , Enum = - \(variants : List Model.EnumVariant) -> - Prelude.List.map - Model.EnumVariant - PythonNamespace.Binding - ( \(variant : Model.EnumVariant) -> - { namespace = - "enum members for custom type ${customSource}" - , owner = - "enum label ${Text/show variant.pgName}" - , name = - PyIdent.pySafeName - variant.name.inScreamingSnakeCase - , remediation = - "Rename one PostgreSQL enum label" - } - ) - variants - , Domain = - \(_ : Model.Value) -> - [] : List PythonNamespace.Binding - } - customType.definition - - in PythonNamespace.validate bindings - - let queryValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - Model.Query - {} - validateQuery - queries - ) - - let customTypeValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - Model.CustomType - {} - validateCustomType - customTypes - ) - - in Lude.Compiled.flatMap - {} - {} - (\(_ : {}) -> customTypeValidation) - queryValidation - -- Header of every emitted .py file. The marker truthfully warns that another -- generation replaces manual changes. The SPDX pair (REUSE convention) -- licenses the emitted file itself as MIT-0. @@ -528,16 +76,12 @@ let IndexedCustomType = { index : Natural, value : Model.CustomType } let LookupKind = < Composite | Enum | Domain > let LookupEntry = - { contractName : Text - , kind : LookupKind - , identity : CustomKind.Identity - } + { kind : LookupKind, identity : CustomKind.Identity } let ResolvedCustomType = { value : Model.CustomType, lookupEntry : LookupEntry } let resolveCustomTypes = - \(nameMappings : List PythonNameMapping.CustomType) -> \(customTypes : List Model.CustomType) -> Prelude.List.map IndexedCustomType @@ -546,14 +90,10 @@ let resolveCustomTypes = let customType = entry.value let pythonName = - PythonNameMapping.resolveCustomType - nameMappings - { schema = customType.pgSchema, name = customType.pgName } - { snakeCase = - PyIdent.typeModuleSafeName - customType.name.inSnakeCase - , pascalCase = PyIdent.pySafeName customType.name.inPascalCase - } + { snakeCase = + PyIdent.typeModuleSafeName customType.name.inSnakeCase + , pascalCase = PyIdent.pySafeName customType.name.inPascalCase + } let identity = { className = pythonName.pascalCase @@ -570,45 +110,97 @@ let resolveCustomTypes = customType.definition in { value = customType - , lookupEntry = - { contractName = customType.name.inSnakeCase - , kind - , identity - } + , lookupEntry = { kind, identity } } ) (Prelude.List.indexed Model.CustomType customTypes) -let lookupEntries = - \(customTypes : List ResolvedCustomType) -> +-- The fixed, never-shrinking kind classification of every custom type, in +-- `input.customTypes` order (so position i == a `CustomTypeRef.index` of i). +-- A type's kind (Enum/Composite/Domain) is a property of its own definition +-- and never changes across Skip removal passes; Domain has no Python identity, +-- so it classifies as `Absent`. Survivorship is layered on separately (see +-- `supported`/`lookup` in `run`), keeping the two concerns that the former +-- `buildLookup` conflated cleanly apart. +let kindOf + : List ResolvedCustomType -> CustomKind.Lookup + = \(entries : List ResolvedCustomType) -> Prelude.List.map ResolvedCustomType - LookupEntry - (\(customType : ResolvedCustomType) -> customType.lookupEntry) - customTypes - -let buildLookup = - \(entries : List LookupEntry) -> - Prelude.List.fold - LookupEntry + CustomKind.TypeKind + ( \(rt : ResolvedCustomType) -> + merge + { Composite = + CustomKind.TypeKind.Composite rt.lookupEntry.identity + , Enum = CustomKind.TypeKind.Enum rt.lookupEntry.identity + , Domain = CustomKind.TypeKind.Absent + } + rt.lookupEntry.kind + ) entries - CustomKind.Lookup - ( \(entry : LookupEntry) -> - \(rest : CustomKind.Lookup) -> - \(name : Model.Name) -> - -- Text/equal is a pgn embedded-Dhall builtin. The upstream exit is - -- a stable custom kind or id on Scalar.Custom. - if Text/equal name.inSnakeCase entry.contractName - then merge - { Composite = - CustomKind.TypeKind.Composite entry.identity - , Enum = CustomKind.TypeKind.Enum entry.identity - , Domain = CustomKind.TypeKind.Absent + +-- True when the primitive has a faithful Python mapping (`money`, `bit`, geo +-- types, etc. do not). Reuses the Primitive interpreter as the oracle rather +-- than duplicating its 60-way supported/unsupported table, so this predicate +-- can never drift from what `CustomTypeGen.run` actually emits. +let primitiveSupported = + \(primitive : Model.Primitive) -> + merge + { Ok = + \(_ : { warnings : List Report, value : PrimitiveGen.Output }) -> + True + , Err = \(_ : Report) -> False + } + (PrimitiveGen.run {=} primitive) + +-- Whether a custom type's OWN definition compiles, assuming every custom type +-- it references is present (the transitive "is a referenced type still a +-- survivor" half is handled by `Sdk.CustomTypes.supportedCustomTypesReasoned`, +-- which folds this predicate over the topologically-sorted `customTypes`). +-- This exactly mirrors the own-shape failure modes of `CustomTypeGen.run`: +-- * Domain -> never supported (no Python lowering). +-- * Enum -> always compiles. +-- * Composite -> every member must compile: a primitive member needs a +-- supported primitive; a custom member must be a bare +-- scalar (CustomType.dhall rejects any custom array +-- field, regardless of the referenced kind). +-- Using this as the survivorship predicate (instead of the former +-- `buildLookup`-based render probe) keeps Skip mode dropping exactly the set of +-- types that could not be emitted, so no surviving type later fails to render. +let ownDefinitionSupported + : Model.CustomTypeDefinition -> Bool + = \(definition : Model.CustomTypeDefinition) -> + merge + { Composite = + \(members : List Model.Member) -> + Prelude.List.all + Model.Member + ( \(member : Model.Member) -> + merge + { Primitive = + \(primitive : Model.Primitive) -> + primitiveSupported primitive + , Custom = + \(_ : Model.CustomTypeRef) -> + Natural/isZero member.value.dimensionality } - entry.kind - else rest name - ) - (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) + member.value.scalar + ) + members + , Enum = \(_ : List Model.EnumVariant) -> True + , Domain = \(_ : Model.Value) -> False + } + definition + +let boolAt = + \(bools : List Bool) -> + \(index : Natural) -> + Prelude.Optional.fold + Bool + (Prelude.List.index index Bool bools) + Bool + (\(b : Bool) -> b) + False let sameOrder = \(left : Natural) -> @@ -699,161 +291,6 @@ let registrationOrder = [ "register_types" ] "Unresolved or cyclic custom type dependencies: ${unresolved}" -let validateProjectNamespaces = - \(config : ResolvedConfig) -> - \(queries : List QueryGen.Output) -> - \(customTypes : List CustomTypeGen.Output) -> - let mappingRemediation = - "Rename the source or configure a typed name mapping" - - let queryOwner = - \(query : QueryGen.Output) -> - "query \"${query.sourceName}\" (${query.sourcePath})" - - let queryModuleBindings = - Prelude.List.map - QueryGen.Output - PythonNamespace.Binding - ( \(query : QueryGen.Output) -> - { namespace = "generated statement modules" - , owner = queryOwner query - , name = query.functionName - , remediation = mappingRemediation - } - ) - queries - - let typeModuleBindings = - Prelude.List.map - CustomTypeGen.Output - PythonNamespace.Binding - ( \(customType : CustomTypeGen.Output) -> - { namespace = "generated custom type modules" - , owner = - "custom type \"${customType.pgSchema}.${customType.pgName}\"" - , name = customType.moduleName - , remediation = mappingRemediation - } - ) - customTypes - - let coreFacadeBindings = - [ { namespace = "package facade" - , owner = "generated core symbol JsonValue" - , name = "JsonValue" - , remediation = mappingRemediation - } - , { namespace = "package facade" - , owner = "generated core symbol NoRowError" - , name = "NoRowError" - , remediation = mappingRemediation - } - ] : List PythonNamespace.Binding - - let syncFacadeBindings = - if config.emitSync - then [ { namespace = "package facade" - , owner = "generated sync facade" - , name = "sync" - , remediation = mappingRemediation - } - ] - else [] : List PythonNamespace.Binding - - let registrationFacadeBindings = - if Prelude.List.null CustomTypeGen.Output customTypes - then [] : List PythonNamespace.Binding - else [ { namespace = "package facade" - , owner = "generated type registration function" - , name = "register_types" - , remediation = mappingRemediation - } - ] - - let queryFacadeBindings = - Prelude.List.concatMap - QueryGen.Output - PythonNamespace.Binding - ( \(query : QueryGen.Output) -> - let functionBinding = - { namespace = "package facade" - , owner = queryOwner query - , name = query.functionName - , remediation = mappingRemediation - } - - let rowBindings = - Prelude.Optional.fold - Text - query.rowClassName - (List PythonNamespace.Binding) - ( \(rowClassName : Text) -> - [ { namespace = "package facade" - , owner = - "Row class from ${queryOwner query}" - , name = rowClassName - , remediation = mappingRemediation - } - ] - ) - ([] : List PythonNamespace.Binding) - - in [ functionBinding ] # rowBindings - ) - queries - - let typeFacadeBindings = - Prelude.List.map - CustomTypeGen.Output - PythonNamespace.Binding - ( \(customType : CustomTypeGen.Output) -> - { namespace = "package facade" - , owner = - "custom type \"${customType.pgSchema}.${customType.pgName}\"" - , name = customType.typeName - , remediation = mappingRemediation - } - ) - customTypes - - -- validate freezes a right-folded state, so groups are supplied in - -- reverse diagnostic priority. Module/file collisions remain the - -- primary cause when the same pair would also collide in a facade. - let projectValidation = - PythonNamespace.validate - ( typeFacadeBindings - # queryFacadeBindings - # registrationFacadeBindings - # syncFacadeBindings - # coreFacadeBindings - # typeModuleBindings - # queryModuleBindings - ) - - -- A module-name collision is fatal in Fail and Skip alike. These - -- bindings travel through CustomType.Output so Skip's support probe - -- cannot mistake a naming error for an unsupported PostgreSQL shape. - let moduleValidation = - Lude.Compiled.map - (List {}) - {} - (\(_ : List {}) -> {=}) - ( Lude.Compiled.traverseList - CustomTypeGen.Output - {} - ( \(customType : CustomTypeGen.Output) -> - PythonNamespace.validate - customType.moduleBindings - ) - customTypes - ) - - in Lude.Compiled.flatMap - {} - {} - (\(_ : {}) -> projectValidation) - moduleValidation - let combineOutputs = \(config : ResolvedConfig) -> \(input : Input) -> @@ -1101,117 +538,102 @@ let run = (\(m : OnUnsupported.Mode) -> m) OnUnsupported.Mode.Fail - let queryNameMappings = - Prelude.Optional.fold - (List PythonNameMapping.Query) - config.queryNameMappings - (List PythonNameMapping.Query) - (\(mappings : List PythonNameMapping.Query) -> mappings) - ([] : List PythonNameMapping.Query) - - let customTypeNameMappings = - Prelude.Optional.fold - (List PythonNameMapping.CustomType) - config.customTypeNameMappings - (List PythonNameMapping.CustomType) - (\(mappings : List PythonNameMapping.CustomType) -> mappings) - ([] : List PythonNameMapping.CustomType) - let importName = Prelude.Text.replace "-" "_" packageName let resolvedConfig : ResolvedConfig - = { packageName - , importName - , emitSync - , onUnsupported - , queryNameMappings - , customTypeNameMappings - } + = { packageName, importName, emitSync, onUnsupported } - let queryConfig = - resolvedConfig.{ emitSync, queryNameMappings } + let queryConfig = resolvedConfig.{ emitSync } - let customTypeConfig = - resolvedConfig.{ customTypeNameMappings } + let customTypeConfig = {=} let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported - let typeSucceedsWith = - \(candidateLookup : CustomKind.Lookup) -> - \(ct : Model.CustomType) -> - merge - { Ok = - \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> - True - , Err = \(_ : Report) -> False - } - (CustomTypeGen.run customTypeConfig candidateLookup ct) + let resolvedCustomTypes = resolveCustomTypes input.customTypes - -- Nested under the type's own name so the warning names the type - -- that failed, not just the inner member/column that triggered it - -- (CustomType.run itself does not). - let typeWarningWith = - \(candidateLookup : CustomKind.Lookup) -> - \(ct : Model.CustomType) -> - merge - { Ok = - \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> - None Report - , Err = - \(err : Report) -> Some { path = [ ct.name.inSnakeCase ] # err.path, message = err.message } - } - (CustomTypeGen.run customTypeConfig candidateLookup ct) + -- The unmasked, index-aligned kind classification of every custom type. + let fixedKindOf = kindOf resolvedCustomTypes + + -- Per-index survivorship. In Skip mode this is the one-pass transitive + -- cascade from gen-sdk: `supportedCustomTypesReasoned` folds + -- `ownDefinitionSupported` over the topologically-sorted `customTypes`, + -- returning `None` (supported) or `Some ` (unsupported) + -- per index; we keep only the Bool. In Fail mode nothing is ever + -- dropped, so every index is supported. + let supported + : List Bool + = if skip + then Prelude.List.map + (Optional Model.CustomTypeRef) + Bool + (Prelude.Optional.null Model.CustomTypeRef) + ( Sdk.CustomTypes.supportedCustomTypesReasoned + ownDefinitionSupported + input.customTypes + ) + else Prelude.List.map + ResolvedCustomType + Bool + (\(_ : ResolvedCustomType) -> True) + resolvedCustomTypes + + -- The kind lookup handed to every interpreter. In Skip mode an + -- unsupported index is masked to `Absent` regardless of its real kind, + -- so a reference to a removed type reads exactly as a missing type + -- (the "Absent on either failure" invariant the former `buildLookup` + -- provided by shrinking its entry list). In Fail mode it is the + -- unmasked classification, so behaviour is identical to before Skip. + let lookup + : CustomKind.Lookup + = if skip + then Prelude.List.map + { index : Natural, value : CustomKind.TypeKind } + CustomKind.TypeKind + ( \(e : { index : Natural, value : CustomKind.TypeKind }) -> + if boolAt supported e.index + then e.value + else CustomKind.TypeKind.Absent + ) + (Prelude.List.indexed CustomKind.TypeKind fixedKindOf) + else fixedKindOf - let resolvedCustomTypes = - resolveCustomTypes - resolvedConfig.customTypeNameMappings - input.customTypes + let indexedResolvedCustomTypes + : List { index : Natural, value : ResolvedCustomType } + = Prelude.List.indexed ResolvedCustomType resolvedCustomTypes - -- Nested custom support requires transitive closure: after one type is - -- removed, composites depending on it must be reconsidered against the - -- smaller lookup. Each bounded pass can only remove survivors. let effectiveResolvedCustomTypes - : List ResolvedCustomType + : List { index : Natural, value : ResolvedCustomType } = if skip - then Natural/fold - (Prelude.List.length Model.CustomType input.customTypes) - (List ResolvedCustomType) - ( \(survivors : List ResolvedCustomType) -> - let candidateLookup = - buildLookup (lookupEntries survivors) - - in Prelude.List.filter - ResolvedCustomType - ( \(customType : ResolvedCustomType) -> - typeSucceedsWith - candidateLookup - customType.value - ) - survivors + then Prelude.List.filter + { index : Natural, value : ResolvedCustomType } + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + boolAt supported e.index ) - resolvedCustomTypes - else resolvedCustomTypes + indexedResolvedCustomTypes + else indexedResolvedCustomTypes - let effectiveCustomTypes = - Prelude.List.map - ResolvedCustomType - Model.CustomType - (\(customType : ResolvedCustomType) -> customType.value) + let effectiveCustomTypes + : List { index : Natural, value : Model.CustomType } + = Prelude.List.map + { index : Natural, value : ResolvedCustomType } + { index : Natural, value : Model.CustomType } + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + { index = e.index, value = e.value.value } + ) effectiveResolvedCustomTypes - let lookup = - buildLookup (lookupEntries effectiveResolvedCustomTypes) - - -- Fail mode: identical to the pre-Skip code (traverseList straight - -- over input.customTypes), so its error message/path is unchanged. + -- Fail mode: `effectiveCustomTypes` is every input type (indexed), so + -- this is the pre-Skip traversal with each type's own index threaded to + -- CustomTypeGen.run for its self-lookup; its error message/path is + -- unchanged. Skip mode traverses only the survivors. let typesForCombine : Lude.Compiled.Type (List CustomTypeGen.Output) = Lude.Compiled.traverseList - Model.CustomType + { index : Natural, value : Model.CustomType } CustomTypeGen.Output - ( \(ct : Model.CustomType) -> - CustomTypeGen.run customTypeConfig lookup ct + ( \(e : { index : Natural, value : Model.CustomType }) -> + CustomTypeGen.run customTypeConfig lookup e.index e.value ) effectiveCustomTypes @@ -1261,16 +683,39 @@ let run = registrationOrder typesForCombine + -- Nested under the type's own name so the warning names the type that + -- failed, not just the inner member/column that triggered it + -- (CustomType.run itself does not). Uses the single final `lookup` + -- (which already masks removed types to Absent) and each type's own + -- real index, so every input type is diagnosed at its true position. + let typeWarning = + \(ct : Model.CustomType) -> + \(index : Natural) -> + merge + { Ok = + \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> + None Report + , Err = + \(err : Report) -> + Some + { path = [ ct.name.inSnakeCase ] # err.path + , message = err.message + } + } + (CustomTypeGen.run customTypeConfig lookup index ct) + let skipWarnings : List Report = if skip then Prelude.List.unpackOptionals Report ( Prelude.List.map - Model.CustomType + { index : Natural, value : ResolvedCustomType } (Optional Report) - (typeWarningWith lookup) - input.customTypes + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + typeWarning e.value.value e.index + ) + indexedResolvedCustomTypes ) # Prelude.List.unpackOptionals Report @@ -1295,66 +740,19 @@ let run = let combined : Lude.Compiled.Type Output - = Lude.Compiled.flatMap + = Lude.Compiled.map CombinedInputs Output ( \(compiled : CombinedInputs) -> - Lude.Compiled.map - {} - Output - ( \(_ : {}) -> - combineOutputs - resolvedConfig - input - compiled.queries - compiled.customTypes - compiled.registrationTypes - ) - ( validateProjectNamespaces - resolvedConfig - compiled.queries - compiled.customTypes - ) + combineOutputs + resolvedConfig + input + compiled.queries + compiled.customTypes + compiled.registrationTypes ) compiledInputs - let mappingsValid = - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - validateCustomTypeMappings - resolvedConfig.customTypeNameMappings - input.customTypes - ) - ( validateQueryMappings - resolvedConfig.queryNameMappings - input.queries - ) - ) - (validateCustomTypeIdentities input.customTypes) - - let mappingsAndLocalsValid = - Lude.Compiled.flatMap - {} - {} - ( \(_ : {}) -> - validateLocalNamespaces - effectiveQueries - effectiveCustomTypes - ) - mappingsValid - - in Lude.Compiled.flatMap - {} - Output - ( \(_ : {}) -> - Lude.Compiled.appendWarnings Output skipWarnings combined - ) - mappingsAndLocalsValid + in Lude.Compiled.appendWarnings Output skipWarnings combined in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Query.dhall b/src/Interpreters/Query.dhall index b4eb4a5..30d80b6 100644 --- a/src/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -12,8 +12,6 @@ let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall -let PythonNameMapping = ../Structures/PythonNameMapping.dhall - let ResultModule = ./Result.dhall let QueryFragmentsModule = ./QueryFragments.dhall @@ -22,10 +20,7 @@ let ParamsMember = ./ParamsMember.dhall let StatementModule = ../Templates/StatementModule.dhall -let Config = - { emitSync : Bool - , queryNameMappings : List PythonNameMapping.Query - } +let Config = { emitSync : Bool } let Compiled = Lude.Compiled @@ -129,12 +124,9 @@ let run = \(lookup : CustomKind.Lookup) -> \(input : Input) -> let pythonName = - PythonNameMapping.resolveQuery - config.queryNameMappings - input.name.inSnakeCase - { snakeCase = PyIdent.querySafeName input.name.inSnakeCase - , pascalCase = input.name.inPascalCase - } + { snakeCase = PyIdent.querySafeName input.name.inSnakeCase + , pascalCase = input.name.inPascalCase + } let rowClassName = pythonName.pascalCase ++ "Row" diff --git a/src/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall index ba1e749..9fa4c3d 100644 --- a/src/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -19,7 +19,7 @@ let ScalarDecode = < Passthrough | Custom > let Output = { pyType : Text , imports : ImportSet.Type - , customRef : Optional Model.Name + , customRef : Optional Model.CustomTypeRef , decode : ScalarDecode } @@ -35,18 +35,18 @@ let run = ( \(p : Primitive.Output) -> { pyType = p.pyType , imports = p.imports - , customRef = None Model.Name + , customRef = None Model.CustomTypeRef , decode = ScalarDecode.Passthrough } ) (Primitive.run {=} primitive) , Custom = - \(name : Model.Name) -> + \(ref : Model.CustomTypeRef) -> Lude.Compiled.ok Output - { pyType = name.inPascalCase + { pyType = ref.name.inPascalCase , imports = ImportSet.empty - , customRef = Some name + , customRef = Some ref , decode = ScalarDecode.Custom } } diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall index f9717de..5ba1381 100644 --- a/src/Interpreters/Value.dhall +++ b/src/Interpreters/Value.dhall @@ -29,19 +29,21 @@ let run = Scalar.Output Output ( \(scalar : Scalar.Output) -> - Prelude.Optional.fold - Model.ArraySettings - input.arraySettings - Output - ( \(arraySettings : Model.ArraySettings) -> - let elementType = - if arraySettings.elementIsNullable + if Natural/isZero input.dimensionality + then { pyType = scalar.pyType + , imports = scalar.imports + , scalar + , dims = 0 + , elementIsNullable = False + } + else let elementType = + if input.elementIsNullable then "${scalar.pyType} | None" else scalar.pyType let arrayType = Natural/fold - arraySettings.dimensionality + input.dimensionality Text (\(inner : Text) -> "list[${inner}]") elementType @@ -49,16 +51,9 @@ let run = in { pyType = arrayType , imports = scalar.imports , scalar - , dims = arraySettings.dimensionality - , elementIsNullable = arraySettings.elementIsNullable + , dims = input.dimensionality + , elementIsNullable = input.elementIsNullable } - ) - { pyType = scalar.pyType - , imports = scalar.imports - , scalar - , dims = 0 - , elementIsNullable = False - } ) (Scalar.run {=} input.scalar) @@ -68,12 +63,12 @@ let qualifyCustom \(className : Text) -> \(value : Output) -> Prelude.Optional.fold - Model.Name + Model.CustomTypeRef value.scalar.customRef Text - ( \(name : Model.Name) -> + ( \(ref : Model.CustomTypeRef) -> Text/replace - name.inPascalCase + ref.name.inPascalCase (prefix ++ className) value.pyType ) diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall index 1621f13..4748022 100644 --- a/src/Structures/CustomKind.dhall +++ b/src/Structures/CustomKind.dhall @@ -1,4 +1,4 @@ -let Model = ../Deps/Contract.dhall +let Prelude = ../Deps/Prelude.dhall let Identity = { className : Text, moduleName : Text, order : Natural } @@ -9,6 +9,22 @@ let TypeKind = | Absent > -let Lookup = Model.Name -> TypeKind +-- Index-aligned with a project's `customTypes` list: `Lookup` at position `i` +-- is the kind classification of `customTypes[i]`. Replaces the former +-- `Model.Name -> TypeKind` closure (which relied on `Text/equal`), so a +-- reference resolves by its `CustomTypeRef.index` instead of by name. +let Lookup = List TypeKind -in { TypeKind, Lookup, Identity } +-- Resolve a reference's kind by index; an out-of-range index reads as `Absent` +-- (mirrors gen-sdk's own `optionalIndex` helper in supportedCustomTypes.dhall). +let at = + \(lookup : Lookup) -> + \(index : Natural) -> + Prelude.Optional.fold + TypeKind + (Prelude.List.index index TypeKind lookup) + TypeKind + (\(kind : TypeKind) -> kind) + TypeKind.Absent + +in { TypeKind, Lookup, Identity, at } diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall index c427d2d..822f44d 100644 --- a/src/Structures/PyIdent.dhall +++ b/src/Structures/PyIdent.dhall @@ -1,5 +1,3 @@ -let Prelude = ../Deps/Prelude.dhall - -- pgn passes identifier names through verbatim (the keyword-param fixture proves a -- column or placeholder may be spelled as a Python reserved word), so any name that -- becomes a Python identifier in the emitted code must be sanitized. Shared so the @@ -43,42 +41,20 @@ let pythonKeywords = , "yield" ] +let Lude = ../Deps/Lude.dhall + -- Add the caller's suffix when `name` collides with one of `reserved`. Callers -- keep the raw name for the SQL placeholder / dict key / row[...] lookup; only -- the Python identifier is sanitized. --- --- Equality without Text/equal: java.gen's escapeJavaKeyword delimiter trick does --- not apply here because pgn's embedded Text/replace misses needles spanning a --- text concatenation boundary (verified against the pinned pgn), so a "|"-wrapped --- name never matches its wrapped keyword. Bare replaces do work: --- `Text/replace name markTrue candidate` yields exactly `markTrue` only when --- `name` equals `candidate`; any mismatch residue keeps a letter of the --- alphabetic reserved word or grows past the digits-only marker, so it can never --- match inside `markTrue`, the second replace maps match to `name` and mismatch --- to `markTrue`, and the final replace rewrites `acc` on a match only. --- Limitations: a name containing the literal marker string is corrupted by the --- mismatch branch (the marker becomes the needle replaced in `acc`), and `acc` --- must be read exactly once per fold step or the expression re-embeds itself at --- every reserved word and blows up exponentially. -let markTrue = "0000000000000000000000000001" - -let suffixAgainst = - \(suffix : Text) -> +-- Dhall (and pgn's evaluator, as of pgn 0.11.0) has no Text/equal builtin, so +-- Lude.Text.replaceIfOneOf gets exact-match replacement via a sentinel-wrapped +-- Text/replace instead. +let suffixAgainst + : Text -> List Text -> Text -> Text + = \(suffix : Text) -> \(reserved : List Text) -> \(name : Text) -> - List/fold - Text - reserved - Text - ( \(candidate : Text) -> - \(acc : Text) -> - let signal = Text/replace name markTrue candidate - - let finalNeedle = Text/replace signal name markTrue - - in Text/replace finalNeedle (name ++ suffix) acc - ) - name + Lude.Text.replaceIfOneOf reserved (name ++ suffix) name let sanitizeAgainst = suffixAgainst "_" @@ -151,64 +127,6 @@ let querySafeName = let typeModuleSafeName = \(name : Text) -> sanitizeAgainst (pythonKeywords # moduleMetadataNames) name -let asciiLetters = - [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m" - , "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z" - , "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M" - , "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" - ] - -let digits = [ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" ] - -let containsOnly = - \(allowed : List Text) -> - \(name : Text) -> - let residue = - List/fold - Text - allowed - Text - (\(character : Text) -> \(rest : Text) -> Text/replace character "" rest) - name - - in Text/equal residue "" - --- The string is already limited to identifier characters before this check, so --- its Text/show value contains a quote only at each end. Replacing `"` can --- therefore match only the first character and gives us a safe head predicate --- without a non-standard regex builtin. -let startsWithOneOf = - \(allowed : List Text) -> - \(name : Text) -> - let shown = Text/show name - - in Prelude.List.any - Text - ( \(character : Text) -> - Prelude.Bool.not - ( Text/equal - (Text/replace ("\"" ++ character) "" shown) - shown - ) - ) - allowed - -let isSnakeIdentifier = - \(name : Text) -> - let letters = Prelude.List.take 26 Text asciiLetters - - in Prelude.Bool.not (Text/equal name "") - && containsOnly (letters # digits # [ "_" ]) name - && startsWithOneOf letters name - -let isPascalIdentifier = - \(name : Text) -> - let uppercaseLetters = Prelude.List.drop 26 Text asciiLetters - - in Prelude.Bool.not (Text/equal name "") - && containsOnly (asciiLetters # digits) name - && startsWithOneOf uppercaseLetters name - in { pythonKeywords , moduleMetadataNames , sanitizeAgainst @@ -218,6 +136,4 @@ in { pythonKeywords , parameterSafeName , querySafeName , typeModuleSafeName - , isSnakeIdentifier - , isPascalIdentifier } diff --git a/src/Structures/PythonNameMapping.dhall b/src/Structures/PythonNameMapping.dhall deleted file mode 100644 index a955517..0000000 --- a/src/Structures/PythonNameMapping.dhall +++ /dev/null @@ -1,48 +0,0 @@ -let Prelude = ../Deps/Prelude.dhall - -let PythonName = { snakeCase : Text, pascalCase : Text } - -let Query = { source : Text, target : PythonName } - -let CustomTypeSource = { schema : Text, name : Text } - -let CustomType = { source : CustomTypeSource, target : PythonName } - -let resolveQuery = - \(mappings : List Query) -> - \(source : Text) -> - \(defaultName : PythonName) -> - List/fold - Query - mappings - PythonName - ( \(mapping : Query) -> - \(resolved : PythonName) -> - if Text/equal mapping.source source then mapping.target else resolved - ) - defaultName - -let resolveCustomType = - \(mappings : List CustomType) -> - \(source : CustomTypeSource) -> - \(defaultName : PythonName) -> - List/fold - CustomType - mappings - PythonName - ( \(mapping : CustomType) -> - \(resolved : PythonName) -> - if Text/equal mapping.source.schema source.schema - && Text/equal mapping.source.name source.name - then mapping.target - else resolved - ) - defaultName - -in { PythonName - , Query - , CustomTypeSource - , CustomType - , resolveQuery - , resolveCustomType - } diff --git a/src/Structures/PythonNamespace.dhall b/src/Structures/PythonNamespace.dhall deleted file mode 100644 index 28a9fcc..0000000 --- a/src/Structures/PythonNamespace.dhall +++ /dev/null @@ -1,97 +0,0 @@ -let Lude = ../Deps/Lude.dhall - -let Prelude = ../Deps/Prelude.dhall - -let Binding = - { namespace : Text - , owner : Text - , name : Text - , remediation : Text - } - -let Collision = - { namespace : Text - , firstOwner : Text - , secondOwner : Text - , name : Text - , remediation : Text - } - -let State = { seen : List Binding, collision : Optional Collision } - -let validate = - \(bindings : List Binding) -> - let step = - \(binding : Binding) -> - \(state : State) -> - Prelude.Optional.fold - Collision - state.collision - State - (\(_ : Collision) -> state) - ( let previousOwner = - List/fold - Binding - state.seen - (Optional Text) - ( \(previous : Binding) -> - \(found : Optional Text) -> - if Text/equal - previous.namespace - binding.namespace - && Text/equal previous.name binding.name - then Some previous.owner - else found - ) - (None Text) - - let collision = - Prelude.Optional.fold - Text - previousOwner - (Optional Collision) - ( \(owner : Text) -> - Some - { namespace = binding.namespace - , firstOwner = owner - , secondOwner = binding.owner - , name = binding.name - , remediation = binding.remediation - } - ) - (None Collision) - - in { seen = state.seen # [ binding ], collision } - ) - - let result = - List/fold - Binding - bindings - State - step - { seen = [] : List Binding, collision = None Collision } - - in Prelude.Optional.fold - Collision - result.collision - (Lude.Compiled.Type {}) - ( \(collision : Collision) -> - Lude.Compiled.report - {} - [ "pythonNames", collision.namespace, collision.name ] - ( "Generated Python name collision in " - ++ collision.namespace - ++ ": " - ++ collision.firstOwner - ++ " and " - ++ collision.secondOwner - ++ " both resolve to \"" - ++ collision.name - ++ "\". " - ++ collision.remediation - ) - ) - (Lude.Compiled.ok {} {=}) - -in { Binding, validate } diff --git a/src/package.dhall b/src/package.dhall index a820150..67ff3c1 100644 --- a/src/package.dhall +++ b/src/package.dhall @@ -2,8 +2,6 @@ let Sdk = ./Deps/Sdk.dhall let OnUnsupported = ./Structures/OnUnsupported.dhall -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - let ProjectInterpreter = ./Interpreters/Project.dhall -- User-facing config for this generator. Async output is always emitted; @@ -13,14 +11,11 @@ let ProjectInterpreter = ./Interpreters/Project.dhall -- query or custom type hits a PG shape the generator cannot render; see -- Structures/OnUnsupported.dhall. All fields are Optional so a project may -- omit the whole config block or any subset of its keys; --- Interpreters/Project.dhall's `run` supplies the defaults. Typed name mappings --- provide explicit whole-entity renames after collision checks. +-- Interpreters/Project.dhall's `run` supplies the defaults. let Config = { packageName : Optional Text , emitSync : Optional Bool , onUnsupported : Optional OnUnsupported.Mode - , queryNameMappings : Optional (List PythonNameMapping.Query) - , customTypeNameMappings : Optional (List PythonNameMapping.CustomType) } : Type let Config/default @@ -28,8 +23,6 @@ let Config/default = { packageName = None Text , emitSync = None Bool , onUnsupported = None OnUnsupported.Mode - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = None (List PythonNameMapping.CustomType) } in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run diff --git a/tests/conftest.py b/tests/conftest.py index f276560..78c3bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,7 +43,7 @@ def pgn_bin() -> str: out = subprocess.run(["mise", "which", "pgn"], cwd=HERE, capture_output=True, text=True) path = out.stdout.strip() if out.returncode != 0 or not path: - pytest.fail("pgn 0.9.1 is required but is not resolvable via PATH or mise") + pytest.fail("pgn 0.12.0 is required but is not resolvable via PATH or mise") return path diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index 7f11f41..a99ea57 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -82,9 +82,6 @@ # isort: on # isort: off -from ._generated.types.a_codec_wrapper import ( - ACodecWrapper as ACodecWrapper, -) from ._generated.types.mood import ( Mood as Mood, ) @@ -97,6 +94,9 @@ from ._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +from ._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) # isort: on __all__ = [ @@ -105,11 +105,11 @@ ] # Project order is intentional for public re-export groups. __all__ += [ # noqa: RUF022, RUF100 - "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", + "ACodecWrapper", ] __all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index 9a8d597..3b56de9 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -3,9 +3,9 @@ # SPDX-License-Identifier: MIT-0 # isort: off -from .a_codec_wrapper import ACodecWrapper as ACodecWrapper from .mood import Mood as Mood from .point_2_d import Point2D as Point2D from .tag_value import TagValue as TagValue from .z_codec_payload import ZCodecPayload as ZCodecPayload +from .a_codec_wrapper import ACodecWrapper as ACodecWrapper # isort: on diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index 4a89f76..21d00e8 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -79,9 +79,6 @@ # isort: on # isort: off -from .._generated.types.a_codec_wrapper import ( - ACodecWrapper as ACodecWrapper, -) from .._generated.types.mood import ( Mood as Mood, ) @@ -94,6 +91,9 @@ from .._generated.types.z_codec_payload import ( ZCodecPayload as ZCodecPayload, ) +from .._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) # isort: on __all__ = [ @@ -102,11 +102,11 @@ ] # Project order is intentional for public re-export groups. __all__ += [ # noqa: RUF022, RUF100 - "ACodecWrapper", "Mood", "Point2D", "TagValue", "ZCodecPayload", + "ACodecWrapper", ] __all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", diff --git a/tests/test_python_name_collisions.py b/tests/test_python_name_collisions.py deleted file mode 100644 index 8503264..0000000 --- a/tests/test_python_name_collisions.py +++ /dev/null @@ -1,692 +0,0 @@ -"""Real-pgn coverage for generated Python namespace collisions and mappings.""" - -from __future__ import annotations - -import ast -import importlib -import shutil -import subprocess -import sys -from collections.abc import Iterable, Mapping, Sequence -from pathlib import Path - -import pytest - -from tests._harness import SRC_DIR, run_pgn - -QueryMapping = tuple[str, str, str] -CustomTypeMapping = tuple[str, str, str, str] - - -def _project_yaml( - *, - query_mappings: Sequence[QueryMapping] = (), - custom_type_mappings: Sequence[CustomTypeMapping] = (), - on_unsupported: str | None = None, -) -> str: - lines = [ - "space: python-gen", - "name: python-name-collisions", - "version: 0.0.0", - "postgres: 18", - "artifacts:", - " python:", - " gen: ../src/package.dhall", - " config:", - " packageName: mapping-client", - " emitSync: true", - ] - - if on_unsupported is not None: - lines.append(f" onUnsupported: {on_unsupported}") - - if query_mappings: - lines.append(" queryNameMappings:") - for source, snake_case, pascal_case in query_mappings: - lines.extend( - [ - f" - source: {source}", - " target:", - f" snakeCase: {snake_case}", - f" pascalCase: {pascal_case}", - ] - ) - - if custom_type_mappings: - lines.append(" customTypeNameMappings:") - for schema, name, snake_case, pascal_case in custom_type_mappings: - lines.extend( - [ - " - source:", - f" schema: {schema}", - f" name: {name}", - " target:", - f" snakeCase: {snake_case}", - f" pascalCase: {pascal_case}", - ] - ) - - return "\n".join(lines) + "\n" - - -def _fresh_project( - tmp_path: Path, - *, - queries: Mapping[str, str], - migration: str = "SELECT 1;\n", - query_mappings: Sequence[QueryMapping] = (), - custom_type_mappings: Sequence[CustomTypeMapping] = (), - on_unsupported: str | None = None, -) -> Path: - root = tmp_path / "pygen" - _ = shutil.copytree(SRC_DIR, root / "src") - project = root / "project" - migrations = project / "migrations" - query_dir = project / "queries" - migrations.mkdir(parents=True) - query_dir.mkdir() - - _ = (migrations / "1.sql").write_text(migration) - for name, sql in queries.items(): - _ = (query_dir / name).write_text(sql) - _ = (project / "project1.pgn.yaml").write_text( - _project_yaml( - query_mappings=query_mappings, - custom_type_mappings=custom_type_mappings, - on_unsupported=on_unsupported, - ) - ) - return project - - -def _write_structural_scope_probe(project: Path, *, kind: str, duplicate_identity: bool) -> None: - root = project.parent - right_name = "leftName" if duplicate_identity else "rightName" - definitions = { - "composite": ( - "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" - ".Composite [ valueMember ]" - ), - "enum": ( - "< Enum : List Model.EnumVariant | Composite : List Model.Member | Domain : Model.Value >" - ".Enum [ readyVariant ]" - ), - } - definition = definitions[kind] - wrapper = f""" -let Model = ./Deps/Contract.dhall - -let Sdk = ./Deps/Sdk.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - -let Target = ./Interpreters/Project.dhall - -let Config = - {{ packageName : Optional Text - , emitSync : Optional Bool - , onUnsupported : Optional OnUnsupported.Mode - }} - -let Config/default = - {{ packageName = None Text - , emitSync = None Bool - , onUnsupported = None OnUnsupported.Mode - }} - -let valueName - : Model.Name - = {{ inCamelCase = "value" - , inPascalCase = "Value" - , inKebabCase = "value" - , inTrainCase = "Value" - , inScreamingKebabCase = "VALUE" - , inSnakeCase = "value" - , inCamelSnakeCase = "Value" - , inScreamingSnakeCase = "VALUE" - }} - -let leftName - : Model.Name - = valueName // {{ inCamelCase = "leftSource", inPascalCase = "LeftSource", inSnakeCase = "left_source" }} - -let rightName - : Model.Name - = valueName // {{ inCamelCase = "rightSource", inPascalCase = "RightSource", inSnakeCase = "right_source" }} - -let valueMember - : Model.Member - = {{ isNullable = False - , name = valueName - , pgName = "value" - , value = - {{ arraySettings = None Model.ArraySettings - , scalar = Model.Scalar.Primitive Model.Primitive.Int8 - }} - }} - -let readyVariant - : Model.EnumVariant - = {{ name = valueName // {{ inScreamingSnakeCase = "READY" }} - , pgName = "ready" - }} - -let leftType - : Model.CustomType - = {{ definition = {definition} - , name = leftName - , pgName = "c" - , pgSchema = "a.b" - }} - -let rightType - : Model.CustomType - = {{ definition = {definition} - , name = {right_name} - , pgName = "b.c" - , pgSchema = "a" - }} - -let targetConfig = - {{ packageName = Some "mapping-client" - , emitSync = Some False - , onUnsupported = Some OnUnsupported.Mode.Fail - , queryNameMappings = None (List PythonNameMapping.Query) - , customTypeNameMappings = - Some - [ {{ source = {{ schema = "a.b", name = "c" }} - , target = {{ snakeCase = "schema_dot_c", pascalCase = "SchemaDotC" }} - }} - , {{ source = {{ schema = "a", name = "b.c" }} - , target = {{ snakeCase = "type_dot_c", pascalCase = "TypeDotC" }} - }} - ] - }} - -let run = - \\(_ : Config) -> - \\(input : Model.Project) -> - Target.run - targetConfig - ( input - // {{ customTypes = [ leftType, rightType ] - , queries = [] : List Model.Query - }} - ) - -in Sdk.Sigs.generator Config Config/default run -""" - _ = (root / "src" / "structural-scope-probe.dhall").write_text(wrapper) - project_file = project / "project1.pgn.yaml" - _ = project_file.write_text( - project_file.read_text().replace("../src/package.dhall", "../src/structural-scope-probe.dhall") - ) - - -def _analyse(pgn_bin: str, pgn_admin_url: str, project: Path) -> None: - result = run_pgn(pgn_bin, pgn_admin_url, project, "analyse") - assert result.returncode == 0, f"pgn analyse rejected the collision fixture:\n{result.stdout}\n{result.stderr}" - - -def _diagnostic(result: subprocess.CompletedProcess[str]) -> str: - return f"{result.stdout}\n{result.stderr}" - - -def _assert_no_python_files(project: Path) -> None: - artifact = project / "artifacts" / "python" - assert not artifact.exists() or not any(artifact.rglob("*.py")) - - -def _assert_generation_fails( - pgn_bin: str, - pgn_admin_url: str, - project: Path, - expected: Iterable[str], -) -> None: - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode != 0, f"pgn generate unexpectedly succeeded:\n{diagnostic}" - for fragment in expected: - assert fragment in diagnostic, f"missing {fragment!r} in pgn diagnostic:\n{diagnostic}" - _assert_no_python_files(project) - - -def _package_dir(project: Path) -> Path: - package = project / "artifacts" / "python" / "src" / "mapping_client" - assert package.is_dir() - return package - - -def _clear_package_modules() -> None: - for name in list(sys.modules): - if name == "mapping_client" or name.startswith("mapping_client."): - del sys.modules[name] - - -def test_reserved_query_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - queries={ - "sync.sql": "SELECT 1::int8 AS value\n", - "sync_query.sql": "SELECT 2::int8 AS value\n", - }, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in generated statement modules", - 'query "sync"', - 'query "sync_query"', - 'both resolve to "sync_query"', - ], - ) - - -def test_json_value_custom_type_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", - queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in package facade", - "generated core symbol JsonValue", - 'custom type "public.json_value"', - 'both resolve to "JsonValue"', - ], - ) - - -def test_query_row_and_custom_type_collision_fails_before_emission( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE foo_row AS ENUM ('ready');\n", - queries={"foo.sql": "SELECT 'ready'::foo_row AS value\n"}, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - "Generated Python name collision in package facade", - 'Row class from query "foo"', - 'custom type "public.foo_row"', - 'both resolve to "FooRow"', - ], - ) - - -@pytest.mark.parametrize( - ("native_type", "native_value", "mapped_snake", "mapped_pascal", "on_unsupported"), - [ - ( - "uuid", - "'00000000-0000-0000-0000-000000000001'::uuid", - "custom_uuid", - "UUID", - None, - ), - ("numeric", "1.25::numeric", "custom_decimal", "Decimal", None), - ( - "uuid", - "'00000000-0000-0000-0000-000000000001'::uuid", - "custom_uuid", - "UUID", - "Skip", - ), - ], - ids=["uuid-fail", "decimal-fail", "uuid-skip"], -) -def test_mapped_custom_dependency_cannot_shadow_composite_import( - native_type: str, - native_value: str, - mapped_snake: str, - mapped_pascal: str, - on_unsupported: str | None, - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration=( - "CREATE TYPE import_dependency AS ENUM ('ready');\n" - f"CREATE TYPE import_holder AS (native_value {native_type}, dependency import_dependency);\n" - ), - queries={ - "read_import_holder.sql": ( - f"SELECT ROW({native_value}, 'ready'::import_dependency)::import_holder AS value\n" - ) - }, - custom_type_mappings=[("public", "import_dependency", mapped_snake, mapped_pascal)], - on_unsupported=on_unsupported, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - 'Generated Python name collision in custom type module for schema "public", type "import_holder"', - f"{mapped_pascal} primitive import", - "custom dependency import", - f'both resolve to "{mapped_pascal}"', - ], - ) - - -@pytest.mark.parametrize("kind", ["composite", "enum"]) -def test_custom_local_namespaces_keep_schema_and_name_structured( - kind: str, - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) - _write_structural_scope_probe(project, kind=kind, duplicate_identity=False) - _analyse(pgn_bin, pgn_admin_url, project) - - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode == 0, f"structured local namespace probe failed:\n{diagnostic}" - - package = _package_dir(project) - left_source = (package / "_generated" / "types" / "schema_dot_c.py").read_text() - right_source = (package / "_generated" / "types" / "type_dot_c.py").read_text() - expected = ( - ("class SchemaDotC:", "class TypeDotC:", "value: int") - if kind == "composite" - else ("class SchemaDotC(StrEnum):", "class TypeDotC(StrEnum):", 'READY = "ready"') - ) - assert expected[0] in left_source - assert expected[1] in right_source - assert expected[2] in left_source and expected[2] in right_source - for source in package.rglob("*.py"): - _ = ast.parse(source.read_text(), filename=str(source)) - - -def test_duplicate_unqualified_custom_contract_identity_fails_loudly( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries={"seed.sql": "SELECT 1::int8 AS value\n"}) - _write_structural_scope_probe(project, kind="composite", duplicate_identity=True) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails( - pgn_bin, - pgn_admin_url, - project, - [ - 'Ambiguous unqualified custom type identity "left_source"', - 'schema "a.b", type "c"', - 'schema "a", type "b.c"', - "upstream contract must preserve a distinct schema-qualified custom identifier", - "Python mappings cannot recover it", - ], - ) - - -def test_typed_mappings_propagate_through_generated_package( - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration=( - "CREATE TYPE \"json_value\" AS ENUM ('ready');\n" - "CREATE TYPE mapped_inner AS (value int8);\n" - "CREATE TYPE mapped_outer AS (child mapped_inner);\n" - ), - queries={ - "sync.sql": "SELECT 1::int8 AS value\n", - "sync_query.sql": "SELECT 2::int8 AS value\n", - "read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n", - "read_mapped_outer.sql": ("SELECT ROW(ROW(7::int8)::mapped_inner)::mapped_outer AS value\n"), - }, - query_mappings=[("sync_query", "api_v2", "ApiV2")], - custom_type_mappings=[ - ("public", "json_value", "pg_json_value", "PgJsonValue"), - ("public", "mapped_inner", "mapped_inner_v2", "MappedInnerV2"), - ], - ) - _analyse(pgn_bin, pgn_admin_url, project) - result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") - diagnostic = _diagnostic(result) - assert result.returncode == 0, f"mapped pgn generate failed:\n{diagnostic}" - - package = _package_dir(project) - statements = package / "_generated" / "statements" - assert {path.name for path in statements.glob("*.py")} == { - "__init__.py", - "api_v2.py", - "read_json_value.py", - "read_mapped_outer.py", - "sync_query.py", - } - api_source = (statements / "api_v2.py").read_text() - assert "class ApiV2Row:" in api_source - assert "async def api_v2(" in api_source - assert "def api_v2_sync(" in api_source - - type_source = (package / "_generated" / "types" / "pg_json_value.py").read_text() - assert "class PgJsonValue(StrEnum):" in type_source - inner_source = (package / "_generated" / "types" / "mapped_inner_v2.py").read_text() - assert "class MappedInnerV2:" in inner_source - outer_source = (package / "_generated" / "types" / "mapped_outer.py").read_text() - assert "from .mapped_inner_v2 import MappedInnerV2" in outer_source - assert "child: MappedInnerV2" in outer_source - json_statement = (statements / "read_json_value.py").read_text() - assert "value: _db_types.PgJsonValue" in json_statement - register_source = (package / "_generated" / "_register.py").read_text() - assert "_db_types.PgJsonValue" in register_source - assert "_db_types.MappedInnerV2" in register_source - assert register_source.index('"public.mapped_inner"') < register_source.index('"public.mapped_outer"') - assert "public.json_value" in register_source - - facade_source = (package / "__init__.py").read_text() - assert facade_source.index("statements.sync_query") < facade_source.index("statements.api_v2") - assert facade_source.index("types.pg_json_value") < facade_source.index("types.mapped_inner_v2") - assert facade_source.index('"PgJsonValue"') < facade_source.index('"MappedInnerV2"') - types_init_source = (package / "_generated" / "types" / "__init__.py").read_text() - assert types_init_source.index(".pg_json_value import") < types_init_source.index(".mapped_inner_v2 import") - - for source in package.rglob("*.py"): - _ = ast.parse(source.read_text(), filename=str(source)) - - ruff = subprocess.run( - [ - "ruff", - "check", - "--config", - str(SRC_DIR.parent / "pyproject.toml"), - str(package), - ], - capture_output=True, - text=True, - ) - assert ruff.returncode == 0, f"mapped generated package failed Ruff:\n{ruff.stdout}\n{ruff.stderr}" - - generated_src = package.parent - sys.path.insert(0, str(generated_src)) - _clear_package_modules() - try: - client = importlib.import_module("mapping_client") - sync_client = importlib.import_module("mapping_client.sync") - assert client.api_v2.__name__ == "api_v2" - assert sync_client.api_v2.__name__ == "api_v2_sync" - assert client.ApiV2Row is sync_client.ApiV2Row - assert client.PgJsonValue is sync_client.PgJsonValue - assert client.MappedInnerV2 is sync_client.MappedInnerV2 - assert client.MappedOuter is sync_client.MappedOuter - assert client.JsonValue is not client.PgJsonValue - finally: - _clear_package_modules() - sys.path.remove(str(generated_src)) - - -@pytest.mark.parametrize( - ("queries", "mappings", "expected"), - [ - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "ApiV2"), ("alpha", "other_api", "OtherApi")], - ['Duplicate query name mapping source "alpha"'], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("missing", "api_v2", "ApiV2")], - ['Unknown query name mapping source "missing"'], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "2api", "ApiV2")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "2Api")], - ["Mapped query pascalCase must start with an uppercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "api_v2", "apiV2")], - ["Mapped query pascalCase must start with an uppercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "__init__", "Init")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - {"alpha.sql": "SELECT 1::int8 AS value\n"}, - [("alpha", "__all__", "All")], - ["Mapped query snakeCase must start with a lowercase ASCII letter"], - ), - ( - { - "alpha.sql": "SELECT 1::int8 AS value\n", - "beta.sql": "SELECT 2::int8 AS value\n", - }, - [("beta", "alpha", "BetaResult")], - [ - "Generated Python name collision in generated statement modules", - 'query "alpha"', - 'query "beta"', - 'both resolve to "alpha"', - ], - ), - ], - ids=[ - "duplicate-source", - "unknown-source", - "leading-digit-snake", - "leading-digit-pascal", - "lowercase-pascal", - "init-module", - "all-facade", - "mapped-final-collision", - ], -) -def test_invalid_query_mappings_fail_before_emission( - queries: Mapping[str, str], - mappings: Sequence[QueryMapping], - expected: Sequence[str], - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project(tmp_path, queries=queries, query_mappings=mappings) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) - - -@pytest.mark.parametrize( - ("mappings", "expected"), - [ - ( - [ - ("public", "json_value", "pg_json_value", "PgJsonValue"), - ("public", "json_value", "other_json_value", "OtherJsonValue"), - ], - ['Duplicate custom type name mapping source "public.json_value"'], - ), - ( - [("public", "missing", "pg_missing", "PgMissing")], - ['Unknown custom type name mapping source "public.missing"'], - ), - ( - [("public", "json_value", "pg_json_value", "NoRowError")], - [ - "Generated Python name collision in package facade", - "generated core symbol NoRowError", - 'custom type "public.json_value"', - 'both resolve to "NoRowError"', - ], - ), - ( - [("public", "json_value", "pg_json_value", "JsonValue")], - [ - "Generated Python name collision in package facade", - "generated core symbol JsonValue", - 'custom type "public.json_value"', - 'both resolve to "JsonValue"', - ], - ), - ( - [("public", "json_value", "pg_json_value", "pgJsonValue")], - ["Mapped custom type pascalCase must start with an uppercase ASCII letter"], - ), - ( - [("public", "json_value", "__path__", "PgJsonValue")], - ["Mapped custom type snakeCase must start with a lowercase ASCII letter"], - ), - ], - ids=[ - "duplicate-source", - "unknown-source", - "mapped-final-collision", - "mapped-json-value", - "lowercase-pascal", - "path-module", - ], -) -def test_invalid_custom_type_mappings_fail_before_emission( - mappings: Sequence[CustomTypeMapping], - expected: Sequence[str], - pgn_bin: str, - pgn_admin_url: str, - tmp_path: Path, -) -> None: - project = _fresh_project( - tmp_path, - migration="CREATE TYPE \"json_value\" AS ENUM ('ready');\n", - queries={"read_json_value.sql": "SELECT 'ready'::public.\"json_value\" AS value\n"}, - custom_type_mappings=mappings, - ) - _analyse(pgn_bin, pgn_admin_url, project) - _assert_generation_fails(pgn_bin, pgn_admin_url, project, expected) diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index e9fd292..b88cfb4 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -122,11 +122,6 @@ def _write_contract_probe( dimensionality: int, nested: bool, ) -> None: - array_settings = ( - "None Model.ArraySettings" - if dimensionality == 0 - else f"Some {{ dimensionality = {dimensionality}, elementIsNullable = False }}" - ) lookup = { "absent": "CustomKind.TypeKind.Absent", "enum": ('CustomKind.TypeKind.Enum { className = "ProbeValue", moduleName = "probe_value", order = 0 }'), @@ -134,10 +129,14 @@ def _write_contract_probe( 'CustomKind.TypeKind.Composite { className = "ProbeValue", moduleName = "probe_value", order = 0 }' ), }[lookup_kind] - interpreter_config = ( - "{ customTypeNameMappings = [] : List PythonNameMapping.CustomType }" if interpreter == "CustomType" else "{=}" - ) target_input = "customType" if nested else "member" + # CustomKind.Lookup is now a `List CustomKind.TypeKind` indexed by + # CustomTypeRef.index; these probes reference exactly one custom type + # ("ProbeValue") at index 0, so a one-element list suffices. CustomType.run + # also gained an explicit self-index parameter (0 here), threaded only for + # the CustomType interpreter (Member/ParamsMember resolve by ref.index and + # never needed a self-index). + index_argument = " 0" if interpreter == "CustomType" else "" custom_type = ( """ let customType @@ -166,8 +165,6 @@ def _write_contract_probe( let CustomKind = ./Structures/CustomKind.dhall -let PythonNameMapping = ./Structures/PythonNameMapping.dhall - let Target = ./Interpreters/{interpreter}.dhall let Config = @@ -182,8 +179,7 @@ def _write_contract_probe( , onUnsupported = None OnUnsupported.Mode }} -let interpreterConfig = - {interpreter_config} +let interpreterConfig = {{=}} let run = \\(_ : Config) -> @@ -196,21 +192,28 @@ def _write_contract_probe( , name , pgName = "probe_value" , value = - {{ arraySettings = {array_settings} - , scalar = Model.Scalar.Custom name + {{ dimensionality = {dimensionality} + , elementIsNullable = False + , scalar = + Model.Scalar.Custom + {{ name + , pgSchema = "public" + , pgName = "probe_value" + , index = 0 + }} }} }} {custom_type} let lookup : CustomKind.Lookup - = \\(_ : Model.Name) -> {lookup} + = [ {lookup} ] in Lude.Compiled.map Target.Output Lude.Files.Type (\\(_ : Target.Output) -> [] : Lude.Files.Type) - (Target.run interpreterConfig lookup {target_input}) + (Target.run interpreterConfig lookup{index_argument} {target_input}) in Sdk.Sigs.generator Config Config/default run """ @@ -382,7 +385,7 @@ def test_custom_shape_contracts_succeed( assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" -def test_skip_preserves_mapped_nested_registration_chain( +def test_skip_preserves_nested_registration_chain( pgn_bin: str, pgn_admin_url: str, tmp_path: Path, @@ -402,22 +405,11 @@ def test_skip_preserves_mapped_nested_registration_chain( "SELECT ROW(ROW('ready'::z_survivor_status)::m_survivor_leaf)::n_survivor_outer AS value\n" ) _ = (project / "queries" / "read_bad.sql").write_text("SELECT ROW(1::money)::a_bad AS value\n") - _write_single_artifact(project, "../../src/package.dhall", "skip-mapping-chain", "Skip") - config = project / "project1.pgn.yaml" - _ = config.write_text( - config.read_text() - + " customTypeNameMappings:\n" - + " - source:\n" - + " schema: public\n" - + " name: z_survivor_status\n" - + " target:\n" - + " snakeCase: mapped_status\n" - + " pascalCase: MappedStatus\n" - ) + _write_single_artifact(project, "../../src/package.dhall", "skip-nested-chain", "Skip") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") diagnostic = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", _combined_output(result)) - assert result.returncode == 0, f"mapped Skip generation failed:\n{diagnostic}" + assert result.returncode == 0, f"Skip generation failed:\n{diagnostic}" warnings = re.findall(r"Warning: ([^\n]+)\nStage: ([^\n]+)", diagnostic) assert warnings == [ ("Unsupported type", "Generating > python > Compiling > money > amount > a_bad"), @@ -427,13 +419,13 @@ def test_skip_preserves_mapped_nested_registration_chain( ), ] - package = project / "artifacts" / "python" / "src" / "skip_mapping_chain" + package = project / "artifacts" / "python" / "src" / "skip_nested_chain" generated = package / "_generated" types = generated / "types" assert {path.name for path in types.glob("*.py")} == { "__init__.py", "m_survivor_leaf.py", - "mapped_status.py", + "z_survivor_status.py", "n_survivor_outer.py", } @@ -445,9 +437,9 @@ def test_skip_preserves_mapped_nested_registration_chain( leaf_source = (types / "m_survivor_leaf.py").read_text() outer_source = (types / "n_survivor_outer.py").read_text() assert [line for line in leaf_source.splitlines() if line.startswith("from .")] == [ - "from .mapped_status import MappedStatus" + "from .z_survivor_status import ZSurvivorStatus" ] - assert "status: MappedStatus" in leaf_source + assert "status: ZSurvivorStatus" in leaf_source assert [line for line in outer_source.splitlines() if line.startswith("from .")] == [ "from .m_survivor_leaf import MSurvivorLeaf" ] @@ -458,7 +450,7 @@ def test_skip_preserves_mapped_nested_registration_chain( line for line in register_source.splitlines() if line.startswith("_") and "_pg_name = " in line ] assert pg_name_constants == [ - '_mapped_status_pg_name = "public.z_survivor_status"', + '_z_survivor_status_pg_name = "public.z_survivor_status"', '_m_survivor_leaf_pg_name = "public.m_survivor_leaf"', '_n_survivor_outer_pg_name = "public.n_survivor_outer"', ]