diff --git a/.github/scripts/build-contract-shell.sh b/.github/scripts/build-contract-shell.sh index 3845a1d..037bb43 100755 --- a/.github/scripts/build-contract-shell.sh +++ b/.github/scripts/build-contract-shell.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Wraps the Dhall-generated package (from tests/Exhaustive.dhall) in a minimal +# Wraps the Dhall-generated package (from fixtures/Exhaustive.dhall) in a minimal # consumer shell, mirroring the hand-written tests/golden/ shell (pyproject.toml # + py.typed) so basedpyright strict runs against the same layout a real # consumer would import, per the full_package pattern in tests/conftest.py. @@ -31,7 +31,7 @@ build-backend = "hatchling.build" name = "${pkg_name//_/-}" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] +dependencies = ["psycopg>=3.3.4,<4"] [tool.hatch.build.targets.wheel] packages = ["src/$pkg_name"] diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml index 02c9c8a..360aaed 100644 --- a/.github/workflows/bump.yml +++ b/.github/workflows/bump.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7664a04..cc7e7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master] + branches: [main] pull_request: workflow_call: inputs: @@ -47,16 +47,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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,13 +74,16 @@ 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 - name: Sync the harness venv - run: mise x -- uv sync + run: mise run install + + - name: Lint the harness and committed generated package + run: mise run lint # Hosted runners cannot fit a full 7-artifact pgn generate (~31 GB peak # RSS measured locally); keep only the "python" artifact for this CI @@ -94,7 +94,7 @@ jobs: run: | set -euo pipefail - python3 - <<'EOF' + mise exec -- python - <<'EOF' from pathlib import Path p = Path("tests/fixture-project/project1.pgn.yaml") @@ -117,31 +117,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref || github.sha }} - # The `as Source` import mode on gen/Deps only changes how pgn loads the - # remote packages (unnormalized, to save RAM), not what they evaluate to. - # The pinned action below bundles a dhall fork that predates the mode, so - # strip it here; the evaluation is semantically identical either way. The - # sha256 pins go with it: an as-Source pin hashes the import's source, - # not the normalized expression a plain import verifies against, so after - # the strip the pins would be checked against the wrong algorithm. - - name: Strip `as Source` and its source-hash pins for the action's dhall - shell: bash - run: | - set -euo pipefail - sed -i -e 's/^[[:space:]]*as Source$//' -e 's/^[[:space:]]*sha256:[0-9a-f]\{64\}$//' gen/Deps/*.dhall - - # A plain, standard-Dhall evaluator cannot run this: gen/Interpreters/Project.dhall's - # buildLookup and gen-sdk's own Fixtures.Exhaustive both use Text/equal, a builtin - # from pgn's forked Dhall, absent from the upstream dhall-lang Prelude. This 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: - dhall_file: tests/Exhaustive.dhall + dhall_file: fixtures/Exhaustive.dhall output_dir: contract-output - name: Assert the compile did not fail @@ -166,12 +151,27 @@ jobs: - name: Install mise-managed tools uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 - - name: Create a venv and install psycopg + basedpyright + - name: Create a venv and install contract tools + run: | + set -euo pipefail + + mise exec -- uv venv contract-shell/.venv + mise exec -- uv pip install --python contract-shell/.venv/bin/python \ + "psycopg[binary]>=3.3.4,<4" \ + "basedpyright==1.39.9" \ + "ruff==0.15.20" + + - name: Ruff check the generated package + shell: bash run: | set -euo pipefail - mise x -- uv venv contract-shell/.venv - mise x -- uv pip install --python contract-shell/.venv/bin/python "psycopg[binary]>=3.2" basedpyright + mise exec -- contract-shell/.venv/bin/ruff check \ + --config "$PWD/pyproject.toml" \ + contract-shell/src + mise exec -- contract-shell/.venv/bin/ruff format --check \ + --config "$PWD/pyproject.toml" \ + contract-shell/src - name: basedpyright strict on the generated package shell: bash @@ -189,7 +189,7 @@ jobs: } JSON - output="$(contract-shell/.venv/bin/basedpyright --project contract-pyrightconfig.json --outputjson)" || true + output="$(mise exec -- contract-shell/.venv/bin/basedpyright --project contract-pyrightconfig.json --outputjson)" || true echo "$output" | jq . files=$(jq '.summary.filesAnalyzed' <<<"$output") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e92c7..a8b4799 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -77,16 +77,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Resolve Dhall id: resolve - uses: nikita-volkov/dhall-resolve.github-action@7caaf1fdb40ac864bc02e37575f577ee084713a8 # v3 + uses: nikita-volkov/dhall-resolve.github-action@b7a346816b48a0f58282855a56e19b2a2c77f35d # v3 with: - file: gen/Gen.dhall + file: src/package.dhall minify: true - name: Prepare changelog for release @@ -124,7 +124,7 @@ jobs: echo "commit-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Create GitHub Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ inputs.version }} name: "v${{ inputs.version }}" @@ -158,7 +158,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -257,7 +257,7 @@ jobs: cmp "$bundled" release-asset/resolved.dhall - name: Upload the built distributions as workflow artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pgenie-python-gen-dist path: wheel/dist/* @@ -279,7 +279,7 @@ jobs: steps: - name: Download the built distributions - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pgenie-python-gen-dist path: dist diff --git a/AGENTS.md b/AGENTS.md index f6d2533..79f0a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,12 @@ already says. ## Dhall -`gen/` pins its remote imports by sha256 (`gen/Deps/*.dhall`). Bump those +`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 8cc7b2c..2f0001a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,105 @@ # Upcoming -- The test harness now runs every pgn subprocess in its own process group under - an RSS watchdog: a thread polls `ps -o rss=` every 2 s and, on breach of - `PGN_MAX_RSS_GB` (default 40 GB), kills the whole group and fails the test with - the observed RSS. A single-artifact generate peaks ~35 GB on the reference - machine; an unbounded run once hit ~80 GB and had to be emergency-killed, so - the budget keeps a runaway generate from taking down the host. -- Emitted packages gained a surface-agnostic `_generated/_core.py` that owns the - shared names (the `JsonValue` alias, `NoRowError`, a new `DecodeError`, and the - `require_array` decode guard) with no I/O. Both `_runtime.py` modules are now - I/O-only and re-export `JsonValue`/`NoRowError`/`require_array` from `_core` so - off-contract `from ._runtime import ...` keeps working; `_rows.py`, the - statement modules, and the facades import the shared names from `_core` - directly. -- The release wheel now ships its GPL compliance files inside the artifact: - the build fetches the GPLv3 text into `COPYING` (sha256-pinned) and bundles - the committed `wheel/NOTICE` describing the composition; both land in - `dist-info/licenses` and the release job asserts their presence in the - wheel and the sdist. The repository LICENSE (MIT) is no longer copied into - the wheel, where it misstated the artifact's license. -- Emitted files now carry REUSE-style SPDX header lines - (`SPDX-FileCopyrightText`, `SPDX-License-Identifier: MIT-0`) right after - the `@generated` marker: license scanners in consuming projects see a - standard permissive id for the generated code instead of guessing its - provenance. -- Added a `pgenie-python-gen` wheel channel (`wheel/`): the release build bundles - the resolved generator as an installable package with a `path`/`url`/`vendor` - CLI; publication to PyPI ships wired but disabled. -- Keyword escaping (`PyIdent.dhall`) no longer needs the fork-only - `Text/equal` builtin; it's rewritten against a `Text/replace`-based marker - trick, since pgn's embedded `Text/replace` doesn't match a needle spanning - a concatenation boundary, which is what the java.gen-style delimiter trick - relied on. -- The generator config is now fully optional, folded through a single - defaults record in `compile.dhall` (`packageName` from the project name, - `emitSync` off, `onUnsupported` `Fail`). A project can omit `config:` - entirely, pass `config: {}`, or set any subset of the keys. -- gen-sdk pinned to `v0.10.2`. -- Fixed single-field composite param binding: it rendered as `(x.field)`, - parentheses around a bare expression, not a one-element Python tuple. - Now renders `(x.field,)`. Covered by a dedicated fixture composite - (`tag_value`) exercised as both a param and a result column. -- Added `onUnsupported: Fail | Skip`. `Fail` (default) is the existing - loud-abort behavior. `Skip` drops the smallest self-consistent unit (a - statement or a custom type, cascading to anything that references it) and - keeps generating the rest. +- 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 + runtime, and adjacent sync functions in the same canonical statement modules. + SQL, Row classes, custom types, core errors, and registration stay shared, so + async and sync facades expose exact model identities. + +- Moved PostgreSQL conversion to psycopg's class-aware adapters. Generated enums + are pure `StrEnum` classes, composites and statement rows are frozen slotted + dataclasses, and `args_row` constructs each canonical Row positionally through + `BaseRowFactory`. `EnumInfo`/`register_enum` and + `CompositeInfo`/`register_composite` register generated classes in + dependency-first order. Statement SQL remains one `LiteralString`; generated + output has no query decoders, model codecs, casts, or bytes SQL. + +- Completed custom-type coverage for scalar enums, enum arrays through rank 2, + scalar composite load and dump, rank-1 composite arrays, nested scalar + composites, nullable members, and sanitized identifiers. Unsupported enum + ranks, composite ranks, custom-array fields inside composites, and missing or + unsupported custom types fail loudly. + +- 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 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 + begins with the exact marker + `# @generated by python.gen (pGenie); regeneration overwrites manual changes.` + followed by REUSE copyright and MIT-0 SPDX lines. Takeover contract tests pin + the marker and greenfield layout. Consumers must disable regeneration and + replace the marker before treating the files as owned Python. + +- Added Ruff check and format gates, authored and SQL line-length gates, raw + output checks with no postformat step, public identity checks, import-boundary + checks, adapter AST checks, and async/sync PostgreSQL round trips. Final + evidence: H1 CONFIRM with psycopg 3.3.4, consumer `psycopg>=3.3.4,<4`, + 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: 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 + entry point is `src/package.dhall`, and the contract fixture is + `fixtures/Exhaustive.dhall`. Remote imports in `src/Deps/` are ordinary + sha256-pinned imports. + +- Preserved the pgn subprocess RSS watchdog. Each process runs in its own process + group, is polled every 2 seconds, and is killed on a + `PGN_MAX_RSS_GB` breach, with a 40 GB default. Heavy fixture generation remains + serial. + +- Fixed one-field composite binding. Generated dumpers construct field-ordered + 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 + `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 + and bundles that same asset into the `pgenie-python-gen` wheel. Repository + source remains MIT, emitted Python is MIT-0, and the combined resolved artifact + and wheel are GPL-3.0-or-later because they inline gen-sdk. The wheel includes + the sha256-pinned GPL text and `wheel/NOTICE`; PyPI publication remains + disabled behind its explicit gate. diff --git a/DESIGN.md b/DESIGN.md index bff5a2c..1496cb6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,639 +1,435 @@ -# python.gen DESIGN +# python.gen design -Status: current. This describes the generator as shipped, not a plan. The code -is the source of truth: the Dhall generator under `gen/`, the fixture project -and golden output under `tests/`. When this doc and the tree disagree, the -tree wins. +Status: current. The Dhall under `src/` and the live PostgreSQL harness under +`tests/` are the source of truth. This document describes the verified +greenfield client, not an intermediate migration design. -The generator turns a pgn project (queries and custom types, analyzed against -a live PostgreSQL) into a typed Python database client. It emits two parallel -surfaces, async (`psycopg.AsyncConnection`) and sync (`psycopg.Connection`), -over one shared set of Row dataclasses and decode functions. +The generator consumes pgn's analyzed project and emits one typed Python +package. Async is always the root API. `emitSync: true` adds a sync facade and +sync I/O functions without duplicating SQL or model definitions. -Constraints that bound every decision below: +## 1. Ownership and package layout -- Generated code depends ONLY on `psycopg>=3.2` and the stdlib. No pydantic, - no third-party codec library. -- Generated code passes `basedpyright` strict with zero errors/warnings and - `ruff` clean. -- No em dashes, comments only for non-obvious WHY, English everywhere. -- psycopg is the target driver. Driver portability is out of scope (see - "Deliberate driver scope" below). +pgn receives a `List { path, content }` from the generator and writes it below +the artifact root. `packageName` is converted to an import name by replacing +`-` with `_`. ---- +For a project with custom types and `emitSync: true`, generated ownership is: -## 1. Generated package layout - -pgn writes the `compile` output (a `List { path, content }`) under the -artifact's own root. Every `path` is relative to that root and starts with -`src//`, where `` is `packageName` with `-` replaced -by `_` (`my-db-client` -> `my_db_client`). +```text +src//__init__.py +src//sync/__init__.py +src//_generated/__init__.py +src//_generated/_core.py +src//_generated/_runtime.py +src//_generated/_register.py +src//_generated/sync/_runtime.py +src//_generated/statements/__init__.py +src//_generated/statements/.py +src//_generated/types/__init__.py +src//_generated/types/.py +``` -The generator emits the entire `src//_generated/` subtree plus -the two package-root facade modules (`__init__.py` and, when sync is on, -`sync/__init__.py`). Everything else, a `pyproject.toml` and a `py.typed` -marker, is the consumer's own hand-written shell; the generator never -touches it. +The sync facade and runtime are omitted unless `emitSync` is true. Type and +registration files are omitted when the project has no custom types. A +consumer-owned shell, such as `pyproject.toml` and `py.typed`, sits around this +output and is not generated. -Every emitted `.py` file starts with the exact first line -`# @generated by python.gen (pGenie). DO NOT EDIT.` followed by a blank line. -The header is prepended once in `Interpreters/Project.dhall` (`withHeader`) -so it applies to all modules. +There is one package, one generated type namespace, and one canonical statement +module per query. Internal generated paths are greenfield implementation detail; +the project makes no compatibility promise for them. -### What the generator emits (async surface, always) +`Interpreters/Project.dhall` prepends one header to every Python file. Its exact +first line is: ```text -src//__init__.py # generated flat facade (re-exports everything) -src//_generated/__init__.py # docstring + __all__: list[str] = [] -src//_generated/_runtime.py # async I/O helpers + JsonValue + NoRowError -src//_generated/_rows.py # SHARED Row dataclasses + decode_ fns -src//_generated/_register.py # only if the project has composites or enums -src//_generated/types/__init__.py # only if customTypes is non-empty -src//_generated/types/.py # one per custom type (enum / composite) -src//_generated/statements/__init__.py -src//_generated/statements/.py # one thin async wrapper per query +# @generated by python.gen (pGenie); regeneration overwrites manual changes. ``` -### What the generator adds when `emitSync` +The two SPDX lines immediately following it grant MIT-0 for emitted code. The +marker is intentionally operational: manual edits are safe only after all +regeneration paths have been disabled and the marker has been replaced. -```text -src//sync/__init__.py # generated sync facade -src//_generated/sync/__init__.py -src//_generated/sync/_runtime.py # sync I/O helpers; re-exports JsonValue/NoRowError -src//_generated/sync/_register.py # only if composites or enums -src//_generated/sync/statements/__init__.py -src//_generated/sync/statements/.py # one thin sync wrapper per query -``` +## 2. Canonical statement modules and rows -The sync surface reuses the SAME `_rows.py` and `types/` modules. Only the -I/O wrappers (statements, runtime, register) and the sync facade are -sync-specific. Sync statement modules sit one package deeper, so they reach -the shared modules with an extra dot (`..._rows`, `...types`); the async -ones use `.._rows`, `..types`. This dot-depth difference is exactly what -`Structures/Surface.dhall` parameterizes (section 4). - -### Naming (all from the pgn `Name` object, never recomputed) - -- module file: `name.inSnakeCase + ".py"`. -- query function: `query.name.inSnakeCase` (e.g. `get_specimen`). -- Row dataclass: `query.name.inPascalCase + "Row"` (e.g. `GetSpecimenRow`). -- decode function: `"decode_" + query.name.inSnakeCase` (e.g. - `decode_get_specimen`). -- enum / composite class: `customType.name.inPascalCase`. - -One exception to "never recomputed": a name that becomes a Python identifier -(a param, a result-row field, or a composite field) and collides with a -Python keyword is sanitized to `name_` via `Structures/PyIdent.dhall`. The -SQL placeholder, the params-dict key, and the `row["..."]` lookup keep the -raw name, so only the emitted identifier changes. Section 13 covers how that -sanitizing actually works and why it can't just use `Text/equal`. - ---- - -## 2. Shared type layer: `_rows.py` and `types/` - -Decode is CENTRALIZED. For each query that returns rows, `_rows.py` holds one -`@dataclass(frozen=True, slots=True)` Row plus a module-level -`decode_(row: Mapping[str, object]) -> ` function. A statement -module imports both from the shared module and does no decoding inline: - -```python -# statements/get_specimen.py (async) -from .._rows import GetSpecimenRow, decode_get_specimen -from .._runtime import fetch_single -... -return await fetch_single(conn, _SQL, params, decode_get_specimen) -``` +`Interpreters/Query.dhall` compiles a query into a single statement module. The +module contains, in order: -Because `_rows.py` is shared by both surfaces, async and sync return the SAME -Row type for a given query (cross-surface identity, not two equal-but-distinct -classes). `Templates/RowsModule.dhall` renders it; `Interpreters/Project.dhall` -concatenates every query's `rowDef` into the single file and unions their -imports. - -`types/.py` holds the enum / composite class. Custom-type imports -inside `_rows.py` use the `.types.` prefix (one level above `types/`); inside -a statement module they use `..types.` (async) or `...types.` (sync). - -### Decode per column - -psycopg3 default adapters already return the right Python object for `bool`, -`int`, `float`, `Decimal`, `str`, `UUID`, `date`, `datetime`, `time`, -`timedelta`, `bytes`, parsed JSON objects, and Python `list` for arrays. For -those the decode is a `cast(, row[""])` to satisfy strict -(the dict value type is `object`). Real conversion happens only for: - -- enum column: `Mood(cast(str, row["feeling"]))`; nullable guards `None`. -- enum array column: element-wise rebuild, - `[Mood(v) for v in cast(list[str], row["..."])]`, with per-element and - outer `None` guards driven by `elementIsNullable` / column nullability. - Requires the enum's TypeInfo registered (section 6). -- composite column: psycopg returns a namedtuple once the composite is - registered; decode is `TypeName(*cast(tuple[...], row["..."]))` with the - exact field types. - -`Member.run` (`Interpreters/Member.dhall`) builds `decodeExpr` as a -`Text -> Text` function next to the type info, so `_rows.py` and `types/` -compose the same logic. A composite array, or an enum array with -`dims > 1`, is unimplemented and fails loudly (`Compiled.report`) rather -than emitting wrong Python. - ---- - -## 3. `_runtime.py` API - -Self-contained, emitted once per surface from `Templates/RuntimeModule.dhall` -(`content` for async, `runSync` for sync). Five helpers, one per cardinality: - -| cardinality | helper | return | -| ---------------------- | ----------------------- | ------------- | -| `Rows` `Optional` | `fetch_optional` | `Row \| None` | -| `Rows` `Single` | `fetch_single` | `Row` | -| `Rows` `Multiple` | `fetch_many` | `list[Row]` | -| `RowsAffected` | `execute_rows_affected` | `int` | -| `Void` | `execute_void` | `None` | - -The async runtime defines `JsonValue` and `NoRowError`; the sync runtime -re-exports both from the async one (`from .._runtime import JsonValue, -NoRowError`) so there is one canonical identity. The missing-single-row case -raises `NoRowError(RuntimeError)`. - -Result helpers open `conn.cursor(row_factory=dict_row)` and pass each row -dict to the `decode` closure; void / rows-affected use a plain cursor. The -helpers take `sql: bytes`; each statement module encodes its SQL once at -import (`_SQL = SQL.encode()`, see section 8) and passes the cached bytes, -which also sidesteps psycopg's `LiteralString` requirement under strict -(the SQL string carries `%(name)s` placeholders, so it is a runtime -template, not a `LiteralString`). Each `cursor.execute(...)` result is bound -to `_` so `reportUnusedCallResult` stays quiet. - -`JsonValue` is a recursive alias: - -```python -type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] -``` +- imports needed by parameters, results, and selected I/O surfaces; +- one `SQL` string; +- one `@dataclass(frozen=True, slots=True)` Row when the query returns rows; +- the async function; +- an adjacent function with the `_sync` suffix when `emitSync` is true. + +The public facades alias those canonical objects. The root facade exports the +async function. `.sync` exports the adjacent sync function under the same +public name. Both facades export the exact same Row classes, custom types, +`JsonValue`, and `NoRowError` identities. + +For a row-returning query the statement passes `args_row(Row)` to the runtime. +psycopg's `BaseRowFactory` constructs the dataclass positionally in result-column +order. There is no mapping row, generated query decoder, model codec, +`typing.cast`, or second Row definition. This makes the statement's Row +the canonical runtime result type for both surfaces. + +Imports for custom annotations use the generated `types` namespace. The +`ImportSet` project index is both a deduplication key and stable sort key, so a +statement or composite imports each referenced type once in project order. + +Python identifiers are sanitized only where Python syntax requires it. +Parameters, result fields, composite fields, and query function names use the +safe identifier. SQL placeholders and PostgreSQL names retain their original +spelling. For example, a PostgreSQL column named `class` becomes the Python +field `class_`, while its positional value still occupies the analyzed column +position. + +## 3. Adapter-owned custom types and registration + +Generated models are pure declarations: + +- enums are `StrEnum` subclasses containing the exact PostgreSQL labels; +- composites are frozen, slotted dataclasses containing their typed fields; +- statement rows are frozen, slotted dataclasses. -`json`/`jsonb` columns and params type as `JsonValue`. The client stays -faithful to the SQL type; a consumer that wants `dict[str, JsonValue]` -narrows at its own boundary. +They contain no database conversion methods. psycopg owns loading and dumping. +`Templates/RegisterModule.dhall` emits class-aware registration into one shared +module: ---- +- `EnumInfo.fetch` and `register_enum` receive the generated enum class and an + explicit member-to-label mapping; +- `CompositeInfo.fetch` and `register_composite` receive the generated + dataclass plus validated object and sequence callbacks. -## 4. Surface mechanism (async / sync) +The callback factory compares sanitized PostgreSQL field names with dataclass +field names before constructing or flattening an object. This catches schema and +model drift at the adapter boundary. -`Structures/Surface.dhall` is a five-field token table that captures -everything that varies between the two surfaces: +`register_types` is async. When sync output is enabled, the same module also +contains `register_types_sync`, which the sync facade exports as +`register_types`. Registration is a per-new-connection precondition and must run +after the database migrations and custom types exist. Missing catalog entries +raise `LookupError` rather than falling back to text. + +Registration order is dependency-first. Enums are ready immediately; +composites are emitted only when all referenced custom types are already ready. +A bounded pass either resolves the full order or reports unresolved or cyclic +dependencies. This ordering lets psycopg recursively adapt nested scalar +composites and arrays of the supported ranks. + +Verified custom shapes are scalar enums, enum arrays through rank 2, scalar +composite load and dump, rank-1 composite arrays, nested scalar composites, +nullable fields, and sanitized fields. Async and sync return the same exact +model identities. + +## 4. Runtime and surfaces + +`Structures/Surface.dhall` is a small render-token record for adjacent functions: ```dhall -{ defKeyword : Text -- "async def" | "def" -, connType : Text -- "AsyncConnection" | "Connection" -, awaitKw : Text -- "await " | "" -, rowsImport : Text -- ".._rows" | "..._rows" -, typesPrefix: Text -- "..types" | "...types" +{ defKeyword : Text +, connType : Text +, awaitKw : Text +, functionSuffix : Text +, runtimePrefix : Text +, helperSuffix : Text +, corePrefix : Text } ``` -`Surface.async` and `Surface.sync` are the two values. The statement-module -template (`Templates/StatementModule.dhall`) and the register template take -a `surface` field and render `${surface.defKeyword} ${functionName}(... conn: -${surface.connType}[object] ...)`, `return ${surface.awaitKw}${helperName}(...)`, -and the surface-correct relative imports. `Interpreters/Query.dhall` renders -each query twice (`mkModule Surface.async`, `mkModule Surface.sync`) and -returns both an `asyncContent`/`asyncModulePath` and a -`syncContent`/`syncModulePath`. `Interpreters/Project.dhall` always emits the -async files; the sync files are emitted only `if config.emitSync`. - -The type layer (`_rows.py`, `types/`) is rendered once and is -surface-agnostic, so adding the sync surface costs only the thin wrappers, -not a second copy of the types or decode. - ---- - -## 5. Type mapping and the loud-fail policy - -`Interpreters/Primitive.dhall` switches on the pgn `Primitive` union tag (the -Dhall constructor, NOT the sig YAML string) and maps the supported set (see -the README's Supported types table for the full mapping). Every other -primitive (`money`, network types, ranges/multiranges, geometry, `bit`, -`xml`, `tsvector`, `timetz`, ...) maps to -`Lude.Compiled.report Output [pgTypeName] "Unsupported type"`. There is no -silent fallback. The report propagates through `Compiled.nest` at each level -(query `srcPath` -> `"result"`/`"sql"`/`"params"` -> column/param `pgName` -> -pg type), so the error names the precise query and column. Generation fails -non-zero (or the query is skipped, see section 11); pgn prints the report; -no partial output for the affected query under `Fail`. Add a mapping to -`Primitive.dhall` to support a new type. - -The param side (`Interpreters/ParamsMember.dhall`) carries the same -loud-fail contract for bind shapes psycopg cannot adapt faithfully. It -rejects, rather than silently mis-binds: - -- a `json`/`jsonb` ARRAY param (`Jsonb` wraps a scalar, not element-wise). -- a composite ARRAY param (psycopg cannot adapt the dataclass array). - -### Arrays and nullability - -`Interpreters/Value.dhall` reads `arraySettings = { dimensionality, -elementIsNullable }` and wraps the scalar `pyType` in one `list[...]` per -dimension, applying `elementIsNullable` to the inner element. -`Interpreters/Member.dhall` then applies the member-level `isNullable` on -the outside. So a nullable array of nullable UUIDs renders -`list[UUID | None] | None`. - -### Enums -> StrEnum - -`Templates/EnumModule.dhall` renders an enum custom type to a `StrEnum` -subclass. Member name = `variant.name.inScreamingSnakeCase`; value = -`variant.pgName` (the exact PG label): - -```python -class Mood(StrEnum): - HAPPY = "happy" - SAD = "sad" - MEH = "meh" -``` +The async value renders `async def`, `AsyncConnection`, and `await`. The sync +value renders `def`, `Connection`, and `_sync` implementation names. It does not +select between mutually exclusive package shapes. -Encode (param): pass the StrEnum value; psycopg sends text and PostgreSQL -coerces the unknown-typed param to the enum in assignment / `::enum` -contexts; enum-array params carry an explicit `::[]` cast in the SQL. -Decode (scalar): the `StrEnum(str)` constructor validates the label. - -### Composite -> frozen dataclass - -`Templates/CompositeModule.dhall` renders a composite to a -`@dataclass(frozen=True, slots=True)` with one field per member -(`member.name.inSnakeCase`, type from the Value mapping + nullability). -Encode and decode go through psycopg's registered `CompositeInfo` -(section 6). - -A composite param is bound as its field tuple, e.g. `(x.first, x.second)` -for two fields. A single-field composite is the edge case: `Text.concatMapSep` -never inserts a separator for one element, so the naive tuple expression -renders `(x.field)`, parentheses around a bare expression, not a Python -tuple. `ParamsMember.dhall`'s `compositeBind` forces the trailing comma for -exactly the one-field case (`(x.field,)`); the fixture's `tag_value` -composite (a single `value: str | None` field) exists specifically to cover -this, exercised as both a param and a `RETURNING` result column in the same -statement, with a round-trip test. - -Composite fields cannot themselves reference another custom type. -`CustomType.dhall` hardcodes the nested-member lookup to `Absent` -(`nestedLookup`), so a composite nesting a composite (or an enum) fails the -same loud-fail path as any other unresolvable reference rather than -guessing a decode. Widening this is possible but has not been needed by any -project this generator has shipped against yet. - ---- - -## 6. Type registration (`_register.py`) - -`_register.py` is emitted only when the project has composites or enums, -once per surface (`Templates/RegisterModule.dhall`). It exposes -`register_types(conn)`: - -- composites: `CompositeInfo.fetch` + `register_composite`, so psycopg - decodes the composite to a namedtuple the generated decode can splat. -- enums: `TypeInfo.fetch` + `enum_info.register`. - -Scalar enums do NOT need registration: a scalar enum encodes as text -(PostgreSQL coerces it in assignment / `::enum` contexts) and decodes via -the `StrEnum(str)` constructor in `_rows.py`. A project can therefore ignore -`register_types` entirely as long as it never selects an enum-array or -composite result column; calling it costs a `TypeInfo`/`CompositeInfo` -fetch per type per connection and raises `LookupError` on a database where -the type doesn't exist yet (a pre-migration or partial database), so it's -opt-in rather than wired in automatically. Enum-array params sidestep the -whole question by carrying an explicit `%(p)s::[]` cast in the SQL, so -they bind without registration either way. - ---- - -## 7. Cardinality fidelity - -The generator is faithful to pgn's cardinality and never overrides it. -`Interpreters/Result.dhall` maps `Result` straight through: - -- `Rows` `Optional` -> `Row | None` via `fetch_optional`. -- `Rows` `Single` -> `Row` via `fetch_single`. -- `Rows` `Multiple` -> `list[Row]` via `fetch_many`. -- `RowsAffected` -> `int` via `execute_rows_affected` (no Row, no decode). -- `Void` -> `None` via `execute_void` (no Row, no decode). - -pgn defaults DML-with-RETURNING to `Multiple` and cannot prove single-row -from a predicate on its own, so a predicate-targeted `DELETE ... RETURNING` -returns `list[Row]` even when the caller treats it as a single row or a -boolean. Reconciling `list[Row] -> bool` (or a single row) is the consumer's -job; the generator does not narrow cardinality, because that would drift -from pgn's own analysis. - ---- - -## 8. SQL rendering (`Interpreters/QueryFragments.dhall`) - -A query's `fragments` (`List < Sql : Text | Var : { name, rawName, -paramIndex } >`) render to one Python `str` literal `SQL` per statement -module in psycopg named style: - -- `Var` -> `%()s`, using the snake_case SQL param name. - Repeated occurrences of the same param collapse to one dict key (psycopg - named style). -- `Sql` text -> emitted verbatim after escaping for the Python literal and - after doubling any literal `%` to `%%` (psycopg's placeholder sigil). The - Var branch is not subject to `%` doubling. - -The SQL is emitted as a triple-double-quoted string with a leading backslash -so the first line stays flush, and a trailing newline before the closing -quotes (a harmless newline for psycopg). The params dict is rendered -multi-line with a magic trailing comma so ruff keeps it format-stable at any -width. - -Explicit casts (`::jsonb`, `::uuid[]`, `::[]`) already live in the SQL -text where pgn needs them; the generator does not append per-param cast -suffixes. - ---- - -## 9. The generated facade (`__init__.py`) - -`Templates/FacadeModule.dhall` renders the flat public surface that -consumers import. It re-exports, with PEP 484 `X as X` markers and a -matching `__all__`: - -- every custom type from `_generated/types/`, -- every Row dataclass from `_generated/_rows`, -- every statement function from `_generated/statements/` (async) or - `_generated/sync/statements/` (sync). - -So consumers do `from my_db_client import get_specimen, GetSpecimenRow, -Mood` against the flat root, and `from my_db_client.sync import ...` for the -sync surface. The async facade lives at `/__init__.py` (prefix -`._generated`, statements under `statements`); the sync facade lives at -`/sync/__init__.py` (prefix `.._generated`, statements under -`sync.statements`). Both re-export the SAME Rows and enums. +`_core.py` is emitted once and owns `JsonValue` and `NoRowError`. The async +runtime is always emitted; the sync runtime is additive. Each runtime contains +the same five cardinality helpers: -This file is GENERATED: it carries the `@generated` header and is -overwritten on every run. Do not hand-edit it. +| helper | result | +| --- | --- | +| `fetch_optional` | `Row | None` | +| `fetch_single` | `Row`, or `NoRowError` | +| `fetch_many` | `list[Row]` | +| `execute_rows_affected` | `int` | +| `execute_void` | `None` | ---- +Row helpers accept `BaseRowFactory[T]` and return psycopg's constructed object +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`. -## 10. Generator decomposition +## 5. Configuration and unsupported shapes -`gen/Gen.dhall` is the entry point handed to gen-sdk: +The public Dhall config is: ```dhall -let Sdk = ./Deps/Sdk.dhall - -in Sdk ./Config.dhall ./compile.dhall +{ packageName : Optional Text +, emitSync : Optional Bool +, onUnsupported : Optional < Fail | Skip > +} ``` -The Sdk `module` function has signature `\(Config : Type) -> \(compile) -> -{ contractVersion, Config, compile, compileToFileMap }`, and -`compile : Optional Config -> Project -> Lude.Compiled.Type Lude.Files.Type`, -where `Files.Type = List { path : Text, content : Text }`. `compile.dhall` -folds the optional user config into the internal interpreter config and -calls `Interpreters/Project.dhall`, which traverses queries and custom types -and assembles the file list. +`src/package.dhall` passes that type and an all-`None` default to +`Sdk.Sigs.generator`. `Project.run` resolves each field independently: + +- `packageName`: project name in kebab case; +- `emitSync`: `False`; +- `onUnsupported`: `Fail`. + +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 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, +numeric, textual, UUID, date/time/timestamp/interval, bytea, and JSON. Arrays +wrap the scalar type once per analyzed dimension, preserving element and outer +nullability independently. JSON parameters use psycopg's `Json` or `Jsonb` +wrapper only for a scalar value. + +`CustomKind.Lookup` classifies each custom reference as enum, composite, or +absent. For enum and composite references it returns only the generated Python +class and module identity plus stable project order; composite fields stay with +the original model. That classification drives type imports, supported array +ranks, nested dependencies, and adapter registration. The generator reports +instead of guessing when a primitive or custom type cannot be mapped. + +The explicit custom limits are: + +- enum dimensions above 2 are unsupported; +- composite dimensions above 1 are unsupported; +- a custom-array field inside a composite is unsupported; +- an absent or unsupported custom type is unsupported. + +Domains are unsupported. JSON array parameters are also rejected because a +single JSON wrapper cannot adapt elements faithfully. Every failure carries the +query or type path and member name through `Compiled.nest`. + +## 6. Cardinality fidelity + +`Interpreters/Result.dhall` preserves pgn's cardinality verbatim: + +- `Rows Optional` maps to `Row | None` and `fetch_optional`; +- `Rows Single` maps to `Row` and `fetch_single`; +- `Rows Multiple` maps to `list[Row]` and `fetch_many`; +- `RowsAffected` maps to `int` and `execute_rows_affected`; +- `Void` maps to `None` and `execute_void`. + +The generator does not infer a narrower return from SQL predicates. If pgn +classifies a returning statement as multiple, its API remains `list[Row]`. + +## 7. SQL rendering + +`Interpreters/QueryFragments.dhall` converts pgn fragments into one Python +triple-quoted `SQL` string in each statement module: + +- a variable becomes `%()s`; +- raw SQL remains verbatim after Python-literal escaping; +- literal percent signs are doubled for psycopg named style; +- the variable branch is not percent-doubled. + +The literal begins with a backslash so the first SQL line is flush and retains +a trailing newline. Parameter dictionaries use raw PostgreSQL names as keys and +sanitized Python expressions as values. Source SQL owns explicit database casts; +the generator does not append adaptation casts. The same `SQL` object is passed +to async and sync helpers as a string whose static type satisfies +`LiteralString`. Output is the raw Dhall render; no Python formatter or SQL +postprocessor rewrites generated files. + +## 8. Project pipeline and `onUnsupported` + +`Interpreters/Project.dhall` is the project-level coordinator: -`gen/` mirrors a typical pgn gen-sdk generator, Python-flavored. The -algebra/interpreter/template split keeps assembly separate from rendering. +```text +optional config + -> resolved config + -> 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. 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 +render call. Dhall normalization substitutes call sites, and multiplying those +sites caused a measured minutes-scale regression. Custom-type checks use their +separately measured fast shape. + +## 9. Generator decomposition + +The source follows the gen-sdk interpreter/template split: ```text -gen/ - Gen.dhall # Sdk Config compile (entry handed to gen-sdk) - Config.dhall # user config TYPE: { packageName, emitSync, onUnsupported } - compile.dhall # derive interpreter Config from user Config, call Project.run - Deps/ # pinned remote imports (gen-sdk module + Project, lude, Prelude) - Algebras/ - Interpreter.dhall # Config + `module Input Output run` ; Run = Config -> Input -> Compiled Output - Template.dhall # `module Params run` ; Run = Params -> Text +src/ + package.dhall + Deps/ # sha256-pinned remote packages Structures/ - Surface.dhall # async/sync token table (section 4) - CustomKind.dhall # Lookup : Name -> < Enum | Composite | Absent > + composite fields - ImportSet.dhall # per-module import flags + combine - PyIdent.dhall # sanitize names that become Python identifiers (keyword -> name_) - OnUnsupported.dhall # < Fail | Skip > (section 11) + CustomKind.dhall + ImportSet.dhall + OnUnsupported.dhall + PyIdent.dhall + Surface.dhall Interpreters/ - Primitive.dhall # pgn Primitive -> Python type / unsupported report - Scalar.dhall # Primitive | Custom -> pyType + decode hint - Value.dhall # arrays + element nullability - Member.dhall # a Member -> field name/type/nullable + decodeExpr (Text -> Text) - ParamsMember.dhall # a param Member -> arg + bind expr (jsonb wrap) + imports - CustomType.dhall # Enum/Composite -> module ; Domain -> unsupported report - QueryFragments.dhall # fragments -> escaped, %%-doubled, named-style SQL literal - ResultColumns.dhall # Rows columns -> Row field lines + decode body - Result.dhall # Void|RowsAffected|Rows -> return type + helper + rowClass - Query.dhall # assemble one query: shared rowDef + async/sync statement modules - Project.dhall # traverse queries+customTypes, assemble all files + facade + header, - # apply the Skip filter (section 11) + Primitive.dhall + Scalar.dhall + Value.dhall + Member.dhall + ParamsMember.dhall + CustomType.dhall + QueryFragments.dhall + ResultColumns.dhall + Result.dhall + Query.dhall + Project.dhall Templates/ - RuntimeModule.dhall # async + sync _runtime.py bodies - RowsModule.dhall # shared _rows.py (Row dataclasses + decode fns) - StatementModule.dhall # one per-surface statement wrapper - RegisterModule.dhall # _register.py (per surface) - FacadeModule.dhall # the flat package-root facade - EnumModule.dhall / CompositeModule.dhall / TypesInit.dhall / InitModule.dhall -``` - -The cross-cutting dependency is the custom-type lookup: `Scalar`/`Value`/ -`Primitive` stop at "Custom + Name"; `Project.run` builds a -`CustomKind.Lookup : Name -> < Enum | Composite | Absent >` from the -(post-Skip-filter) custom types and threads it to `Query.run` -> -`Result`/`ResultColumns`/`ParamsMember`/`Member`. The lookup compares by -`name.inSnakeCase` (the column carries a distinct `Name` occurrence, so -record identity cannot be relied on) and carries the type's alphabetical -index so per-module custom-type import blocks stay sorted. Section 12 -covers why this one comparison still needs a Dhall fork builtin. - -### Config flow - -`Config.dhall` is the user-facing type `{ packageName : Optional Text, -emitSync : Optional Bool, onUnsupported : Optional OnUnsupported.Mode }`. -`compile.dhall` folds the optional user config, and each of its optional -fields, into the internal interpreter Config `{ packageName, importName, -emitAsync = True, emitSync, onUnsupported }`, with `packageName` falling -back to the project name in kebab case, `emitSync` to `False`, and -`onUnsupported` to `Fail`. `importName` = `packageName` with `-` -> `_`. -`emitAsync` is always `True`. A project's artifact config can therefore omit -`config:` entirely, supply `config: {}`, or set any subset of the three -keys; see the README's Config reference for the decode semantics pgn itself -applies before this fold ever runs. - ---- - -## 11. `onUnsupported`: Fail vs Skip - -`Fail` (the default) is the code path this generator has always had: any -unsupported primitive, unsupported param shape, or domain custom type -aborts the whole `Project.run` non-zero via `Lude.Compiled.report`. - -`Skip` asks a different question of the same failures: rather than aborting, -drop the smallest self-consistent unit and keep the rest of the project -generating. `Project.dhall`'s `run` computes, once, whether each custom type -and each query would have compiled cleanly (`typeSucceeds` / the `keep` -field of a per-query `QueryCheck`), filters the failing ones out before -building the custom-type lookup and the final query list, and separately -collects a `Report` per dropped unit into `combined.warnings`. A query that -references a skipped custom type resolves that reference to `Absent` -through the (already-filtered) lookup and fails its own compile the same -way any other unsupported shape would, which is what makes the drop cascade -without any special-cased "type X depends on type Y" bookkeeping. - -The precedent for this shape is java.gen, an earlier gen-sdk generator for a -different target language: it skips unconditionally and silently (an -`Alternative.optional` layered over `Compiled.or`, with the failure report -simply discarded). This generator's deliberate difference is twofold: Skip -is opt-in behind an explicit config knob rather than the only behavior, and -the report for each dropped unit is preserved in `Compiled.warnings` instead -of thrown away, even though pgn 0.6.5 doesn't currently surface that -warnings list anywhere on a successful run (see the README's Unsupported -types section). If a future pgn version starts printing `Compiled.warnings` -on success, or another generator-runner consumes it directly, this -generator is already ready for that; it isn't waiting on it. - ---- - -## 12. Forked-Dhall (`Text/equal`) dependency risk - -ACCEPTED RISK, recorded so nobody is surprised. pgn is a prebuilt binary -that embeds a FORKED Dhall providing a `Text/equal` builtin, which is not -part of the upstream Dhall standard Prelude. This generator uses it in -exactly one place left: `Interpreters/Project.dhall`'s `buildLookup`, to -match a custom type by its snake-case name while building the -`CustomKind.Lookup`. Everywhere else that used to need name equality -(keyword sanitizing in `PyIdent.dhall`) has since been rewritten against a -`Text/replace`-based trick that needs no fork builtin at all; section 13 -covers how. - -`buildLookup`'s use is harder to remove the same way: it returns a -structural `TypeKind` value, not `Text`, so the replace-based marker trick -(built for sanitizing a `Text -> Text` name) doesn't carry over as-is. -gen-sdk's own `Fixtures` module relies on the same builtin, so a Dhall -evaluator without it can't even typecheck gen-sdk's full package entry -point, only the narrower `module.dhall`/`Project.dhall` imports this -generator pins directly. The clean fix is upstream: a `kind` tag or a -`Natural` index carried directly on `Scalar.Custom`, so the lookup becomes -an equality-free structural match. That is a planned ask against gen-sdk, -not something this generator can do unilaterally. - -Consequence: end-to-end regeneration requires the pgn binary (for its -embedded fork Dhall), a live Postgres, and currently-live remote Dhall -imports (`Deps/*` resolve gen-sdk, lude, and the Prelude over the network, -pinned by sha256). There is no pure-upstream-dhall path to reproduce the -output today. - ---- - -## 13. Keyword escaping without `Text/equal` - -`PyIdent.dhall`'s `sanitizeAgainst` needs to answer "does this name equal -one of the Python keywords" and, if so, append an underscore, all in plain -`Text`, without the fork's `Text/equal`. The obvious trick borrowed from -java.gen's equivalent (`escapeJavaKeyword`) is a delimiter wrap: replace -`name` with some sentinel only where it matches a candidate keyword exactly, -by wrapping both in a marker the surrounding text can't produce by -accident. That trick relies on `Text/replace` matching a needle that spans -a concatenation boundary, and pgn's embedded `Text/replace` was found not to -do that (verified against the pinned pgn binary): a needle straddling where -two `Text` values were joined at Dhall-normalization time simply doesn't -match. - -The working replacement threads two bare `Text/replace` calls per candidate -keyword instead of one wrapped comparison: - -```dhall -let markTrue = "0000000000000000000000000001" - -let signal = Text/replace name markTrue candidate -let finalNeedle = Text/replace signal name markTrue -in Text/replace finalNeedle (name ++ "_") acc + CoreModule.dhall + RuntimeModule.dhall + StatementModule.dhall + RegisterModule.dhall + FacadeModule.dhall + EnumModule.dhall + CompositeModule.dhall + TypesInit.dhall + InitModule.dhall ``` -`Text/replace name markTrue candidate` yields exactly `markTrue` only when -`name` equals `candidate` verbatim; any mismatch leaves at least one -alphabetic character of the keyword in place (or overshoots the -digits-only marker), so it can never coincidentally read as `markTrue` -afterward. The second replace maps an exact match to `name` and every -mismatch to `markTrue`; the third rewrites the accumulator only on that -match. Folded over the keyword list, this reproduces "append `_` iff `name` -is a reserved word" without ever comparing two `Text` values for equality. - -Two sharp edges, both called out in `PyIdent.dhall` itself: a `name` that -happens to contain the literal marker string gets corrupted by the mismatch -branch (the marker itself becomes a needle replaced in the accumulator), -and the accumulator must be read exactly once per fold step, or the -expression re-embeds itself at every reserved word and blows up -exponentially. Neither has come up in practice; both are worth knowing if -this ever needs extending to a second reserved-word list. - ---- - -## 14. Perf note: call-site count and Dhall normalization cost - -Dhall normalizes by substitution, so evaluating the same interpreter -function (`QueryGen.run` or `CustomTypeGen.run`) from more than one literal -call site in the source multiplies the normalization work per extra site, -not just per extra invocation. This was found by bisection while wiring up -Skip mode: an earlier draft of `Project.dhall`'s `run` called `QueryGen.run` -once to decide keep/drop, once more to render, and once more to build a -warning, three literal call sites, and `pgn generate`'s wall time on the -fixture project regressed from a few seconds to minutes. - -The fix folds the keep/drop decision, the warning, and the query itself -into one `QueryCheck` record computed from a single `QueryGen.run` call per -query (see `queryChecks` in `Project.dhall`), so the query side is back down -to two literal call sites: one to build `queryChecks`, one for the final -`queriesForCombine` render. That is the side worth holding the line on, -since it scales with the whole query set. The custom-type side took the -opposite shape empirically (`typeSucceeds` and `typeWarning` as two small -separate functions, plus the render call in `typesForCombine`, three call -sites in total) and stayed fast enough regardless, because a project -typically has far fewer custom types than queries; the asymmetry is -deliberate, not an oversight. If a future change makes the custom-type side -slow too, the same single-record collapsing trick applies there. - ---- - -## 15. Sanctioned escape hatch: hand-written SQL on a raw connection - -The generator covers the analyzable, drift-gated query surface. It is not -the only way to reach the database. A consumer obtains a raw connection -from wherever its own connection-management layer provides one, and passes -it to the generated functions; the SAME connection may be used to run -hand-written SQL directly (`conn.execute(...)`, a cursor) for the rare -statement that pgn cannot analyze, or that has no business being in the -generated set. - -This is sanctioned, not a smell. Hand-written SQL on the raw connection -lives outside whatever regeneration drift check a project wires into its -own CI: it isn't a `.sql` file this generator ever saw, so no diff against -generated output will ever touch it. Use it sparingly and keep it in -application code, not anywhere near `_generated`. - ---- - -## 16. Deliberate driver scope - -psycopg `>=3.2` is the target and the only supported driver. The runtime -helpers, the `dict_row` factory, `Jsonb` wrapping, `CompositeInfo`/`TypeInfo` -registration, and the `AsyncConnection`/`Connection` split are all -psycopg-specific by design. Abstracting over other drivers (asyncpg, a -DB-API shim) is explicitly out of scope: it would add an indirection layer -for a portability nobody asked for and would dilute the strict-typed, -zero-dependency surface. If the driver ever changes, the runtime templates -and the Surface token table are the blast radius; the type layer and the -SQL rendering are largely driver-agnostic. - ---- - -## 17. Development - -CI runs two independent jobs (`.github/workflows/ci.yml`): `harness` (the -pytest suite against a live Postgres) and `contract` (compiles gen-sdk's -`Fixtures.Exhaustive` via `tests/Exhaustive.dhall` and runs basedpyright -strict on the result). The `contract` job needs `nikita-volkov/dhall-directory-tree.github-action`, -a Docker action bundling a forked Dhall evaluator; the local `dhall` CLI most -people have installed is the standard dhall-lang build and does not -understand `Text/equal`, so it cannot run `tests/Exhaustive.dhall` directly. -Reproduce the `contract` job locally with [`act`](https://github.com/nektos/act) -(not installed in this environment; `act -j contract` pulls the same pinned -Docker action and runs the job as GitHub would). +`Scalar` identifies primitive versus custom values. `Value` adds array rank and +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, +single-pass survivor filtering, registration order, file selection, and +facades. + +## 10. The pinned `Text/equal` constraint + +`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 + +The generated marker means regeneration is authoritative. A consumer may eject +only as a coordinated transition: + +1. generate and commit the intended complete client; +2. disable every local and CI regeneration path before edits; +3. replace the marker with a project ownership header; +4. maintain the result as ordinary Python; +5. keep the package structure unless intentionally refactoring all imports; +6. gate the result with basedpyright strict, Ruff, and database tests. + +Once regeneration is disabled, there is no generator behavior involved. Before +that point, manual edits are expected to be overwritten. + +## 12. Verification evidence + +The final fixture was regenerated as raw output and verified against live +PostgreSQL with these verdicts: + +- H1 CONFIRM: psycopg 3.3.4; consumer `psycopg>=3.3.4,<4`; + class-aware `EnumInfo`/`register_enum` and + `CompositeInfo`/`register_composite`; dependency-first registration; pure + `StrEnum` and frozen slotted dataclasses; `args_row` constructs canonical Row; + 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: 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 +composite load and dump, rank-1 composite arrays, nested scalar composites, +nullable fields, and sanitized names. Negative contract tests cover unsupported +ranks, custom-array fields inside composites, and missing or unsupported custom +types. + +## 13. Development and release constraints + +Use `mise` for every repository command. `mise run test` drives real pgn +subprocesses against a live PostgreSQL server; `PGN_TEST_DATABASE_URL` selects a +non-default server. The harness only creates and drops its own scratch database. +Golden regeneration is intentionally serial and memory-heavy; use only +`mise run golden` and review its diff. + +`src/Deps/*.dhall` pins remote imports by sha256 and should be changed one at a +time. `src/package.dhall` is the working-tree generator entry point. +`fixtures/Exhaustive.dhall` is the contract fixture. The release workflow +resolves the entry point into `resolved.dhall`, publishes that release asset, +then byte-checks and bundles it into the wheel. Repository source is MIT, +generated Python is MIT-0, and the combined resolved artifact and wheel are +GPL-3.0-or-later because they inline gen-sdk. diff --git a/README.md b/README.md index db3226f..0f5b25d 100644 --- a/README.md +++ b/README.md @@ -1,313 +1,274 @@ # python.gen -A Dhall-authored code generator for [pgenie](https://github.com/pgenie-io/pgenie) -(`pgn`). +A Dhall-authored code generator for [pGenie](https://github.com/pgenie-io/pgenie) +(`pgn`). It turns queries and custom types analyzed against PostgreSQL into a +strictly typed Python client for psycopg 3. -## What it generates - -pgn reads a project's SQL queries and custom types from a live PostgreSQL, -infers parameter and result types, then hands them to this generator, which -emits a typed Python client: one async client (`psycopg.AsyncConnection`), -optionally a parallel sync one (`psycopg.Connection`), sharing a single set -of Row dataclasses, decode functions, and enum/composite types. Generated -code depends on `psycopg>=3.2` and the stdlib only, nothing else. - -Every emitted file starts with `# @generated by python.gen (pGenie). DO NOT -EDIT.` and an `SPDX-License-Identifier: MIT-0` header: the generated code is -yours to use under your own project's terms, no attribution required. - -## Key features - -- **Async and sync clients over psycopg 3.** One shared set of Row dataclasses, - decode functions, and enum/composite types serves both surfaces; install - `psycopg[binary]` for the fast C implementation. -- **Fully typed, end to end.** Parameter and result types are inferred from - your live PostgreSQL by preparing the real queries, and every emitted file - passes `basedpyright` strict with zero errors or warnings. -- **Zero runtime dependencies.** Generated code needs `psycopg>=3.2` and the - stdlib, nothing else: no pydantic, no codec libraries, no framework lock-in. +The generated package has no separately installed generator runtime. Consumers +must depend on `psycopg>=3.3.4,<4`; the verified fixture uses psycopg 3.3.4. The +rest of the generated imports are from the Python standard library. ## Quickstart -Point a pgn artifact's `gen:` key at this generator and give it a package -name: +Point an artifact at the working-tree entry point and choose a package name: ```yaml artifacts: python: - gen: https://raw.githubusercontent.com/slavashvets/python.gen/master/gen/Gen.dhall + gen: https://raw.githubusercontent.com/slavashvets/python.gen/main/src/package.dhall config: packageName: my-db-client emitSync: true ``` -`master` moves; once a `v0.1.0` tag exists, pin to the tagged raw URL instead -so a later generator change cannot silently reshape your output. - -Then run pgn against a project that has migrations and `.sql` queries under -it, same as any other pgn generator: +Then generate against the PostgreSQL server pgn may use for its scratch +database: ```bash pgn --database-url "$DATABASE_URL" generate ``` -pgn creates its own scratch database on that server, applies your migrations, -prepares each query to infer its types, generates, then drops the scratch -database. - -### Pointing `gen:` at this generator - -`gen:` accepts a few forms; only two of them are actually useful: - -| form | example | works? | -| --------------- | --------------------------------------- | ------------------------------------------ | -| plain http(s) | `https://.../python.gen/gen/Gen.dhall` | yes; pgn fetches it and every relative import over HTTP | -| relative path | `../path/to/python.gen/gen/Gen.dhall` | yes, if you keep a local checkout next to your project | -| absolute path | `/abs/path/to/python.gen/gen/Gen.dhall` | yes, but the resulting freeze key is machine-specific | -| `file://` URL | `file:///abs/.../Gen.dhall` | rejected; pgn's project schema does not accept `file://` | - -Whichever form you use, the freeze file that caches the resolved generator -(section "Freeze lifecycle" below) keys on the literal `gen:` value and -stores a hash of the resolved Dhall, so the hash is identical across forms -as long as they resolve to the same source. - -## Config reference - -| key | type | default | -| --------------- | ------------------ | ------------------------------ | -| `packageName` | `Text` | the project name, kebab-cased | -| `emitSync` | `Bool` | `False` | -| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | - -All three keys are optional, and so is the `config:` block itself. Decode -semantics observed against pgn 0.6.5 (undocumented by pgn, pinned by this -generator's own test suite rather than assumed): - -- omitting `config:` entirely behaves exactly like `config: {}`. -- a key set to `null` decodes as absent, i.e. its default. -- an unknown key is ignored, not rejected as a schema error. -- each key falls back to its own default independently, so a partial config - (e.g. only `emitSync`) works field by field. - -`packageName` becomes the Python import name by replacing `-` with `_` -(`my-db-client` -> `my_db_client`). - -## Supported types - -| PostgreSQL type | Python type | import | -| -------------------------------------------------- | ----------- | ------------ | -| `bool` | `bool` | - | -| `int2` `int4` `int8` `oid` | `int` | - | -| `float4` `float8` | `float` | - | -| `numeric` | `Decimal` | `decimal` | -| `text` `varchar` `bpchar` `char` `citext` `name` | `str` | - | -| `uuid` | `UUID` | `uuid` | -| `date` | `date` | `datetime` | -| `time` | `time` | `datetime` | -| `timestamp` `timestamptz` | `datetime` | `datetime` | -| `interval` | `timedelta` | `datetime` | -| `bytea` | `bytes` | - | -| `json` `jsonb` | `JsonValue` | (runtime) | - -`JsonValue` is a recursive alias emitted once in the runtime module: -`None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]`. - -Arrays wrap the scalar mapping in one `list[...]` per dimension; nullability -of the element and of the column/param are tracked independently, so a -nullable array of nullable UUIDs types as `list[UUID | None] | None`. Enums -render as `StrEnum` subclasses; composites render as frozen dataclasses. See -Notes below for both. - -## Unsupported types and `onUnsupported` - -Everything not in the table above (`money`, network types, ranges and -multiranges, geometry types, `bit`/`varbit`, `xml`, `tsvector`, `timetz`, -domains, ...) has no Python mapping and is rejected rather than guessed at. -A `jsonb` array parameter and a composite array parameter are also rejected: -psycopg cannot bind either faithfully (`Jsonb` wraps a single scalar, not an -element-wise array; there is no adapter for an array of dataclasses). - -`onUnsupported` controls what happens when generation hits one of these -shapes: - -- **`Fail`** (default): abort generation non-zero, naming the offending query - or type and the exact column/param. No partial output. -- **`Skip`**: drop the smallest self-consistent unit and keep generating the - rest. For a query that unit is the whole statement module, its Row, and its - facade entry. For a custom type it is that type's module plus its - `types/__init__` and `_register` entries. A query that references a skipped - custom type is skipped too (the lookup built from the surviving types - resolves the reference to nothing, and that failure propagates through the - same loud-fail machinery as any other unsupported shape). - -Skip mode still succeeds with an empty return code, but there is a real -limitation worth knowing about: on a successful run, pgn 0.6.5 does not -surface the generator's warnings anywhere, not stdout, not stderr, no -side-channel file. The warnings for every skipped unit are assembled -internally (so a future pgn version or a different consumer of this contract -could print them), but today they are only visible on the `Fail` path, where -the failing report is what aborts the run. Skip is silent-but-safe in -practice: check the emitted file list if you need to know what got dropped. - -## Notes - -**Nullability.** A member's nullability comes straight from pgn's analysis -and is applied on top of the mapped Python type; nothing here infers or -overrides it. - -**Arrays.** See Supported types above. - -**Enums.** An enum custom type renders as a `StrEnum`, one member per PG -label: - -```python -class Mood(StrEnum): - HAPPY = "happy" - SAD = "sad" - MEH = "meh" +The raw `main` URL is valid but moving. For reproducible use, pin an actual +published `resolved.dhall` release asset or vendor the artifact delivered by +the release wheel. Do not guess a release tag that has not been published. +A relative path such as `../python.gen/src/package.dhall` is appropriate while +developing against a local checkout. An absolute path also works but makes the +freeze key machine-specific; pgn project configuration does not accept a +`file://` URL. + +## Configuration + +| key | type | default | +| --- | --- | --- | +| `packageName` | `Text` | project name in kebab case | +| `emitSync` | `Bool` | `False` | +| `onUnsupported` | `"Fail" \| "Skip"` | `"Fail"` | + +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` +emits the async client only. `emitSync: true` keeps that async root and adds a +`.sync` facade, a sync runtime, and an adjacent sync function in each +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, 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 + +The generator owns the package-root facade and the generated subtree. With +`emitSync: true`, the layout is: + +```text +src// + __init__.py + sync/__init__.py + _generated/ + __init__.py + _core.py + _runtime.py + _register.py # when custom types need registration + sync/_runtime.py + statements/__init__.py + statements/.py + types/__init__.py # when custom types exist + types/.py ``` -Encoding passes the string value and lets PostgreSQL coerce it in an -assignment or `::enum` context; decoding constructs the enum from the -returned label. A scalar enum needs no connection registration. An -enum-array *result* column does, because psycopg otherwise hands back the -unregistered array as literal text instead of a list; the generated decode -raises a clear error telling you to call `register_types` in that case. -Enum-array *params* carry an explicit `::[]` cast in the query's SQL -and bind without registration either way. +Each statement has one canonical module. That module owns one `SQL` string, +its frozen and slotted `Row` dataclass when rows are returned, the async +function, and the optional adjacent sync function. The SQL value remains a +string accepted by the runtime as `LiteralString`; it is not encoded to bytes. +For row-returning queries, psycopg's `args_row(Row)` constructs the canonical +dataclass directly through `BaseRowFactory`. The output has no query decoders, +model codecs, `typing.cast` calls, or bytes SQL. -**Composites.** A composite custom type renders as a frozen, slotted -dataclass, one field per member: +The async and sync facades share the same SQL, Row classes, custom types, +`_core`, registration module, and `NoRowError`. The only duplicated I/O layer is +the small sync runtime. Internal generated paths are a greenfield implementation +detail and carry no compatibility promise. -```python -@dataclass(frozen=True, slots=True) -class TagValue: - value: str | None +Every generated Python file begins with this exact first line: + +```text +# @generated by python.gen (pGenie); regeneration overwrites manual changes. ``` -Composite params and results both go through psycopg's `CompositeInfo`, -which does require `register_types(conn)` first. A single-field composite -binds as a one-element Python tuple with the trailing comma Python needs to -tell it apart from a plain parenthesized expression (`(value,)`, not -`(value)`); this was a real gap fixed in this repository's history, and the -fixture now covers it with a dedicated round-trip test. Nested composites -(a composite field whose type is itself a custom type) are not supported; -the generator resolves any such reference to nothing and fails loudly on it -rather than emit a wrong decode. - -**Domains.** A domain custom type has no Python mapping and is rejected with -a message pointing at the workaround: lower the column to its base type in -the analysis-time schema. There is no silent unwrapping. - -**Freeze lifecycle.** pgn caches the resolved generator expression's hash in -a `freeze*.pgn.yaml` file keyed on the artifact's `gen:` value, so repeated -runs skip re-resolving it. That cache does not know when the generator's own -source changed underneath a stable path or URL; if you edit this generator -in place and regenerate against a local checkout, delete the freeze file -first, or pgn will silently reuse the old, cached generator. - -**Query comments and `$params`.** The generator renders each SQL fragment -verbatim, comments included, and turns each bound placeholder into a -`%(name)s` entry keyed by its param name. A `$word` written inside a query -comment is a latent hazard: if pgn's own tokenizer picks it up as a -placeholder, it now needs a matching bound param somewhere in the query, and -if it doesn't, the comment text still ships into the emitted SQL string -unchanged either way. The generator's renderer is deliberately not the place -to fix this (rewriting comment text would make the emitted SQL diverge from -the source `.sql` file); keep it as an authoring rule instead: don't name a -`$param` inside a query comment that the query doesn't also bind. - -## Using the generated code - -Import from the flat facade, not from `_generated` directly: +The next two lines are the REUSE copyright and `SPDX-License-Identifier: MIT-0` +headers. + +## Type support + +Primitive mappings are intentionally conservative: + +| PostgreSQL | Python | +| --- | --- | +| `bool` | `bool` | +| `int2`, `int4`, `int8`, `oid` | `int` | +| `float4`, `float8` | `float` | +| `numeric` | `Decimal` | +| `text`, `varchar`, `bpchar`, `char`, `citext`, `name` | `str` | +| `uuid` | `UUID` | +| `date` | `date` | +| `time` | `time` | +| `timestamp`, `timestamptz` | `datetime` | +| `interval` | `timedelta` | +| `bytea` | `bytes` | +| `json`, `jsonb` | recursive `JsonValue` | + +Primitive arrays preserve dimensionality, element nullability, and outer +nullability. Custom types use psycopg's class-aware adapters and stay ordinary +Python models: + +- PostgreSQL enums become `StrEnum` classes. Scalar enums and enum arrays are + supported, including the rank-2 enum-array contract. +- PostgreSQL composites become `@dataclass(frozen=True, slots=True)` classes. + Scalar load and dump, rank-1 composite arrays, and nested scalar composites + are supported. +- Nullable members and Python-keyword names are preserved safely. SQL names + remain raw while Python fields and parameters gain a trailing underscore when + required. + +Unsupported primitive types, domains, missing custom types, and unsupported +custom kinds fail loudly. Custom-shape limits are enum arrays above rank 2, +composite arrays above rank 1, and any custom-array field nested inside a +composite. A `json` or `jsonb` array parameter is also rejected because wrapping +the whole list is not a faithful element-wise psycopg adaptation. + +## Registration and use + +`register_types` is a precondition for each newly opened connection once the +database types and migrations exist. Async and sync registration are separate +operations because psycopg fetches catalog metadata through the connection. +Registration is dependency-first and class-aware: + +- `EnumInfo.fetch` plus `register_enum` binds the generated `StrEnum` class. +- `CompositeInfo.fetch` plus `register_composite` binds the generated dataclass + with validated object and sequence callbacks. + +Async use: ```python -from my_db_client import get_specimen, GetSpecimenRow, Mood +from my_db_client import get_specimen, register_types + + +async def load(conn, specimen_id: int): + await register_types(conn) + return await get_specimen(conn, id=specimen_id) ``` -The facade re-exports every Row dataclass, every enum/composite, and every -statement function with `X as X` markers and a matching `__all__`. If the -project has composites or enums, call `register_types(conn)` once per -connection before relying on native composite decode or enum-array results; -scalar enums do not need it. +Sync use when `emitSync: true`: -With `emitSync: true`, a second surface appears under `my_db_client.sync`, -built from the exact same Row types and custom types as the async one, only -the I/O wrappers differ: +```python +from my_db_client.sync import get_specimen, register_types + + +def load(conn, specimen_id: int): + register_types(conn) + return get_specimen(conn, id=specimen_id) +``` + +The facades expose the exact same model and error objects: ```python -from my_db_client.sync import get_specimen, GetSpecimenRow +import my_db_client +from my_db_client import sync + +assert my_db_client.GetSpecimenRow is sync.GetSpecimenRow +assert my_db_client.Mood is sync.Mood +assert my_db_client.NoRowError is sync.NoRowError ``` -## Development +Cardinality follows pgn without narrowing: optional rows return `Row | None`, +single rows return `Row` or raise `NoRowError`, multiple rows return +`list[Row]`, affected-row statements return `int`, and void statements return +`None`. The generated runtimes implement exactly those five helpers. -This repo's own harness (`tests/`) needs a live PostgreSQL: it runs `pgn -analyse`/`generate` against a real scratch database, diffs the result -against a committed golden tree, type-checks the golden package with -`basedpyright` strict, and round-trips every generated statement. Point it -at a non-default server with `PGN_TEST_DATABASE_URL`; it never touches an -existing database, only the temporary one pgn creates and drops itself. The -full suite takes on the order of ten minutes, most of it pgn's own type -inference against Postgres. The contract test that pins the committed -`.sig1.pgn.yaml` signatures byte-for-byte only runs in CI, gated through a -fork-safe Actions workflow, since it needs database credentials a -fork-triggered run should not receive. +## Taking ownership + +Generated code is ordinary Python and can be taken over deliberately: + +1. Generate the desired client and commit the complete result. +2. Disable every regeneration path first, including local tasks, CI jobs, and + automation that invokes pgn for this artifact. +3. Replace the generated marker with a project-owned header before editing. +4. Maintain the files as ordinary Python. Keep the package structure unless a + deliberate refactor updates imports and consumers together. +5. Run basedpyright strict, Ruff check and format checks, and the project's + database tests before accepting the takeover. + +Do not edit while regeneration is still enabled. The marker is a truthful +overwrite warning, and ownership starts only after that overwrite path is +disabled. + +## Development -Run everything through `mise`; do not invoke `uv`/`pytest` directly. +This repository pins pgn v0.12.0 in `mise.toml`. Run all tools through `mise`: ```bash +mise run install +mise run lint mise run test ``` -## Why psycopg 3 - -Generated clients target psycopg 3 rather than asyncpg. On a real FastAPI -app (Gold Lapel benchmark, March 2026: PostgreSQL 16, a filtered SELECT over -500k rows, a 20-connection pool, 500 concurrent users) psycopg 3 in binary -mode sits within ~23% of asyncpg on throughput, nothing like the "5x" from -synthetic microbenchmarks: - -| metric | asyncpg | psycopg 3 binary | psycopg 3 text | -| ---------------- | ---------- | ---------------- | -------------- | -| p50 latency | 1.2 ms | 1.5 ms | 2.1 ms | -| p99 latency | 8.7 ms | 10.2 ms | 14.3 ms | -| throughput | 12,400 rps | 10,100 rps | 7,800 rps | -| connection setup | 4.2 ms | 2.8 ms | 2.8 ms | - -What psycopg 3 buys in exchange: one API for sync and async (this generator -emits both surfaces over shared types), pipeline mode that asyncpg lacks -(batching independent queries saves 20-100 ms per round trip when the -database is remote), 33% faster connection setup (serverless, autoscaling, -pool churn), and well-defined behavior behind connection poolers. Keep the -scale in mind: the driver accounts for roughly 5-10% of real query latency; -the other 90-95% is indexes, join shapes, and pool sizing. If you chase -speed, use the `psycopg[binary]` extra: text mode is measurably slower. - -## Provenance - -Extracted from a production code base where it generates the data layer for -a 115-query corpus. - -## License - -Three layers, three licenses: - -- **This repository** (templates, interpreters, harness): MIT. - SPDX-License-Identifier: MIT, see [LICENSE](LICENSE). - Copyright (C) 2026 Viacheslav Shvets. -- **Emitted code**: MIT-0. Every emitted file carries an - `SPDX-License-Identifier: MIT-0` header; the template text it reproduces - is granted attribution-free, so the containing project may use, modify, - and distribute it under terms of its own choice with no notice to carry. -- **Release artifacts** (`resolved.dhall` and the `pgenie-python-gen` - wheel): GPL-3.0-or-later as a whole. The release build inlines the - [gen-sdk](https://github.com/pgenie-io/gen-sdk) Dhall contract, which is - GPL-3.0-or-later, so the frozen artifact is distributed under the GPL - (the MIT parts inside it stay MIT). - -The GPL layer does not restrict using the generator or its output. The GPL's -conditions attach to redistribution of the artifact, not to running it: -executing `pgn` with this generator in a build or CI pipeline is the same as -compiling with gcc or generating a parser with bison. None of gen-sdk's code -appears in the emitted Python, so the output carries only the MIT-0 grant -above. +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 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. + +Regenerate the committed fixture only with `mise run golden` and follow +[`tests/golden/README.md`](tests/golden/README.md). The task deletes stale +freeze and artifact state in a temporary fixture before invoking pgn. Never +hand-edit generated fixture files. + +pgn freezes a resolved generator by the literal `gen` value. If source behind a +stable local path or URL changes, delete the fixture's stale freeze file before +regenerating or pgn may reuse the cached generator. + +Remote imports under `src/Deps/` are sha256-pinned. Bump them deliberately, one +at a time, and rerun the harness. The release workflow resolves +`src/package.dhall` into the `resolved.dhall` release asset, then builds the +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. 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 + +The generator was extracted from a production code base where it generates the +data layer for a 115-query corpus. + +- Repository source is MIT, see [LICENSE](LICENSE). +- Emitted Python is MIT-0 under its file headers. +- `resolved.dhall` and the `pgenie-python-gen` wheel are + GPL-3.0-or-later as combined artifacts because they inline gen-sdk. The wheel + carries the pinned GPL text and `wheel/NOTICE`; generated Python contains no + gen-sdk code and remains MIT-0. diff --git a/bench/as-source.sh b/bench/as-source.sh index 203b973..3446e80 100755 --- a/bench/as-source.sh +++ b/bench/as-source.sh @@ -1,15 +1,14 @@ #!/usr/bin/env bash -# Cold-cache benchmark of `pgn generate` on the fixture project, comparing the -# current gen (Deps imported `as Source`) against the same tree with the -# pre-as-Source Deps (plain pinned imports). Both variants run the same pgn -# binary by default, so the measurement isolates the import mode itself. +# Historical cold-cache benchmark of a retired Dhall import-mode experiment. +# Current src/Deps uses ordinary sha256-pinned imports, so results from the +# current checkout do not reproduce the original comparison. # # mise run bench:as-source # # Needs a reachable Postgres; override with PGN_TEST_DATABASE_URL. Each variant # gets a fresh XDG_CACHE_HOME, so every run pays the full cold import cost. # PGN_BEFORE_BIN selects a different pgn for the before variant (e.g. 0.8.0, -# which predates `as Source` and can only run that variant): +# which predates the retired mode and can only run the ordinary-import variant): # # PGN_BEFORE_BIN="$(mise x github:pgenie-io/pgenie@0.8.0 -- sh -c 'command -v pgn')" \ # mise run bench:as-source @@ -24,19 +23,20 @@ trap 'rm -rf "$work"' EXIT prepare() { # $1 = variant root; mirrors the repo layout the fixture expects rm -rf "$1" && mkdir -p "$1/tests" - cp -R "$root/gen" "$1/gen" + cp -R "$root/src" "$1/src" cp -R "$root/tests/fixture-project" "$1/tests/fixture-project" rm -f "$1/tests/fixture-project/freeze1.pgn.yaml" rm -rf "$1/tests/fixture-project/artifacts" } -# Rewrite Deps to the pre-as-Source form: drop the mode and restore the -# normalized-expression pins (an `as Source` pin hashes the import's source, -# so the two modes need different sha256 values for the same version). +# Legacy rewrite for a historical checkout. Current Deps already use ordinary +# imports and must not be interpreted as the old comparison baseline. strip_as_source() { # $1 = variant root - perl -i -ne 'print unless /^\s*as Source$/' "$1"/gen/Deps/*.dhall - perl -i -pe 's/8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6/b9f7bb842345f3864c71e877fda4200306ba5c044a43e6f7713a23bc4769b91a/' "$1/gen/Deps/Sdk.dhall" - perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$1/gen/Deps/Lude.dhall" + perl -i -ne 'print unless /^\s*as Source$/' "$1"/src/Deps/*.dhall + + # The substitution below belongs to the old dependency snapshot. It is a + # no-op for the current ordinary imports and their current hashes. + perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$1/src/Deps/Lude.dhall" } measure() { # $1 = label, $2 = variant root, $3 = pgn binary diff --git a/bench/generate.sh b/bench/generate.sh index a2acd50..e99d038 100755 --- a/bench/generate.sh +++ b/bench/generate.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash -# Build the fixture client from the working-tree gen, in one of two variants: -# with - gen/Deps as committed (imports `as Source`) -# without - the pre-as-Source Deps (mode stripped, normalized-expression -# pins restored; byte-identical to commit fb78869's Deps) +# Historical fixture helper for a retired Dhall import-mode comparison. +# Current src/Deps uses ordinary sha256-pinned imports, so the two labels no +# longer describe distinct current dependency configurations. # Output lands in ./demo-with-as-source or ./demo-without-as-source. # Uses the normal dhall cache; for cold-cache measurements use bench/as-source.sh. set -euo pipefail @@ -15,15 +14,17 @@ url="${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/post out="$root/demo-$variant-as-source" rm -rf "$out" && mkdir -p "$out/tests" -cp -R "$root/gen" "$out/gen" +cp -R "$root/src" "$out/src" cp -R "$root/tests/fixture-project" "$out/tests/fixture-project" rm -f "$out/tests/fixture-project/freeze1.pgn.yaml" rm -rf "$out/tests/fixture-project/artifacts" if [ "$variant" = without ]; then - perl -i -ne 'print unless /^\s*as Source$/' "$out"/gen/Deps/*.dhall - perl -i -pe 's/8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6/b9f7bb842345f3864c71e877fda4200306ba5c044a43e6f7713a23bc4769b91a/' "$out/gen/Deps/Sdk.dhall" - perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$out/gen/Deps/Lude.dhall" + perl -i -ne 'print unless /^\s*as Source$/' "$out"/src/Deps/*.dhall + + # These rewrites belong to the old dependency snapshot and are no-ops for + # the current ordinary imports and hashes. + perl -i -pe 's/46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77/14c43eec97972ae27afe3386ff937d04db66f84273d5551476361db12d2c4b50/' "$out/src/Deps/Lude.dhall" fi cd "$out/tests/fixture-project" diff --git a/docs/adr/0001-generated-python-name-collisions.md b/docs/adr/0001-generated-python-name-collisions.md new file mode 100644 index 0000000..d3024ee --- /dev/null +++ b/docs/adr/0001-generated-python-name-collisions.md @@ -0,0 +1,34 @@ +# ADR 0001: Generated Python name collisions + +Status: Superseded + +## Context + +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. + +Those scans were quadratic and expensive under Dhall normalization. They ran on +every generation, including projects that did not configure any name mappings. + +## Superseding decision + +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. + +Lexical handling remains. Source-derived defaults are escaped for Python +keywords and generator-owned implementation names. + +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 + +- 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 44c3417..1bac627 100644 --- a/docs/upstream-asks.md +++ b/docs/upstream-asks.md @@ -1,140 +1,63 @@ -# Upstream asks: pgn and gen-sdk +# Upstream resolution status: pgn and gen-sdk -This is a negotiation brief, not architecture documentation (that stays in -DESIGN.md). It collects three asks whose implementations live outside this -repository: two against pgn, one against gen-sdk. They travel as one package -because they interlock; the sequencing note in ask 3 explains how. +This brief records the current status of three upstream integration points. +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: parse annotation comments into the model +## 1. pgn annotation metadata: resolved -Issue #66: https://github.com/pgenie-io/pgenie/issues/66 +Issue [#66](https://github.com/pgenie-io/pgenie/issues/66) is closed. -### Motivation +The pgn-side move is complete. The generator consumes nullability and +cardinality directly from `Model.Member` and `Model.Result` metadata. The former +SQL comment scanner and annotation bridge have already been removed, so no +generator-side annotation parsing remains. -Nullability and cardinality overrides currently ride in SQL comments that -only this generator parses, after the fact, out of already-parsed query -fragments: one line per subject, `-- @param not null|nullable` (with -a `-- @param [] ...` form for array elements), `-- @column not -null`, and `-- @returns single|optional|scalar|optional scalar`, names -unsigiled. Only pgn, which owns the SQL tokenizer, can carry these into the -model so that consumers do not each re-parse comments on their own. +## 2. pgn warning surfacing: resolved -Observed pgn 0.6.5 behavior, filed separately as a bug (issue #65: -https://github.com/pgenie-io/pgenie/issues/65): pgn tokenizes `$`-tokens -inside SQL comments as if they were live placeholders. A comment-only -`$token` receives a param index in the fragments yet never appears in -`params`, and a comment `$id` merges onto the real `$id` param. This is why -the annotation grammar is unsigiled: a sigiled `-- @param $id ...` line -never reaches a comment scanner as literal text. +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 v0.12.0. -### Proposed change +With `onUnsupported: Skip`, the generator retains a report for every dropped +unit while a single-pass survivor fold removes unsupported custom types, their +dependents, and affected statements. See `DESIGN.md`, section 8. -pgn parses the per-line, unsigiled comment grammar and reflects it in the -model: +## 3. Preserve qualified custom-type identity: contract support shipped -- Params: `-- @param not null|nullable` (and `-- @param [] ...` - for array elements), reflected as `Member.isNullable` / - `elementIsNullable`. -- Columns: `-- @column not null`, reflected as column nullability. -- Cardinality: `-- @returns single|optional|scalar|optional scalar`, - reflected as `Result` cardinality. +Project-wide custom-type lookup classifies every custom reference as an enum, +composite, or absent. Shipped models are pure declarations with no class +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. -The names in the comment are bare throughout, never `$`-prefixed. +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. -This is framed as a prototype offer, not a committed ship: we are willing -to build a working prototype of the comment-into-model parsing against pgn -and hand it over, so the grammar can be judged on running code rather than -on a proposal. +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. -#### Rejected alternative +The upstream ask has two inseparable parts: -An earlier sketch put the marker on the placeholder itself, an inline -`$name!` (with `$name[]!` for array elements) stripped by pgn before the -statement reached Postgres. Two things sank it. pgn 0.6.5 tokenizes -`$`-tokens inside comments (issue #65), so the sigiled forms are unsafe to -place near live SQL; and a per-use-site marker has a consistency problem, -since the same parameter can appear at several placeholders with nothing -forcing the markers to agree. The per-line comment grammar keeps one -annotation per subject and sidesteps both. +1. Preserve every schema-qualified custom type in `Project.customTypes`. +2. Put a stable qualified identifier on `Scalar.Custom`, for example a project + index whose target retains schema and PostgreSQL name. -### What the generator deletes afterwards +That identifier may also carry a stable kind tag, but a kind without identity is +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. -The Dhall comment-scanner (`Pragma.dhall`) and the `-- @param` comment -bridge are deleted outright. The generator then consumes nullability and -cardinality from the model like any other query metadata. - -### Compatibility notes - -User-facing syntax does not change; existing queries keep working -unmodified. The move is purely a shift of parsing responsibility from this -generator into pgn and the model, so no annotation needs rewriting. - -## 2. pgn: print `Compiled.warnings` on successful runs - -Issue #67: https://github.com/pgenie-io/pgenie/issues/67 - -### Motivation - -Skip mode drops the smallest failing unit and keeps the rest of the project -generating, and the generator already collects a report per dropped unit -into `Compiled.warnings` per the contract (DESIGN.md, section 11). pgn does -not surface that list anywhere on a successful run, so Skip currently drops -queries silently. The collection side is done; pgn only needs to print it. - -### Proposed change - -On successful runs, print `Compiled.warnings` to stderr in the same format -as the failure report, keeping exit code 0. Optionally, a -`--fail-on-warnings` flag that raises the exit code when the warnings list -is non-empty, for CI setups that want silent drops to fail the build. - -### What the generator simplifies afterwards - -Nothing to delete; the generator was built ready for this (section 11). -The existing warnings plumbing starts paying off, and the README's caveat -about silent drops in Skip mode goes away. - -### Compatibility notes - -Purely additive: exit codes are unchanged by default, stdout is untouched, -and the new stderr output only appears when warnings are non-empty. -`--fail-on-warnings` is opt-in. - -## 3. gen-sdk: `kind` tag or `Natural` index on `Scalar.Custom` - -### Motivation - -This is the ask already planned in DESIGN.md, section 12. The generator's -last remaining use of the fork-only `Text/equal` builtin is -`Interpreters/Project.dhall`'s `buildLookup`, which matches a custom type -by its snake-case name while building the `CustomKind.Lookup`. It returns a -structural `TypeKind` value, not `Text`, so the `Text/replace`-based trick -that removed `Text/equal` from keyword sanitizing (section 13) does not -carry over. - -### Proposed change - -As recorded in section 12: a `kind` tag or a `Natural` index carried -directly on `Scalar.Custom`, so the lookup becomes an equality-free -structural match. This is a change to gen-sdk's model types, not something -this generator can do unilaterally. - -### What the generator deletes afterwards - -`buildLookup`'s name-equality matching, which removes the last need for the -forked `Text/equal` builtin in this generator's own code. - -Sequencing constraint: the pragma work in ask 1 adds `Text/equal` uses in -`Pragma.dhall`, so landing this ask alone does not de-fork the generator. -Full removal of the fork dependency is possible only after ask 1 lands and -`Pragma.dhall` is deleted; the order matters and is part of this -negotiation, not an implementation detail. - -### Compatibility notes - -A new field on `Scalar.Custom` touches every gen-sdk consumer (java.gen -included), so it should be introduced in coordination with them. This -generator pins gen-sdk imports by sha256 and adopts the change on the next -pin bump. Note that gen-sdk's own `Fixtures` module also relies on the fork -builtin (section 12), so this ask de-forks generators, not gen-sdk's full -package entry point. +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 new file mode 100644 index 0000000..fd1030b --- /dev/null +++ b/fixtures/Exhaustive.dhall @@ -0,0 +1,28 @@ +-- Applies this generator to gen-sdk's shared cross-backend fixture project +-- (the same "music_catalogue" project java.gen's own fixtures/Exhaustive.dhall +-- exercises), so a Python client compiles from it and passes basedpyright +-- strict. Loading through src/Deps/Sdk.dhall keeps this fixture on the same +-- ordinary sha256-pinned gen-sdk import as the generator. +-- +-- The fixture project deliberately covers PG types this generator does not +-- support (box, inet, money, ranges, ...), so onUnsupported is set to Skip: +-- those statements/types are dropped with a warning instead of aborting the +-- whole compile. +-- +-- 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 project = Sdk.Fixtures.Exhaustive + +let config = + Some + { packageName = None Text + , emitSync = Some False + , onUnsupported = Some OnUnsupported.Mode.Skip + } + +in Sdk.Output.toFileMap (Gen.compile config project) diff --git a/gen/Algebras/Interpreter.dhall b/gen/Algebras/Interpreter.dhall deleted file mode 100644 index 7532153..0000000 --- a/gen/Algebras/Interpreter.dhall +++ /dev/null @@ -1,21 +0,0 @@ -let Deps = ../Deps/package.dhall - -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let Config = - { packageName : Text - , importName : Text - , emitSync : Bool - , onUnsupported : OnUnsupported.Mode - } - -let module = - \(Input : Type) -> - \(Output : Type) -> - let Result = Deps.Lude.Compiled.Type Output - - let Run = Config -> Input -> Result - - in \(run : Run) -> { Input, Output, Result, Run, run } - -in { Config, module } diff --git a/gen/Algebras/Template.dhall b/gen/Algebras/Template.dhall deleted file mode 100644 index 15f2624..0000000 --- a/gen/Algebras/Template.dhall +++ /dev/null @@ -1,5 +0,0 @@ -let module = - \(Params : Type) -> - let Run = Params -> Text in \(run : Run) -> { Params, Run, run } - -in { module } diff --git a/gen/Algebras/package.dhall b/gen/Algebras/package.dhall deleted file mode 100644 index f0f64bd..0000000 --- a/gen/Algebras/package.dhall +++ /dev/null @@ -1 +0,0 @@ -{ Interpreter = ./Interpreter.dhall, Template = ./Template.dhall } diff --git a/gen/Config.dhall b/gen/Config.dhall deleted file mode 100644 index 7ec1fe6..0000000 --- a/gen/Config.dhall +++ /dev/null @@ -1,15 +0,0 @@ --- User-facing config for this generator. `emitSync` adds a parallel sync surface --- (psycopg.Connection) alongside the default async one, so one project can serve --- both an async backend and a sync (Dagster) consumer from shared Row types. --- `onUnsupported` picks Fail (default, abort loudly) or Skip (drop the --- unsupported statement/type and its dependents, with a warning) when a 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; compile.dhall supplies the --- defaults. -let OnUnsupported = ./Structures/OnUnsupported.dhall - -in { packageName : Optional Text - , emitSync : Optional Bool - , onUnsupported : Optional OnUnsupported.Mode - } : Type diff --git a/gen/Deps/Lude.dhall b/gen/Deps/Lude.dhall deleted file mode 100644 index 30f1f5a..0000000 --- a/gen/Deps/Lude.dhall +++ /dev/null @@ -1,3 +0,0 @@ -https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.1.0/src/package.dhall - sha256:46b527b071eba96a17e76b4bc5774645714dd5b4355974d221e705aa7c126e77 - as Source diff --git a/gen/Deps/Sdk.dhall b/gen/Deps/Sdk.dhall deleted file mode 100644 index 6cf6014..0000000 --- a/gen/Deps/Sdk.dhall +++ /dev/null @@ -1,3 +0,0 @@ -https://raw.githubusercontent.com/pgenie-io/gen-sdk/v0.11.0/dhall/package.dhall - sha256:8d43544ecb0e612406af3133bdbca51138c704a77a5a29ef62fe034d0e77a3a6 - as Source diff --git a/gen/Deps/package.dhall b/gen/Deps/package.dhall deleted file mode 100644 index b395cd9..0000000 --- a/gen/Deps/package.dhall +++ /dev/null @@ -1,4 +0,0 @@ -{ Sdk = ./Sdk.dhall -, Lude = ./Lude.dhall -, Prelude = ./Prelude.dhall -} diff --git a/gen/Gen.dhall b/gen/Gen.dhall deleted file mode 100644 index 9cb87fb..0000000 --- a/gen/Gen.dhall +++ /dev/null @@ -1,3 +0,0 @@ -let Sdk = ./Deps/Sdk.dhall - -in Sdk.module ./Config.dhall ./compile.dhall diff --git a/gen/Interpreters/CustomType.dhall b/gen/Interpreters/CustomType.dhall deleted file mode 100644 index 7927f0a..0000000 --- a/gen/Interpreters/CustomType.dhall +++ /dev/null @@ -1,177 +0,0 @@ -let Deps = ../Deps/package.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let CustomKind = ../Structures/CustomKind.dhall - -let Algebra = ../Algebras/Interpreter.dhall - -let Lude = Deps.Lude - -let Prelude = Deps.Prelude - -let Model = Deps.Sdk.Project - -let MemberGen = ./Member.dhall - -let EnumModule = ../Templates/EnumModule.dhall - -let CompositeModule = ../Templates/CompositeModule.dhall - -let Input = Model.CustomType - -let TypeKind = < Enum | Composite > - --- moduleName/pgSchema/pgName mirror the input Name/CustomType so Project.dhall's --- combineOutputs can derive the facade export, types/__init__ export, and the --- composite/enum registration name from this Output alone (the surviving list --- after Skip filtering), without a second, separately-threaded List --- Model.CustomType parameter. -let Output = - { modulePath : Text - , moduleContent : Text - , typeName : Text - , moduleName : Text - , pgSchema : Text - , pgName : Text - , kind : TypeKind - } - --- Composite fields could in principle reference other custom types, but pgn never --- nests customs in our corpus and CustomType.run does not receive the project --- lookup (Project.run threads it only to queries). Resolving any nested custom to --- Absent makes Member.run fail loudly instead of guessing a type. -let nestedLookup - : CustomKind.Lookup - = \(_ : Model.Name) -> CustomKind.TypeKind.Absent - --- Render the stdlib/runtime imports a composite field type needs, in a fixed --- order so output stays byte-stable. Reads only the standard flags; nested custom --- types are out of Wave 2 scope (no composite in the corpus references one). -let renderExtraImports = - \(imports : ImportSet.Type) -> - let datetimeNames = - (if imports.date then [ "date" ] else [] : List Text) - # (if imports.datetime then [ "datetime" ] else [] : List Text) - # (if imports.time then [ "time" ] else [] : List Text) - # (if imports.timedelta then [ "timedelta" ] else [] : List Text) - - let datetimeLine = - if Prelude.List.null Text datetimeNames - then [] : List Text - else [ "from datetime import ${Prelude.Text.concatSep - ", " - datetimeNames}" ] - - in (if imports.uuid then [ "from uuid import UUID" ] else [] : List Text) - # datetimeLine - # ( if imports.decimal - then [ "from decimal import Decimal" ] - else [] : List Text - ) - # ( if imports.jsonValue - then [ "from .._runtime import JsonValue" ] - else [] : List Text - ) - -let run = - \(config : Algebra.Config) -> - \(input : Input) -> - let typeName = input.name.inPascalCase - - let moduleName = input.name.inSnakeCase - - let modulePath = "types/${moduleName}.py" - - in merge - { Enum = - \(variants : List Model.EnumVariant) -> - let templateVariants = - Prelude.List.map - Model.EnumVariant - EnumModule.Variant - ( \(variant : Model.EnumVariant) -> - { memberName = variant.name.inScreamingSnakeCase - , pgValue = variant.pgName - } - ) - variants - - in Lude.Compiled.ok - Output - { modulePath - , moduleContent = - EnumModule.run - { typeName, variants = templateVariants } - , typeName - , moduleName - , pgSchema = input.pgSchema - , pgName = input.pgName - , kind = TypeKind.Enum - } - , Composite = - \(members : List Model.Member) -> - let compiledMembers - : Lude.Compiled.Type (List MemberGen.Output) - = Lude.Compiled.traverseList - Model.Member - MemberGen.Output - ( \(m : Model.Member) -> - MemberGen.run config nestedLookup m - ) - members - - let assemble = - \(memberOutputs : List MemberGen.Output) -> - let combinedImports = - Prelude.List.fold - MemberGen.Output - memberOutputs - ImportSet.Type - ( \(m : MemberGen.Output) -> - \(acc : ImportSet.Type) -> - ImportSet.combine m.imports acc - ) - ImportSet.empty - - let fields = - Prelude.List.map - MemberGen.Output - CompositeModule.Field - ( \(m : MemberGen.Output) -> - { fieldName = m.fieldName - , fieldType = m.pyType - } - ) - memberOutputs - - in { modulePath - , moduleContent = - CompositeModule.run - { typeName - , extraImports = - renderExtraImports combinedImports - , fields - } - , typeName - , moduleName - , pgSchema = input.pgSchema - , pgName = input.pgName - , kind = TypeKind.Composite - } - - in Lude.Compiled.map - (List MemberGen.Output) - Output - assemble - compiledMembers - , Domain = - \(_ : Model.Value) -> - Lude.Compiled.report - Output - [ input.pgName ] - "Domain types are not supported; lower to base type in the pgn migration copy" - } - input.definition - -in { Input, Output, TypeKind, run } diff --git a/gen/Interpreters/Member.dhall b/gen/Interpreters/Member.dhall deleted file mode 100644 index 672dd18..0000000 --- a/gen/Interpreters/Member.dhall +++ /dev/null @@ -1,240 +0,0 @@ -let Deps = ../Deps/package.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let CustomKind = ../Structures/CustomKind.dhall - -let PyIdent = ../Structures/PyIdent.dhall - -let Algebra = ../Algebras/Interpreter.dhall - -let Lude = Deps.Lude - -let Model = Deps.Sdk.Project - -let Value = ./Value.dhall - -let Input = Model.Member - --- decodeExpr is a Dhall function: given the source expression (e.g. row["x"] or --- a composite tuple slot), it returns the Python decode expression. ResultColumns --- and CustomType compose these so decode logic stays next to the type info. -let Output = - { fieldName : Text - , pgName : Text - , pyType : Text - , isNullable : Bool - , imports : ImportSet.Type - , decodeExpr : Text -> Text - } - -let run = - \(config : Algebra.Config) -> - \(lookup : CustomKind.Lookup) -> - \(input : Input) -> - -- Result-column / composite-field name becomes a dataclass field and decode - -- kwarg, so a keyword-named column must be sanitized; row["..."] keeps the - -- raw pgName. Only the keyword set is load-bearing here (a field named `row` - -- is valid), unlike params which also avoid the template locals. - let fieldName = PyIdent.pySafeName input.name.inSnakeCase - - let buildOutput = - \(value : Value.Output) -> - let nullableSuffix = if input.isNullable then " | None" else "" - - let pyType = value.pyType ++ nullableSuffix - - let castTarget = pyType - - let baseImports = value.imports - - let passthroughDecode = - \(src : Text) -> "cast(${castTarget}, ${src})" - - let enumDecode = - \(enumName : Text) -> - \(src : Text) -> - let call = "${enumName}(cast(str, ${src}))" - - in if input.isNullable - then "None if ${src} is None else ${call}" - else call - - -- psycopg returns an enum array as a list of text, so each element - -- is rebuilt into the StrEnum. The cast pins the iterable's element - -- type; the per-element None guard mirrors elementIsNullable and the - -- outer None guard mirrors a nullable column. - let enumArrayDecode = - \(enumName : Text) -> - \(src : Text) -> - let elemCast = - if value.elementIsNullable - then "list[str | None]" - else "list[str]" - - let elemDecode = - if value.elementIsNullable - then "None if v is None else ${enumName}(v)" - else "${enumName}(v)" - - -- require_array fails loudly if the array came back as - -- text (the connection did not register the enum type) - -- instead of iterating a string into bogus members. - let elements = - "[${elemDecode} for v in cast(${elemCast}, require_array(${src}))]" - - in if input.isNullable - then "None if ${src} is None else ${elements}" - else elements - - -- Composites are registered per connection (see register_types), - -- so psycopg returns a namedtuple. The fixed-length tuple cast - -- with each field's exact pyType lets the splat satisfy strict. - let compositeDecode = - \(typeName : Text) -> - \(fields : List CustomKind.CompositeField) -> - \(src : Text) -> - let fieldTypes = - Deps.Prelude.Text.concatMapSep - ", " - CustomKind.CompositeField - (\(f : CustomKind.CompositeField) -> f.pyType) - fields - - let call = - "${typeName}(*cast(tuple[${fieldTypes}], ${src}))" - - in if input.isNullable - then "None if ${src} is None else ${call}" - else call - - in merge - { Passthrough = - Lude.Compiled.ok - Output - { fieldName - , pgName = input.pgName - , pyType - , isNullable = input.isNullable - , imports = baseImports - , decodeExpr = passthroughDecode - } - , Custom = - Deps.Prelude.Optional.fold - Model.Name - value.scalar.customRef - (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> - let typeName = name.inPascalCase - - let customImport = - \(order : Natural) -> - { className = typeName - , moduleName = name.inSnakeCase - , order - } - - let mkOutput = - \(customImports : ImportSet.Type) -> - \(decodeExpr : Text -> Text) -> - { fieldName - , pgName = input.pgName - , pyType - , isNullable = input.isNullable - , imports = - ImportSet.combine - baseImports - customImports - , decodeExpr - } - - -- A 1-D enum array decodes element-wise; a scalar - -- custom (dims == 0) keeps the single-value decode. - -- Composite arrays and dims > 1 are unimplemented, - -- so fail loudly rather than emit wrong Python. - let dimsIsOne = - Natural/isZero (Natural/subtract 1 value.dims) - - in merge - { Enum = - \(order : Natural) -> - let enumImport = - ImportSet.customEnum - (customImport order) - - in if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - enumImport - (enumDecode typeName) - ) - else if dimsIsOne - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - enumImport - ImportSet.enumArray - ) - (enumArrayDecode typeName) - ) - else Lude.Compiled.report - Output - [ input.pgName - , name.inSnakeCase - ] - "Array of an enum with dimensionality > 1 is not supported" - , Composite = - \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural - } - ) -> - if Natural/isZero value.dims - then Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.customComposite - (customImport composite.order) - ) - ( compositeDecode - typeName - composite.fields - ) - ) - else Lude.Compiled.report - Output - [ input.pgName - , name.inSnakeCase - ] - "Array of a composite type is not supported (element-wise decode is unimplemented)" - , Absent = - Lude.Compiled.report - Output - [ name.inSnakeCase ] - "Custom type not found in project customTypes" - } - (lookup name) - ) - ( Lude.Compiled.report - Output - [ input.pgName ] - "Custom scalar without a customRef name" - ) - } - value.scalar.decode - - let compiledValue - : Lude.Compiled.Type Value.Output - = Lude.Compiled.nest - Value.Output - input.pgName - (Value.run config input.value) - - in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue - -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } diff --git a/gen/Interpreters/Project.dhall b/gen/Interpreters/Project.dhall deleted file mode 100644 index d51438f..0000000 --- a/gen/Interpreters/Project.dhall +++ /dev/null @@ -1,545 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Algebra = ../Algebras/Interpreter.dhall - -let Lude = Deps.Lude - -let Prelude = Deps.Prelude - -let Model = Deps.Sdk.Project - -let CustomKind = ../Structures/CustomKind.dhall - -let PyIdent = ../Structures/PyIdent.dhall - -let Value = ./Value.dhall - -let QueryGen = ./Query.dhall - -let CustomTypeGen = ./CustomType.dhall - -let CoreModule = ../Templates/CoreModule.dhall - -let RuntimeModule = ../Templates/RuntimeModule.dhall - -let InitModule = ../Templates/InitModule.dhall - -let TypesInit = ../Templates/TypesInit.dhall - -let RegisterModule = ../Templates/RegisterModule.dhall - -let FacadeModule = ../Templates/FacadeModule.dhall - -let RowsModule = ../Templates/RowsModule.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let Surface = ../Structures/Surface.dhall - -let OnUnsupported = ../Structures/OnUnsupported.dhall - -let Report = { path : List Text, message : Text } - -let Input = Model.Project - -let Output = Lude.Files.Type - --- Header of every emitted .py file. The @generated + DO NOT EDIT convention --- (Go/Phabricator/linguist) marks the file as machine-written; tooling and --- reviewers key on it. The SPDX pair (REUSE convention) licenses the emitted --- file itself as MIT-0: the template text it reproduces is the generator --- author's copyright, granted here attribution-free so the containing --- project can use it under its own terms and compliance scanners see a --- standard permissive id instead of the generator's license. --- Prepended once here so it applies to all modules. -let generatedHeader = - '' - # @generated by python.gen (pGenie). DO NOT EDIT. - # SPDX-FileCopyrightText: 2026 Viacheslav Shvets - # SPDX-License-Identifier: MIT-0 - - '' - -let withHeader = - \(file : Lude.File.Type) -> - { path = file.path, content = generatedHeader ++ file.content } - --- Internal interpreter config (importName etc.) is only needed to satisfy --- Value.run's signature; rendering a composite field's pyType does not read it. -let lookupConfig - : Algebra.Config - = { packageName = "" - , importName = "" - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - --- Render a composite member's Python type purely. The Err branch is unreachable --- for a valid composite (CustomType.run fails the whole generation first on any --- unsupported field type), so the fallback string is dead. -let memberPyType = - \(member : Model.Member) -> - let valuePyType = - merge - { Ok = - \(wrapped : { value : Value.Output, warnings : List Report }) -> - wrapped.value.pyType - , Err = \(_ : Report) -> "object" - } - (Value.run lookupConfig member.value) - - in valuePyType ++ (if member.isNullable then " | None" else "") - -let compositeFields = - \(members : List Model.Member) -> - Prelude.List.map - Model.Member - CustomKind.CompositeField - ( \(member : Model.Member) -> - { fieldName = PyIdent.pySafeName member.name.inSnakeCase - , pyType = memberPyType member - } - ) - members - --- Schema-qualified type names for psycopg CompositeInfo.fetch (search-path --- safe). Reads CustomTypeGen.Output (already a combineOutputs parameter, and --- already the post-Skip-filter surviving set), not Model.CustomType, so --- there is no second List Model.CustomType parameter to thread through. -let compositePgNames = - \(customTypes : List CustomTypeGen.Output) -> - Prelude.List.map - CustomTypeGen.Output - Text - (\(ct : CustomTypeGen.Output) -> "${ct.pgSchema}.${ct.pgName}") - ( Prelude.List.filter - CustomTypeGen.Output - (\(ct : CustomTypeGen.Output) -> merge { Composite = True, Enum = False } ct.kind) - customTypes - ) - --- Enum TypeInfos are registered too so enum arrays parse (see RegisterModule). -let enumPgNames = - \(customTypes : List CustomTypeGen.Output) -> - Prelude.List.map - CustomTypeGen.Output - Text - (\(ct : CustomTypeGen.Output) -> "${ct.pgSchema}.${ct.pgName}") - ( Prelude.List.filter - CustomTypeGen.Output - (\(ct : CustomTypeGen.Output) -> merge { Composite = False, Enum = True } ct.kind) - customTypes - ) - --- A custom type referenced by a Scalar.Custom name resolves by matching its --- snake-case key. The query/column carries a distinct Name occurrence, so the --- lookup compares by inSnakeCase rather than relying on record identity. -let IndexedCustomType = { index : Natural, value : Model.CustomType } - --- pgn emits customTypes alphabetically by name, so the list index is the type's --- alphabetical order. The import renderer sorts on it to keep the per-module --- `from ..types.X import Y` block alphabetical independent of column order. -let buildLookup = - \(customTypes : List Model.CustomType) -> - Prelude.List.fold - IndexedCustomType - (Prelude.List.indexed Model.CustomType customTypes) - CustomKind.Lookup - ( \(entry : IndexedCustomType) -> - \(rest : CustomKind.Lookup) -> - \(name : Model.Name) -> - let ct = entry.value - - -- Text/equal is a pgn embedded-Dhall builtin, absent from the Dhall - -- standard 23.1 Prelude. PyIdent.dhall's replace-trick cannot stand in - -- here: this branch returns a structural TypeKind, not Text. gen-sdk's - -- Fixtures module relies on the same builtin; a kind tag or a Natural - -- index on Scalar.Custom is a planned upstream ask. - in if Text/equal name.inSnakeCase ct.name.inSnakeCase - then merge - { Composite = - \(members : List Model.Member) -> - CustomKind.TypeKind.Composite - { fields = compositeFields members - , order = entry.index - } - , Enum = - \(_ : List Model.EnumVariant) -> - CustomKind.TypeKind.Enum entry.index - , Domain = - \(_ : Model.Value) -> CustomKind.TypeKind.Absent - } - ct.definition - else rest name - ) - (\(_ : Model.Name) -> CustomKind.TypeKind.Absent) - -let combineOutputs = - \(config : Algebra.Config) -> - \(input : Input) -> - \(queries : List QueryGen.Output) -> - -- Already the post-Skip-filter surviving set (see `run`); equal to - -- input.customTypes' compiled outputs verbatim when nothing was skipped - -- (including every Fail-mode run, since Fail never drops anything). - -- Facade/typesInit/register entries are built from this, not - -- input.customTypes, so a skipped type leaves no dangling export. - \(customTypes : List CustomTypeGen.Output) -> - -- The generator emits the _generated subtree plus the package-root - -- __init__.py facade. The rest of the shell (pyproject.toml, py.typed) is - -- hand-written and committed once, never overwritten by generation. - let packagePrefix = "src/${config.importName}/" - - let srcPrefix = packagePrefix ++ "_generated/" - - let topInit = - { path = srcPrefix ++ "__init__.py" - , content = - InitModule.run - { docstring = - "Generated database client for ${config.packageName}." - } - } - - let facadeStatements = - Prelude.List.map - QueryGen.Output - FacadeModule.StatementExport - ( \(query : QueryGen.Output) -> - { functionName = query.functionName - , rowClassName = query.rowClassName - } - ) - queries - - let facadeTypes = - Prelude.List.map - CustomTypeGen.Output - FacadeModule.TypeExport - ( \(ct : CustomTypeGen.Output) -> - { moduleName = ct.moduleName, className = ct.typeName } - ) - customTypes - - let asyncFacade = - { path = packagePrefix ++ "__init__.py" - , content = - FacadeModule.run - { generatedPrefix = "._generated" - , statementsPath = "statements" - , statements = facadeStatements - , types = facadeTypes - } - } - - -- Surface-agnostic; performs no I/O, so exactly one copy is emitted - -- regardless of surface. Both _runtime.py modules re-export from it. - let coreModule = - { path = srcPrefix ++ "_core.py", content = CoreModule.run {=} } - - let runtimeModule = - { path = srcPrefix ++ "_runtime.py", content = RuntimeModule.run {=} } - - let statementsInit = - { path = srcPrefix ++ "statements/__init__.py" - , content = - InitModule.run { docstring = "Generated SQL statements." } - } - - -- The shared Row dataclasses + decode functions, imported by both the - -- async and sync statement modules so the two surfaces share one set of - -- types (cross-surface identity). - let rowDefs = - Prelude.List.concatMap - QueryGen.Output - RowsModule.RowDef - ( \(query : QueryGen.Output) -> - Prelude.Optional.toList RowsModule.RowDef query.rowDef - ) - queries - - let rowImports = - ImportSet.combineAll - ( Prelude.List.map - QueryGen.Output - ImportSet.Type - (\(query : QueryGen.Output) -> query.rowImports) - queries - ) - - let rowsFiles = - if Prelude.List.null RowsModule.RowDef rowDefs - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "_rows.py" - , content = - RowsModule.run { rows = rowDefs, imports = rowImports } - } - ] - - let asyncStatementFiles = - Prelude.List.map - QueryGen.Output - Lude.File.Type - ( \(query : QueryGen.Output) -> - { path = srcPrefix ++ query.asyncModulePath - , content = query.asyncContent - } - ) - queries - - let typeFiles = - Prelude.List.map - CustomTypeGen.Output - Lude.File.Type - ( \(ct : CustomTypeGen.Output) -> - { path = srcPrefix ++ ct.modulePath - , content = ct.moduleContent - } - ) - customTypes - - let typesInitExports = - Prelude.List.map - CustomTypeGen.Output - TypesInit.Export - (\(ct : CustomTypeGen.Output) -> { moduleName = ct.moduleName, typeName = ct.typeName }) - customTypes - - let typesInitFiles = - if Prelude.List.null CustomTypeGen.Output customTypes - then [] : List Lude.File.Type - else [ { path = srcPrefix ++ "types/__init__.py" - , content = TypesInit.run { exports = typesInitExports } - } - ] - - let compositeNames = compositePgNames customTypes - - let enumNames = enumPgNames customTypes - - let hasCustomRegistration = - Prelude.Bool.not - ( Prelude.Bool.and - [ Prelude.List.null Text compositeNames - , Prelude.List.null Text enumNames - ] - ) - - let registerFiles = - if hasCustomRegistration - then [ { path = srcPrefix ++ "_register.py" - , content = - RegisterModule.run - { compositeNames, enumNames, surface = Surface.async } - } - ] - else [] : List Lude.File.Type - - -- The sync surface mirrors the async one under `sync/`, gated on - -- config.emitSync. It reuses the shared `_rows.py` and `types/`, so only - -- the I/O wrappers (statements, runtime, register) and the sync facade - -- are sync-specific. - let syncStatementFiles = - Prelude.List.map - QueryGen.Output - Lude.File.Type - ( \(query : QueryGen.Output) -> - { path = srcPrefix ++ query.syncModulePath - , content = query.syncContent - } - ) - queries - - let syncSubpackageInit = - { path = srcPrefix ++ "sync/__init__.py" - , content = - InitModule.run { docstring = "Generated sync database client." } - } - - let syncStatementsInit = - { path = srcPrefix ++ "sync/statements/__init__.py" - , content = - InitModule.run { docstring = "Generated SQL statements (sync)." } - } - - let syncRuntime = - { path = srcPrefix ++ "sync/_runtime.py" - , content = RuntimeModule.runSync {=} - } - - let syncRegisterFiles = - if hasCustomRegistration - then [ { path = srcPrefix ++ "sync/_register.py" - , content = - RegisterModule.run - { compositeNames, enumNames, surface = Surface.sync } - } - ] - else [] : List Lude.File.Type - - let syncFacade = - { path = packagePrefix ++ "sync/__init__.py" - , content = - FacadeModule.run - { generatedPrefix = ".._generated" - , statementsPath = "sync.statements" - , statements = facadeStatements - , types = facadeTypes - } - } - - let syncFiles = - if config.emitSync - then [ syncSubpackageInit - , syncRuntime - , syncStatementsInit - , syncFacade - ] - # syncRegisterFiles - # syncStatementFiles - else [] : List Lude.File.Type - - let asyncStaticFiles = - [ asyncFacade, topInit, coreModule, runtimeModule, statementsInit ] - - let allFiles = - asyncStaticFiles - # registerFiles - # rowsFiles - # typesInitFiles - # typeFiles - # asyncStatementFiles - # syncFiles - - in Prelude.List.map Lude.File.Type Lude.File.Type withHeader allFiles - : Output - --- Per-element keep/drop decision plus its warning, computed once from a --- single QueryGen.run call and reused for both the Skip filter and the --- warning list. Custom types use a pair of plain functions instead of an --- equivalent record (see `typeSucceeds`/`typeWarning` below) purely because --- that was the faster shape empirically for the type side, and the query --- side re-uses `queryChecks` because `lookup` (built from the surviving --- custom types) threads into every query's Member/ParamsMember resolution: --- calling QueryGen.run config lookup query from more than one place in this --- function (once to decide keep/drop, again to render, again for a --- warning -- each a fresh, separate call site in the source) measurably --- multiplies Dhall's normalization cost per extra call site, confirmed by --- bisection against `pgn generate` wall time (a few seconds regressed to --- minutes with three call sites; this file keeps it to two: one to build --- `queryChecks`, one for the final render). -let QueryCheck = { query : Model.Query, keep : Bool, warning : Optional Report } - -let run = - \(config : Algebra.Config) -> - \(input : Input) -> - let skip = merge { Fail = False, Skip = True } config.onUnsupported - - let typeSucceeds - : Model.CustomType -> Bool - = \(ct : Model.CustomType) -> - merge - { Ok = - \(_ : { value : CustomTypeGen.Output, warnings : List Report }) -> - True - , Err = \(_ : Report) -> False - } - (CustomTypeGen.run config ct) - - -- 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 typeWarning - : Model.CustomType -> Optional Report - = \(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 config ct) - - -- A skipped custom type resolves to Absent for any query that - -- references it, and that query's own Member/ParamsMember - -- resolution fails with "Custom type not found" -- caught the same - -- way any other unsupported shape is, cascading the skip onto every - -- dependent query. - let effectiveCustomTypes - : List Model.CustomType - = if skip - then Prelude.List.filter Model.CustomType typeSucceeds input.customTypes - else input.customTypes - - let lookup = buildLookup effectiveCustomTypes - - -- Fail mode: identical to the pre-Skip code (traverseList straight - -- over input.customTypes), so its error message/path is unchanged. - let typesForCombine - : Lude.Compiled.Type (List CustomTypeGen.Output) - = Lude.Compiled.traverseList - Model.CustomType - CustomTypeGen.Output - (\(ct : Model.CustomType) -> CustomTypeGen.run config ct) - effectiveCustomTypes - - let queryChecks - : List QueryCheck - = Prelude.List.map - Model.Query - QueryCheck - ( \(query : Model.Query) -> - merge - { Ok = - \(_ : { value : QueryGen.Output, warnings : List Report }) -> - { query, keep = True, warning = None Report } - , Err = \(err : Report) -> { query, keep = False, warning = Some err } - } - (QueryGen.run config lookup query) - ) - input.queries - - let effectiveQueries - : List Model.Query - = if skip - then Prelude.List.map - QueryCheck - Model.Query - (\(qc : QueryCheck) -> qc.query) - (Prelude.List.filter QueryCheck (\(qc : QueryCheck) -> qc.keep) queryChecks) - else input.queries - - -- Fail mode: identical to the pre-Skip code (traverseList straight - -- over input.queries), so its error message/path is unchanged. - let queriesForCombine - : Lude.Compiled.Type (List QueryGen.Output) - = Lude.Compiled.traverseList - Model.Query - QueryGen.Output - (\(query : Model.Query) -> QueryGen.run config lookup query) - effectiveQueries - - let skipWarnings - : List Report - = if skip - then Prelude.List.unpackOptionals - Report - (Prelude.List.map Model.CustomType (Optional Report) typeWarning input.customTypes) - # Prelude.List.unpackOptionals - Report - (Prelude.List.map QueryCheck (Optional Report) (\(qc : QueryCheck) -> qc.warning) queryChecks) - else [] : List Report - - let combined - : Lude.Compiled.Type Output - = Lude.Compiled.map2 - (List QueryGen.Output) - (List CustomTypeGen.Output) - Output - (combineOutputs config input) - queriesForCombine - typesForCombine - - in Lude.Compiled.appendWarnings Output skipWarnings combined - -in Algebra.module Input Output run diff --git a/gen/Interpreters/Value.dhall b/gen/Interpreters/Value.dhall deleted file mode 100644 index de69fd0..0000000 --- a/gen/Interpreters/Value.dhall +++ /dev/null @@ -1,65 +0,0 @@ -let Deps = ../Deps/package.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let Algebra = ../Algebras/Interpreter.dhall - -let Lude = Deps.Lude - -let Prelude = Deps.Prelude - -let Model = Deps.Sdk.Project - -let Scalar = ./Scalar.dhall - -let Input = Model.Value - -let Output = - { pyType : Text - , imports : ImportSet.Type - , scalar : Scalar.Output - , dims : Natural - , elementIsNullable : Bool - } - -let run = - \(config : Algebra.Config) -> - \(input : Input) -> - Lude.Compiled.map - Scalar.Output - Output - ( \(scalar : Scalar.Output) -> - Prelude.Optional.fold - Model.ArraySettings - input.arraySettings - Output - ( \(arraySettings : Model.ArraySettings) -> - let elementType = - if arraySettings.elementIsNullable - then "${scalar.pyType} | None" - else scalar.pyType - - let arrayType = - Natural/fold - arraySettings.dimensionality - Text - (\(inner : Text) -> "list[${inner}]") - elementType - - in { pyType = arrayType - , imports = scalar.imports - , scalar - , dims = arraySettings.dimensionality - , elementIsNullable = arraySettings.elementIsNullable - } - ) - { pyType = scalar.pyType - , imports = scalar.imports - , scalar - , dims = 0 - , elementIsNullable = False - } - ) - (Scalar.run config input.scalar) - -in Algebra.module Input Output run diff --git a/gen/Structures/CustomKind.dhall b/gen/Structures/CustomKind.dhall deleted file mode 100644 index 22d3031..0000000 --- a/gen/Structures/CustomKind.dhall +++ /dev/null @@ -1,30 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Model = Deps.Sdk.Project - --- A composite field as the decode/encode sites need it: the Python attribute --- name and the rendered Python type (already nullability-applied). Threaded so --- a column decode can build `Point2D(*cast(tuple[x, y], src))` and a param --- encode can build `(value.x, value.y)` without re-reading the customType. -let CompositeField = { fieldName : Text, pyType : Text } - --- Classification of a custom type referenced by a Scalar.Custom name. Composite --- carries its field list so callers render per-field decode/encode. Absent means --- the name did not resolve against project.customTypes, which is a model --- inconsistency the caller turns into a Compiled error. --- Enum and Composite both carry `order`, the type's alphabetical position in --- project.customTypes; the import renderer sorts on it so the per-module --- `from ..types.X import Y` block stays alphabetical regardless of column order. --- NOTE: union is named TypeKind because `Kind` is a reserved Dhall keyword. -let TypeKind = - < Enum : Natural - | Composite : { fields : List CompositeField, order : Natural } - | Absent - > - --- Resolves a custom-type Name to its TypeKind. Project.run builds this from --- project.customTypes and threads it into Member/ParamsMember (and onward to --- Result/ResultColumns) so those interpreters can pick the right decode/encode. -let Lookup = Model.Name -> TypeKind - -in { TypeKind, Lookup, CompositeField } diff --git a/gen/Structures/PyIdent.dhall b/gen/Structures/PyIdent.dhall deleted file mode 100644 index e7db139..0000000 --- a/gen/Structures/PyIdent.dhall +++ /dev/null @@ -1,84 +0,0 @@ --- 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 --- params signature, the result-row dataclass fields, and composite fields all use --- one rule. -let pythonKeywords = - [ "False" - , "None" - , "True" - , "and" - , "as" - , "assert" - , "async" - , "await" - , "break" - , "class" - , "continue" - , "def" - , "del" - , "elif" - , "else" - , "except" - , "finally" - , "for" - , "from" - , "global" - , "if" - , "import" - , "in" - , "is" - , "lambda" - , "nonlocal" - , "not" - , "or" - , "pass" - , "raise" - , "return" - , "try" - , "while" - , "with" - , "yield" - ] - --- Suffix an underscore 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 sanitizeAgainst = - \(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 ++ "_") acc - ) - name - -let pySafeName - : Text -> Text - = sanitizeAgainst pythonKeywords - -in { pythonKeywords, sanitizeAgainst, pySafeName } diff --git a/gen/Structures/Surface.dhall b/gen/Structures/Surface.dhall deleted file mode 100644 index 50019bc..0000000 --- a/gen/Structures/Surface.dhall +++ /dev/null @@ -1,42 +0,0 @@ --- A code-generation surface: the async or sync flavour of a statement module. --- Backend (async) and ingest (sync) share one project, one generate, and the --- same Row dataclasses + enums; only the I/O wrapper differs per surface. The --- fields are the exact tokens that vary between `async def`/`def`, --- `AsyncConnection`/`Connection`, `await `/``, and the relative-import depth of --- the shared `_rows`/`types` modules (sync statement modules sit one package --- deeper under `sync/statements/`, so they reach the shared modules with an --- extra dot). The runtime import is `.._runtime` for both surfaces (async from --- `statements/`, sync from `sync/statements/`), so it needs no field here. --- corePrefix mirrors rowsImport's depth: statement modules import JsonValue from --- `_core` directly, so sync reaches it with an extra dot (`..._core`) exactly --- like it reaches `_rows` (`..._rows`). -let Surface = - { defKeyword : Text - , connType : Text - , awaitKw : Text - , rowsImport : Text - , corePrefix : Text - , typesPrefix : Text - } - -let async - : Surface - = { defKeyword = "async def" - , connType = "AsyncConnection" - , awaitKw = "await " - , rowsImport = ".._rows" - , corePrefix = ".._core" - , typesPrefix = "..types" - } - -let sync - : Surface - = { defKeyword = "def" - , connType = "Connection" - , awaitKw = "" - , rowsImport = "..._rows" - , corePrefix = "..._core" - , typesPrefix = "...types" - } - -in { Type = Surface, async, sync } diff --git a/gen/Templates/CoreModule.dhall b/gen/Templates/CoreModule.dhall deleted file mode 100644 index d3938c2..0000000 --- a/gen/Templates/CoreModule.dhall +++ /dev/null @@ -1,46 +0,0 @@ -let Algebra = ../Algebras/Template.dhall - --- The surface-agnostic core of a generated package, emitted once at --- _generated/_core.py. It owns the names shared by every module and by both the --- async and sync surfaces: the JsonValue alias, the NoRowError/DecodeError --- exceptions, and the require_array decode guard. It performs no I/O, so there is --- exactly one copy regardless of surface. The two _runtime.py modules re-export --- JsonValue/NoRowError/require_array from here so off-contract imports keep --- working, and _rows.py, the statement modules, and the facades import these --- names from _core directly. -let content = - '' - """Shared types and decode helpers, surface-agnostic; no I/O.""" - - from __future__ import annotations - - from typing import cast - - type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] - - - class NoRowError(RuntimeError): - """A single-row query returned no rows.""" - - - class DecodeError(RuntimeError): - """A row value failed to decode into its target type.""" - - - def require_array(value: object) -> list[object]: - """Guard an enum-array column decode. - - psycopg returns an enum array as a Python list only when the enum type is - registered on the connection (register_types); without it the value comes - back as the raw array text, which would iterate into bogus members. Fail - clearly instead. - """ - if isinstance(value, list): - return cast(list[object], value) - raise RuntimeError( - "enum array decoded as text; call register_types() on the connection " - "before decoding enum-array columns" - ) - '' - -in Algebra.module {} (\(_ : {}) -> content) diff --git a/gen/Templates/FacadeModule.dhall b/gen/Templates/FacadeModule.dhall deleted file mode 100644 index 2beb74c..0000000 --- a/gen/Templates/FacadeModule.dhall +++ /dev/null @@ -1,129 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Algebra = ../Algebras/Template.dhall - -let Prelude = Deps.Prelude - --- A statement's public surface: the function and, when the query returns rows, --- its frozen Row dataclass. functionName doubles as the leaf module name. -let StatementExport = - { functionName : Text, rowClassName : Optional Text } - --- A custom type re-exported from types/: the leaf module name plus the class. -let TypeExport = { moduleName : Text, className : Text } - --- generatedPrefix and statementsPath select the surface: the async facade lives --- at the package root (prefix `._generated`, statements under `statements`); the --- sync facade lives one level down at `sync/__init__.py` (prefix `.._generated`, --- statements under `sync.statements`). Both re-export the SAME Row dataclasses --- from `_rows` and the SAME enums/composites from `types`, so the two surfaces --- share one set of types. -let Params = - { generatedPrefix : Text - , statementsPath : Text - , statements : List StatementExport - , types : List TypeExport - } - --- "x as x" re-export markers (PEP 484) so basedpyright strict and ruff treat the --- names as exports of the package root, not unused imports. -let alias = \(name : Text) -> "${name} as ${name}" - -let rowNames - : List StatementExport -> List Text - = \(statements : List StatementExport) -> - Prelude.List.concatMap - StatementExport - Text - ( \(s : StatementExport) -> - merge - { None = [] : List Text, Some = \(row : Text) -> [ row ] } - s.rowClassName - ) - statements - --- The core names that are part of the public surface: the JsonValue alias used --- by callers typing jsonb columns, and the NoRowError raised by single-row --- statements. _core.py is always emitted, so these are always re-exported. -let runtimeNames = [ "JsonValue", "NoRowError" ] - -let run = - \(params : Params) -> - let runtimeBlock = - "from ${params.generatedPrefix}._core import " - ++ Prelude.Text.concatMapSep - ", " - Text - alias - runtimeNames - - let typeBlock = - Prelude.Text.concatMapSep - "\n" - TypeExport - ( \(t : TypeExport) -> - "from ${params.generatedPrefix}.types.${t.moduleName} import ${alias t.className}" - ) - params.types - - let rows = rowNames params.statements - - let rowBlock = - "from ${params.generatedPrefix}._rows import (\n" - ++ Prelude.Text.concatMap - Text - (\(name : Text) -> " ${alias name},\n") - rows - ++ ")" - - let statementBlock = - Prelude.Text.concatMapSep - "\n" - StatementExport - ( \(s : StatementExport) -> - "from ${params.generatedPrefix}.${params.statementsPath}.${s.functionName} import ${alias s.functionName}" - ) - params.statements - - let importGroups = - [ runtimeBlock ] - # ( if Prelude.List.null TypeExport params.types - then [] : List Text - else [ typeBlock ] - ) - # (if Prelude.List.null Text rows then [] : List Text else [ rowBlock ]) - # ( if Prelude.List.null StatementExport params.statements - then [] : List Text - else [ statementBlock ] - ) - - let importSection = Prelude.Text.concatSep "\n\n" importGroups - - let allNames = - runtimeNames - # Prelude.List.map - TypeExport - Text - (\(t : TypeExport) -> t.className) - params.types - # rows - # Prelude.List.map - StatementExport - Text - (\(s : StatementExport) -> s.functionName) - params.statements - - let allEntries = - Prelude.Text.concatMap - Text - (\(name : Text) -> " \"${name}\",\n") - allNames - - in '' - ${importSection} - - __all__ = [ - ${allEntries}] - '' - -in Algebra.module Params run /\ { StatementExport, TypeExport } diff --git a/gen/Templates/RegisterModule.dhall b/gen/Templates/RegisterModule.dhall deleted file mode 100644 index ec6886b..0000000 --- a/gen/Templates/RegisterModule.dhall +++ /dev/null @@ -1,99 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Algebra = ../Algebras/Template.dhall - -let Surface = ../Structures/Surface.dhall - -let Prelude = Deps.Prelude - --- Per-connection type registration, emitted once per surface. psycopg decodes an --- unregistered composite as a text string; registering its CompositeInfo makes --- it decode to a namedtuple, which the generated decode then splats into the --- frozen dataclass. An enum scalar already decodes as text, but an enum ARRAY --- does not parse without the type registered, so each enum's TypeInfo is --- registered too (elements stay text and the generated decode rebuilds the --- StrEnum). Call once per connection before using the statements. Emitted only --- when the project has composites or enums. -let Params = - { compositeNames : List Text - , enumNames : List Text - , surface : Surface.Type - } - -let tupleLiteral = - \(names : List Text) -> - "(" - ++ Prelude.Text.concatMapSep ", " Text (\(n : Text) -> "\"${n}\"") names - ++ ",)" - -let compositeLoop = - \(awaitKw : Text) -> - [ " for name in _COMPOSITE_TYPES:" - , " composite_info = ${awaitKw}CompositeInfo.fetch(conn, name)" - , " if composite_info is None:" - , " raise LookupError(f\"composite type {name!r} not found; cannot register it\")" - , " register_composite(composite_info, conn)" - ] - -let enumLoop = - \(awaitKw : Text) -> - [ " for name in _ENUM_TYPES:" - , " enum_info = ${awaitKw}TypeInfo.fetch(conn, name)" - , " if enum_info is None:" - , " raise LookupError(f\"enum type {name!r} not found; cannot register it\")" - , " enum_info.register(conn)" - ] - -let run = - \(params : Params) -> - let surface = params.surface - - let hasComposites = - Prelude.Bool.not (Prelude.List.null Text params.compositeNames) - - let hasEnums = Prelude.Bool.not (Prelude.List.null Text params.enumNames) - - let importLines = - [ "from __future__ import annotations" - , "" - , "from psycopg import ${surface.connType}" - ] - # ( if hasEnums - then [ "from psycopg.types import TypeInfo" ] - else [] : List Text - ) - # ( if hasComposites - then [ "from psycopg.types.composite import CompositeInfo, register_composite" - ] - else [] : List Text - ) - - let constantLines = - ( if hasComposites - then [ "_COMPOSITE_TYPES = ${tupleLiteral params.compositeNames}" ] - else [] : List Text - ) - # ( if hasEnums - then [ "_ENUM_TYPES = ${tupleLiteral params.enumNames}" ] - else [] : List Text - ) - - let bodyLines = - ( if hasComposites - then compositeLoop surface.awaitKw - else [] : List Text - ) - # (if hasEnums then enumLoop surface.awaitKw else [] : List Text) - - let allLines = - importLines - # [ "", "" ] - # constantLines - # [ "", "" ] - # [ "${surface.defKeyword} register_types(conn: ${surface.connType}[object]) -> None:" - ] - # bodyLines - - in Prelude.Text.concatSep "\n" allLines ++ "\n" - -in Algebra.module Params run diff --git a/gen/Templates/RowsModule.dhall b/gen/Templates/RowsModule.dhall deleted file mode 100644 index 0107fa2..0000000 --- a/gen/Templates/RowsModule.dhall +++ /dev/null @@ -1,121 +0,0 @@ -let Deps = ../Deps/package.dhall - -let Algebra = ../Algebras/Template.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let Prelude = Deps.Prelude - --- The shared row module: every query's frozen Row dataclass and its decode --- function live here once, so the async and sync statement modules import the --- same types (cross-surface identity) and decode logic. decodeName is unique per --- query (decode_) since they now co-habit one module. -let RowDef = - { className : Text - , fieldsBlock : Text - , decodeBlock : Text - , decodeName : Text - } - -let Params = { rows : List RowDef, imports : ImportSet.Type } - -let indentAll - : Natural -> Text -> Text - = \(n : Natural) -> - \(text : Text) -> - let pad = Prelude.Text.replicate n " " - - in pad ++ Deps.Lude.Text.indentNonEmpty n text - -let importLineIf - : Bool -> Text -> List Text - = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text - -let datetimeImport - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - let names = - importLineIf imports.date "date" - # importLineIf imports.datetime "datetime" - # importLineIf imports.time "time" - # importLineIf imports.timedelta "timedelta" - - in if Prelude.Bool.not (Prelude.List.null Text names) - then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] - else [] : List Text - --- Custom-type imports resolve from `_rows.py` (one level above `types/`), so the --- prefix is `.types`, unlike a statement module's `..types`. -let customImportLines - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - Prelude.List.map - ImportSet.CustomImport - Text - ( \(c : ImportSet.CustomImport) -> - "from .types." ++ c.moduleName ++ " import " ++ c.className - ) - (ImportSet.sortedCustoms imports) - -let renderImports - : ImportSet.Type -> Text - = \(imports : ImportSet.Type) -> - let stdlibBlock = - [ "from collections.abc import Mapping" - , "from dataclasses import dataclass" - ] - # datetimeImport imports - # importLineIf imports.decimal "from decimal import Decimal" - # [ "from typing import cast" ] - # importLineIf imports.uuid "from uuid import UUID" - - let localBlock = - importLineIf imports.jsonValue "from ._core import JsonValue" - # importLineIf imports.enumArray "from ._core import require_array" - # customImportLines imports - - let groups = - [ [ "from __future__ import annotations" ], stdlibBlock, localBlock ] - - let nonEmptyGroups = - Prelude.List.filter - (List Text) - ( \(g : List Text) -> - Prelude.Bool.not (Prelude.List.null Text g) - ) - groups - - in Prelude.Text.concatMapSep - "\n\n" - (List Text) - (\(g : List Text) -> Prelude.Text.concatSep "\n" g) - nonEmptyGroups - -let renderRow - : RowDef -> Text - = \(row : RowDef) -> - "@dataclass(frozen=True, slots=True)\n" - ++ "class " - ++ row.className - ++ ":\n" - ++ indentAll 4 row.fieldsBlock - ++ "\n\n\n" - ++ "def " - ++ row.decodeName - ++ "(row: Mapping[str, object]) -> " - ++ row.className - ++ ":\n" - ++ " return " - ++ row.className - ++ "(\n" - ++ indentAll 8 row.decodeBlock - ++ "\n )" - -let run = - \(params : Params) -> - renderImports params.imports - ++ "\n\n\n" - ++ Prelude.Text.concatMapSep "\n\n\n" RowDef renderRow params.rows - ++ "\n" - -in Algebra.module Params run /\ { RowDef } diff --git a/gen/Templates/RuntimeModule.dhall b/gen/Templates/RuntimeModule.dhall deleted file mode 100644 index ddaf53a..0000000 --- a/gen/Templates/RuntimeModule.dhall +++ /dev/null @@ -1,164 +0,0 @@ -let Algebra = ../Algebras/Template.dhall - --- The fixed _runtime.py body, emitted once per generated package. No per-query --- customization. Mirrors DESIGN section 3 with two strict-clean adjustments the --- design's hard "basedpyright strict, zero warnings" constraint forces: the --- unused Sequence import is dropped, and each cursor.execute result is bound to --- `_` so reportUnusedCallResult stays quiet. This module is I/O-only: the shared --- JsonValue/NoRowError/require_array names live in _core and are re-exported here --- so off-contract `from .._runtime import ...` keeps working. -let content = - '' - from __future__ import annotations - - from collections.abc import Callable, Mapping - from typing import TypeVar - - from psycopg import AsyncConnection - from psycopg.rows import dict_row - - from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array - - _T = TypeVar("_T") - _Row = Mapping[str, object] - _Params = Mapping[str, object] - - - async def fetch_optional( - conn: AsyncConnection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> _T | None: - async with conn.cursor(row_factory=dict_row) as cur: - _ = await cur.execute(sql, params) - row = await cur.fetchone() - return None if row is None else decode(row) - - - async def fetch_single( - conn: AsyncConnection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> _T: - async with conn.cursor(row_factory=dict_row) as cur: - _ = await cur.execute(sql, params) - row = await cur.fetchone() - if row is None: - raise NoRowError(sql.decode()) - return decode(row) - - - async def fetch_many( - conn: AsyncConnection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> list[_T]: - async with conn.cursor(row_factory=dict_row) as cur: - _ = await cur.execute(sql, params) - rows = await cur.fetchall() - return [decode(row) for row in rows] - - - async def execute_rows_affected( - conn: AsyncConnection[object], - sql: bytes, - params: _Params, - ) -> int: - async with conn.cursor() as cur: - _ = await cur.execute(sql, params) - return cur.rowcount - - - async def execute_void( - conn: AsyncConnection[object], - sql: bytes, - params: _Params, - ) -> None: - async with conn.cursor() as cur: - _ = await cur.execute(sql, params) - '' - --- The sync mirror, emitted at _generated/sync/_runtime.py when emitSync. The --- five helpers are the same shape with `def`/`Connection`/`with`/no-`await`. --- JsonValue/NoRowError/require_array are re-exported from _core (two levels up) --- so both surfaces share one canonical identity rather than two equal-but- --- distinct definitions. -let syncContent = - '' - from __future__ import annotations - - from collections.abc import Callable, Mapping - from typing import TypeVar - - from psycopg import Connection - from psycopg.rows import dict_row - - from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array - - _T = TypeVar("_T") - _Row = Mapping[str, object] - _Params = Mapping[str, object] - - - def fetch_optional( - conn: Connection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> _T | None: - with conn.cursor(row_factory=dict_row) as cur: - _ = cur.execute(sql, params) - row = cur.fetchone() - return None if row is None else decode(row) - - - def fetch_single( - conn: Connection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> _T: - with conn.cursor(row_factory=dict_row) as cur: - _ = cur.execute(sql, params) - row = cur.fetchone() - if row is None: - raise NoRowError(sql.decode()) - return decode(row) - - - def fetch_many( - conn: Connection[object], - sql: bytes, - params: _Params, - decode: Callable[[_Row], _T], - ) -> list[_T]: - with conn.cursor(row_factory=dict_row) as cur: - _ = cur.execute(sql, params) - rows = cur.fetchall() - return [decode(row) for row in rows] - - - def execute_rows_affected( - conn: Connection[object], - sql: bytes, - params: _Params, - ) -> int: - with conn.cursor() as cur: - _ = cur.execute(sql, params) - return cur.rowcount - - - def execute_void( - conn: Connection[object], - sql: bytes, - params: _Params, - ) -> None: - with conn.cursor() as cur: - _ = cur.execute(sql, params) - '' - -in Algebra.module {} (\(_ : {}) -> content) - /\ { runSync = \(_ : {}) -> syncContent } diff --git a/gen/Templates/StatementModule.dhall b/gen/Templates/StatementModule.dhall deleted file mode 100644 index f717aa7..0000000 --- a/gen/Templates/StatementModule.dhall +++ /dev/null @@ -1,211 +0,0 @@ -let Algebra = ../Algebras/Template.dhall - -let Deps = ../Deps/package.dhall - -let ImportSet = ../Structures/ImportSet.dhall - -let Surface = ../Structures/Surface.dhall - -let Prelude = Deps.Prelude - --- Prefix every line (including the first) with `n` spaces, leaving blank lines --- untouched so trailing whitespace never appears. -let indentAll - : Natural -> Text -> Text - = \(n : Natural) -> - \(text : Text) -> - let pad = Prelude.Text.replicate n " " - - in pad ++ Deps.Lude.Text.indentNonEmpty n text - --- A statement module is the thin per-surface I/O wrapper: it imports its Row --- dataclass and decode function from the shared `_rows` module and renders one --- `async def`/`def` over a connection. `imports` carries the PARAMETER type --- imports only; the result-column imports live with the Row in `_rows`. -let Params = - { functionName : Text - , returnType : Text - , helperName : Text - , callsDecode : Bool - , sqlLiteral : Text - , rowClassName : Optional Text - , decodeName : Text - , paramSigLines : List Text - , paramDictEntries : List Text - , imports : ImportSet.Type - , surface : Surface.Type - } - -let importLineIf - : Bool -> Text -> List Text - = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text - --- "from datetime import ..." collapses the four datetime members into one line. -let datetimeImport - : ImportSet.Type -> List Text - = \(imports : ImportSet.Type) -> - let names = - importLineIf imports.date "date" - # importLineIf imports.datetime "datetime" - # importLineIf imports.time "time" - # importLineIf imports.timedelta "timedelta" - - in if Prelude.Bool.not (Prelude.List.null Text names) - then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] - else [] : List Text - --- The I/O helper (fetch_*/execute_*) always comes from _runtime; JsonValue, when --- used, comes from _core via the surface's corePrefix (statement modules import --- the shared name directly, not through the _runtime re-export). -let runtimeImport - : Text -> Text - = \(helperName : Text) -> "from .._runtime import " ++ helperName - -let coreImport - : Text -> Bool -> List Text - = \(corePrefix : Text) -> - \(jsonValue : Bool) -> - if jsonValue - then [ "from ${corePrefix} import JsonValue" ] - else [] : List Text - -let customImportLines - : Text -> ImportSet.Type -> List Text - = \(typesPrefix : Text) -> - \(imports : ImportSet.Type) -> - Prelude.List.map - ImportSet.CustomImport - Text - ( \(c : ImportSet.CustomImport) -> - "from ${typesPrefix}.${c.moduleName} import ${c.className}" - ) - (ImportSet.sortedCustoms imports) - -let rowsImportLine - : Params -> List Text - = \(params : Params) -> - merge - { None = [] : List Text - , Some = - \(rowClass : Text) -> - [ "from ${params.surface.rowsImport} import ${rowClass}, ${params.decodeName}" - ] - } - params.rowClassName - -let renderImports - : Params -> Text - = \(params : Params) -> - let imports = params.imports - - let stdlibBlock = - datetimeImport imports - # importLineIf imports.decimal "from decimal import Decimal" - # importLineIf imports.uuid "from uuid import UUID" - - let psycopgBlock = - [ "from psycopg import ${params.surface.connType}" ] - # importLineIf - imports.json - "from psycopg.types.json import Json" - # importLineIf - imports.jsonb - "from psycopg.types.json import Jsonb" - - let localBlock = - coreImport params.surface.corePrefix imports.jsonValue - # rowsImportLine params - # [ runtimeImport params.helperName ] - # customImportLines params.surface.typesPrefix imports - - let groups = - [ [ "from __future__ import annotations" ] - , stdlibBlock - , psycopgBlock - , localBlock - ] - - let nonEmptyGroups = - Prelude.List.filter - (List Text) - ( \(g : List Text) -> - Prelude.Bool.not (Prelude.List.null Text g) - ) - groups - - in Prelude.Text.concatMapSep - "\n\n" - (List Text) - (\(g : List Text) -> Prelude.Text.concatSep "\n" g) - nonEmptyGroups - -let renderSignature - : Params -> Text - = \(params : Params) -> - let hasParams = - Prelude.Bool.not (Prelude.List.null Text params.paramSigLines) - - let kwMarker = if hasParams then " *,\n" else "" - - let paramBlock = - Prelude.Text.concatMap - Text - (\(line : Text) -> " " ++ line ++ ",\n") - params.paramSigLines - - in params.surface.defKeyword - ++ " " - ++ params.functionName - ++ "(\n" - ++ " conn: ${params.surface.connType}[object],\n" - ++ kwMarker - ++ paramBlock - ++ ") -> " - ++ params.returnType - ++ ":" - --- Emit the dict multi-line with a magic trailing comma so ruff keeps it --- expanded at any width, which keeps the generated file format-stable. -let renderParamsDict - : Params -> Text - = \(params : Params) -> - if Prelude.List.null Text params.paramDictEntries - then "params: dict[str, object] = {}" - else "params: dict[str, object] = {\n" - ++ Prelude.Text.concatMap - Text - (\(entry : Text) -> " " ++ entry ++ ",\n") - params.paramDictEntries - ++ "}" - -let renderCall - : Params -> Text - = \(params : Params) -> - let await = params.surface.awaitKw - - in if params.callsDecode - then "return ${await}${params.helperName}(conn, _SQL, params, ${params.decodeName})" - else "return ${await}${params.helperName}(conn, _SQL, params)" - -in Algebra.module - Params - ( \(params : Params) -> - renderImports params - ++ "\n\n" - -- The leading backslash after the opening quotes keeps the first SQL - -- line flush (no blank line); the newline before the closing quotes is - -- the only deviation from the raw text, a harmless trailing newline for - -- psycopg. - ++ "SQL = \"\"\"\\\n" - ++ params.sqlLiteral - -- Encode once at import; the helpers take bytes so each call skips a - -- per-query str->bytes allocation (psycopg auto-prepare keys on the - -- bytes value, so equal bytes still hit the prepared-statement cache). - ++ "\n\"\"\"\n\n_SQL = SQL.encode()\n\n\n" - ++ renderSignature params - ++ "\n" - ++ indentAll 4 (renderParamsDict params) - ++ "\n" - ++ indentAll 4 (renderCall params) - ++ "\n" - ) diff --git a/gen/compile.dhall b/gen/compile.dhall deleted file mode 100644 index cd12642..0000000 --- a/gen/compile.dhall +++ /dev/null @@ -1,67 +0,0 @@ -let Deps = ./Deps/package.dhall - -let Model = Deps.Sdk.Project - -let Prelude = Deps.Prelude - -let Config = ./Config.dhall - -let OnUnsupported = ./Structures/OnUnsupported.dhall - -let ProjectInterpreter = ./Interpreters/Project.dhall - --- Entry point handed to gen-sdk's module. `config` and each of its fields are --- Optional, so a project may omit the whole config block or any subset of its --- keys; `defaults` collects every fallback in one place (packageName from the --- project name in kebab case, emitSync off, onUnsupported Fail), so a future --- knob's default is added here alongside the others. The async surface is --- always emitted; emitSync adds the sync mirror. -in \(config : Optional Config) -> - \(project : Model.Project) -> - let defaults = - { packageName = project.name.inKebabCase - , emitSync = False - , onUnsupported = OnUnsupported.Mode.Fail - } - - let packageName = - Prelude.Optional.fold - Config - config - Text - (\(c : Config) -> - Prelude.Optional.fold Text c.packageName Text (\(t : Text) -> t) defaults.packageName - ) - defaults.packageName - - let emitSync = - Prelude.Optional.fold - Config - config - Bool - (\(c : Config) -> - Prelude.Optional.fold Bool c.emitSync Bool (\(b : Bool) -> b) defaults.emitSync - ) - defaults.emitSync - - let onUnsupported = - Prelude.Optional.fold - Config - config - OnUnsupported.Mode - ( \(c : Config) -> - Prelude.Optional.fold - OnUnsupported.Mode - c.onUnsupported - OnUnsupported.Mode - (\(m : OnUnsupported.Mode) -> m) - defaults.onUnsupported - ) - defaults.onUnsupported - - let importName = Prelude.Text.replace "-" "_" packageName - - let interpreterConfig = - { packageName, importName, emitSync, onUnsupported } - - in ProjectInterpreter.run interpreterConfig 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 040920b..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" @@ -14,31 +14,35 @@ description = "Run the generator harness tests" depends = ["install"] run = "uv run pytest tests -q" -# Cold-cache generate benchmark: current gen (`as Source` Deps) vs the same -# tree with pre-as-Source Deps, same pgn binary, fresh dhall cache per run. -# Needs a reachable Postgres (PGN_TEST_DATABASE_URL). See bench/as-source.sh -# for the PGN_BEFORE_BIN override that runs the before variant on pgn 0.8.0. +[tasks.lint] +description = "Check Python lint and formatting" +depends = ["install"] +run = ''' +uv run ruff check --config pyproject.toml tests wheel +uv run ruff format --check --config pyproject.toml tests wheel +''' + +# Historical cold-cache import-mode benchmark. Current Deps are ordinary +# sha256-pinned imports, so a current checkout does not reproduce its variants. [tasks."bench:as-source"] -description = "Benchmark cold `pgn generate` with vs without `as Source` Deps" +description = "Run the historical cold import-mode benchmark" run = "bench/as-source.sh" # One-command builds of the fixture client for eyeballing the two variants; # output goes to ./demo-with-as-source / ./demo-without-as-source. [tasks."generate:as-source"] -description = "Build the fixture client with the committed `as Source` Deps" +description = "Run the historical with-mode fixture helper" run = "bench/generate.sh with" [tasks."generate:no-as-source"] -description = "Build the fixture client with the pre-as-Source Deps" +description = "Run the historical ordinary-import fixture helper" run = "bench/generate.sh without" -# Regenerates only the "python" artifact: a full 7-artifact generate peaks at -# ~31 GB RSS (memory goes to normalizing the generator closure per artifact, -# see ci.yml), a single-artifact one at ~10 GB. The temp copy guarantees a -# fresh resolve of the working-tree gen/ (no stale freeze) and keeps pgn's -# scratch files out of the repo. +# Regenerates only the combined "python" artifact. A full 7-artifact generate +# peaks at ~31 GB RSS, so the temp fixture keeps only the golden artifact. The +# temp copy also forces a fresh working-tree resolve and isolates pgn scratch. [tasks.golden] -description = "Refresh tests/golden from the working-tree gen/ (single-artifact run)" +description = "Refresh the combined tests/golden package from the working-tree src/" run = ''' #!/usr/bin/env bash set -euo pipefail @@ -48,19 +52,144 @@ trap 'rm -rf "$tmp"' EXIT cp -R "$root/tests/fixture-project/." "$tmp/fixture" rm -f "$tmp/fixture/freeze1.pgn.yaml" rm -rf "$tmp/fixture/artifacts" -python3 - "$tmp/fixture/project1.pgn.yaml" "$root/gen/Gen.dhall" <<'EOF' -import sys -from pathlib import Path - -p = Path(sys.argv[1]) -head, sep, _ = p.read_text().partition(" # The variants below") -assert sep, "variants marker not found in project1.pgn.yaml" -_ = p.write_text(head.rstrip().replace("../../gen/Gen.dhall", sys.argv[2]) + "\n") -EOF +sed -e '/^ # The variants below/,$d' \ + -e "s|../../src/package.dhall|$root/src/package.dhall|" \ + "$tmp/fixture/project1.pgn.yaml" > "$tmp/fixture/project1.pgn.yaml.new" +mv "$tmp/fixture/project1.pgn.yaml.new" "$tmp/fixture/project1.pgn.yaml" cd "$tmp/fixture" pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" generate rsync -a --delete artifacts/python/src/specimen_client/_generated/ "$root/tests/golden/src/specimen_client/_generated/" cp artifacts/python/src/specimen_client/__init__.py "$root/tests/golden/src/specimen_client/__init__.py" +mkdir -p "$root/tests/golden/src/specimen_client/sync" cp artifacts/python/src/specimen_client/sync/__init__.py "$root/tests/golden/src/specimen_client/sync/__init__.py" -echo "golden refreshed; review with: git diff tests/golden" +rm -rf "$root/tests/golden_sync" +echo "combined golden refreshed; review with: git diff tests/golden" +''' + +[tasks.signatures] +description = "Check or apply an explicit allowlist of freshly analyzed fixture signatures" +run = ''' +#!/usr/bin/env bash +set -euo pipefail +root="$(pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/tests" +cp -R "$root/src" "$tmp/src" +cp -R "$root/tests/fixture-project" "$tmp/tests/fixture-project" +fixture="$tmp/tests/fixture-project" +rm -f "$fixture/freeze1.pgn.yaml" +rm -rf "$fixture/artifacts" +find "$fixture" -type f -name '*.sig1.pgn.yaml' -delete + +marker=' # The variants below' +before_markers="$(grep -Fc "$marker" "$fixture/project1.pgn.yaml" || true)" +if [[ "$before_markers" != 1 ]]; then + echo "expected exactly one project variant marker, found $before_markers" >&2 + exit 1 +fi +cp "$fixture/project1.pgn.yaml" "$tmp/project1.pgn.yaml.before" + +( + cd "$fixture" + pgn --database-url "${PGN_TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable}" analyse +) + +after_markers="$(grep -Fc "$marker" "$fixture/project1.pgn.yaml" || true)" +if [[ "$after_markers" != 1 ]]; then + echo "project variant marker changed during analysis" >&2 + exit 1 +fi +cmp "$tmp/project1.pgn.yaml.before" "$fixture/project1.pgn.yaml" + +mode="${PGN_SIGNATURE_MODE:-check}" +allowlist="${PGN_SIGNATURE_PATHS:-}" +if [[ -z "$allowlist" ]]; then + echo "PGN_SIGNATURE_PATHS must explicitly list the expected changed signatures" >&2 + exit 1 +fi +if [[ "$mode" != check && "$mode" != apply ]]; then + echo "PGN_SIGNATURE_MODE must be check or apply" >&2 + exit 1 +fi + +mise exec -- python - "$root/tests/fixture-project" "$fixture" "$mode" "$allowlist" <<'PY' +from __future__ import annotations + +import difflib +import shutil +import sys +from pathlib import Path, PurePosixPath + +committed = Path(sys.argv[1]) +fresh = Path(sys.argv[2]) +mode = sys.argv[3] +raw_allowlist = sys.argv[4] + +allowed_items = raw_allowlist.split() +if len(allowed_items) != len(set(allowed_items)): + raise SystemExit("PGN_SIGNATURE_PATHS contains duplicate paths") + +allowed: set[PurePosixPath] = set() +for item in allowed_items: + path = PurePosixPath(item) + if path.is_absolute() or ".." in path.parts or "\\" in item: + raise SystemExit(f"unsafe signature path: {item}") + if not path.parts or path.parts[0] not in {"queries", "types"}: + raise SystemExit(f"signature path must be under queries/ or types/: {item}") + if not item.endswith(".sig1.pgn.yaml") or path.as_posix() != item: + raise SystemExit(f"not a normalized signature path: {item}") + allowed.add(path) + + +def signatures(root: Path) -> dict[PurePosixPath, Path]: + return { + PurePosixPath(path.relative_to(root).as_posix()): path + for path in root.rglob("*.sig1.pgn.yaml") + } + + +old = signatures(committed) +new = signatures(fresh) +deletions = sorted(old.keys() - new.keys(), key=str) +if deletions: + raise SystemExit("fresh analysis deleted signatures: " + ", ".join(map(str, deletions))) + +changed = { + path + for path in old.keys() | new.keys() + if path not in old or path not in new or old[path].read_bytes() != new[path].read_bytes() +} +unexpected = sorted(changed - allowed, key=str) +if unexpected: + raise SystemExit("unexpected signature drift: " + ", ".join(map(str, unexpected))) + +not_changed = sorted(allowed - changed, key=str) +if not_changed: + labels = [] + for path in not_changed: + state = "unchanged" if path in old and path in new else "missing" + labels.append(f"{path} ({state})") + raise SystemExit("allowlisted signatures did not change: " + ", ".join(labels)) + +for path in sorted(changed, key=str): + before = old[path].read_text().splitlines(keepends=True) if path in old else [] + after = new[path].read_text().splitlines(keepends=True) + sys.stdout.writelines( + difflib.unified_diff( + before, + after, + fromfile=str(path) if path in old else "/dev/null", + tofile=str(path), + ) + ) + +if mode == "check": + raise SystemExit("signature differences found; review them, then rerun with PGN_SIGNATURE_MODE=apply") + +for path in sorted(changed, key=str): + destination = committed / Path(path.as_posix()) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(new[path], destination) +PY ''' diff --git a/pyproject.toml b/pyproject.toml index a958ad3..7e38c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,23 @@ dependencies = [] [dependency-groups] dev = [ "pytest>=8", - "psycopg[binary]>=3.2", - "basedpyright==1.39.7", + "psycopg[binary]>=3.3.4,<4", + "basedpyright==1.39.9", + "ruff==0.15.20", ] +[tool.ruff] +target-version = "py312" +line-length = 120 +preview = false + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"] +preview = false + +[tool.ruff.format] +preview = false + [tool.uv] # This project relies on mise for the Python interpreter, matching the root. python-preference = "only-system" diff --git a/src/Deps/Contract.dhall b/src/Deps/Contract.dhall new file mode 100644 index 0000000..6fdb9e8 --- /dev/null +++ b/src/Deps/Contract.dhall @@ -0,0 +1,2 @@ +https://raw.githubusercontent.com/pgenie-io/gen-contract/v5.0.0/src/package.dhall + sha256:a1b48fe025c5536b13907bcd2db307cd438f3ae0f67a59222b3aa39e4bdac9ef diff --git a/src/Deps/Lude.dhall b/src/Deps/Lude.dhall new file mode 100644 index 0000000..a4b5116 --- /dev/null +++ b/src/Deps/Lude.dhall @@ -0,0 +1,2 @@ +https://raw.githubusercontent.com/codemine-io/lude.dhall/v5.2.0/src/package.dhall + sha256:b04ff9a38c8be087dfacfe76d057fc5272afec60cac28c9e6c1b78cade7ff6ef diff --git a/gen/Deps/Prelude.dhall b/src/Deps/Prelude.dhall similarity index 93% rename from gen/Deps/Prelude.dhall rename to src/Deps/Prelude.dhall index 54107fc..76e2a7a 100644 --- a/gen/Deps/Prelude.dhall +++ b/src/Deps/Prelude.dhall @@ -1,3 +1,2 @@ https://raw.githubusercontent.com/dhall-lang/dhall-lang/v23.1.0/Prelude/package.dhall sha256:931cbfae9d746c4611b07633ab1e547637ab4ba138b16bf65ef1b9ad66a60b7f - as Source diff --git a/src/Deps/Sdk.dhall b/src/Deps/Sdk.dhall new file mode 100644 index 0000000..95de509 --- /dev/null +++ b/src/Deps/Sdk.dhall @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..b158fce --- /dev/null +++ b/src/Interpreters/CustomType.dhall @@ -0,0 +1,248 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let CustomKind = ../Structures/CustomKind.dhall + +let PyIdent = ../Structures/PyIdent.dhall + +let MemberGen = ./Member.dhall + +let EnumModule = ../Templates/EnumModule.dhall + +let CompositeModule = ../Templates/CompositeModule.dhall + +let Config = {} + +let Input = Model.CustomType + +let TypeKind = < Enum | Composite > + +let Output = + { modulePath : Text + , moduleContent : Text + , typeName : Text + , moduleName : Text + , pgSchema : Text + , pgName : Text + , kind : TypeKind + , order : Natural + , dependencies : List Natural + } + +let renderExtraImports = + \(imports : ImportSet.Type) -> + let datetimeNames = + (if imports.date then [ "date" ] else [] : List Text) + # (if imports.datetime then [ "datetime" ] else [] : List Text) + # (if imports.time then [ "time" ] else [] : List Text) + # (if imports.timedelta then [ "timedelta" ] else [] : List Text) + + let datetimeLine = + if Prelude.List.null Text datetimeNames + then [] : List Text + else [ "from datetime import ${Prelude.Text.concatSep + ", " + datetimeNames}" ] + + let customLines = + Prelude.List.map + ImportSet.CustomImport + Text + ( \(custom : ImportSet.CustomImport) -> + "from .${custom.moduleName} import ${custom.className}" + ) + (ImportSet.sortedCustoms imports) + + in (if imports.uuid then [ "from uuid import UUID" ] else [] : List Text) + # datetimeLine + # ( if imports.decimal + then [ "from decimal import Decimal" ] + else [] : List Text + ) + # ( if imports.jsonValue + then [ "from .._core import JsonValue" ] + else [] : List Text + ) + # customLines + +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 = + { snakeCase = PyIdent.typeModuleSafeName input.name.inSnakeCase + , pascalCase = PyIdent.pySafeName input.name.inPascalCase + } + + let typeName = pythonName.pascalCase + + let moduleName = pythonName.snakeCase + + let modulePath = "types/${moduleName}.py" + + in merge + { Enum = + \(variants : List Model.EnumVariant) -> + let templateVariants = + Prelude.List.map + Model.EnumVariant + EnumModule.Variant + ( \(variant : Model.EnumVariant) -> + { memberName = variant.name.inScreamingSnakeCase + , pgValue = variant.pgName + } + ) + variants + + in merge + { Enum = + \(identity : CustomKind.Identity) -> + Lude.Compiled.ok + Output + { modulePath + , moduleContent = + EnumModule.run + { typeName + , variants = templateVariants + } + , typeName + , moduleName + , pgSchema = input.pgSchema + , pgName = input.pgName + , kind = TypeKind.Enum + , order = identity.order + , dependencies = [] : List Natural + } + , Composite = + \(_ : CustomKind.Identity) -> + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type lookup kind is inconsistent with enum definition" + , Absent = + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type not found in project customTypes" + } + (CustomKind.at lookup index) + , Composite = + \(members : List Model.Member) -> + let compiledMembers + : Lude.Compiled.Type (List MemberGen.Output) + = Lude.Compiled.traverseList + Model.Member + MemberGen.Output + ( \(m : Model.Member) -> + merge + { Primitive = + \(_ : Model.Primitive) -> + MemberGen.run {=} lookup m + , Custom = + \(ref : Model.CustomTypeRef) -> + if Prelude.Bool.not + (Natural/isZero m.value.dimensionality) + then Lude.Compiled.report + MemberGen.Output + [ m.pgName, ref.name.inSnakeCase ] + "Custom array fields inside a composite type are not supported" + else MemberGen.run {=} lookup m + } + m.value.scalar + ) + members + + let assemble = + \(memberOutputs : List MemberGen.Output) -> + let combinedImports = + Prelude.List.fold + MemberGen.Output + memberOutputs + ImportSet.Type + ( \(m : MemberGen.Output) -> + \(acc : ImportSet.Type) -> + ImportSet.combine m.imports acc + ) + ImportSet.empty + + let fields = + Prelude.List.map + MemberGen.Output + CompositeModule.Field + ( \(m : MemberGen.Output) -> + { fieldName = m.fieldName + , fieldType = m.pyType + } + ) + memberOutputs + + let dependencies = + Prelude.List.map + ImportSet.CustomImport + Natural + ( \(custom : ImportSet.CustomImport) -> + custom.dedupKey + ) + (ImportSet.sortedCustoms combinedImports) + + in merge + { Composite = + \(identity : CustomKind.Identity) -> + Lude.Compiled.ok + Output + { modulePath + , moduleContent = + CompositeModule.run + { typeName + , extraImports = + renderExtraImports + combinedImports + , fields + } + , typeName + , moduleName + , pgSchema = input.pgSchema + , pgName = input.pgName + , kind = TypeKind.Composite + , order = identity.order + , dependencies + } + , Enum = + \(_ : CustomKind.Identity) -> + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type lookup kind is inconsistent with composite definition" + , Absent = + Lude.Compiled.report + Output + [ input.pgName ] + "Custom type not found in project customTypes" + } + (CustomKind.at lookup index) + + in Lude.Compiled.flatMap + (List MemberGen.Output) + Output + assemble + compiledMembers + , Domain = + \(_ : Model.Value) -> + Lude.Compiled.report + Output + [ input.pgName ] + "Domain types are not supported; lower to base type in the pgn migration copy" + } + input.definition + +in { Input, Output, run } diff --git a/src/Interpreters/Member.dhall b/src/Interpreters/Member.dhall new file mode 100644 index 0000000..bffb7bb --- /dev/null +++ b/src/Interpreters/Member.dhall @@ -0,0 +1,137 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let CustomKind = ../Structures/CustomKind.dhall + +let PyIdent = ../Structures/PyIdent.dhall + +let Value = ./Value.dhall + +let Config = {} + +let Input = Model.Member + +let Output = + { fieldName : Text + , pyType : Text + , imports : ImportSet.Type + } + +let runWithPrefix = + \(prefix : Text) -> + \(_ : Config) -> + \(lookup : CustomKind.Lookup) -> + \(input : Input) -> + -- Result-column / composite-field name becomes a dataclass field and decode + -- kwarg, so a keyword-named column must be sanitized; row["..."] keeps the + -- raw pgName. Only the keyword set is load-bearing here (a field named `row` + -- is valid), unlike params which also avoid the template locals. + let fieldName = PyIdent.pySafeName input.name.inSnakeCase + + let buildOutput = + \(value : Value.Output) -> + let nullableSuffix = if input.isNullable then " | None" else "" + + let baseImports = value.imports + + in merge + { Passthrough = + Lude.Compiled.ok + Output + { fieldName + , pyType = value.pyType ++ nullableSuffix + , imports = baseImports + } + , Custom = + Prelude.Optional.fold + Model.CustomTypeRef + value.scalar.customRef + (Lude.Compiled.Type Output) + ( \(ref : Model.CustomTypeRef) -> + let mkOutput = + \(identity : CustomKind.Identity) -> + \(customImports : ImportSet.Type) -> + { fieldName + , pyType = + Value.qualifyCustom + prefix + identity.className + value + ++ nullableSuffix + , imports = + ImportSet.combine baseImports customImports + } + + let dimsAtMostTwo = + Natural/isZero (Natural/subtract 2 value.dims) + + let dimsAtMostOne = + Natural/isZero (Natural/subtract 1 value.dims) + + in merge + { Enum = + \(identity : CustomKind.Identity) -> + let enumImport = + ImportSet.customEnum + identity + + in if dimsAtMostTwo + then Lude.Compiled.ok + Output + (mkOutput identity enumImport) + else Lude.Compiled.report + Output + [ input.pgName + , ref.name.inSnakeCase + ] + "Array of an enum with dimensionality > 2 is not supported" + , Composite = + \(identity : CustomKind.Identity) -> + if dimsAtMostOne + then Lude.Compiled.ok + Output + ( mkOutput + identity + ( ImportSet.customComposite + identity + ) + ) + else Lude.Compiled.report + Output + [ input.pgName + , ref.name.inSnakeCase + ] + "Array of a composite type with dimensionality > 1 is not supported" + , Absent = + Lude.Compiled.report + Output + [ ref.name.inSnakeCase ] + "Custom type not found in project customTypes" + } + (CustomKind.at lookup ref.index) + ) + ( Lude.Compiled.report + Output + [ input.pgName ] + "Custom scalar without a customRef name" + ) + } + value.scalar.decode + + let compiledValue + : Lude.Compiled.Type Value.Output + = Lude.Compiled.nest + Value.Output + input.pgName + (Value.run {=} input.value) + + in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue + +let run = runWithPrefix "" + +in { Input, Output, run, runWithPrefix } diff --git a/gen/Interpreters/ParamsMember.dhall b/src/Interpreters/ParamsMember.dhall similarity index 59% rename from gen/Interpreters/ParamsMember.dhall rename to src/Interpreters/ParamsMember.dhall index 47bef3f..d2fb6d3 100644 --- a/gen/Interpreters/ParamsMember.dhall +++ b/src/Interpreters/ParamsMember.dhall @@ -1,42 +1,27 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall - -let CustomKind = ../Structures/CustomKind.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Model = ../Deps/Contract.dhall -let Lude = Deps.Lude +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let CustomKind = ../Structures/CustomKind.dhall let Value = ./Value.dhall +let Config = {} + let Input = Model.Member let PyIdent = ../Structures/PyIdent.dhall --- Identifiers the statement template hardcodes around the splatted params: the --- receiver `conn` and the locals `sql`/`params`/`decode`/`cur`/`row`. A param --- named like one of these would collide (e.g. a duplicate `conn` argument), so --- they are sanitized alongside the Python keywords. -let reservedNames = - [ "conn", "sql", "params", "decode", "cur", "row" ] - --- A SQL placeholder may be spelled as a Python reserved word (e.g. $class, $from) --- or clash with a template-reserved name (e.g. $conn). Suffix an underscore so the --- emitted signature and bind variable are valid Python; the params-dict key and --- the %(name)s placeholder keep the raw name (see Query.dhall), so psycopg still --- binds by the original name. -let pySafeName = PyIdent.sanitizeAgainst (PyIdent.pythonKeywords # reservedNames) - let Output = { fieldName : Text , pgName : Text , pyType : Text , imports : ImportSet.Type , bindExpr : Text - , needsJsonbImport : Bool } -- True when the primitive is exactly json or jsonb (every other variant is @@ -181,38 +166,36 @@ 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) -> - Deps.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 -- the two must not be collapsed. let isJsonbScalar = \(value : Model.Value) -> - Deps.Prelude.Bool.and - [ scalarIsJsonb value, Deps.Prelude.Bool.not (valueIsArray value) ] + Prelude.Bool.and + [ scalarIsJsonb value, Prelude.Bool.not (valueIsArray value) ] let isJsonScalar = \(value : Model.Value) -> - Deps.Prelude.Bool.and + Prelude.Bool.and [ scalarIsJson value - , Deps.Prelude.Bool.not (scalarIsJsonb value) - , Deps.Prelude.Bool.not (valueIsArray value) + , Prelude.Bool.not (scalarIsJsonb value) + , Prelude.Bool.not (valueIsArray value) ] -- A json/jsonb ARRAY param has no faithful psycopg bind (Jsonb wraps a scalar, @@ -221,13 +204,13 @@ let isJsonScalar = -- in SQL is the supported route (the param then types as text[], not json[]). let isJsonArray = \(value : Model.Value) -> - Deps.Prelude.Bool.and [ scalarIsJson value, valueIsArray value ] + Prelude.Bool.and [ scalarIsJson value, valueIsArray value ] let run = - \(config : Algebra.Config) -> + \(_ : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> - let fieldName = pySafeName input.name.inSnakeCase + let fieldName = PyIdent.parameterSafeName input.name.inSnakeCase let needsJsonbImport = isJsonbScalar input.value @@ -246,44 +229,10 @@ let run = then wrapJson "Jsonb" else if needsJsonImport then wrapJson "Json" else fieldName - -- psycopg binds a bare tuple to an anonymous composite, but cannot adapt - -- a dataclass, so a composite param is converted to its field tuple. - let compositeBind = - \(fields : List CustomKind.CompositeField) -> - let joinedFields = - Deps.Prelude.Text.concatMapSep - ", " - CustomKind.CompositeField - ( \(f : CustomKind.CompositeField) -> - "${fieldName}.${f.fieldName}" - ) - fields - - -- concatMapSep never emits a separator for a single-element list, so - -- a one-field composite would render "(x.f)": parens around a bare - -- expression, not a tuple. Python only treats trailing-comma parens - -- as a 1-tuple, so force it for exactly one field; concatMapSep - -- already inserts the internal comma for two or more. - let trailingComma = - if Deps.Prelude.Natural.equal - ( Deps.Prelude.List.length - CustomKind.CompositeField - fields - ) - 1 - then "," - else "" - - let tupleExpr = "(" ++ joinedFields ++ trailingComma ++ ")" - - in if input.isNullable - then "None if ${fieldName} is None else ${tupleExpr}" - else tupleExpr - let buildOutput = \(value : Value.Output) -> - let pyType = - value.pyType ++ (if input.isNullable then " | None" else "") + let nullableSuffix = + if input.isNullable then " | None" else "" let jsonImport = if needsJsonbImport @@ -292,71 +241,86 @@ let run = let mkOutput = \(typeImports : ImportSet.Type) -> + \(pyType : Text) -> \(bindExpr : Text) -> { fieldName , pgName = input.pgName , pyType , imports = ImportSet.combine typeImports jsonImport , bindExpr - , needsJsonbImport } - in Deps.Prelude.Optional.fold - Model.Name + in Prelude.Optional.fold + Model.CustomTypeRef value.scalar.customRef (Lude.Compiled.Type Output) - ( \(name : Model.Name) -> - let customImport = - \(order : Natural) -> - { className = name.inPascalCase - , moduleName = name.inSnakeCase - , order - } + ( \(ref : Model.CustomTypeRef) -> + let dimsAtMostTwo = + Natural/isZero (Natural/subtract 2 value.dims) + + let dimsAtMostOne = + Natural/isZero (Natural/subtract 1 value.dims) in merge { Enum = - \(order : Natural) -> - Lude.Compiled.ok - Output - ( mkOutput - ( ImportSet.combine - value.imports - ( ImportSet.customEnum - (customImport order) - ) - ) - defaultBind - ) + \(identity : CustomKind.Identity) -> + let enumImport = + ImportSet.customEnum + identity + + in if dimsAtMostTwo + then Lude.Compiled.ok + Output + ( mkOutput + ( ImportSet.combine + value.imports + enumImport + ) + ( Value.qualifyCustom + "_db_types." + identity.className + value + ++ nullableSuffix + ) + fieldName + ) + else Lude.Compiled.report + Output + [ input.pgName + , ref.name.inSnakeCase + ] + "Array of an enum parameter with dimensionality > 2 is not supported" , Composite = - \ ( composite - : { fields : - List CustomKind.CompositeField - , order : Natural - } - ) -> - if Natural/isZero value.dims + \(identity : CustomKind.Identity) -> + if dimsAtMostOne then Lude.Compiled.ok Output ( mkOutput ( ImportSet.combine value.imports ( ImportSet.customComposite - (customImport composite.order) + identity ) ) - (compositeBind composite.fields) + ( Value.qualifyCustom + "_db_types." + identity.className + value + ++ nullableSuffix + ) + fieldName ) else Lude.Compiled.report Output - [ input.pgName, name.inSnakeCase ] - "Array of a composite type as a parameter is not supported" + [ 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 @@ -365,7 +329,11 @@ let run = "json/jsonb array as a parameter is not supported" else Lude.Compiled.ok Output - (mkOutput value.imports defaultBind) + ( mkOutput + value.imports + (value.pyType ++ nullableSuffix) + defaultBind + ) ) let compiledValue @@ -373,10 +341,8 @@ let run = = Lude.Compiled.nest Value.Output input.pgName - (Value.run config input.value) + (Value.run {=} input.value) in Lude.Compiled.flatMap Value.Output Output buildOutput compiledValue -let Run = Algebra.Config -> CustomKind.Lookup -> Input -> Lude.Compiled.Type Output - -in { Input, Output, Run, run } +in { Input, Output, run } diff --git a/gen/Interpreters/Primitive.dhall b/src/Interpreters/Primitive.dhall similarity index 90% rename from gen/Interpreters/Primitive.dhall rename to src/Interpreters/Primitive.dhall index 0e1cb70..65bc55b 100644 --- a/gen/Interpreters/Primitive.dhall +++ b/src/Interpreters/Primitive.dhall @@ -1,10 +1,12 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall +let Model = ../Deps/Contract.dhall + +let Sdk = ../Deps/Sdk.dhall -let Algebra = ../Algebras/Interpreter.dhall +let ImportSet = ../Structures/ImportSet.dhall -let Model = Deps.Sdk.Project +let Config = {} let Input = Model.Primitive @@ -13,16 +15,16 @@ let Output = { pyType : Text, imports : ImportSet.Type } let supported = \(pyType : Text) -> \(imports : ImportSet.Type) -> - Deps.Lude.Compiled.ok Output { pyType, imports } + Lude.Compiled.ok Output { pyType, imports } let unsupported = \(pgType : Text) -> - Deps.Lude.Compiled.report Output [ pgType ] "Unsupported type" + Lude.Compiled.report Output [ pgType ] "Unsupported type" let plain = \(pyType : Text) -> supported pyType ImportSet.empty let run = - \(config : Algebra.Config) -> + \(_ : Config) -> \(input : Input) -> merge { Bit = unsupported "bit" @@ -89,4 +91,4 @@ let run = } input -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/src/Interpreters/Project.dhall b/src/Interpreters/Project.dhall new file mode 100644 index 0000000..68b62f4 --- /dev/null +++ b/src/Interpreters/Project.dhall @@ -0,0 +1,758 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall + +let Sdk = ../Deps/Sdk.dhall + +let CustomKind = ../Structures/CustomKind.dhall + +let PyIdent = ../Structures/PyIdent.dhall + +let QueryGen = ./Query.dhall + +let CustomTypeGen = ./CustomType.dhall + +let PrimitiveGen = ./Primitive.dhall + +let CoreModule = ../Templates/CoreModule.dhall + +let RuntimeModule = ../Templates/RuntimeModule.dhall + +let InitModule = ../Templates/InitModule.dhall + +let TypesInit = ../Templates/TypesInit.dhall + +let RegisterModule = ../Templates/RegisterModule.dhall + +let FacadeModule = ../Templates/FacadeModule.dhall + +let OnUnsupported = ../Structures/OnUnsupported.dhall + +let Report = { path : List Text, message : Text } + +-- The generator's public Config: every field is independently Optional, so a +-- project may omit the whole `config:` block or any subset of its keys. +-- `run` below resolves the fallbacks itself (packageName from the project +-- name, sync emission off, onUnsupported Fail); there is no separate config type +-- or resolve step between package.dhall and here. +let Config = + { packageName : Optional Text + , emitSync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + } + +-- 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 + } + +let Input = Model.Project + +let Output = Lude.Files.Type + +-- 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. +let generatedHeader = + '' + # @generated by python.gen (pGenie); regeneration overwrites manual changes. + # SPDX-FileCopyrightText: 2026 Viacheslav Shvets + # SPDX-License-Identifier: MIT-0 + + '' + +let withHeader = + \(file : Lude.File.Type) -> + { path = file.path, content = generatedHeader ++ file.content } + +let IndexedCustomType = { index : Natural, value : Model.CustomType } + +let LookupKind = < Composite | Enum | Domain > + +let LookupEntry = + { kind : LookupKind, identity : CustomKind.Identity } + +let ResolvedCustomType = + { value : Model.CustomType, lookupEntry : LookupEntry } + +let resolveCustomTypes = + \(customTypes : List Model.CustomType) -> + Prelude.List.map + IndexedCustomType + ResolvedCustomType + ( \(entry : IndexedCustomType) -> + let customType = entry.value + + let pythonName = + { snakeCase = + PyIdent.typeModuleSafeName customType.name.inSnakeCase + , pascalCase = PyIdent.pySafeName customType.name.inPascalCase + } + + let identity = + { className = pythonName.pascalCase + , moduleName = pythonName.snakeCase + , order = entry.index + } + + let kind = + merge + { Composite = \(_ : List Model.Member) -> LookupKind.Composite + , Enum = \(_ : List Model.EnumVariant) -> LookupKind.Enum + , Domain = \(_ : Model.Value) -> LookupKind.Domain + } + customType.definition + + in { value = customType + , lookupEntry = { kind, identity } + } + ) + (Prelude.List.indexed Model.CustomType customTypes) + +-- 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 + 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 + +-- 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 + } + 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) -> + \(right : Natural) -> + Natural/isZero (Natural/subtract left right) + && Natural/isZero (Natural/subtract right left) + +let containsOrder = + \(order : Natural) -> + \(customTypes : List CustomTypeGen.Output) -> + Prelude.List.any + CustomTypeGen.Output + (\(custom : CustomTypeGen.Output) -> sameOrder order custom.order) + customTypes + +let dependenciesReady = + \(emitted : List CustomTypeGen.Output) -> + \(custom : CustomTypeGen.Output) -> + Prelude.Bool.not + ( Prelude.List.any + Natural + (\(dependency : Natural) -> Prelude.Bool.not (containsOrder dependency emitted)) + custom.dependencies + ) + +let RegistrationState = + { emitted : List CustomTypeGen.Output + , remaining : List CustomTypeGen.Output + } + +let registrationOrder = + \(customTypes : List CustomTypeGen.Output) -> + let enums = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + merge { Enum = True, Composite = False } custom.kind + ) + customTypes + + let composites = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + merge { Enum = False, Composite = True } custom.kind + ) + customTypes + + let pass = + \(state : RegistrationState) -> + let ready = + Prelude.List.filter + CustomTypeGen.Output + (dependenciesReady state.emitted) + state.remaining + + let blocked = + Prelude.List.filter + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + Prelude.Bool.not + (dependenciesReady state.emitted custom) + ) + state.remaining + + in { emitted = state.emitted # ready, remaining = blocked } + + let ordered = + Natural/fold + (Prelude.List.length CustomTypeGen.Output customTypes) + RegistrationState + pass + { emitted = enums, remaining = composites } + + let unresolved = + Prelude.Text.concatMapSep + ", " + CustomTypeGen.Output + ( \(custom : CustomTypeGen.Output) -> + "${custom.pgSchema}.${custom.pgName}" + ) + ordered.remaining + + in if Prelude.List.null CustomTypeGen.Output ordered.remaining + then Lude.Compiled.ok (List CustomTypeGen.Output) ordered.emitted + else Lude.Compiled.report + (List CustomTypeGen.Output) + [ "register_types" ] + "Unresolved or cyclic custom type dependencies: ${unresolved}" + +let combineOutputs = + \(config : ResolvedConfig) -> + \(input : Input) -> + -- Already the post-Skip-filter surviving set (see `run`); equal to + -- input.queries' compiled outputs verbatim when nothing was skipped + -- (including every Fail-mode run, since Fail never drops anything). + -- Facade/statement/row entries are built from this, not input.queries, + -- so a skipped query leaves no dangling export. + \(queries : List QueryGen.Output) -> + -- Same as above, but for customTypes. Facade/typesInit/register + -- entries are built from this, not input.customTypes, so a skipped + -- type leaves no dangling export. + \(customTypes : List CustomTypeGen.Output) -> + -- Same compiled custom types in dependency-first order, used only by the + -- shared register module. File and facade order remains project order. + \(registrationTypes : List CustomTypeGen.Output) -> + -- The generator emits the _generated subtree plus the package-root + -- __init__.py facade. The rest of the shell (pyproject.toml, py.typed) is + -- hand-written and committed once, never overwritten by generation. + let packagePrefix = "src/${config.importName}/" + + let srcPrefix = packagePrefix ++ "_generated/" + + let topInit = + { path = srcPrefix ++ "__init__.py" + , content = + InitModule.run + { docstring = + "Generated database client for ${config.packageName}." + } + } + + let facadeStatements = + Prelude.List.map + QueryGen.Output + FacadeModule.StatementExport + ( \(query : QueryGen.Output) -> + { functionName = query.functionName + , rowClassName = query.rowClassName + } + ) + queries + + let facadeTypes = + Prelude.List.map + CustomTypeGen.Output + FacadeModule.TypeExport + ( \(ct : CustomTypeGen.Output) -> + { moduleName = ct.moduleName, className = ct.typeName } + ) + customTypes + + -- Surface-agnostic; performs no I/O, so exactly one copy is emitted + -- regardless of surface. Both runtime bodies re-export from it. + let coreModule = + { path = srcPrefix ++ "_core.py", content = CoreModule.run {=} } + + let runtimeModule = + { path = srcPrefix ++ "_runtime.py" + , content = RuntimeModule.run {=} + } + + let syncRuntimeFiles = + if config.emitSync + then [ { path = srcPrefix ++ "sync/_runtime.py" + , content = RuntimeModule.runSync {=} + } + ] + else [] : List Lude.File.Type + + let statementsInit = + { path = srcPrefix ++ "statements/__init__.py" + , content = + InitModule.run { docstring = "Generated SQL statements." } + } + + let statementFiles = + Prelude.List.map + QueryGen.Output + Lude.File.Type + ( \(query : QueryGen.Output) -> + { path = srcPrefix ++ query.modulePath + , content = query.content + } + ) + queries + + let typeFiles = + Prelude.List.map + CustomTypeGen.Output + Lude.File.Type + ( \(ct : CustomTypeGen.Output) -> + { path = srcPrefix ++ ct.modulePath + , content = ct.moduleContent + } + ) + customTypes + + let typesInitExports = + Prelude.List.map + CustomTypeGen.Output + TypesInit.Export + (\(ct : CustomTypeGen.Output) -> { moduleName = ct.moduleName, typeName = ct.typeName }) + customTypes + + let typesInitFiles = + if Prelude.List.null CustomTypeGen.Output customTypes + then [] : List Lude.File.Type + else [ { path = srcPrefix ++ "types/__init__.py" + , content = TypesInit.run { exports = typesInitExports } + } + ] + + let hasCustomRegistration = + Prelude.Bool.not + (Prelude.List.null CustomTypeGen.Output registrationTypes) + + let registrationEntries = + Prelude.List.map + CustomTypeGen.Output + RegisterModule.CustomType + ( \(custom : CustomTypeGen.Output) -> + { typeName = custom.typeName + , moduleName = custom.moduleName + , pgSchema = custom.pgSchema + , pgName = custom.pgName + , kind = custom.kind + } + ) + registrationTypes + + let registerFiles = + if hasCustomRegistration + then [ { path = srcPrefix ++ "_register.py" + , content = + RegisterModule.run + { customTypes = registrationEntries + , emitSync = config.emitSync + } + } + ] + else [] : List Lude.File.Type + + let registrationSource = + if hasCustomRegistration + then Some "register_types" + else None Text + + let facade = + { path = packagePrefix ++ "__init__.py" + , content = + FacadeModule.run + { statements = facadeStatements + , types = facadeTypes + , generatedPrefix = "._generated" + , functionSuffix = "" + , registrationSource + , includeSyncModule = config.emitSync + } + } + + let syncFacadeFiles = + if config.emitSync + then [ { path = packagePrefix ++ "sync/__init__.py" + , content = + FacadeModule.run + { statements = facadeStatements + , types = facadeTypes + , generatedPrefix = ".._generated" + , functionSuffix = "_sync" + , registrationSource = + if hasCustomRegistration + then Some "register_types_sync" + else None Text + , includeSyncModule = False + } + } + ] + else [] : List Lude.File.Type + + let staticFiles = + [ facade, topInit, coreModule, runtimeModule, statementsInit ] + # syncFacadeFiles + # syncRuntimeFiles + + let allFiles = + staticFiles + # registerFiles + # typesInitFiles + # typeFiles + # statementFiles + + in Prelude.List.map Lude.File.Type Lude.File.Type withHeader allFiles + : Output + +-- Per-element keep/drop decision plus its warning, computed once from a +-- single QueryGen.run call and reused for both the Skip filter and the +-- warning list. Custom types use a pair of plain functions instead of an +-- equivalent record (see `typeSucceeds`/`typeWarning` below) purely because +-- that was the faster shape empirically for the type side, and the query +-- side re-uses `queryChecks` because calling QueryGen.run queryConfig lookup query from +-- more than one place in this function (once to decide keep/drop, again to +-- render, again for a warning -- each a fresh, separate call site in the +-- source) measurably multiplies Dhall's normalization cost per extra call +-- site, confirmed by bisection against `pgn generate` wall time (a few +-- seconds regressed to minutes with three call sites; this file keeps it to +-- two: one to build `queryChecks`, one for the final render). +let QueryCheck = { query : Model.Query, keep : Bool, warning : Optional Report } + +let CombinedInputs = + { queries : List QueryGen.Output + , customTypes : List CustomTypeGen.Output + , registrationTypes : List CustomTypeGen.Output + } + +let run = + \(config : Config) -> + \(input : Input) -> + -- config's fields are independently Optional (a project may omit the + -- whole config: block or any subset of its keys); the fallbacks are + -- resolved here rather than in a separate config type or resolve + -- step, since this is the root interpreter and Config above is + -- exactly the generator's public Config. + let packageName = + Prelude.Optional.fold + Text + config.packageName + Text + (\(t : Text) -> t) + input.name.inKebabCase + + let emitSync = + Prelude.Optional.fold + Bool + config.emitSync + Bool + (\(b : Bool) -> b) + False + + let onUnsupported = + Prelude.Optional.fold + OnUnsupported.Mode + config.onUnsupported + OnUnsupported.Mode + (\(m : OnUnsupported.Mode) -> m) + OnUnsupported.Mode.Fail + + let importName = Prelude.Text.replace "-" "_" packageName + + let resolvedConfig + : ResolvedConfig + = { packageName, importName, emitSync, onUnsupported } + + let queryConfig = resolvedConfig.{ emitSync } + + let customTypeConfig = {=} + + let skip = merge { Fail = False, Skip = True } resolvedConfig.onUnsupported + + let resolvedCustomTypes = resolveCustomTypes input.customTypes + + -- 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 indexedResolvedCustomTypes + : List { index : Natural, value : ResolvedCustomType } + = Prelude.List.indexed ResolvedCustomType resolvedCustomTypes + + let effectiveResolvedCustomTypes + : List { index : Natural, value : ResolvedCustomType } + = if skip + then Prelude.List.filter + { index : Natural, value : ResolvedCustomType } + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + boolAt supported e.index + ) + indexedResolvedCustomTypes + else indexedResolvedCustomTypes + + 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 + + -- 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 + { index : Natural, value : Model.CustomType } + CustomTypeGen.Output + ( \(e : { index : Natural, value : Model.CustomType }) -> + CustomTypeGen.run customTypeConfig lookup e.index e.value + ) + effectiveCustomTypes + + let queryChecks + : List QueryCheck + = Prelude.List.map + Model.Query + QueryCheck + ( \(query : Model.Query) -> + merge + { Ok = + \(_ : { value : QueryGen.Output, warnings : List Report }) -> + { query, keep = True, warning = None Report } + , Err = \(err : Report) -> { query, keep = False, warning = Some err } + } + (QueryGen.run queryConfig lookup query) + ) + input.queries + + let effectiveQueries + : List Model.Query + = if skip + then Prelude.List.map + QueryCheck + Model.Query + (\(qc : QueryCheck) -> qc.query) + (Prelude.List.filter QueryCheck (\(qc : QueryCheck) -> qc.keep) queryChecks) + else input.queries + + -- Fail mode: identical to the pre-Skip code (traverseList straight + -- over input.queries), so its error message/path is unchanged. + let queriesForCombine + : Lude.Compiled.Type (List QueryGen.Output) + = Lude.Compiled.traverseList + Model.Query + QueryGen.Output + ( \(query : Model.Query) -> + QueryGen.run queryConfig lookup query + ) + effectiveQueries + + let registrationTypesForCombine + : Lude.Compiled.Type (List CustomTypeGen.Output) + = Lude.Compiled.flatMap + (List CustomTypeGen.Output) + (List CustomTypeGen.Output) + 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 + { index : Natural, value : ResolvedCustomType } + (Optional Report) + ( \(e : { index : Natural, value : ResolvedCustomType }) -> + typeWarning e.value.value e.index + ) + indexedResolvedCustomTypes + ) + # Prelude.List.unpackOptionals + Report + (Prelude.List.map QueryCheck (Optional Report) (\(qc : QueryCheck) -> qc.warning) queryChecks) + else [] : List Report + + let compiledInputs + : Lude.Compiled.Type CombinedInputs + = Lude.Compiled.map3 + (List QueryGen.Output) + (List CustomTypeGen.Output) + (List CustomTypeGen.Output) + CombinedInputs + ( \(queries : List QueryGen.Output) -> + \(customTypes : List CustomTypeGen.Output) -> + \(registrationTypes : List CustomTypeGen.Output) -> + { queries, customTypes, registrationTypes } + ) + queriesForCombine + typesForCombine + registrationTypesForCombine + + let combined + : Lude.Compiled.Type Output + = Lude.Compiled.map + CombinedInputs + Output + ( \(compiled : CombinedInputs) -> + combineOutputs + resolvedConfig + input + compiled.queries + compiled.customTypes + compiled.registrationTypes + ) + compiledInputs + + in Lude.Compiled.appendWarnings Output skipWarnings combined + +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Query.dhall b/src/Interpreters/Query.dhall similarity index 55% rename from gen/Interpreters/Query.dhall rename to src/Interpreters/Query.dhall index b0e688d..30d80b6 100644 --- a/gen/Interpreters/Query.dhall +++ b/src/Interpreters/Query.dhall @@ -1,6 +1,8 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall @@ -10,8 +12,6 @@ let PyIdent = ../Structures/PyIdent.dhall let Surface = ../Structures/Surface.dhall -let RowsModule = ../Templates/RowsModule.dhall - let ResultModule = ./Result.dhall let QueryFragmentsModule = ./QueryFragments.dhall @@ -20,45 +20,35 @@ let ParamsMember = ./ParamsMember.dhall let StatementModule = ../Templates/StatementModule.dhall -let Prelude = Deps.Prelude - -let Lude = Deps.Lude +let Config = { emitSync : Bool } let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project - let Input = Model.Query --- A query contributes a shared Row (assembled into `_rows.py` by Project) plus a --- thin statement module per surface. asyncModule is always emitted; syncModule is --- emitted only when config.emitSync. rowImports are the result-column imports, --- folded into `_rows.py`. +-- A query renders to one canonical statement module: its Row dataclass when it +-- returns rows, the async function, and an optional adjacent sync function. +-- rowClassName is also surfaced because Project.dhall needs the name for facade +-- re-exports; the Row's definition remains in this module. let Output = - { functionName : Text + { sourceName : Text + , sourcePath : Text + , functionName : Text , rowClassName : Optional Text - , rowDef : Optional RowsModule.RowDef - , rowImports : ImportSet.Type - , asyncModulePath : Text - , asyncContent : Text - , syncModulePath : Text - , syncContent : Text + , modulePath : Text + , content : Text } let render = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> + \(functionName : Text) -> \(result : ResultModule.Output) -> \(fragments : QueryFragmentsModule.Output) -> \(params : List ParamsMember.Output) -> - -- The function name is also the module filename and the facade import name; - -- a query named like a Python keyword would emit `def class(...)`, a module - -- `class.py`, and `from ... import class` (all SyntaxErrors), so sanitize it - -- like params and result columns. SQL/dict/row lookups key off raw names. - let functionName = PyIdent.pySafeName input.name.inSnakeCase - - let decodeName = "decode_${functionName}" - + -- The function name is also the module filename and facade import name, so + -- protect both Python syntax and the private globals in a statement module. + -- SQL/dict/row lookups still key off raw names. let paramSigLines = Prelude.List.map ParamsMember.Output @@ -94,47 +84,51 @@ let render = let rowDef = Prelude.Optional.map ResultModule.RowClass - RowsModule.RowDef + StatementModule.RowDef ( \(rc : ResultModule.RowClass) -> { className = rc.name , fieldsBlock = rc.fieldsBlock - , decodeBlock = rc.decodeBlock - , decodeName } ) result.rowClass - let mkModule = - \(surface : Surface.Type) -> - StatementModule.run - { functionName - , returnType = result.returnType - , helperName = result.helperName - , callsDecode = result.callsDecode - , sqlLiteral = fragments.sqlLiteral - , rowClassName - , decodeName - , paramSigLines - , paramDictEntries - , imports = paramImports - , surface - } - - in { functionName + -- Row and parameter imports share the canonical statement file, so they + -- must merge into one ImportSet before rendering. + let mergedImports = ImportSet.combine paramImports result.imports + + let content = + StatementModule.run + { functionName + , returnType = result.returnType + , helperName = result.helperName + , sqlLiteral = fragments.sqlLiteral + , rowDef + , paramSigLines + , paramDictEntries + , imports = mergedImports + , emitSync = config.emitSync + , asyncSurface = Surface.async + , syncSurface = Surface.sync + } + + in { sourceName = input.name.inSnakeCase + , sourcePath = input.srcPath + , functionName , rowClassName - , rowDef - , rowImports = result.imports - , asyncModulePath = "statements/${functionName}.py" - , asyncContent = mkModule Surface.async - , syncModulePath = "sync/statements/${functionName}.py" - , syncContent = mkModule Surface.sync + , modulePath = "statements/${functionName}.py" + , content } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> \(input : Input) -> - let rowClassName = input.name.inPascalCase ++ "Row" + let pythonName = + { snakeCase = PyIdent.querySafeName input.name.inSnakeCase + , pascalCase = input.name.inPascalCase + } + + let rowClassName = pythonName.pascalCase ++ "Row" in Compiled.nest Output @@ -144,16 +138,20 @@ let run = QueryFragmentsModule.Output (List ParamsMember.Output) Output - (render config input) + (render config input pythonName.snakeCase) ( Compiled.nest ResultModule.Output "result" - (ResultModule.run config lookup rowClassName input.result) + ( ResultModule.run + { rowClassName } + lookup + input.result + ) ) ( Compiled.nest QueryFragmentsModule.Output "sql" - (QueryFragmentsModule.run config input.fragments) + (QueryFragmentsModule.run {=} input.fragments) ) ( Compiled.nest (List ParamsMember.Output) @@ -165,7 +163,7 @@ let run = Compiled.nest ParamsMember.Output member.pgName - (ParamsMember.run config lookup member) + (ParamsMember.run {=} lookup member) ) input.params ) diff --git a/gen/Interpreters/QueryFragments.dhall b/src/Interpreters/QueryFragments.dhall similarity index 83% rename from gen/Interpreters/QueryFragments.dhall rename to src/Interpreters/QueryFragments.dhall index 066bc75..bc8a3e1 100644 --- a/gen/Interpreters/QueryFragments.dhall +++ b/src/Interpreters/QueryFragments.dhall @@ -1,16 +1,14 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall -let Prelude = Deps.Prelude - -let Sdk = Deps.Sdk - -let Lude = Deps.Lude +let Sdk = ../Deps/Sdk.dhall let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +let Model = ../Deps/Contract.dhall + +let Config = {} let Input = Model.QueryFragments @@ -47,8 +45,8 @@ let renderSql Prelude.Text.concatMap Model.QueryFragment renderFragment fragments let run = - \(config : Algebra.Config) -> + \(_ : Config) -> \(input : Input) -> Compiled.ok Output { sqlLiteral = renderSql input } -in Algebra.module Input Output run +in Sdk.Sigs.interpreter Config Input Output run diff --git a/gen/Interpreters/Result.dhall b/src/Interpreters/Result.dhall similarity index 73% rename from gen/Interpreters/Result.dhall rename to src/Interpreters/Result.dhall index a6638e3..37ae10e 100644 --- a/gen/Interpreters/Result.dhall +++ b/src/Interpreters/Result.dhall @@ -1,6 +1,8 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall + +let Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall @@ -8,24 +10,24 @@ let CustomKind = ../Structures/CustomKind.dhall let ResultColumns = ./ResultColumns.dhall -let Prelude = Deps.Prelude - -let Lude = Deps.Lude - let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +-- rowClassName is supplied by the caller (Query.dhall derives it from the +-- query's own name) rather than living on Model.Result, so it rides on this +-- interpreter's own local Config instead of widening Input away from +-- Model.Result. ResultColumns and lower interpreters use empty configs. +let Config = + { rowClassName : Text } let Input = Model.Result -let RowClass = { name : Text, fieldsBlock : Text, decodeBlock : Text } +let RowClass = { name : Text, fieldsBlock : Text } let Output = { returnType : Text , helperName : Text , rowClass : Optional RowClass , imports : ImportSet.Type - , callsDecode : Bool } let noResult @@ -36,7 +38,6 @@ let noResult , helperName , rowClass = None RowClass , imports = ImportSet.empty - , callsDecode = False } let cardinalityShape @@ -57,11 +58,10 @@ let cardinalityShape cardinality let rowsOutput = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(rows : Model.ResultRows) -> - let shape = cardinalityShape rows.cardinality rowClassName + let shape = cardinalityShape rows.cardinality config.rowClassName let columns = Prelude.NonEmpty.toList Model.Member rows.columns @@ -73,26 +73,23 @@ let rowsOutput = { returnType = shape.returnType , helperName = shape.helperName , rowClass = Some - { name = rowClassName + { name = config.rowClassName , fieldsBlock = cols.fieldsBlock - , decodeBlock = cols.decodeBlock } , imports = cols.imports - , callsDecode = True } ) - (ResultColumns.run config lookup rowClassName columns) + (ResultColumns.run {=} lookup columns) let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(input : Input) -> merge { Void = Compiled.ok Output (noResult "None" "execute_void") , RowsAffected = Compiled.ok Output (noResult "int" "execute_rows_affected") - , Rows = rowsOutput config lookup rowClassName + , Rows = rowsOutput config lookup } input diff --git a/gen/Interpreters/ResultColumns.dhall b/src/Interpreters/ResultColumns.dhall similarity index 63% rename from gen/Interpreters/ResultColumns.dhall rename to src/Interpreters/ResultColumns.dhall index ef550ec..cb0f835 100644 --- a/gen/Interpreters/ResultColumns.dhall +++ b/src/Interpreters/ResultColumns.dhall @@ -1,6 +1,8 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Interpreter.dhall +let Lude = ../Deps/Lude.dhall + +let Model = ../Deps/Contract.dhall let ImportSet = ../Structures/ImportSet.dhall @@ -8,40 +10,24 @@ let CustomKind = ../Structures/CustomKind.dhall let Member = ./Member.dhall -let Prelude = Deps.Prelude - -let Lude = Deps.Lude - let Compiled = Lude.Compiled -let Model = Deps.Sdk.Project +let Config = {} let Input = List Model.Member -let Output = { fieldsBlock : Text, decodeBlock : Text, imports : ImportSet.Type } +let Output = { fieldsBlock : Text, imports : ImportSet.Type } let renderField : Member.Output -> Text = \(col : Member.Output) -> col.fieldName ++ ": " ++ col.pyType -let renderDecodeKwarg - : Member.Output -> Text - = \(col : Member.Output) -> - let src = "row[\"" ++ col.pgName ++ "\"]" - - in col.fieldName ++ "=" ++ col.decodeExpr src ++ "," - let assemble : List Member.Output -> Output = \(columns : List Member.Output) -> + -- args_row is positional, so Row field order must match pgn columns. { fieldsBlock = Prelude.Text.concatMapSep "\n" Member.Output renderField columns - , decodeBlock = - Prelude.Text.concatMapSep - "\n" - Member.Output - renderDecodeKwarg - columns , imports = ImportSet.combineAll ( Prelude.List.map @@ -53,9 +39,8 @@ let assemble } let run = - \(config : Algebra.Config) -> + \(_ : Config) -> \(lookup : CustomKind.Lookup) -> - \(rowClassName : Text) -> \(input : Input) -> Compiled.map (List Member.Output) @@ -68,7 +53,7 @@ let run = Compiled.nest Member.Output member.pgName - (Member.run config lookup member) + (Member.runWithPrefix "_db_types." {=} lookup member) ) input ) diff --git a/gen/Interpreters/Scalar.dhall b/src/Interpreters/Scalar.dhall similarity index 63% rename from gen/Interpreters/Scalar.dhall rename to src/Interpreters/Scalar.dhall index b0eff70..9fa4c3d 100644 --- a/gen/Interpreters/Scalar.dhall +++ b/src/Interpreters/Scalar.dhall @@ -1,30 +1,30 @@ -let Deps = ../Deps/package.dhall +let Lude = ../Deps/Lude.dhall -let ImportSet = ../Structures/ImportSet.dhall - -let Algebra = ../Algebras/Interpreter.dhall +let Model = ../Deps/Contract.dhall -let Lude = Deps.Lude +let Sdk = ../Deps/Sdk.dhall -let Model = Deps.Sdk.Project +let ImportSet = ../Structures/ImportSet.dhall let Primitive = ./Primitive.dhall +let Config = {} + let Input = Model.Scalar -- Passthrough for primitives; Custom is opaque here. The enum-vs-composite --- decision needs the project customTypes lookup, which lives in F/G, not here. +-- decision is mandatory in Member, ParamsMember, and CustomType consumers. let ScalarDecode = < Passthrough | Custom > let Output = { pyType : Text , imports : ImportSet.Type - , customRef : Optional Model.Name + , customRef : Optional Model.CustomTypeRef , decode : ScalarDecode } let run = - \(config : Algebra.Config) -> + \(config : Config) -> \(input : Input) -> merge { Primitive = @@ -35,21 +35,21 @@ let run = ( \(p : Primitive.Output) -> { pyType = p.pyType , imports = p.imports - , customRef = None Model.Name + , customRef = None Model.CustomTypeRef , decode = ScalarDecode.Passthrough } ) - (Primitive.run config primitive) + (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 } } input -in Algebra.module Input Output run /\ { ScalarDecode } +in Sdk.Sigs.interpreter Config Input Output run /\ { ScalarDecode } diff --git a/src/Interpreters/Value.dhall b/src/Interpreters/Value.dhall new file mode 100644 index 0000000..5ba1381 --- /dev/null +++ b/src/Interpreters/Value.dhall @@ -0,0 +1,77 @@ +let Lude = ../Deps/Lude.dhall + +let Prelude = ../Deps/Prelude.dhall + +let Model = ../Deps/Contract.dhall + +let Sdk = ../Deps/Sdk.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let Scalar = ./Scalar.dhall + +let Config = {} + +let Input = Model.Value + +let Output = + { pyType : Text + , imports : ImportSet.Type + , scalar : Scalar.Output + , dims : Natural + , elementIsNullable : Bool + } + +let run = + \(config : Config) -> + \(input : Input) -> + Lude.Compiled.map + Scalar.Output + Output + ( \(scalar : Scalar.Output) -> + 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 + input.dimensionality + Text + (\(inner : Text) -> "list[${inner}]") + elementType + + in { pyType = arrayType + , imports = scalar.imports + , scalar + , dims = input.dimensionality + , elementIsNullable = input.elementIsNullable + } + ) + (Scalar.run {=} input.scalar) + +let qualifyCustom + : Text -> Text -> Output -> Text + = \(prefix : Text) -> + \(className : Text) -> + \(value : Output) -> + Prelude.Optional.fold + Model.CustomTypeRef + value.scalar.customRef + Text + ( \(ref : Model.CustomTypeRef) -> + Text/replace + ref.name.inPascalCase + (prefix ++ className) + value.pyType + ) + value.pyType + +in Sdk.Sigs.interpreter Config Input Output run /\ { qualifyCustom } diff --git a/src/Structures/CustomKind.dhall b/src/Structures/CustomKind.dhall new file mode 100644 index 0000000..4748022 --- /dev/null +++ b/src/Structures/CustomKind.dhall @@ -0,0 +1,30 @@ +let Prelude = ../Deps/Prelude.dhall + +let Identity = + { className : Text, moduleName : Text, order : Natural } + +let TypeKind = + < Enum : Identity + | Composite : Identity + | Absent + > + +-- 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 + +-- 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/gen/Structures/ImportSet.dhall b/src/Structures/ImportSet.dhall similarity index 70% rename from gen/Structures/ImportSet.dhall rename to src/Structures/ImportSet.dhall index 04fc239..db84a61 100644 --- a/gen/Structures/ImportSet.dhall +++ b/src/Structures/ImportSet.dhall @@ -1,10 +1,7 @@ let Prelude = ../Deps/Prelude.dhall --- A custom-type import line: "from ..types. import ". --- dedupKey identifies the type for deduplication and orders the rendered import --- block. Dhall (upstream) has no Text comparison, so the caller assigns each --- distinct custom type its alphabetical position in project.customTypes as the --- key; dedup keys on it and the render step sorts ascending to stay alphabetical. +-- dedupKey is the type's project index, which also gives imports a stable order +-- without relying on unavailable standard-Dhall Text comparison. let CustomImport = { className : Text, moduleName : Text, dedupKey : Natural } let Self = @@ -17,7 +14,6 @@ let Self = , jsonb : Bool , json : Bool , jsonValue : Bool - , enumArray : Bool , customTypes : List CustomImport } @@ -31,7 +27,6 @@ let base = , jsonb = False , json = False , jsonValue = False - , enumArray = False , customTypes = [] : List CustomImport } @@ -75,17 +70,10 @@ let jsonValue : Self = base // { jsonValue = True } -let enumArray - : Self - = base // { enumArray = True } - let custom : CustomImport -> Self = \(c : CustomImport) -> base // { customTypes = [ c ] } --- Named-type imports dedup and sort on `order`, the type's alphabetical index in --- project.customTypes. Two references to the same type carry the same order and --- collapse; distinct types keep distinct keys so the render step can sort them. let customEnum : { className : Text, moduleName : Text, order : Natural } -> Self = \(c : { className : Text, moduleName : Text, order : Natural }) -> @@ -115,7 +103,7 @@ let dedupCustoms let known = Prelude.List.any Natural - (\(k : Natural) -> eqNat k item.dedupKey) + (\(key : Natural) -> eqNat key item.dedupKey) state.seen in if known @@ -132,11 +120,11 @@ let dedupCustoms { seen = [] : List Natural, acc = [] : List CustomImport } ).acc -let leNat = \(a : Natural) -> \(b : Natural) -> Natural/isZero (Natural/subtract b a) +let leNat = + \(a : Natural) -> + \(b : Natural) -> + Natural/isZero (Natural/subtract b a) --- Insertion sort over custom imports by ascending dedupKey (the alphabetical --- order). The list is tiny (one or two types per module), so the cost is moot --- and the result is a stable alphabetical import block. let sortCustoms : List CustomImport -> List CustomImport = \(items : List CustomImport) -> @@ -145,20 +133,18 @@ let sortCustoms \(acc : List CustomImport) -> let State = { placed : Bool, out : List CustomImport } - -- foldLeft so the scan runs left-to-right and `item` lands before - -- the FIRST element that is >= it (a right List/fold visits the - -- list back-to-front and would insert before the last such - -- element, mis-ordering blocks of 3+ types). let step = \(state : State) -> - \(cur : CustomImport) -> + \(current : CustomImport) -> if state.placed - then { placed = True, out = state.out # [ cur ] } - else if leNat item.dedupKey cur.dedupKey + then { placed = True, out = state.out # [ current ] } + else if leNat item.dedupKey current.dedupKey then { placed = True - , out = state.out # [ item, cur ] + , out = state.out # [ item, current ] + } + else { placed = False + , out = state.out # [ current ] } - else { placed = False, out = state.out # [ cur ] } let folded = Prelude.List.foldLeft @@ -168,11 +154,14 @@ let sortCustoms step { placed = False, out = [] : List CustomImport } - in if folded.placed - then folded.out - else folded.out # [ item ] + in if folded.placed then folded.out else folded.out # [ item ] - in List/fold CustomImport items (List CustomImport) insert ([] : List CustomImport) + in List/fold + CustomImport + items + (List CustomImport) + insert + ([] : List CustomImport) let combine = \(left : Self) -> @@ -186,7 +175,6 @@ let combine = , jsonb = left.jsonb || right.jsonb , json = left.json || right.json , jsonValue = left.jsonValue || right.jsonValue - , enumArray = left.enumArray || right.enumArray , customTypes = dedupCustoms (left.customTypes # right.customTypes) } @@ -198,6 +186,10 @@ let sortedCustoms : Self -> List CustomImport = \(self : Self) -> sortCustoms self.customTypes +let hasCustom + : Self -> Bool + = \(self : Self) -> Prelude.Bool.not (Prelude.List.null CustomImport self.customTypes) + in { Type = Self , CustomImport , empty @@ -210,11 +202,11 @@ in { Type = Self , jsonb , json , jsonValue - , enumArray , custom , customEnum , customComposite , combine , combineAll , sortedCustoms + , hasCustom } diff --git a/gen/Structures/OnUnsupported.dhall b/src/Structures/OnUnsupported.dhall similarity index 100% rename from gen/Structures/OnUnsupported.dhall rename to src/Structures/OnUnsupported.dhall diff --git a/src/Structures/PyIdent.dhall b/src/Structures/PyIdent.dhall new file mode 100644 index 0000000..822f44d --- /dev/null +++ b/src/Structures/PyIdent.dhall @@ -0,0 +1,139 @@ +-- 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 +-- params signature, the result-row dataclass fields, and composite fields all use +-- one rule. +let pythonKeywords = + [ "False" + , "None" + , "True" + , "and" + , "as" + , "assert" + , "async" + , "await" + , "break" + , "class" + , "continue" + , "def" + , "del" + , "elif" + , "else" + , "except" + , "finally" + , "for" + , "from" + , "global" + , "if" + , "import" + , "in" + , "is" + , "lambda" + , "nonlocal" + , "not" + , "or" + , "pass" + , "raise" + , "return" + , "try" + , "while" + , "with" + , "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. +-- 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) -> + Lude.Text.replaceIfOneOf reserved (name ++ suffix) name + +let sanitizeAgainst = suffixAgainst "_" + +let pySafeName + : Text -> Text + = sanitizeAgainst pythonKeywords + +let moduleMetadataNames = + [ "__init__" + , "__path__" + , "__package__" + , "__spec__" + , "__name__" + , "__loader__" + , "__file__" + , "__cached__" + , "__builtins__" + , "__annotations__" + , "__all__" + ] + +let moduleReservedNames = + [ "_db_types" + , "_args_row" + , "_fetch_optional" + , "_fetch_single" + , "_fetch_many" + , "_execute_rows_affected" + , "_execute_void" + , "_fetch_optional_sync" + , "_fetch_single_sync" + , "_fetch_many_sync" + , "_execute_rows_affected_sync" + , "_execute_void_sync" + , "sync" + , "register_types" + , "date" + , "datetime" + , "time" + , "timedelta" + ] # moduleMetadataNames + +let parameterReservedNames = + [ "conn" + , "sql" + , "params" + , "decode" + , "cur" + , "row" + , "_args_row" + , "_fetch_optional" + , "_fetch_single" + , "_fetch_many" + , "_execute_rows_affected" + , "_execute_void" + , "_fetch_optional_sync" + , "_fetch_single_sync" + , "_fetch_many_sync" + , "_execute_rows_affected_sync" + , "_execute_void_sync" + ] + +let parameterSafeName + : Text -> Text + = sanitizeAgainst (pythonKeywords # parameterReservedNames) + +let querySafeName = + \(name : Text) -> suffixAgainst "_query" moduleReservedNames (pySafeName name) + +let typeModuleSafeName = + \(name : Text) -> sanitizeAgainst (pythonKeywords # moduleMetadataNames) name + +in { pythonKeywords + , moduleMetadataNames + , sanitizeAgainst + , pySafeName + , moduleReservedNames + , parameterReservedNames + , parameterSafeName + , querySafeName + , typeModuleSafeName + } diff --git a/src/Structures/Surface.dhall b/src/Structures/Surface.dhall new file mode 100644 index 0000000..b9c2577 --- /dev/null +++ b/src/Structures/Surface.dhall @@ -0,0 +1,35 @@ +-- Render tokens for the two explicit functions co-located in each canonical +-- statement module. Both surfaces import the same core and custom types. +let Surface = + { defKeyword : Text + , connType : Text + , awaitKw : Text + , functionSuffix : Text + , runtimePrefix : Text + , helperSuffix : Text + , corePrefix : Text + } + +let async + : Surface + = { defKeyword = "async def" + , connType = "AsyncConnection" + , awaitKw = "await " + , functionSuffix = "" + , runtimePrefix = ".._runtime" + , helperSuffix = "" + , corePrefix = ".._core" + } + +let sync + : Surface + = { defKeyword = "def" + , connType = "Connection" + , awaitKw = "" + , functionSuffix = "_sync" + , runtimePrefix = "..sync._runtime" + , helperSuffix = "_sync" + , corePrefix = ".._core" + } + +in { Type = Surface, async, sync } diff --git a/gen/Templates/CompositeModule.dhall b/src/Templates/CompositeModule.dhall similarity index 61% rename from gen/Templates/CompositeModule.dhall rename to src/Templates/CompositeModule.dhall index 198c77d..fdfa84a 100644 --- a/gen/Templates/CompositeModule.dhall +++ b/src/Templates/CompositeModule.dhall @@ -1,6 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Field = { fieldName : Text, fieldType : Text } @@ -12,7 +12,7 @@ let Params = { typeName : Text, extraImports : List Text, fields : List Field } let run = \(params : Params) -> let fieldLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Field ( \(field : Field) -> @@ -21,12 +21,12 @@ let run = params.fields let imports = - if Deps.Prelude.List.null Text params.extraImports + if Prelude.List.null Text params.extraImports then "from dataclasses import dataclass" else '' from dataclasses import dataclass - ${Deps.Prelude.Text.concatSep "\n" params.extraImports}'' + ${Prelude.Text.concatSep "\n" params.extraImports}'' in '' ${imports} @@ -34,13 +34,7 @@ let run = @dataclass(frozen=True, slots=True) class ${params.typeName}: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated _decode cannot splat into the dataclass. - """ - ${fieldLines} '' -in Algebra.module Params run /\ { Field } +in Sdk.Sigs.template Params run /\ { Field } diff --git a/src/Templates/CoreModule.dhall b/src/Templates/CoreModule.dhall new file mode 100644 index 0000000..4ed8056 --- /dev/null +++ b/src/Templates/CoreModule.dhall @@ -0,0 +1,21 @@ +let Sdk = ../Deps/Sdk.dhall + +-- The surface-agnostic core is emitted once per generated package. +let content = + '' + """Shared types and errors, surface-agnostic; no I/O.""" + + from __future__ import annotations + + type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] + + + class NoRowError(RuntimeError): + """A single-row query returned no rows.""" + + def __init__(self, sql: str) -> None: + self.sql = sql + super().__init__(f"single-row query returned no rows: {sql}") + '' + +in Sdk.Sigs.template {} (\(_ : {}) -> content) diff --git a/gen/Templates/EnumModule.dhall b/src/Templates/EnumModule.dhall similarity index 68% rename from gen/Templates/EnumModule.dhall rename to src/Templates/EnumModule.dhall index 7f82ebf..1ebf486 100644 --- a/gen/Templates/EnumModule.dhall +++ b/src/Templates/EnumModule.dhall @@ -1,6 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Variant = { memberName : Text, pgValue : Text } @@ -16,18 +16,18 @@ let run = let escapeLabel : Text -> Text = \(raw : Text) -> - Deps.Prelude.Function.composeList + Prelude.Function.composeList Text - [ Deps.Prelude.Text.replace "\\" "\\\\" - , Deps.Prelude.Text.replace "\r" "\\r" - , Deps.Prelude.Text.replace "\n" "\\n" - , Deps.Prelude.Text.replace "\t" "\\t" - , Deps.Prelude.Text.replace "\"" "\\\"" + [ Prelude.Text.replace "\\" "\\\\" + , Prelude.Text.replace "\r" "\\r" + , Prelude.Text.replace "\n" "\\n" + , Prelude.Text.replace "\t" "\\t" + , Prelude.Text.replace "\"" "\\\"" ] raw let memberLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Variant ( \(variant : Variant) -> @@ -43,4 +43,4 @@ let run = ${memberLines} '' -in Algebra.module Params run /\ { Variant } +in Sdk.Sigs.template Params run /\ { Variant } diff --git a/src/Templates/FacadeModule.dhall b/src/Templates/FacadeModule.dhall new file mode 100644 index 0000000..fda53d4 --- /dev/null +++ b/src/Templates/FacadeModule.dhall @@ -0,0 +1,227 @@ +let Prelude = ../Deps/Prelude.dhall + +let Sdk = ../Deps/Sdk.dhall + +-- A statement's public surface: the function and, when the query returns rows, +-- its frozen Row dataclass. functionName doubles as the leaf module name. +let StatementExport = + { functionName : Text, rowClassName : Optional Text } + +-- A custom type re-exported from types/: the leaf module name plus the class. +let TypeExport = { moduleName : Text, className : Text } + +let Params = + { statements : List StatementExport + , types : List TypeExport + , generatedPrefix : Text + , functionSuffix : Text + , registrationSource : Optional Text + , includeSyncModule : Bool + } + +-- "x as x" re-export markers (PEP 484) so basedpyright strict and ruff treat the +-- names as exports of the package root, not unused imports. +let alias = \(name : Text) -> "${name} as ${name}" + +let importBlock = + \(source : Text) -> + \(entry : Text) -> + "from ${source} import (\n" + ++ " ${entry},\n" + ++ ")" + +let importBlocks = + \(source : Text) -> + \(entries : List Text) -> + Prelude.Text.concatMapSep + "\n" + Text + (\(entry : Text) -> importBlock source entry) + entries + +-- Dhall has no Text ordering, so project-ordered imports need an explicit +-- formatter boundary instead of a comparator that would only handle fixtures. +let preserveImportOrder = + \(block : Text) -> "\n# isort: off\n${block}\n# isort: on" + +let rowNames + : List StatementExport -> List Text + = \(statements : List StatementExport) -> + Prelude.List.concatMap + StatementExport + Text + ( \(s : StatementExport) -> + merge + { None = [] : List Text, Some = \(row : Text) -> [ row ] } + s.rowClassName + ) + statements + +-- The core names that are part of the public surface: the JsonValue alias used +-- by callers typing jsonb columns, and the NoRowError raised by single-row +-- statements. _core.py is always emitted, so these are always re-exported. +let runtimeNames = [ "JsonValue", "NoRowError" ] + +let run = + \(params : Params) -> + let runtimeBlock = + importBlocks + "${params.generatedPrefix}._core" + (Prelude.List.map Text Text alias runtimeNames) + + let typeBlock = + Prelude.Text.concatMapSep + "\n" + TypeExport + ( \(t : TypeExport) -> + importBlock + "${params.generatedPrefix}.types.${t.moduleName}" + (alias t.className) + ) + params.types + + let typeBlock = preserveImportOrder typeBlock + + let rows = rowNames params.statements + + -- A query's Row class lives in its statement module. Keep the row + -- alias before the function alias, matching the canonical import order. + let statementBlock = + Prelude.Text.concatMapSep + "\n" + StatementExport + ( \(s : StatementExport) -> + let rowEntries = + merge + { None = [] : List Text + , Some = \(row : Text) -> [ alias row ] + } + s.rowClassName + + let functionEntry = + s.functionName + ++ params.functionSuffix + ++ " as " + ++ s.functionName + + in importBlocks + "${params.generatedPrefix}.statements.${s.functionName}" + (rowEntries # [ functionEntry ]) + ) + params.statements + + let statementBlock = preserveImportOrder statementBlock + + let registrationBlock = + merge + { None = [] : List Text + , Some = + \(source : Text) -> + [ importBlock + "${params.generatedPrefix}._register" + "${source} as register_types" + ] + } + params.registrationSource + + let syncBlock = + if params.includeSyncModule + then [ importBlock "." "sync as sync" ] + else [] : List Text + + let importGroups = + syncBlock + # [ runtimeBlock ] + # registrationBlock + # ( if Prelude.List.null StatementExport params.statements + then [] : List Text + else [ statementBlock ] + ) + # ( if Prelude.List.null TypeExport params.types + then [] : List Text + else [ typeBlock ] + ) + + let importSection = Prelude.Text.concatSep "\n" importGroups + + let renderAllBlock = + \(operator : Text) -> + \(names : List Text) -> + "__all__ ${operator} [\n" + ++ Prelude.Text.concatMap + Text + (\(name : Text) -> " \"${name}\",\n") + names + ++ "]" + + let renderProjectAllBlock = + \(names : List Text) -> + "__all__ += [ # noqa: RUF022, RUF100\n" + ++ Prelude.Text.concatMap + Text + (\(name : Text) -> " \"${name}\",\n") + names + ++ "]" + + let typeNames = + Prelude.List.map + TypeExport + Text + (\(t : TypeExport) -> t.className) + params.types + + let functionNames = + Prelude.List.map + StatementExport + Text + (\(s : StatementExport) -> s.functionName) + params.statements + + let registrationNames = + merge + { None = [] : List Text + , Some = \(_ : Text) -> [ "register_types" ] + } + params.registrationSource + + let syncNames = + if params.includeSyncModule then [ "sync" ] else [] : List Text + + let projectAllGroups = + Prelude.List.filter + (List Text) + (\(group : List Text) -> Prelude.Bool.not (Prelude.List.null Text group)) + [ typeNames, rows, functionNames ] + + let fixedAllGroups = + Prelude.List.filter + (List Text) + (\(group : List Text) -> Prelude.Bool.not (Prelude.List.null Text group)) + [ registrationNames, syncNames ] + + let projectAllBlocks = + if Prelude.List.null (List Text) projectAllGroups + then [] : List Text + else [ "# Project order is intentional for public re-export groups." ] + # Prelude.List.map + (List Text) + Text + renderProjectAllBlock + projectAllGroups + + let allBlocks = + [ renderAllBlock "=" runtimeNames ] + # projectAllBlocks + # Prelude.List.map + (List Text) + Text + (renderAllBlock "+=") + fixedAllGroups + + in '' + ${importSection} + + ${Prelude.Text.concatSep "\n" allBlocks} + '' + +in Sdk.Sigs.template Params run /\ { StatementExport, TypeExport } diff --git a/gen/Templates/InitModule.dhall b/src/Templates/InitModule.dhall similarity index 87% rename from gen/Templates/InitModule.dhall rename to src/Templates/InitModule.dhall index f609ed6..cf5fc9f 100644 --- a/gen/Templates/InitModule.dhall +++ b/src/Templates/InitModule.dhall @@ -1,4 +1,4 @@ -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall -- A minimal package __init__.py: a module docstring plus an empty __all__. -- Used for the _generated subpackage root and the statements subpackage. The @@ -15,4 +15,4 @@ let render = __all__: list[str] = [] '' -in Algebra.module Params render +in Sdk.Sigs.template Params render diff --git a/src/Templates/RegisterModule.dhall b/src/Templates/RegisterModule.dhall new file mode 100644 index 0000000..e81af66 --- /dev/null +++ b/src/Templates/RegisterModule.dhall @@ -0,0 +1,200 @@ +let Prelude = ../Deps/Prelude.dhall + +let Sdk = ../Deps/Sdk.dhall + +let TypeKind = < Enum | Composite > + +let CustomType = + { typeName : Text + , moduleName : Text + , pgSchema : Text + , pgName : Text + , kind : TypeKind + } + +let Params = { customTypes : List CustomType, emitSync : Bool } + +let pgNameConstant = + \(custom : CustomType) -> "_${custom.moduleName}_pg_name" + +let makeObjectName = + \(custom : CustomType) -> "_${custom.moduleName}_make_object" + +let makeSequenceName = + \(custom : CustomType) -> "_${custom.moduleName}_make_sequence" + +let metadata = + \(custom : CustomType) -> + let pgName = "${custom.pgSchema}.${custom.pgName}" + + let constant = "${pgNameConstant custom} = \"${pgName}\"" + + in merge + { Enum = constant + , Composite = + constant + ++ "\n" + ++ "${makeObjectName custom}, ${makeSequenceName custom} = _dataclass_callbacks(_db_types.${custom.typeName})" + } + custom.kind + +let registration = + \(awaitKw : Text) -> + \(custom : CustomType) -> + let infoName = "${custom.moduleName}_info" + + in merge + { Enum = + " ${infoName} = ${awaitKw}EnumInfo.fetch(conn, ${pgNameConstant custom})\n" + ++ " if ${infoName} is None:\n" + ++ " raise LookupError(f\"enum type {${pgNameConstant custom}!r} not found; cannot register it\")\n" + ++ " register_enum(\n" + ++ " ${infoName},\n" + ++ " conn,\n" + ++ " _db_types.${custom.typeName},\n" + ++ " mapping={member: member.value for member in _db_types.${custom.typeName}},\n" + ++ " )" + , Composite = + " ${infoName} = ${awaitKw}CompositeInfo.fetch(conn, ${pgNameConstant custom})\n" + ++ " if ${infoName} is None:\n" + ++ " raise LookupError(f\"composite type {${pgNameConstant custom}!r} not found; cannot register it\")\n" + ++ " register_composite(\n" + ++ " ${infoName},\n" + ++ " conn,\n" + ++ " _db_types.${custom.typeName},\n" + ++ " make_object=${makeObjectName custom},\n" + ++ " make_sequence=${makeSequenceName custom},\n" + ++ " )" + } + custom.kind + +let run = + \(params : Params) -> + let hasComposites = + Prelude.List.any + CustomType + ( \(custom : CustomType) -> + merge { Enum = False, Composite = True } custom.kind + ) + params.customTypes + + let hasEnums = + Prelude.List.any + CustomType + ( \(custom : CustomType) -> + merge { Enum = True, Composite = False } custom.kind + ) + params.customTypes + + let metadataLines = + Prelude.Text.concatMapSep + "\n" + CustomType + metadata + params.customTypes + + let asyncRegistrations = + Prelude.Text.concatMapSep + "\n" + CustomType + (registration "await ") + params.customTypes + + let syncRegistrations = + Prelude.Text.concatMapSep + "\n" + CustomType + (registration "") + params.customTypes + + let connectionImport = + if params.emitSync + then "from psycopg import AsyncConnection, Connection" + else "from psycopg import AsyncConnection" + + let compositeImports = + if hasComposites + then '' + import keyword + from collections.abc import Callable, Sequence + from dataclasses import fields, is_dataclass + from typing import Any + '' + else "" + + let adapterImportLines = + ( if hasComposites + then [ "from psycopg.types.composite import CompositeInfo, register_composite" ] + else [] : List Text + ) + # ( if hasEnums + then [ "from psycopg.types.enum import EnumInfo, register_enum" ] + else [] : List Text + ) + + let adapterImports = Prelude.Text.concatSep "\n" adapterImportLines + + let compositeCallbacks = + if hasComposites + then '' + + type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] + type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] + + + def _python_name(name: str) -> str: + if keyword.iskeyword(name): + return f"{name}_" + return name + + + def _dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) + + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + '' + else "" + + let syncFunction = + if params.emitSync + then '' + + + + def register_types_sync(conn: Connection[object]) -> None: + ${syncRegistrations}'' + else "" + + in '' + from __future__ import annotations + + ${compositeImports} + ${connectionImport} + ${adapterImports} + + from . import types as _db_types + ${compositeCallbacks} + + ${metadataLines} + + + async def register_types(conn: AsyncConnection[object]) -> None: + ${asyncRegistrations}${syncFunction} + '' + +in Sdk.Sigs.template Params run /\ { CustomType, TypeKind } diff --git a/src/Templates/RuntimeModule.dhall b/src/Templates/RuntimeModule.dhall new file mode 100644 index 0000000..b9f3c73 --- /dev/null +++ b/src/Templates/RuntimeModule.dhall @@ -0,0 +1,144 @@ +let Sdk = ../Deps/Sdk.dhall + +-- The fixed async runtime is emitted once per generated package. +let content = + '' + from __future__ import annotations + + from typing import LiteralString + + from psycopg import AsyncConnection + from psycopg.rows import BaseRowFactory + + from ._core import NoRowError + + _Params = dict[str, object] + + + async def fetch_optional[T]( + conn: AsyncConnection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> T | None: + async with conn.cursor(row_factory=row_factory) as cur: + _ = await cur.execute(sql, params) + return await cur.fetchone() + + + async def fetch_single[T]( + conn: AsyncConnection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> T: + async with conn.cursor(row_factory=row_factory) as cur: + _ = await cur.execute(sql, params) + row = await cur.fetchone() + if row is None: + raise NoRowError(sql) + return row + + + async def fetch_many[T]( + conn: AsyncConnection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> list[T]: + async with conn.cursor(row_factory=row_factory) as cur: + _ = await cur.execute(sql, params) + return await cur.fetchall() + + + async def execute_rows_affected( + conn: AsyncConnection[object], + sql: LiteralString, + params: _Params, + ) -> int: + async with conn.cursor() as cur: + _ = await cur.execute(sql, params) + return cur.rowcount + + + async def execute_void( + conn: AsyncConnection[object], + sql: LiteralString, + params: _Params, + ) -> None: + async with conn.cursor() as cur: + _ = await cur.execute(sql, params) + '' + +-- The sync runtime mirrors the async helpers. +let syncContent = + '' + from __future__ import annotations + + from typing import LiteralString + + from psycopg import Connection + from psycopg.rows import BaseRowFactory + + from .._core import NoRowError + + _Params = dict[str, object] + + + def fetch_optional[T]( + conn: Connection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> T | None: + with conn.cursor(row_factory=row_factory) as cur: + _ = cur.execute(sql, params) + return cur.fetchone() + + + def fetch_single[T]( + conn: Connection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> T: + with conn.cursor(row_factory=row_factory) as cur: + _ = cur.execute(sql, params) + row = cur.fetchone() + if row is None: + raise NoRowError(sql) + return row + + + def fetch_many[T]( + conn: Connection[object], + sql: LiteralString, + params: _Params, + row_factory: BaseRowFactory[T], + ) -> list[T]: + with conn.cursor(row_factory=row_factory) as cur: + _ = cur.execute(sql, params) + return cur.fetchall() + + + def execute_rows_affected( + conn: Connection[object], + sql: LiteralString, + params: _Params, + ) -> int: + with conn.cursor() as cur: + _ = cur.execute(sql, params) + return cur.rowcount + + + def execute_void( + conn: Connection[object], + sql: LiteralString, + params: _Params, + ) -> None: + with conn.cursor() as cur: + _ = cur.execute(sql, params) + '' + +in Sdk.Sigs.template {} (\(_ : {}) -> content) + /\ { runSync = \(_ : {}) -> syncContent } diff --git a/src/Templates/StatementModule.dhall b/src/Templates/StatementModule.dhall new file mode 100644 index 0000000..7f8cd9a --- /dev/null +++ b/src/Templates/StatementModule.dhall @@ -0,0 +1,238 @@ +let Prelude = ../Deps/Prelude.dhall + +let Lude = ../Deps/Lude.dhall + +let Sdk = ../Deps/Sdk.dhall + +let ImportSet = ../Structures/ImportSet.dhall + +let Surface = ../Structures/Surface.dhall + +let RowDef = { className : Text, fieldsBlock : Text } + +let indentAll + : Natural -> Text -> Text + = \(n : Natural) -> + \(text : Text) -> + let pad = Prelude.Text.replicate n " " + + in pad ++ Lude.Text.indentNonEmpty n text + +let renderRow + : RowDef -> Text + = \(row : RowDef) -> + "@dataclass(frozen=True, slots=True)\n" + ++ "class " + ++ row.className + ++ ":\n" + ++ indentAll 4 row.fieldsBlock + +let renderRowBlock + : Optional RowDef -> Text + = \(rowDef : Optional RowDef) -> + merge + { None = "" + , Some = \(row : RowDef) -> "\n\n\n" ++ renderRow row + } + rowDef + +let hasRow + : Optional RowDef -> Bool + = \(rowDef : Optional RowDef) -> + merge { None = False, Some = \(_ : RowDef) -> True } rowDef + +let Params = + { functionName : Text + , returnType : Text + , helperName : Text + , sqlLiteral : Text + , rowDef : Optional RowDef + , paramSigLines : List Text + , paramDictEntries : List Text + , imports : ImportSet.Type + , emitSync : Bool + , asyncSurface : Surface.Type + , syncSurface : Surface.Type + } + +let importLineIf + : Bool -> Text -> List Text + = \(cond : Bool) -> \(line : Text) -> if cond then [ line ] else [] : List Text + +let datetimeImport + : ImportSet.Type -> List Text + = \(imports : ImportSet.Type) -> + let names = + importLineIf imports.date "date" + # importLineIf imports.datetime "datetime" + # importLineIf imports.time "time" + # importLineIf imports.timedelta "timedelta" + + in if Prelude.Bool.not (Prelude.List.null Text names) + then [ "from datetime import " ++ Prelude.Text.concatSep ", " names ] + else [] : List Text + +let runtimeImport + : Surface.Type -> Text -> Text + = \(surface : Surface.Type) -> + \(helperName : Text) -> + "from ${surface.runtimePrefix} import ${helperName} as _${helperName}${surface.helperSuffix}" + +let coreImport + : Text -> Bool -> List Text + = \(corePrefix : Text) -> + \(jsonValue : Bool) -> + if jsonValue + then [ "from ${corePrefix} import JsonValue" ] + else [] : List Text + +let renderImports + : Params -> Text + = \(params : Params) -> + let imports = params.imports + + let rowIsPresent = hasRow params.rowDef + + let asyncSurface = params.asyncSurface + + let syncSurface = params.syncSurface + + let stdlibBlock = + importLineIf rowIsPresent "from dataclasses import dataclass" + # datetimeImport imports + # importLineIf imports.decimal "from decimal import Decimal" + # importLineIf imports.uuid "from uuid import UUID" + + let connectionNames = + [ asyncSurface.connType ] + # (if params.emitSync then [ syncSurface.connType ] else [] : List Text) + + let psycopgBlock = + let jsonNames = + importLineIf imports.json "Json" + # importLineIf imports.jsonb "Jsonb" + + let jsonImport = + if Prelude.List.null Text jsonNames + then [] : List Text + else [ "from psycopg.types.json import " ++ Prelude.Text.concatSep ", " jsonNames ] + + in [ "from psycopg import " ++ Prelude.Text.concatSep ", " connectionNames ] + # importLineIf + rowIsPresent + "from psycopg.rows import args_row as _args_row" + # jsonImport + + let localBlock = + importLineIf + (ImportSet.hasCustom imports) + "from .. import types as _db_types" + # coreImport asyncSurface.corePrefix imports.jsonValue + # [ runtimeImport asyncSurface params.helperName ] + # ( if params.emitSync + then [ runtimeImport syncSurface params.helperName ] + else [] : List Text + ) + + let groups = + [ [ "from __future__ import annotations" ] + , stdlibBlock + , psycopgBlock + , localBlock + ] + + let nonEmptyGroups = + Prelude.List.filter + (List Text) + ( \(group : List Text) -> + Prelude.Bool.not (Prelude.List.null Text group) + ) + groups + + in Prelude.Text.concatMapSep + "\n\n" + (List Text) + (\(group : List Text) -> Prelude.Text.concatSep "\n" group) + nonEmptyGroups + +let renderSignature + : Params -> Surface.Type -> Text + = \(params : Params) -> + \(surface : Surface.Type) -> + let hasParams = + Prelude.Bool.not (Prelude.List.null Text params.paramSigLines) + + let kwMarker = if hasParams then " *,\n" else "" + + let paramBlock = + Prelude.Text.concatMap + Text + (\(line : Text) -> " " ++ line ++ ",\n") + params.paramSigLines + + in surface.defKeyword + ++ " " + ++ params.functionName + ++ surface.functionSuffix + ++ "(\n" + ++ " conn: ${surface.connType}[object],\n" + ++ kwMarker + ++ paramBlock + ++ ") -> " + ++ params.returnType + ++ ":" + +let renderParamsDict + : Params -> Text + = \(params : Params) -> + if Prelude.List.null Text params.paramDictEntries + then "params: dict[str, object] = {}" + else "params: dict[str, object] = {\n" + ++ Prelude.Text.concatMap + Text + (\(entry : Text) -> " " ++ entry ++ ",\n") + params.paramDictEntries + ++ "}" + +let renderCall + : Params -> Surface.Type -> Text + = \(params : Params) -> + \(surface : Surface.Type) -> + let helper = "_" ++ params.helperName ++ surface.helperSuffix + + let rowFactory = + merge + { None = "" + , Some = \(row : RowDef) -> ", _args_row(${row.className})" + } + params.rowDef + + in "return ${surface.awaitKw}${helper}(conn, SQL, params${rowFactory})" + +let renderFunction + : Params -> Surface.Type -> Text + = \(params : Params) -> + \(surface : Surface.Type) -> + renderSignature params surface + ++ "\n" + ++ indentAll 4 (renderParamsDict params) + ++ "\n" + ++ indentAll 4 (renderCall params surface) + +in Sdk.Sigs.template + Params + ( \(params : Params) -> + renderImports params + ++ "\n\nSQL = \"\"\"\\\n" + ++ params.sqlLiteral + ++ "\n\"\"\"" + ++ renderRowBlock params.rowDef + ++ "\n\n\n" + ++ renderFunction params params.asyncSurface + ++ ( if params.emitSync + then "\n\n\n" ++ renderFunction params params.syncSurface + else "" + ) + ++ "\n" + ) + /\ { RowDef } diff --git a/gen/Templates/TypesInit.dhall b/src/Templates/TypesInit.dhall similarity index 54% rename from gen/Templates/TypesInit.dhall rename to src/Templates/TypesInit.dhall index 7583526..adf5a8e 100644 --- a/gen/Templates/TypesInit.dhall +++ b/src/Templates/TypesInit.dhall @@ -1,6 +1,6 @@ -let Deps = ../Deps/package.dhall +let Prelude = ../Deps/Prelude.dhall -let Algebra = ../Algebras/Template.dhall +let Sdk = ../Deps/Sdk.dhall let Export = { moduleName : Text, typeName : Text } @@ -9,7 +9,7 @@ let Params = { exports : List Export } let run = \(params : Params) -> let exportLines = - Deps.Prelude.Text.concatMapSep + Prelude.Text.concatMapSep "\n" Export ( \(export : Export) -> @@ -17,8 +17,13 @@ let run = ) params.exports + let exportSection = + if Prelude.List.null Export params.exports + then "" + else "# isort: off\n${exportLines}\n# isort: on" + in '' - ${exportLines} + ${exportSection} '' -in Algebra.module Params run /\ { Export } +in Sdk.Sigs.template Params run /\ { Export } diff --git a/src/package.dhall b/src/package.dhall new file mode 100644 index 0000000..67ff3c1 --- /dev/null +++ b/src/package.dhall @@ -0,0 +1,28 @@ +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let ProjectInterpreter = ./Interpreters/Project.dhall + +-- User-facing config for this generator. Async output is always emitted; +-- `emitSync = True` adds a sync facade, runtime, and adjacent query functions. +-- `onUnsupported` picks Fail (default, abort loudly) or Skip (drop +-- the unsupported statement/type and its dependents, with a warning) when a +-- 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. +let Config = + { packageName : Optional Text + , emitSync : Optional Bool + , onUnsupported : Optional OnUnsupported.Mode + } : Type + +let Config/default + : Config + = { packageName = None Text + , emitSync = None Bool + , onUnsupported = None OnUnsupported.Mode + } + +in Sdk.Sigs.generator Config Config/default ProjectInterpreter.run diff --git a/tests/Exhaustive.dhall b/tests/Exhaustive.dhall deleted file mode 100644 index bfe8b84..0000000 --- a/tests/Exhaustive.dhall +++ /dev/null @@ -1,33 +0,0 @@ --- Applies this generator to gen-sdk's shared cross-backend fixture project --- (the same "music_catalogue" project java.gen's own tests/Exhaustive.dhall --- exercises), so a Python client compiles from it and passes basedpyright --- strict. Pinned directly at gen-sdk's package.dhall, separately from --- gen/Deps/Sdk.dhall: that file only imports gen-sdk's `module.dhall` (the --- generator-construction function), which has no `Fixtures` field. --- --- The fixture project deliberately covers PG types this generator does not --- support (box, inet, money, ranges, ...), so onUnsupported is set to Skip: --- those statements/types are dropped with a warning instead of aborting the --- whole compile. --- --- Intended to be executed with: --- --- ```bash --- dhall to-directory-tree --file tests/Exhaustive.dhall --output --allow-path-separators --- ``` -let Sdk = ../gen/Deps/Sdk.dhall - -let Gen = ../gen/Gen.dhall - -let OnUnsupported = ../gen/Structures/OnUnsupported.dhall - -let project = Sdk.Fixtures.Exhaustive - -let config = - Some - { packageName = None Text - , emitSync = Some True - , onUnsupported = Some OnUnsupported.Mode.Skip - } - -in Gen.compileToFileMap config project diff --git a/tests/_generated_quality.py b/tests/_generated_quality.py new file mode 100644 index 0000000..5824757 --- /dev/null +++ b/tests/_generated_quality.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import ast +import io +import tokenize +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class LongLine: + path: Path + line_number: int + length: int + + def label(self) -> str: + return f"{self.path}:{self.line_number}:{self.length}" + + +def _module_sql_interior_lines(source: str) -> set[int]: + tree = ast.parse(source) + spans = { + (node.value.lineno, node.value.col_offset, node.value.end_lineno, node.value.end_col_offset) + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == "SQL" + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.end_lineno is not None + and node.value.end_col_offset is not None + } + string_tokens = ( + token for token in tokenize.generate_tokens(io.StringIO(source).readline) if token.type == tokenize.STRING + ) + return { + line_number + for token in string_tokens + if (*token.start, *token.end) in spans + for line_number in range(token.start[0] + 1, token.end[0]) + } + + +def generated_line_length_issues(path: Path, *, limit: int = 120) -> tuple[list[LongLine], list[LongLine]]: + source = path.read_text() + sql_interior = _module_sql_interior_lines(source) + authored: list[LongLine] = [] + sql: list[LongLine] = [] + for line_number, line in enumerate(source.splitlines(), start=1): + if len(line) <= limit: + continue + issue = LongLine(path, line_number, len(line)) + (sql if line_number in sql_interior else authored).append(issue) + return authored, sql diff --git a/tests/_harness.py b/tests/_harness.py index 037ac41..35a3719 100644 --- a/tests/_harness.py +++ b/tests/_harness.py @@ -11,6 +11,7 @@ import signal import subprocess import threading +from contextlib import suppress from pathlib import Path import psycopg @@ -23,7 +24,7 @@ DEFAULT_MAX_RSS_GB = 40.0 HERE = Path(__file__).resolve().parent -GEN_DIR = HERE.parent / "gen" +SRC_DIR = HERE.parent / "src" FIXTURE_PROJECT = HERE / "fixture-project" GOLDEN_DIR = HERE / "golden" @@ -66,13 +67,7 @@ def effective_database_name(url: str) -> str: # them here, else a path-less, dbname-less URL with PGDATABASE set to a protected # name would slip past while libpq connected to the protected DB. Empty (OS-user # fallback only) returns "" and the caller rejects it. - return str( - info.get("dbname") - or os.environ.get("PGDATABASE") - or info.get("user") - or os.environ.get("PGUSER") - or "" - ) + return str(info.get("dbname") or os.environ.get("PGDATABASE") or info.get("user") or os.environ.get("PGUSER") or "") def _rss_gb(pid: int) -> float | None: @@ -116,10 +111,8 @@ def watchdog() -> None: state["peak_gb"] = max(float(state["peak_gb"]), rss) # type: ignore[arg-type] if rss > budget_gb: state["killed"] = True - try: + with suppress(ProcessLookupError, PermissionError): os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except (ProcessLookupError, PermissionError): - pass return if done.wait(2.0): # normal exit signalled, or poll interval elapsed return diff --git a/tests/conftest.py b/tests/conftest.py index d0ccc23..78c3bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,17 +9,22 @@ import shutil import subprocess +import uuid +from collections.abc import Iterator from pathlib import Path +import psycopg import pytest +from psycopg.conninfo import make_conninfo from tests._harness import ( FIXTURE_PROJECT, - GEN_DIR, GOLDEN_DIR, HERE, + SRC_DIR, admin_database_url, effective_database_name, + ensure_droppable, run_pgn, ) @@ -28,9 +33,9 @@ def pgn_bin() -> str: """Absolute path to the mise-managed pgn binary. - pgn is installed via mise from GitHub releases and is not on PATH outside the - monorepo. Resolving it here lets the harness exec it with cwd set to a temp - project copy that lives outside the repo. + pgn is pinned by this repository's mise config and may not be on the caller's + PATH. Resolving it here lets the harness exec it with cwd set to a temporary + project copy. """ resolved = shutil.which("pgn") if resolved: @@ -38,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.skip("pgn binary not resolvable via mise") + pytest.fail("pgn 0.12.0 is required but is not resolvable via PATH or mise") return path @@ -56,6 +61,37 @@ def pgn_admin_url() -> str: return url +@pytest.fixture +def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: + """A uniquely named scratch database on the configured server, dropped on teardown.""" + name = f"pgn_rt_{uuid.uuid4().hex[:12]}" + admin = psycopg.connect(pgn_admin_url, autocommit=True) + try: + # Encoding to bytes sidesteps psycopg's LiteralString-typed execute + # overload for these dynamic admin statements (the db name is a generated + # hex, not user input). + _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) + finally: + admin.close() + + # Rebuild via conninfo (not string surgery) so host/port/user/params survive, + # including a path-less admin URL the guard accepts. + target = make_conninfo(pgn_admin_url, dbname=name) + try: + yield target + finally: + admin = psycopg.connect(pgn_admin_url, autocommit=True) + try: + terminate = ( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()" + ) + _ = admin.execute(terminate.encode(), (name,)) + ensure_droppable(name) + _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) + finally: + admin.close() + + @pytest.fixture def fixture_copy(tmp_path: Path) -> Path: """A writable copy of the fixture pgn project under tmp_path.""" @@ -67,15 +103,15 @@ def fixture_copy(tmp_path: Path) -> Path: def generated_tree(pgn_bin: str, pgn_admin_url: str, tmp_path_factory: pytest.TempPathFactory) -> Path: """Generate the fixture client once and return its artifacts/python dir. - The generator path in project1.pgn.yaml is `../../gen/Gen.dhall`, + The generator path in project1.pgn.yaml is `../../src/package.dhall`, relative to the fixture project. To keep it resolvable the copy mirrors the - real layout: `/gen` and `/tests/fixture-project`. The freeze + real layout: `/src` and `/tests/fixture-project`. The freeze file is dropped so pgn re-resolves the working-tree generator instead of a - cached hash (a stale freeze makes pgn ignore gen/ edits and silently + cached hash (a stale freeze makes pgn ignore src/ edits and silently emit the old output). """ root = tmp_path_factory.mktemp("pygen") - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) @@ -102,15 +138,18 @@ def full_package(generated_tree: Path, tmp_path_factory: pytest.TempPathFactory) root = tmp_path_factory.mktemp("pkg") shell_src = GOLDEN_DIR / "src" generated_src = generated_tree / "src" - _ = shutil.copytree(shell_src, root / "src", ignore=shutil.ignore_patterns("_generated", "__init__.py")) - for pkg_dir in generated_src.iterdir(): - dest_pkg = root / "src" / pkg_dir.name - _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") - _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") - # The sync facade (sync/__init__.py) lives outside _generated, like the - # async facade; overlay it too when the project emits a sync surface. - sync_facade = pkg_dir / "sync" / "__init__.py" - if sync_facade.exists(): - (dest_pkg / "sync").mkdir(parents=True, exist_ok=True) - _ = shutil.copy2(sync_facade, dest_pkg / "sync" / "__init__.py") + packages = [path for path in generated_src.iterdir() if path.is_dir()] + assert len(packages) == 1, f"expected exactly one generated package, found {packages}" + pkg_dir = packages[0] + assert pkg_dir.name == "specimen_client" + + _ = shutil.copytree( + shell_src, + root / "src", + ignore=shutil.ignore_patterns("_generated", "__init__.py", "sync"), + ) + dest_pkg = root / "src" / pkg_dir.name + _ = shutil.copytree(pkg_dir / "_generated", dest_pkg / "_generated") + _ = shutil.copy2(pkg_dir / "__init__.py", dest_pkg / "__init__.py") + _ = shutil.copytree(pkg_dir / "sync", dest_pkg / "sync") return root diff --git a/tests/fixture-project/migrations/1.sql b/tests/fixture-project/migrations/1.sql index ec0f36c..2dd9f33 100644 --- a/tests/fixture-project/migrations/1.sql +++ b/tests/fixture-project/migrations/1.sql @@ -5,6 +5,18 @@ create type mood as enum ('happy', 'sad', 'meh'); +create type z_codec_payload as ( + "class" int8, + pg_decode text, + pg_encode text +); + +create type a_codec_wrapper as ( + payload z_codec_payload, + feeling mood, + note text +); + create type point2d as ( x float8, y float8 @@ -24,10 +36,8 @@ create domain revision as integer create domain jsonb_object as jsonb check (jsonb_typeof(value) = 'object'); --- Single-field composite fixture: exercises the composite-bind edge case where --- a one-field tuple literal needs a trailing comma to stay a tuple in Python --- (point2d's two-field tuple never needed one, so its golden never exercised --- this path). +-- Single-field composite fixture: verifies that the registered dataclass dumper +-- returns a field-ordered one-element tuple and preserves composite arity. create type tag_value as ( value text ); @@ -79,6 +89,9 @@ create table specimen ( -- composite origin point2d, + codec_payload z_codec_payload not null, + codec_payloads z_codec_payload[] not null, + codec_wrapper a_codec_wrapper, -- domains label display_name not null, diff --git a/tests/fixture-project/project1.pgn.yaml b/tests/fixture-project/project1.pgn.yaml index f6bec82..4a40523 100644 --- a/tests/fixture-project/project1.pgn.yaml +++ b/tests/fixture-project/project1.pgn.yaml @@ -4,7 +4,7 @@ version: 0.0.0 postgres: 18 artifacts: python: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: specimen-client emitSync: true @@ -12,25 +12,24 @@ artifacts: # Optional Config knobs (undocumented by pgn itself); see # tests/test_config_variants.py for the assertions. python-name-only: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: name-only-client python-sync-only: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: emitSync: true + python-explicit-false: + gen: ../../src/package.dhall + config: + emitSync: false python-empty: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: {} python-bare: - gen: ../../gen/Gen.dhall - python-unknown-key: - gen: ../../gen/Gen.dhall - config: - packageName: unknown-key-client - bogusField: 1 + gen: ../../src/package.dhall python-null: - gen: ../../gen/Gen.dhall + gen: ../../src/package.dhall config: packageName: null-client emitSync: null diff --git a/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml b/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml index 583383d..71ef063 100644 --- a/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml +++ b/tests/fixture-project/queries/get_specimen.sig1.pgn.yaml @@ -93,6 +93,17 @@ result: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false label: type: text not_null: true diff --git a/tests/fixture-project/queries/get_specimen.sql b/tests/fixture-project/queries/get_specimen.sql index c18d7ce..d643be7 100644 --- a/tests/fixture-project/queries/get_specimen.sql +++ b/tests/fixture-project/queries/get_specimen.sql @@ -6,7 +6,7 @@ SELECT doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, origin, + feeling, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta FROM specimen WHERE id = $id diff --git a/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml b/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml index 32e1164..17a29ad 100644 --- a/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml +++ b/tests/fixture-project/queries/insert_specimen.sig1.pgn.yaml @@ -83,6 +83,17 @@ parameters: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false result: cardinality: one columns: @@ -178,6 +189,17 @@ result: origin: type: point_2_d not_null: false + codec_payload: + type: z_codec_payload + not_null: true + codec_payloads: + type: z_codec_payload + not_null: true + dims: 1 + element_not_null: false + codec_wrapper: + type: a_codec_wrapper + not_null: false label: type: text not_null: true diff --git a/tests/fixture-project/queries/insert_specimen.sql b/tests/fixture-project/queries/insert_specimen.sql index b5bea55..213f7fd 100644 --- a/tests/fixture-project/queries/insert_specimen.sql +++ b/tests/fixture-project/queries/insert_specimen.sql @@ -11,7 +11,7 @@ INSERT INTO specimen ( doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta ) VALUES ( @@ -20,7 +20,7 @@ VALUES ( $doc_json::json, $doc_jsonb::jsonb, $maybe_text, $maybe_int, $maybe_uuid, $maybe_ts, $maybe_num, $tags, $related_ids, $grid, - $feeling, $moods::mood[], $origin, + $feeling, $moods::mood[], $origin, $codec_payload, $codec_payloads, $codec_wrapper, 'specimen', 1, '{}'::jsonb ) RETURNING @@ -30,5 +30,5 @@ RETURNING doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta diff --git a/tests/fixture-project/queries/list_specimens_keyword_column.sql b/tests/fixture-project/queries/list_specimens_keyword_column.sql index 9adb37f..59ac765 100644 --- a/tests/fixture-project/queries/list_specimens_keyword_column.sql +++ b/tests/fixture-project/queries/list_specimens_keyword_column.sql @@ -1,8 +1,8 @@ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw "class". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). +-- Keyword result-column coverage: the SQL column aliased to reserved word class +-- stays named "class", while the statement-local Row field is class_. +-- psycopg args_row constructs that Row positionally, so no name remapping +-- can hide a missed sanitizer. Without sanitization the canonical statement +-- is invalid Python; this query locks the result-column guard. SELECT id, title AS "class" diff --git a/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml b/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml new file mode 100644 index 0000000..7a406e1 --- /dev/null +++ b/tests/fixture-project/types/public/a_codec_wrapper.sig1.pgn.yaml @@ -0,0 +1,10 @@ +composite: + payload: + type: z_codec_payload + not_null: false + feeling: + type: mood + not_null: false + note: + type: text + not_null: false diff --git a/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml b/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml new file mode 100644 index 0000000..e6b2c8d --- /dev/null +++ b/tests/fixture-project/types/public/z_codec_payload.sig1.pgn.yaml @@ -0,0 +1,10 @@ +composite: + class: + type: int8 + not_null: false + pg_decode: + type: text + not_null: false + pg_encode: + type: text + not_null: false diff --git a/tests/golden/README.md b/tests/golden/README.md index 40b9074..b925829 100644 --- a/tests/golden/README.md +++ b/tests/golden/README.md @@ -1,33 +1,41 @@ -# Golden files +# Golden fixture -A committed full fixture package: the hand-written shell (`pyproject.toml`, -`src/specimen_client/py.typed`) plus the generated subtree under -`src/specimen_client/_generated/` and the generated package-root facade -`src/specimen_client/__init__.py`. The generator emits the `_generated/` subtree and -the facade; the rest of the shell is hand-written and committed here as a fixture. +This directory is one committed combined client package. Its hand-written shell +is `pyproject.toml` plus `src/specimen_client/py.typed`. Generator-owned content +is the complete `src/specimen_client/_generated/` subtree, the package-root +`src/specimen_client/__init__.py` facade, and, when `emitSync: true`, the +`src/specimen_client/sync/__init__.py` facade. -`test_generated_matches_golden` regenerates the fixture client into a temp tree -and asserts every file under the fresh `_generated/` plus the facade equals its -golden twin, both ways (no missing, no extra). -`test_generated_passes_basedpyright_strict` runs basedpyright strict on the full -golden package (shell + facade + `_generated`) so the strictness guarantee covers -the real consumer layout. This `README.md` and the hand-written shell stay out of -the byte-comparison. +The harness overlays that hand-written shell with a freshly generated package. +It compares every generator-owned file in both directions, checks +basedpyright strict on the full consumer layout, and exercises both connection +surfaces. There is only one golden package because async and sync share the same +canonical SQL, Row classes, custom types, core, and registration module. -## Updating the golden +## Regenerating -Run only when a generator change legitimately alters the output, and review the -resulting diff before committing. Refresh the `_generated/` subtree and the -facade; never overwrite the hand-written `pyproject.toml` or `py.typed`. +Run the repository task only when an intentional generator or source-query +comment change alters output: ```bash -# Default admin URL is localhost:5432; set PGN_TEST_DATABASE_URL to point -# elsewhere (e.g. a local pg0 instance on a non-default port). +# Set PGN_TEST_DATABASE_URL for a PostgreSQL server on a non-default address. mise run golden ``` -The task (see `mise.toml`) copies the fixture project to a temp dir, cuts it -down to the `python` artifact (a full 7-artifact generate peaks at ~31 GB RSS, -a single-artifact one at ~10 GB), regenerates from the working-tree `gen/` -with a fresh resolve (no stale `freeze1.pgn.yaml`), and rsyncs the -`_generated/` subtree plus both facades back into the golden tree. +The task performs one serial, memory-heavy generation. It copies +`tests/fixture-project/` to a temporary directory, deletes the copied freeze file +and artifact directory, keeps only the Python artifact, and rewrites its `gen` +reference to the working-tree `src/package.dhall`. After pgn succeeds, it syncs +the generated subtree and copies the root and sync facades into this fixture. +The temporary directory is removed on exit. + +Do not hand-edit generator-owned files. Do not copy over `pyproject.toml` or +`py.typed`. The task does not update analyzed signature YAML, so any signature +change is a separate, explicit review and is unexpected for a SQL-comment-only +refresh. + +After regeneration, review the complete fixture diff before committing: + +```bash +mise exec -- git diff -- tests/golden +``` diff --git a/tests/golden/pyproject.toml b/tests/golden/pyproject.toml index 512f22f..f9a51db 100644 --- a/tests/golden/pyproject.toml +++ b/tests/golden/pyproject.toml @@ -6,10 +6,7 @@ build-backend = "hatchling.build" name = "specimen-client" version = "0.0.0" requires-python = ">=3.12" -dependencies = ["psycopg>=3.2"] +dependencies = ["psycopg>=3.3.4,<4"] [tool.hatch.build.targets.wheel] packages = ["src/specimen_client"] - -[tool.ruff] -exclude = ["src/specimen_client/_generated"] diff --git a/tests/golden/src/specimen_client/__init__.py b/tests/golden/src/specimen_client/__init__.py index c34dbc1..a99ea57 100644 --- a/tests/golden/src/specimen_client/__init__.py +++ b/tests/golden/src/specimen_client/__init__.py @@ -1,44 +1,117 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -from ._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from ._generated.types.mood import Mood as Mood -from ._generated.types.point_2_d import Point2D as Point2D -from ._generated.types.tag_value import TagValue as TagValue +from . import ( + sync as sync, +) +from ._generated._core import ( + JsonValue as JsonValue, +) +from ._generated._core import ( + NoRowError as NoRowError, +) +from ._generated._register import ( + register_types as register_types, +) -from ._generated._rows import ( +# isort: off +from ._generated.statements.bump_specimen_revision import ( + bump_specimen_revision as bump_specimen_revision, +) +from ._generated.statements.get_specimen import ( GetSpecimenRow as GetSpecimenRow, +) +from ._generated.statements.get_specimen import ( + get_specimen as get_specimen, +) +from ._generated.statements.get_tagged_item import ( GetTaggedItemRow as GetTaggedItemRow, +) +from ._generated.statements.get_tagged_item import ( + get_tagged_item as get_tagged_item, +) +from ._generated.statements.insert_specimen import ( InsertSpecimenRow as InsertSpecimenRow, +) +from ._generated.statements.insert_specimen import ( + insert_specimen as insert_specimen, +) +from ._generated.statements.insert_tagged_item import ( InsertTaggedItemRow as InsertTaggedItemRow, +) +from ._generated.statements.insert_tagged_item import ( + insert_tagged_item as insert_tagged_item, +) +from ._generated.statements.list_specimens_by_class import ( ListSpecimensByClassRow as ListSpecimensByClassRow, +) +from ._generated.statements.list_specimens_by_class import ( + list_specimens_by_class as list_specimens_by_class, +) +from ._generated.statements.list_specimens_by_feeling import ( ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, +) +from ._generated.statements.list_specimens_by_feeling import ( + list_specimens_by_feeling as list_specimens_by_feeling, +) +from ._generated.statements.list_specimens_by_ids import ( ListSpecimensByIdsRow as ListSpecimensByIdsRow, +) +from ._generated.statements.list_specimens_by_ids import ( + list_specimens_by_ids as list_specimens_by_ids, +) +from ._generated.statements.list_specimens_by_moods import ( ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, +) +from ._generated.statements.list_specimens_by_moods import ( + list_specimens_by_moods as list_specimens_by_moods, +) +from ._generated.statements.list_specimens_keyword_column import ( ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, +) +from ._generated.statements.list_specimens_keyword_column import ( + list_specimens_keyword_column as list_specimens_keyword_column, +) +from ._generated.statements.search_specimens import ( SearchSpecimensRow as SearchSpecimensRow, ) +from ._generated.statements.search_specimens import ( + search_specimens as search_specimens, +) +# isort: on -from ._generated.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from ._generated.statements.get_specimen import get_specimen as get_specimen -from ._generated.statements.get_tagged_item import get_tagged_item as get_tagged_item -from ._generated.statements.insert_specimen import insert_specimen as insert_specimen -from ._generated.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item -from ._generated.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class -from ._generated.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling -from ._generated.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids -from ._generated.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods -from ._generated.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column -from ._generated.statements.search_specimens import search_specimens as search_specimens +# isort: off +from ._generated.types.mood import ( + Mood as Mood, +) +from ._generated.types.point_2_d import ( + Point2D as Point2D, +) +from ._generated.types.tag_value import ( + TagValue as TagValue, +) +from ._generated.types.z_codec_payload import ( + ZCodecPayload as ZCodecPayload, +) +from ._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) +# isort: on __all__ = [ "JsonValue", "NoRowError", +] +# Project order is intentional for public re-export groups. +__all__ += [ # noqa: RUF022, RUF100 "Mood", "Point2D", "TagValue", + "ZCodecPayload", + "ACodecWrapper", +] +__all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -49,6 +122,8 @@ "ListSpecimensByMoodsRow", "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", +] +__all__ += [ # noqa: RUF022, RUF100 "bump_specimen_revision", "get_specimen", "get_tagged_item", @@ -61,3 +136,9 @@ "list_specimens_keyword_column", "search_specimens", ] +__all__ += [ + "register_types", +] +__all__ += [ + "sync", +] diff --git a/tests/golden/src/specimen_client/_generated/__init__.py b/tests/golden/src/specimen_client/_generated/__init__.py index 0abb185..be7142b 100644 --- a/tests/golden/src/specimen_client/_generated/__init__.py +++ b/tests/golden/src/specimen_client/_generated/__init__.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/_core.py b/tests/golden/src/specimen_client/_generated/_core.py index 8927292..a19b9a0 100644 --- a/tests/golden/src/specimen_client/_generated/_core.py +++ b/tests/golden/src/specimen_client/_generated/_core.py @@ -1,35 +1,17 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -"""Shared types and decode helpers, surface-agnostic; no I/O.""" +"""Shared types and errors, surface-agnostic; no I/O.""" from __future__ import annotations -from typing import cast - type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] class NoRowError(RuntimeError): """A single-row query returned no rows.""" - -class DecodeError(RuntimeError): - """A row value failed to decode into its target type.""" - - -def require_array(value: object) -> list[object]: - """Guard an enum-array column decode. - - psycopg returns an enum array as a Python list only when the enum type is - registered on the connection (register_types); without it the value comes - back as the raw array text, which would iterate into bogus members. Fail - clearly instead. - """ - if isinstance(value, list): - return cast(list[object], value) - raise RuntimeError( - "enum array decoded as text; call register_types() on the connection " - "before decoding enum-array columns" - ) + def __init__(self, sql: str) -> None: + self.sql = sql + super().__init__(f"single-row query returned no rows: {sql}") diff --git a/tests/golden/src/specimen_client/_generated/_register.py b/tests/golden/src/specimen_client/_generated/_register.py index b42a0bd..0a3e2fa 100644 --- a/tests/golden/src/specimen_client/_generated/_register.py +++ b/tests/golden/src/specimen_client/_generated/_register.py @@ -1,26 +1,161 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection -from psycopg.types import TypeInfo +import keyword +from collections.abc import Callable, Sequence +from dataclasses import fields, is_dataclass +from typing import Any + +from psycopg import AsyncConnection, Connection from psycopg.types.composite import CompositeInfo, register_composite +from psycopg.types.enum import EnumInfo, register_enum + +from . import types as _db_types + +type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] +type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] + + +def _python_name(name: str) -> str: + if keyword.iskeyword(name): + return f"{name}_" + return name + + +def _dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) -_COMPOSITE_TYPES = ("public.point2d", "public.tag_value",) -_ENUM_TYPES = ("public.mood",) + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + + +_mood_pg_name = "public.mood" +_point_2_d_pg_name = "public.point2d" +_point_2_d_make_object, _point_2_d_make_sequence = _dataclass_callbacks(_db_types.Point2D) +_tag_value_pg_name = "public.tag_value" +_tag_value_make_object, _tag_value_make_sequence = _dataclass_callbacks(_db_types.TagValue) +_z_codec_payload_pg_name = "public.z_codec_payload" +_z_codec_payload_make_object, _z_codec_payload_make_sequence = _dataclass_callbacks(_db_types.ZCodecPayload) +_a_codec_wrapper_pg_name = "public.a_codec_wrapper" +_a_codec_wrapper_make_object, _a_codec_wrapper_make_sequence = _dataclass_callbacks(_db_types.ACodecWrapper) async def register_types(conn: AsyncConnection[object]) -> None: - for name in _COMPOSITE_TYPES: - composite_info = await CompositeInfo.fetch(conn, name) - if composite_info is None: - raise LookupError(f"composite type {name!r} not found; cannot register it") - register_composite(composite_info, conn) - for name in _ENUM_TYPES: - enum_info = await TypeInfo.fetch(conn, name) - if enum_info is None: - raise LookupError(f"enum type {name!r} not found; cannot register it") - enum_info.register(conn) + mood_info = await EnumInfo.fetch(conn, _mood_pg_name) + if mood_info is None: + raise LookupError(f"enum type {_mood_pg_name!r} not found; cannot register it") + register_enum( + mood_info, + conn, + _db_types.Mood, + mapping={member: member.value for member in _db_types.Mood}, + ) + point_2_d_info = await CompositeInfo.fetch(conn, _point_2_d_pg_name) + if point_2_d_info is None: + raise LookupError(f"composite type {_point_2_d_pg_name!r} not found; cannot register it") + register_composite( + point_2_d_info, + conn, + _db_types.Point2D, + make_object=_point_2_d_make_object, + make_sequence=_point_2_d_make_sequence, + ) + tag_value_info = await CompositeInfo.fetch(conn, _tag_value_pg_name) + if tag_value_info is None: + raise LookupError(f"composite type {_tag_value_pg_name!r} not found; cannot register it") + register_composite( + tag_value_info, + conn, + _db_types.TagValue, + make_object=_tag_value_make_object, + make_sequence=_tag_value_make_sequence, + ) + z_codec_payload_info = await CompositeInfo.fetch(conn, _z_codec_payload_pg_name) + if z_codec_payload_info is None: + raise LookupError(f"composite type {_z_codec_payload_pg_name!r} not found; cannot register it") + register_composite( + z_codec_payload_info, + conn, + _db_types.ZCodecPayload, + make_object=_z_codec_payload_make_object, + make_sequence=_z_codec_payload_make_sequence, + ) + a_codec_wrapper_info = await CompositeInfo.fetch(conn, _a_codec_wrapper_pg_name) + if a_codec_wrapper_info is None: + raise LookupError(f"composite type {_a_codec_wrapper_pg_name!r} not found; cannot register it") + register_composite( + a_codec_wrapper_info, + conn, + _db_types.ACodecWrapper, + make_object=_a_codec_wrapper_make_object, + make_sequence=_a_codec_wrapper_make_sequence, + ) + + +def register_types_sync(conn: Connection[object]) -> None: + mood_info = EnumInfo.fetch(conn, _mood_pg_name) + if mood_info is None: + raise LookupError(f"enum type {_mood_pg_name!r} not found; cannot register it") + register_enum( + mood_info, + conn, + _db_types.Mood, + mapping={member: member.value for member in _db_types.Mood}, + ) + point_2_d_info = CompositeInfo.fetch(conn, _point_2_d_pg_name) + if point_2_d_info is None: + raise LookupError(f"composite type {_point_2_d_pg_name!r} not found; cannot register it") + register_composite( + point_2_d_info, + conn, + _db_types.Point2D, + make_object=_point_2_d_make_object, + make_sequence=_point_2_d_make_sequence, + ) + tag_value_info = CompositeInfo.fetch(conn, _tag_value_pg_name) + if tag_value_info is None: + raise LookupError(f"composite type {_tag_value_pg_name!r} not found; cannot register it") + register_composite( + tag_value_info, + conn, + _db_types.TagValue, + make_object=_tag_value_make_object, + make_sequence=_tag_value_make_sequence, + ) + z_codec_payload_info = CompositeInfo.fetch(conn, _z_codec_payload_pg_name) + if z_codec_payload_info is None: + raise LookupError(f"composite type {_z_codec_payload_pg_name!r} not found; cannot register it") + register_composite( + z_codec_payload_info, + conn, + _db_types.ZCodecPayload, + make_object=_z_codec_payload_make_object, + make_sequence=_z_codec_payload_make_sequence, + ) + a_codec_wrapper_info = CompositeInfo.fetch(conn, _a_codec_wrapper_pg_name) + if a_codec_wrapper_info is None: + raise LookupError(f"composite type {_a_codec_wrapper_pg_name!r} not found; cannot register it") + register_composite( + a_codec_wrapper_info, + conn, + _db_types.ACodecWrapper, + make_object=_a_codec_wrapper_make_object, + make_sequence=_a_codec_wrapper_make_sequence, + ) diff --git a/tests/golden/src/specimen_client/_generated/_rows.py b/tests/golden/src/specimen_client/_generated/_rows.py deleted file mode 100644 index e5e586d..0000000 --- a/tests/golden/src/specimen_client/_generated/_rows.py +++ /dev/null @@ -1,294 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import date, datetime -from decimal import Decimal -from typing import cast -from uuid import UUID - -from ._core import JsonValue -from ._core import require_array -from .types.mood import Mood -from .types.point_2_d import Point2D -from .types.tag_value import TagValue - - -@dataclass(frozen=True, slots=True) -class GetSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_get_specimen(row: Mapping[str, object]) -> GetSpecimenRow: - return GetSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood(cast(str, row["feeling"])), - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class GetTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_get_tagged_item(row: Mapping[str, object]) -> GetTaggedItemRow: - return GetTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue(*cast(tuple[str | None], row["tag"])), - ) - - -@dataclass(frozen=True, slots=True) -class InsertSpecimenRow: - id: int - pub_id: UUID - flag: bool - small: int - medium: int - large: int - ratio: float - precise: float - title: str - code: str - letter: str - born_on: date - created_at: datetime - amount: Decimal - blob: bytes - doc_json: JsonValue - doc_jsonb: JsonValue - maybe_text: str | None - maybe_int: int | None - maybe_uuid: UUID | None - maybe_ts: datetime | None - maybe_num: Decimal | None - tags: list[str | None] - related_ids: list[UUID | None] | None - grid: list[int | None] | None - feeling: Mood - moods: list[Mood | None] | None - origin: Point2D | None - label: str - rev: int - meta: JsonValue - - -def decode_insert_specimen(row: Mapping[str, object]) -> InsertSpecimenRow: - return InsertSpecimenRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - flag=cast(bool, row["flag"]), - small=cast(int, row["small"]), - medium=cast(int, row["medium"]), - large=cast(int, row["large"]), - ratio=cast(float, row["ratio"]), - precise=cast(float, row["precise"]), - title=cast(str, row["title"]), - code=cast(str, row["code"]), - letter=cast(str, row["letter"]), - born_on=cast(date, row["born_on"]), - created_at=cast(datetime, row["created_at"]), - amount=cast(Decimal, row["amount"]), - blob=cast(bytes, row["blob"]), - doc_json=cast(JsonValue, row["doc_json"]), - doc_jsonb=cast(JsonValue, row["doc_jsonb"]), - maybe_text=cast(str | None, row["maybe_text"]), - maybe_int=cast(int | None, row["maybe_int"]), - maybe_uuid=cast(UUID | None, row["maybe_uuid"]), - maybe_ts=cast(datetime | None, row["maybe_ts"]), - maybe_num=cast(Decimal | None, row["maybe_num"]), - tags=cast(list[str | None], row["tags"]), - related_ids=cast(list[UUID | None] | None, row["related_ids"]), - grid=cast(list[int | None] | None, row["grid"]), - feeling=Mood(cast(str, row["feeling"])), - moods=None if row["moods"] is None else [None if v is None else Mood(v) for v in cast(list[str | None], require_array(row["moods"]))], - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class InsertTaggedItemRow: - id: int - name: str - tag: TagValue - - -def decode_insert_tagged_item(row: Mapping[str, object]) -> InsertTaggedItemRow: - return InsertTaggedItemRow( - id=cast(int, row["id"]), - name=cast(str, row["name"]), - tag=TagValue(*cast(tuple[str | None], row["tag"])), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByClassRow: - id: int - title: str - - -def decode_list_specimens_by_class(row: Mapping[str, object]) -> ListSpecimensByClassRow: - return ListSpecimensByClassRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByFeelingRow: - id: int - pub_id: UUID - feeling: Mood - title: str - label: str - rev: int - origin: Point2D | None - tags: list[str | None] - meta: JsonValue - - -def decode_list_specimens_by_feeling(row: Mapping[str, object]) -> ListSpecimensByFeelingRow: - return ListSpecimensByFeelingRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - rev=cast(int, row["rev"]), - origin=None if row["origin"] is None else Point2D(*cast(tuple[float | None, float | None], row["origin"])), - tags=cast(list[str | None], row["tags"]), - meta=cast(JsonValue, row["meta"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByIdsRow: - id: int - pub_id: UUID - feeling: Mood - title: str - - -def decode_list_specimens_by_ids(row: Mapping[str, object]) -> ListSpecimensByIdsRow: - return ListSpecimensByIdsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensByMoodsRow: - id: int - pub_id: UUID - feeling: Mood - moods: list[Mood | None] | None - title: str - - -def decode_list_specimens_by_moods(row: Mapping[str, object]) -> ListSpecimensByMoodsRow: - return ListSpecimensByMoodsRow( - id=cast(int, row["id"]), - pub_id=cast(UUID, row["pub_id"]), - feeling=Mood(cast(str, row["feeling"])), - moods=None if row["moods"] is None else [None if v is None else Mood(v) for v in cast(list[str | None], require_array(row["moods"]))], - title=cast(str, row["title"]), - ) - - -@dataclass(frozen=True, slots=True) -class ListSpecimensKeywordColumnRow: - id: int - class_: str - - -def decode_list_specimens_keyword_column(row: Mapping[str, object]) -> ListSpecimensKeywordColumnRow: - return ListSpecimensKeywordColumnRow( - id=cast(int, row["id"]), - class_=cast(str, row["class"]), - ) - - -@dataclass(frozen=True, slots=True) -class SearchSpecimensRow: - id: int - title: str - label: str - meta: JsonValue - - -def decode_search_specimens(row: Mapping[str, object]) -> SearchSpecimensRow: - return SearchSpecimensRow( - id=cast(int, row["id"]), - title=cast(str, row["title"]), - label=cast(str, row["label"]), - meta=cast(JsonValue, row["meta"]), - ) diff --git a/tests/golden/src/specimen_client/_generated/_runtime.py b/tests/golden/src/specimen_client/_generated/_runtime.py index a31fc84..625870a 100644 --- a/tests/golden/src/specimen_client/_generated/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/_runtime.py @@ -1,63 +1,58 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from collections.abc import Callable, Mapping -from typing import TypeVar +from typing import LiteralString from psycopg import AsyncConnection -from psycopg.rows import dict_row +from psycopg.rows import BaseRowFactory -from ._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from ._core import NoRowError -_T = TypeVar("_T") -_Row = Mapping[str, object] -_Params = Mapping[str, object] +_Params = dict[str, object] -async def fetch_optional( +async def fetch_optional[T]( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> _T | None: - async with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> T | None: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - row = await cur.fetchone() - return None if row is None else decode(row) + return await cur.fetchone() -async def fetch_single( +async def fetch_single[T]( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> _T: - async with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> T: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) row = await cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row -async def fetch_many( +async def fetch_many[T]( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> list[_T]: - async with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> list[T]: + async with conn.cursor(row_factory=row_factory) as cur: _ = await cur.execute(sql, params) - rows = await cur.fetchall() - return [decode(row) for row in rows] + return await cur.fetchall() async def execute_rows_affected( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: async with conn.cursor() as cur: @@ -67,7 +62,7 @@ async def execute_rows_affected( async def execute_void( conn: AsyncConnection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: async with conn.cursor() as cur: diff --git a/tests/golden/src/specimen_client/_generated/statements/__init__.py b/tests/golden/src/specimen_client/_generated/statements/__init__.py index 3c3ae7f..d33de0b 100644 --- a/tests/golden/src/specimen_client/_generated/statements/__init__.py +++ b/tests/golden/src/specimen_client/_generated/statements/__init__.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py index 9a8421b..8872bdd 100644 --- a/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py +++ b/tests/golden/src/specimen_client/_generated/statements/bump_specimen_revision.py @@ -1,12 +1,13 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection -from .._runtime import execute_rows_affected +from .._runtime import execute_rows_affected as _execute_rows_affected +from ..sync._runtime import execute_rows_affected as _execute_rows_affected_sync SQL = """\ -- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount @@ -16,8 +17,6 @@ WHERE id = %(id)s """ -_SQL = SQL.encode() - async def bump_specimen_revision( conn: AsyncConnection[object], @@ -27,4 +26,15 @@ async def bump_specimen_revision( params: dict[str, object] = { "id": id, } - return await execute_rows_affected(conn, _SQL, params) + return await _execute_rows_affected(conn, SQL, params) + + +def bump_specimen_revision_sync( + conn: Connection[object], + *, + id: int | None, +) -> int: + params: dict[str, object] = { + "id": id, + } + return _execute_rows_affected_sync(conn, SQL, params) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py index d152d88..5e0d9c9 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_specimen.py @@ -1,13 +1,21 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID -from .._rows import GetSpecimenRow, decode_get_specimen -from .._runtime import fetch_optional +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .. import types as _db_types +from .._core import JsonValue +from .._runtime import fetch_optional as _fetch_optional +from ..sync._runtime import fetch_optional as _fetch_optional_sync SQL = """\ -- zero_or_one: select by pk with limit 1. @@ -18,14 +26,49 @@ doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, origin, + feeling, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta FROM specimen WHERE id = %(id)s LIMIT 1 """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class GetSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: _db_types.Mood + origin: _db_types.Point2D | None + codec_payload: _db_types.ZCodecPayload + codec_payloads: list[_db_types.ZCodecPayload | None] + codec_wrapper: _db_types.ACodecWrapper | None + label: str + rev: int + meta: JsonValue async def get_specimen( @@ -36,4 +79,15 @@ async def get_specimen( params: dict[str, object] = { "id": id, } - return await fetch_optional(conn, _SQL, params, decode_get_specimen) + return await _fetch_optional(conn, SQL, params, _args_row(GetSpecimenRow)) + + +def get_specimen_sync( + conn: Connection[object], + *, + id: int | None, +) -> GetSpecimenRow | None: + params: dict[str, object] = { + "id": id, + } + return _fetch_optional_sync(conn, SQL, params, _args_row(GetSpecimenRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py index 2cede6c..ad56306 100644 --- a/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/get_tagged_item.py @@ -1,13 +1,17 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass -from .._rows import GetTaggedItemRow, decode_get_tagged_item -from .._runtime import fetch_optional +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .. import types as _db_types +from .._runtime import fetch_optional as _fetch_optional +from ..sync._runtime import fetch_optional as _fetch_optional_sync SQL = """\ -- zero_or_one: select by pk with limit 1; the single-field composite here is @@ -19,7 +23,12 @@ LIMIT 1 """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class GetTaggedItemRow: + id: int + name: str + tag: _db_types.TagValue async def get_tagged_item( @@ -30,4 +39,15 @@ async def get_tagged_item( params: dict[str, object] = { "id": id, } - return await fetch_optional(conn, _SQL, params, decode_get_tagged_item) + return await _fetch_optional(conn, SQL, params, _args_row(GetTaggedItemRow)) + + +def get_tagged_item_sync( + conn: Connection[object], + *, + id: int | None, +) -> GetTaggedItemRow | None: + params: dict[str, object] = { + "id": id, + } + return _fetch_optional_sync(conn, SQL, params, _args_row(GetTaggedItemRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py index f97eb70..73aad01 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_specimen.py @@ -1,22 +1,22 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations +from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal from uuid import UUID -from psycopg import AsyncConnection -from psycopg.types.json import Json -from psycopg.types.json import Jsonb +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row +from psycopg.types.json import Json, Jsonb +from .. import types as _db_types from .._core import JsonValue -from .._rows import InsertSpecimenRow, decode_insert_specimen -from .._runtime import fetch_single -from ..types.mood import Mood -from ..types.point_2_d import Point2D +from .._runtime import fetch_single as _fetch_single +from ..sync._runtime import fetch_single as _fetch_single_sync SQL = """\ -- single row: insert ... returning the full type surface. @@ -32,7 +32,7 @@ doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta ) VALUES ( @@ -41,7 +41,7 @@ %(doc_json)s::json, %(doc_jsonb)s::jsonb, %(maybe_text)s, %(maybe_int)s, %(maybe_uuid)s, %(maybe_ts)s, %(maybe_num)s, %(tags)s, %(related_ids)s, %(grid)s, - %(feeling)s, %(moods)s::mood[], %(origin)s, + %(feeling)s, %(moods)s::mood[], %(origin)s, %(codec_payload)s, %(codec_payloads)s, %(codec_wrapper)s, 'specimen', 1, '{}'::jsonb ) RETURNING @@ -51,11 +51,47 @@ doc_json, doc_jsonb, maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, tags, related_ids, grid, - feeling, moods, origin, + feeling, moods, origin, codec_payload, codec_payloads, codec_wrapper, label, rev, meta """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class InsertSpecimenRow: + id: int + pub_id: UUID + flag: bool + small: int + medium: int + large: int + ratio: float + precise: float + title: str + code: str + letter: str + born_on: date + created_at: datetime + amount: Decimal + blob: bytes + doc_json: JsonValue + doc_jsonb: JsonValue + maybe_text: str | None + maybe_int: int | None + maybe_uuid: UUID | None + maybe_ts: datetime | None + maybe_num: Decimal | None + tags: list[str | None] + related_ids: list[UUID | None] | None + grid: list[int | None] | None + feeling: _db_types.Mood + moods: list[_db_types.Mood | None] | None + origin: _db_types.Point2D | None + codec_payload: _db_types.ZCodecPayload + codec_payloads: list[_db_types.ZCodecPayload | None] + codec_wrapper: _db_types.ACodecWrapper | None + label: str + rev: int + meta: JsonValue async def insert_specimen( @@ -83,9 +119,77 @@ async def insert_specimen( tags: list[str | None], related_ids: list[UUID | None] | None, grid: list[int | None] | None, - feeling: Mood, - moods: list[Mood | None] | None, - origin: Point2D | None, + feeling: _db_types.Mood, + moods: list[_db_types.Mood | None] | None, + origin: _db_types.Point2D | None, + codec_payload: _db_types.ZCodecPayload, + codec_payloads: list[_db_types.ZCodecPayload | None], + codec_wrapper: _db_types.ACodecWrapper | None, +) -> InsertSpecimenRow: + params: dict[str, object] = { + "flag": flag, + "small": small, + "medium": medium, + "large": large, + "ratio": ratio, + "precise": precise, + "title": title, + "code": code, + "letter": letter, + "born_on": born_on, + "amount": amount, + "blob": blob, + "doc_json": Json(doc_json), + "doc_jsonb": Jsonb(doc_jsonb), + "maybe_text": maybe_text, + "maybe_int": maybe_int, + "maybe_uuid": maybe_uuid, + "maybe_ts": maybe_ts, + "maybe_num": maybe_num, + "tags": tags, + "related_ids": related_ids, + "grid": grid, + "feeling": feeling, + "moods": moods, + "origin": origin, + "codec_payload": codec_payload, + "codec_payloads": codec_payloads, + "codec_wrapper": codec_wrapper, + } + return await _fetch_single(conn, SQL, params, _args_row(InsertSpecimenRow)) + + +def insert_specimen_sync( + conn: Connection[object], + *, + flag: bool, + small: int, + medium: int, + large: int, + ratio: float, + precise: float, + title: str, + code: str, + letter: str, + born_on: date, + amount: Decimal, + blob: bytes, + doc_json: JsonValue, + doc_jsonb: JsonValue, + maybe_text: str | None, + maybe_int: int | None, + maybe_uuid: UUID | None, + maybe_ts: datetime | None, + maybe_num: Decimal | None, + tags: list[str | None], + related_ids: list[UUID | None] | None, + grid: list[int | None] | None, + feeling: _db_types.Mood, + moods: list[_db_types.Mood | None] | None, + origin: _db_types.Point2D | None, + codec_payload: _db_types.ZCodecPayload, + codec_payloads: list[_db_types.ZCodecPayload | None], + codec_wrapper: _db_types.ACodecWrapper | None, ) -> InsertSpecimenRow: params: dict[str, object] = { "flag": flag, @@ -112,6 +216,9 @@ async def insert_specimen( "grid": grid, "feeling": feeling, "moods": moods, - "origin": None if origin is None else (origin.x, origin.y), + "origin": origin, + "codec_payload": codec_payload, + "codec_payloads": codec_payloads, + "codec_wrapper": codec_wrapper, } - return await fetch_single(conn, _SQL, params, decode_insert_specimen) + return _fetch_single_sync(conn, SQL, params, _args_row(InsertSpecimenRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py index b2fff1c..1185d21 100644 --- a/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py +++ b/tests/golden/src/specimen_client/_generated/statements/insert_tagged_item.py @@ -1,14 +1,17 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass -from .._rows import InsertTaggedItemRow, decode_insert_tagged_item -from .._runtime import fetch_single -from ..types.tag_value import TagValue +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .. import types as _db_types +from .._runtime import fetch_single as _fetch_single +from ..sync._runtime import fetch_single as _fetch_single_sync SQL = """\ -- single row: insert exercising a single-field composite as a parameter and @@ -18,17 +21,35 @@ RETURNING id, name, tag """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class InsertTaggedItemRow: + id: int + name: str + tag: _db_types.TagValue async def insert_tagged_item( conn: AsyncConnection[object], *, name: str, - tag: TagValue, + tag: _db_types.TagValue, +) -> InsertTaggedItemRow: + params: dict[str, object] = { + "name": name, + "tag": tag, + } + return await _fetch_single(conn, SQL, params, _args_row(InsertTaggedItemRow)) + + +def insert_tagged_item_sync( + conn: Connection[object], + *, + name: str, + tag: _db_types.TagValue, ) -> InsertTaggedItemRow: params: dict[str, object] = { "name": name, - "tag": (tag.value,), + "tag": tag, } - return await fetch_single(conn, _SQL, params, decode_insert_tagged_item) + return _fetch_single_sync(conn, SQL, params, _args_row(InsertTaggedItemRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py index 0c93129..9551b8e 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_class.py @@ -1,13 +1,16 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass -from .._rows import ListSpecimensByClassRow, decode_list_specimens_by_class -from .._runtime import fetch_many +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ -- Keyword-param coverage: the placeholder `class` is a Python reserved word, so @@ -22,7 +25,11 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByClassRow: + id: int + title: str async def list_specimens_by_class( @@ -33,4 +40,15 @@ async def list_specimens_by_class( params: dict[str, object] = { "class": class_, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_class) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByClassRow)) + + +def list_specimens_by_class_sync( + conn: Connection[object], + *, + class_: str | None, +) -> list[ListSpecimensByClassRow]: + params: dict[str, object] = { + "class": class_, + } + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByClassRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py index 2173d01..211cb97 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_feeling.py @@ -1,14 +1,19 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass +from uuid import UUID -from .._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling -from .._runtime import fetch_many -from ..types.mood import Mood +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .. import types as _db_types +from .._core import JsonValue +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ -- many: select with order by. Enum parameter ($feeling). @@ -19,15 +24,37 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByFeelingRow: + id: int + pub_id: UUID + feeling: _db_types.Mood + title: str + label: str + rev: int + origin: _db_types.Point2D | None + tags: list[str | None] + meta: JsonValue async def list_specimens_by_feeling( conn: AsyncConnection[object], *, - feeling: Mood | None, + feeling: _db_types.Mood | None, +) -> list[ListSpecimensByFeelingRow]: + params: dict[str, object] = { + "feeling": feeling, + } + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByFeelingRow)) + + +def list_specimens_by_feeling_sync( + conn: Connection[object], + *, + feeling: _db_types.Mood | None, ) -> list[ListSpecimensByFeelingRow]: params: dict[str, object] = { "feeling": feeling, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByFeelingRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py index 29bc6ea..f120f87 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_ids.py @@ -1,15 +1,18 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations +from dataclasses import dataclass from uuid import UUID -from psycopg import AsyncConnection +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row -from .._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids -from .._runtime import fetch_many +from .. import types as _db_types +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ -- many: array parameter via = any($pub_ids). @@ -20,7 +23,13 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByIdsRow: + id: int + pub_id: UUID + feeling: _db_types.Mood + title: str async def list_specimens_by_ids( @@ -31,4 +40,15 @@ async def list_specimens_by_ids( params: dict[str, object] = { "pub_ids": pub_ids, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_ids) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByIdsRow)) + + +def list_specimens_by_ids_sync( + conn: Connection[object], + *, + pub_ids: list[UUID | None] | None, +) -> list[ListSpecimensByIdsRow]: + params: dict[str, object] = { + "pub_ids": pub_ids, + } + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByIdsRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py index 644d601..545c28d 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_by_moods.py @@ -1,14 +1,18 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass +from uuid import UUID -from .._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods -from .._runtime import fetch_many -from ..types.mood import Mood +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .. import types as _db_types +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ -- many: enum array parameter via = any($moods::mood[]); returns the enum array column. @@ -19,15 +23,33 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensByMoodsRow: + id: int + pub_id: UUID + feeling: _db_types.Mood + moods: list[_db_types.Mood | None] | None + title: str async def list_specimens_by_moods( conn: AsyncConnection[object], *, - moods: list[Mood | None] | None, + moods: list[_db_types.Mood | None] | None, +) -> list[ListSpecimensByMoodsRow]: + params: dict[str, object] = { + "moods": moods, + } + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensByMoodsRow)) + + +def list_specimens_by_moods_sync( + conn: Connection[object], + *, + moods: list[_db_types.Mood | None] | None, ) -> list[ListSpecimensByMoodsRow]: params: dict[str, object] = { "moods": moods, } - return await fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensByMoodsRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py index cae6f52..ab5dd48 100644 --- a/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py +++ b/tests/golden/src/specimen_client/_generated/statements/list_specimens_keyword_column.py @@ -1,20 +1,23 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass -from .._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column -from .._runtime import fetch_many +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row + +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw \"class\". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). +-- Keyword result-column coverage: the SQL column aliased to reserved word class +-- stays named \"class\", while the statement-local Row field is class_. +-- psycopg args_row constructs that Row positionally, so no name remapping +-- can hide a missed sanitizer. Without sanitization the canonical statement +-- is invalid Python; this query locks the result-column guard. SELECT id, title AS \"class\" @@ -22,11 +25,22 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class ListSpecimensKeywordColumnRow: + id: int + class_: str async def list_specimens_keyword_column( conn: AsyncConnection[object], ) -> list[ListSpecimensKeywordColumnRow]: params: dict[str, object] = {} - return await fetch_many(conn, _SQL, params, decode_list_specimens_keyword_column) + return await _fetch_many(conn, SQL, params, _args_row(ListSpecimensKeywordColumnRow)) + + +def list_specimens_keyword_column_sync( + conn: Connection[object], +) -> list[ListSpecimensKeywordColumnRow]: + params: dict[str, object] = {} + return _fetch_many_sync(conn, SQL, params, _args_row(ListSpecimensKeywordColumnRow)) diff --git a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py index 7caea94..8cfb55a 100644 --- a/tests/golden/src/specimen_client/_generated/statements/search_specimens.py +++ b/tests/golden/src/specimen_client/_generated/statements/search_specimens.py @@ -1,15 +1,18 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from psycopg import AsyncConnection +from dataclasses import dataclass + +from psycopg import AsyncConnection, Connection +from psycopg.rows import args_row as _args_row from psycopg.types.json import Jsonb from .._core import JsonValue -from .._rows import SearchSpecimensRow, decode_search_specimens -from .._runtime import fetch_many +from .._runtime import fetch_many as _fetch_many +from ..sync._runtime import fetch_many as _fetch_many_sync SQL = """\ -- many: nullable parameter via coalesce, jsonb containment parameter, and a @@ -25,7 +28,13 @@ ORDER BY id ASC """ -_SQL = SQL.encode() + +@dataclass(frozen=True, slots=True) +class SearchSpecimensRow: + id: int + title: str + label: str + meta: JsonValue async def search_specimens( @@ -40,4 +49,19 @@ async def search_specimens( "meta_filter": None if meta_filter is None else Jsonb(meta_filter), "label": label, } - return await fetch_many(conn, _SQL, params, decode_search_specimens) + return await _fetch_many(conn, SQL, params, _args_row(SearchSpecimensRow)) + + +def search_specimens_sync( + conn: Connection[object], + *, + title_like: str | None, + meta_filter: JsonValue | None, + label: str | None, +) -> list[SearchSpecimensRow]: + params: dict[str, object] = { + "title_like": title_like, + "meta_filter": None if meta_filter is None else Jsonb(meta_filter), + "label": label, + } + return _fetch_many_sync(conn, SQL, params, _args_row(SearchSpecimensRow)) diff --git a/tests/golden/src/specimen_client/_generated/sync/__init__.py b/tests/golden/src/specimen_client/_generated/sync/__init__.py deleted file mode 100644 index 47a8df4..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -"""Generated sync database client.""" - -__all__: list[str] = [] diff --git a/tests/golden/src/specimen_client/_generated/sync/_register.py b/tests/golden/src/specimen_client/_generated/sync/_register.py deleted file mode 100644 index f4ffb13..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/_register.py +++ /dev/null @@ -1,26 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection -from psycopg.types import TypeInfo -from psycopg.types.composite import CompositeInfo, register_composite - - -_COMPOSITE_TYPES = ("public.point2d", "public.tag_value",) -_ENUM_TYPES = ("public.mood",) - - -def register_types(conn: Connection[object]) -> None: - for name in _COMPOSITE_TYPES: - composite_info = CompositeInfo.fetch(conn, name) - if composite_info is None: - raise LookupError(f"composite type {name!r} not found; cannot register it") - register_composite(composite_info, conn) - for name in _ENUM_TYPES: - enum_info = TypeInfo.fetch(conn, name) - if enum_info is None: - raise LookupError(f"enum type {name!r} not found; cannot register it") - enum_info.register(conn) diff --git a/tests/golden/src/specimen_client/_generated/sync/_runtime.py b/tests/golden/src/specimen_client/_generated/sync/_runtime.py index 312a249..98b8fca 100644 --- a/tests/golden/src/specimen_client/_generated/sync/_runtime.py +++ b/tests/golden/src/specimen_client/_generated/sync/_runtime.py @@ -1,63 +1,58 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 from __future__ import annotations -from collections.abc import Callable, Mapping -from typing import TypeVar +from typing import LiteralString from psycopg import Connection -from psycopg.rows import dict_row +from psycopg.rows import BaseRowFactory -from .._core import JsonValue as JsonValue, NoRowError as NoRowError, require_array as require_array +from .._core import NoRowError -_T = TypeVar("_T") -_Row = Mapping[str, object] -_Params = Mapping[str, object] +_Params = dict[str, object] -def fetch_optional( +def fetch_optional[T]( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> _T | None: - with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> T | None: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - row = cur.fetchone() - return None if row is None else decode(row) + return cur.fetchone() -def fetch_single( +def fetch_single[T]( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> _T: - with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> T: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) row = cur.fetchone() if row is None: - raise NoRowError(sql.decode()) - return decode(row) + raise NoRowError(sql) + return row -def fetch_many( +def fetch_many[T]( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, - decode: Callable[[_Row], _T], -) -> list[_T]: - with conn.cursor(row_factory=dict_row) as cur: + row_factory: BaseRowFactory[T], +) -> list[T]: + with conn.cursor(row_factory=row_factory) as cur: _ = cur.execute(sql, params) - rows = cur.fetchall() - return [decode(row) for row in rows] + return cur.fetchall() def execute_rows_affected( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> int: with conn.cursor() as cur: @@ -67,7 +62,7 @@ def execute_rows_affected( def execute_void( conn: Connection[object], - sql: bytes, + sql: LiteralString, params: _Params, ) -> None: with conn.cursor() as cur: diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/__init__.py b/tests/golden/src/specimen_client/_generated/sync/statements/__init__.py deleted file mode 100644 index 3f0a22b..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -"""Generated SQL statements (sync).""" - -__all__: list[str] = [] diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/bump_specimen_revision.py b/tests/golden/src/specimen_client/_generated/sync/statements/bump_specimen_revision.py deleted file mode 100644 index 2f39073..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/bump_specimen_revision.py +++ /dev/null @@ -1,30 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from .._runtime import execute_rows_affected - -SQL = """\ --- rows_affected: UPDATE without RETURNING, keyed by id; exercises the rowcount --- execution path. -UPDATE specimen -SET rev = rev + 1 -WHERE id = %(id)s -""" - -_SQL = SQL.encode() - - -def bump_specimen_revision( - conn: Connection[object], - *, - id: int | None, -) -> int: - params: dict[str, object] = { - "id": id, - } - return execute_rows_affected(conn, _SQL, params) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py b/tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py deleted file mode 100644 index 748445d..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/get_specimen.py +++ /dev/null @@ -1,39 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import GetSpecimenRow, decode_get_specimen -from .._runtime import fetch_optional - -SQL = """\ --- zero_or_one: select by pk with limit 1. -SELECT - id, pub_id, - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, created_at, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, origin, - label, rev, meta -FROM specimen -WHERE id = %(id)s -LIMIT 1 -""" - -_SQL = SQL.encode() - - -def get_specimen( - conn: Connection[object], - *, - id: int | None, -) -> GetSpecimenRow | None: - params: dict[str, object] = { - "id": id, - } - return fetch_optional(conn, _SQL, params, decode_get_specimen) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py b/tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py deleted file mode 100644 index 08757e6..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/get_tagged_item.py +++ /dev/null @@ -1,33 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import GetTaggedItemRow, decode_get_tagged_item -from .._runtime import fetch_optional - -SQL = """\ --- zero_or_one: select by pk with limit 1; the single-field composite here is --- purely a result column (no parameter use). -SELECT - id, name, tag -FROM tagged_item -WHERE id = %(id)s -LIMIT 1 -""" - -_SQL = SQL.encode() - - -def get_tagged_item( - conn: Connection[object], - *, - id: int | None, -) -> GetTaggedItemRow | None: - params: dict[str, object] = { - "id": id, - } - return fetch_optional(conn, _SQL, params, decode_get_tagged_item) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py b/tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py deleted file mode 100644 index e8ea5f5..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/insert_specimen.py +++ /dev/null @@ -1,117 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from datetime import date, datetime -from decimal import Decimal -from uuid import UUID - -from psycopg import Connection -from psycopg.types.json import Json -from psycopg.types.json import Jsonb - -from ..._core import JsonValue -from ..._rows import InsertSpecimenRow, decode_insert_specimen -from .._runtime import fetch_single -from ...types.mood import Mood -from ...types.point_2_d import Point2D - -SQL = """\ --- single row: insert ... returning the full type surface. --- jsonb param ($doc_jsonb), enum param ($feeling), composite param ($origin). --- The domain columns (label, rev, meta) get literal/default values rather than --- parameters. pgn cannot bind a parameter to a checked domain column: a --- raw domain param is rejected, and a base-type-cast param fails the domain --- CHECK against the synthetic probe value. Domain mapping is still exercised --- through the RETURNING clause and the read queries below. -INSERT INTO specimen ( - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, moods, origin, - label, rev, meta -) -VALUES ( - %(flag)s, %(small)s, %(medium)s, %(large)s, %(ratio)s, %(precise)s, - %(title)s, %(code)s, %(letter)s, %(born_on)s, %(amount)s, %(blob)s, - %(doc_json)s::json, %(doc_jsonb)s::jsonb, - %(maybe_text)s, %(maybe_int)s, %(maybe_uuid)s, %(maybe_ts)s, %(maybe_num)s, - %(tags)s, %(related_ids)s, %(grid)s, - %(feeling)s, %(moods)s::mood[], %(origin)s, - 'specimen', 1, '{}'::jsonb -) -RETURNING - id, pub_id, - flag, small, medium, large, ratio, precise, - title, code, letter, born_on, created_at, amount, blob, - doc_json, doc_jsonb, - maybe_text, maybe_int, maybe_uuid, maybe_ts, maybe_num, - tags, related_ids, grid, - feeling, moods, origin, - label, rev, meta -""" - -_SQL = SQL.encode() - - -def insert_specimen( - conn: Connection[object], - *, - flag: bool, - small: int, - medium: int, - large: int, - ratio: float, - precise: float, - title: str, - code: str, - letter: str, - born_on: date, - amount: Decimal, - blob: bytes, - doc_json: JsonValue, - doc_jsonb: JsonValue, - maybe_text: str | None, - maybe_int: int | None, - maybe_uuid: UUID | None, - maybe_ts: datetime | None, - maybe_num: Decimal | None, - tags: list[str | None], - related_ids: list[UUID | None] | None, - grid: list[int | None] | None, - feeling: Mood, - moods: list[Mood | None] | None, - origin: Point2D | None, -) -> InsertSpecimenRow: - params: dict[str, object] = { - "flag": flag, - "small": small, - "medium": medium, - "large": large, - "ratio": ratio, - "precise": precise, - "title": title, - "code": code, - "letter": letter, - "born_on": born_on, - "amount": amount, - "blob": blob, - "doc_json": Json(doc_json), - "doc_jsonb": Jsonb(doc_jsonb), - "maybe_text": maybe_text, - "maybe_int": maybe_int, - "maybe_uuid": maybe_uuid, - "maybe_ts": maybe_ts, - "maybe_num": maybe_num, - "tags": tags, - "related_ids": related_ids, - "grid": grid, - "feeling": feeling, - "moods": moods, - "origin": None if origin is None else (origin.x, origin.y), - } - return fetch_single(conn, _SQL, params, decode_insert_specimen) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py b/tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py deleted file mode 100644 index 6d1f35a..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/insert_tagged_item.py +++ /dev/null @@ -1,34 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import InsertTaggedItemRow, decode_insert_tagged_item -from .._runtime import fetch_single -from ...types.tag_value import TagValue - -SQL = """\ --- single row: insert exercising a single-field composite as a parameter and --- (via RETURNING) as a result column, in the same statement. -INSERT INTO tagged_item (name, tag) -VALUES (%(name)s, %(tag)s) -RETURNING id, name, tag -""" - -_SQL = SQL.encode() - - -def insert_tagged_item( - conn: Connection[object], - *, - name: str, - tag: TagValue, -) -> InsertTaggedItemRow: - params: dict[str, object] = { - "name": name, - "tag": (tag.value,), - } - return fetch_single(conn, _SQL, params, decode_insert_tagged_item) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py b/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py deleted file mode 100644 index a5309e4..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_class.py +++ /dev/null @@ -1,36 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import ListSpecimensByClassRow, decode_list_specimens_by_class -from .._runtime import fetch_many - -SQL = """\ --- Keyword-param coverage: the placeholder `class` is a Python reserved word, so --- the generated signature must use `class_` while the params-dict key and the --- %%(class)s placeholder keep the raw name. Without sanitization this query emits --- an un-importable signature, so its presence locks the keyword guard. -SELECT - id, - title -FROM specimen -WHERE title = %(class)s -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_class( - conn: Connection[object], - *, - class_: str | None, -) -> list[ListSpecimensByClassRow]: - params: dict[str, object] = { - "class": class_, - } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_class) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py b/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py deleted file mode 100644 index 4b5f2cd..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_feeling.py +++ /dev/null @@ -1,33 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import ListSpecimensByFeelingRow, decode_list_specimens_by_feeling -from .._runtime import fetch_many -from ...types.mood import Mood - -SQL = """\ --- many: select with order by. Enum parameter ($feeling). -SELECT - id, pub_id, feeling, title, label, rev, origin, tags, meta -FROM specimen -WHERE feeling = %(feeling)s -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_feeling( - conn: Connection[object], - *, - feeling: Mood | None, -) -> list[ListSpecimensByFeelingRow]: - params: dict[str, object] = { - "feeling": feeling, - } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_feeling) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py b/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py deleted file mode 100644 index 94c6299..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_ids.py +++ /dev/null @@ -1,34 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from uuid import UUID - -from psycopg import Connection - -from ..._rows import ListSpecimensByIdsRow, decode_list_specimens_by_ids -from .._runtime import fetch_many - -SQL = """\ --- many: array parameter via = any($pub_ids). -SELECT - id, pub_id, feeling, title -FROM specimen -WHERE pub_id = ANY(%(pub_ids)s) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_ids( - conn: Connection[object], - *, - pub_ids: list[UUID | None] | None, -) -> list[ListSpecimensByIdsRow]: - params: dict[str, object] = { - "pub_ids": pub_ids, - } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_ids) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py b/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py deleted file mode 100644 index 5aaebd0..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_by_moods.py +++ /dev/null @@ -1,33 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import ListSpecimensByMoodsRow, decode_list_specimens_by_moods -from .._runtime import fetch_many -from ...types.mood import Mood - -SQL = """\ --- many: enum array parameter via = any($moods::mood[]); returns the enum array column. -SELECT - id, pub_id, feeling, moods, title -FROM specimen -WHERE feeling = ANY(%(moods)s::mood[]) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_by_moods( - conn: Connection[object], - *, - moods: list[Mood | None] | None, -) -> list[ListSpecimensByMoodsRow]: - params: dict[str, object] = { - "moods": moods, - } - return fetch_many(conn, _SQL, params, decode_list_specimens_by_moods) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py b/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py deleted file mode 100644 index 1b36708..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/list_specimens_keyword_column.py +++ /dev/null @@ -1,32 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection - -from ..._rows import ListSpecimensKeywordColumnRow, decode_list_specimens_keyword_column -from .._runtime import fetch_many - -SQL = """\ --- Keyword result-column coverage: the column aliased to the reserved word class --- must emit a dataclass field and decode kwarg of class_ while the row lookup --- keeps the raw \"class\". Without sanitization the generated _rows.py has a --- SyntaxError, so this query locks the result-column guard (mirrors --- list_specimens_by_class for params). -SELECT - id, - title AS \"class\" -FROM specimen -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def list_specimens_keyword_column( - conn: Connection[object], -) -> list[ListSpecimensKeywordColumnRow]: - params: dict[str, object] = {} - return fetch_many(conn, _SQL, params, decode_list_specimens_keyword_column) diff --git a/tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py b/tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py deleted file mode 100644 index 73006c1..0000000 --- a/tests/golden/src/specimen_client/_generated/sync/statements/search_specimens.py +++ /dev/null @@ -1,43 +0,0 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. -# SPDX-FileCopyrightText: 2026 Viacheslav Shvets -# SPDX-License-Identifier: MIT-0 - -from __future__ import annotations - -from psycopg import Connection -from psycopg.types.json import Jsonb - -from ..._core import JsonValue -from ..._rows import SearchSpecimensRow, decode_search_specimens -from .._runtime import fetch_many - -SQL = """\ --- many: nullable parameter via coalesce, jsonb containment parameter, and a --- domain comparison with the param cast to the base type (pgn cannot bind a --- domain parameter directly). -SELECT - id, title, label, meta -FROM specimen -WHERE - title ILIKE COALESCE(%(title_like)s, '%%') - AND meta @> %(meta_filter)s::jsonb - AND label = COALESCE(%(label)s::text, label) -ORDER BY id ASC -""" - -_SQL = SQL.encode() - - -def search_specimens( - conn: Connection[object], - *, - title_like: str | None, - meta_filter: JsonValue | None, - label: str | None, -) -> list[SearchSpecimensRow]: - params: dict[str, object] = { - "title_like": title_like, - "meta_filter": None if meta_filter is None else Jsonb(meta_filter), - "label": label, - } - return fetch_many(conn, _SQL, params, decode_search_specimens) diff --git a/tests/golden/src/specimen_client/_generated/types/__init__.py b/tests/golden/src/specimen_client/_generated/types/__init__.py index c8a6ba8..3b56de9 100644 --- a/tests/golden/src/specimen_client/_generated/types/__init__.py +++ b/tests/golden/src/specimen_client/_generated/types/__init__.py @@ -1,7 +1,11 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 +# isort: off 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/_generated/types/a_codec_wrapper.py b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py new file mode 100644 index 0000000..ef4894b --- /dev/null +++ b/tests/golden/src/specimen_client/_generated/types/a_codec_wrapper.py @@ -0,0 +1,15 @@ +# @generated by python.gen (pGenie); regeneration overwrites manual changes. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass + +from .mood import Mood +from .z_codec_payload import ZCodecPayload + + +@dataclass(frozen=True, slots=True) +class ACodecWrapper: + payload: ZCodecPayload | None + feeling: Mood | None + note: str | None diff --git a/tests/golden/src/specimen_client/_generated/types/mood.py b/tests/golden/src/specimen_client/_generated/types/mood.py index ece63e0..588f71a 100644 --- a/tests/golden/src/specimen_client/_generated/types/mood.py +++ b/tests/golden/src/specimen_client/_generated/types/mood.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 diff --git a/tests/golden/src/specimen_client/_generated/types/point_2_d.py b/tests/golden/src/specimen_client/_generated/types/point_2_d.py index 1874b55..3cbe493 100644 --- a/tests/golden/src/specimen_client/_generated/types/point_2_d.py +++ b/tests/golden/src/specimen_client/_generated/types/point_2_d.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -7,11 +7,5 @@ @dataclass(frozen=True, slots=True) class Point2D: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated _decode cannot splat into the dataclass. - """ - x: float | None y: float | None diff --git a/tests/golden/src/specimen_client/_generated/types/tag_value.py b/tests/golden/src/specimen_client/_generated/types/tag_value.py index 13814f9..9a456ce 100644 --- a/tests/golden/src/specimen_client/_generated/types/tag_value.py +++ b/tests/golden/src/specimen_client/_generated/types/tag_value.py @@ -1,4 +1,4 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 @@ -7,10 +7,4 @@ @dataclass(frozen=True, slots=True) class TagValue: - """Decoding/encoding this composite requires register_types(conn) first. - - Without per-connection registration psycopg returns the value as a - raw string, which the generated _decode cannot splat into the dataclass. - """ - value: str | None diff --git a/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py new file mode 100644 index 0000000..3d7b1a3 --- /dev/null +++ b/tests/golden/src/specimen_client/_generated/types/z_codec_payload.py @@ -0,0 +1,12 @@ +# @generated by python.gen (pGenie); regeneration overwrites manual changes. +# SPDX-FileCopyrightText: 2026 Viacheslav Shvets +# SPDX-License-Identifier: MIT-0 + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ZCodecPayload: + class_: int | None + pg_decode: str | None + pg_encode: str | None diff --git a/tests/golden/src/specimen_client/sync/__init__.py b/tests/golden/src/specimen_client/sync/__init__.py index 18b75c1..21d00e8 100644 --- a/tests/golden/src/specimen_client/sync/__init__.py +++ b/tests/golden/src/specimen_client/sync/__init__.py @@ -1,44 +1,114 @@ -# @generated by python.gen (pGenie). DO NOT EDIT. +# @generated by python.gen (pGenie); regeneration overwrites manual changes. # SPDX-FileCopyrightText: 2026 Viacheslav Shvets # SPDX-License-Identifier: MIT-0 -from .._generated._core import JsonValue as JsonValue, NoRowError as NoRowError - -from .._generated.types.mood import Mood as Mood -from .._generated.types.point_2_d import Point2D as Point2D -from .._generated.types.tag_value import TagValue as TagValue +from .._generated._core import ( + JsonValue as JsonValue, +) +from .._generated._core import ( + NoRowError as NoRowError, +) +from .._generated._register import ( + register_types_sync as register_types, +) -from .._generated._rows import ( +# isort: off +from .._generated.statements.bump_specimen_revision import ( + bump_specimen_revision_sync as bump_specimen_revision, +) +from .._generated.statements.get_specimen import ( GetSpecimenRow as GetSpecimenRow, +) +from .._generated.statements.get_specimen import ( + get_specimen_sync as get_specimen, +) +from .._generated.statements.get_tagged_item import ( GetTaggedItemRow as GetTaggedItemRow, +) +from .._generated.statements.get_tagged_item import ( + get_tagged_item_sync as get_tagged_item, +) +from .._generated.statements.insert_specimen import ( InsertSpecimenRow as InsertSpecimenRow, +) +from .._generated.statements.insert_specimen import ( + insert_specimen_sync as insert_specimen, +) +from .._generated.statements.insert_tagged_item import ( InsertTaggedItemRow as InsertTaggedItemRow, +) +from .._generated.statements.insert_tagged_item import ( + insert_tagged_item_sync as insert_tagged_item, +) +from .._generated.statements.list_specimens_by_class import ( ListSpecimensByClassRow as ListSpecimensByClassRow, +) +from .._generated.statements.list_specimens_by_class import ( + list_specimens_by_class_sync as list_specimens_by_class, +) +from .._generated.statements.list_specimens_by_feeling import ( ListSpecimensByFeelingRow as ListSpecimensByFeelingRow, +) +from .._generated.statements.list_specimens_by_feeling import ( + list_specimens_by_feeling_sync as list_specimens_by_feeling, +) +from .._generated.statements.list_specimens_by_ids import ( ListSpecimensByIdsRow as ListSpecimensByIdsRow, +) +from .._generated.statements.list_specimens_by_ids import ( + list_specimens_by_ids_sync as list_specimens_by_ids, +) +from .._generated.statements.list_specimens_by_moods import ( ListSpecimensByMoodsRow as ListSpecimensByMoodsRow, +) +from .._generated.statements.list_specimens_by_moods import ( + list_specimens_by_moods_sync as list_specimens_by_moods, +) +from .._generated.statements.list_specimens_keyword_column import ( ListSpecimensKeywordColumnRow as ListSpecimensKeywordColumnRow, +) +from .._generated.statements.list_specimens_keyword_column import ( + list_specimens_keyword_column_sync as list_specimens_keyword_column, +) +from .._generated.statements.search_specimens import ( SearchSpecimensRow as SearchSpecimensRow, ) +from .._generated.statements.search_specimens import ( + search_specimens_sync as search_specimens, +) +# isort: on -from .._generated.sync.statements.bump_specimen_revision import bump_specimen_revision as bump_specimen_revision -from .._generated.sync.statements.get_specimen import get_specimen as get_specimen -from .._generated.sync.statements.get_tagged_item import get_tagged_item as get_tagged_item -from .._generated.sync.statements.insert_specimen import insert_specimen as insert_specimen -from .._generated.sync.statements.insert_tagged_item import insert_tagged_item as insert_tagged_item -from .._generated.sync.statements.list_specimens_by_class import list_specimens_by_class as list_specimens_by_class -from .._generated.sync.statements.list_specimens_by_feeling import list_specimens_by_feeling as list_specimens_by_feeling -from .._generated.sync.statements.list_specimens_by_ids import list_specimens_by_ids as list_specimens_by_ids -from .._generated.sync.statements.list_specimens_by_moods import list_specimens_by_moods as list_specimens_by_moods -from .._generated.sync.statements.list_specimens_keyword_column import list_specimens_keyword_column as list_specimens_keyword_column -from .._generated.sync.statements.search_specimens import search_specimens as search_specimens +# isort: off +from .._generated.types.mood import ( + Mood as Mood, +) +from .._generated.types.point_2_d import ( + Point2D as Point2D, +) +from .._generated.types.tag_value import ( + TagValue as TagValue, +) +from .._generated.types.z_codec_payload import ( + ZCodecPayload as ZCodecPayload, +) +from .._generated.types.a_codec_wrapper import ( + ACodecWrapper as ACodecWrapper, +) +# isort: on __all__ = [ "JsonValue", "NoRowError", +] +# Project order is intentional for public re-export groups. +__all__ += [ # noqa: RUF022, RUF100 "Mood", "Point2D", "TagValue", + "ZCodecPayload", + "ACodecWrapper", +] +__all__ += [ # noqa: RUF022, RUF100 "GetSpecimenRow", "GetTaggedItemRow", "InsertSpecimenRow", @@ -49,6 +119,8 @@ "ListSpecimensByMoodsRow", "ListSpecimensKeywordColumnRow", "SearchSpecimensRow", +] +__all__ += [ # noqa: RUF022, RUF100 "bump_specimen_revision", "get_specimen", "get_tagged_item", @@ -61,3 +133,6 @@ "list_specimens_keyword_column", "search_specimens", ] +__all__ += [ + "register_types", +] diff --git a/tests/test_config_variants.py b/tests/test_config_variants.py index 9b23d18..0680a55 100644 --- a/tests/test_config_variants.py +++ b/tests/test_config_variants.py @@ -2,9 +2,9 @@ pgn's decode behavior for record types is undocumented, so each artifact in project1.pgn.yaml drives a different subset of `config` keys through the same -compile.dhall and the assertions below record what pgn 0.6.5 was observed to do, -not a documented contract. These variants are pinned by the directory/package -name and sync-surface presence they produce, not a full golden tree. +working-tree `src/package.dhall` entry point. The assertions below record what +the pinned pgn was observed to do, not a documented contract. These variants +pin the additive sync surface. """ from __future__ import annotations @@ -40,40 +40,65 @@ def _package_dir(generated_tree: Path, artifact_key: str) -> Path: return packages[0] +def _assert_surfaces(package: Path, *, emit_sync: bool) -> None: + generated = package / "_generated" + assert (package / "__init__.py").is_file() + assert "async def fetch_many" in (generated / "_runtime.py").read_text() + + statements = sorted((generated / "statements").glob("*.py")) + statements = [path for path in statements if path.name != "__init__.py"] + assert statements + for statement in statements: + source = statement.read_text() + assert f"async def {statement.stem}(" in source + assert (f"def {statement.stem}_sync(" in source) is emit_sync + + assert (package / "sync" / "__init__.py").is_file() is emit_sync + assert (generated / "sync" / "_runtime.py").is_file() is emit_sync + assert not (generated / "sync" / "statements").exists() + assert not (generated / "sync" / "types").exists() + assert not (generated / "sync" / "_register.py").exists() + + +def test_main_config_emits_both_surfaces(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python") + assert package.name == "specimen_client" + _assert_surfaces(package, emit_sync=True) + + def test_name_only_config_derives_package_and_defaults_sync_off(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-name-only") assert package.name == "name_only_client" - assert not (package / "sync").exists() + _assert_surfaces(package, emit_sync=False) def test_sync_only_config_defaults_package_name_from_project(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-sync-only") assert package.name == "fixture" - assert (package / "sync" / "__init__.py").is_file() + _assert_surfaces(package, emit_sync=True) + + +def test_explicit_false_omits_sync_additions(generated_tree: Path) -> None: + package = _package_dir(generated_tree, "python-explicit-false") + assert package.name == "fixture" + _assert_surfaces(package, emit_sync=False) def test_empty_config_object_defaults_both_fields(generated_tree: Path) -> None: package = _package_dir(generated_tree, "python-empty") assert package.name == "fixture" - assert not (package / "sync").exists() + _assert_surfaces(package, emit_sync=False) def test_absent_config_key_defaults_both_fields(generated_tree: Path) -> None: """No `config:` key at all decodes the same as `config: {}` (None Config).""" package = _package_dir(generated_tree, "python-bare") assert package.name == "fixture" - assert not (package / "sync").exists() - - -def test_unknown_config_key_is_ignored_not_rejected(generated_tree: Path) -> None: - """An extra key not in Config.dhall (bogusField) does not fail generation.""" - package = _package_dir(generated_tree, "python-unknown-key") - assert package.name == "unknown_key_client" - assert not (package / "sync").exists() + _assert_surfaces(package, emit_sync=False) def test_null_value_decodes_as_absent_field(generated_tree: Path) -> None: - """`emitSync: null` decodes to None, same as omitting the key (default False).""" + """`emitSync: null` decodes to None, like omitting the key.""" package = _package_dir(generated_tree, "python-null") assert package.name == "null_client" - assert not (package / "sync").exists() + _assert_surfaces(package, emit_sync=False) diff --git a/tests/test_generated.py b/tests/test_generated.py index b671193..6eea99d 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -6,27 +6,29 @@ End to end: validate the fixture pgn project (`pgn analyse`), generate the Python client (`pgn generate`), diff it against the committed golden tree, typecheck the generated package with basedpyright strict, and round-trip every generated -statement function against a throwaway database on the local pg0 instance. +statement function against a uniquely named scratch database on the configured +PostgreSQL server. """ from __future__ import annotations +import ast import asyncio import importlib +import inspect import json import subprocess import sys import uuid -from collections.abc import Iterator -from decimal import Decimal +from contextlib import contextmanager from datetime import date +from decimal import Decimal from pathlib import Path import psycopg import pytest -from psycopg.conninfo import make_conninfo -from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, ensure_droppable, run_pgn +from tests._harness import FIXTURE_PROJECT, GOLDEN_DIR, HERE, run_pgn HARNESS_ROOT = HERE.parent @@ -34,9 +36,25 @@ # __init__.py facade; the rest of the shell (pyproject.toml, py.typed) is # hand-written and lives in golden as a committed fixture, not produced by # generate. -GENERATED_SUBTREE = Path("src/specimen_client/_generated") -FACADE_INIT = Path("src/specimen_client/__init__.py") -SYNC_FACADE_INIT = Path("src/specimen_client/sync/__init__.py") +GENERATED_PACKAGE = Path("src/specimen_client") +QUERY_NAMES = ( + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", +) +ROW_NAMES = { + name: "".join(part.title() for part in name.split("_")) + "Row" + for name in QUERY_NAMES + if name != "bump_specimen_revision" +} def test_fixture_project_analyses_clean(pgn_bin: str, pgn_admin_url: str, fixture_copy: Path) -> None: @@ -77,31 +95,31 @@ def test_generate_produces_python_package(generated_tree: Path) -> None: assert list(generated_tree.rglob("*.py")), "generator produced no Python modules" -def _relative_files(root: Path) -> set[Path]: +def _relative_files(root: Path, *, exclude: frozenset[Path] = frozenset()) -> set[Path]: # Skip bytecode: the generator never emits it, but a local `import specimen_client` # leaves __pycache__ under golden and would false-fail the file-set comparison. return { p.relative_to(root) for p in root.rglob("*") - if p.is_file() and "__pycache__" not in p.parts + if p.is_file() and "__pycache__" not in p.parts and p.relative_to(root) not in exclude } def test_generated_matches_golden(generated_tree: Path) -> None: - """The generated subtree and the facade must equal golden's byte for byte. + """Every generated package file must equal golden byte for byte. - Generate produces the /_generated subtree and the package-root - __init__.py facade; the rest of the shell in golden is a hand-written + Generate produces the /_generated subtree plus the root and sync + facades; the rest of the shell in golden is a hand-written committed fixture and stays out of this comparison. Update flow when the generator legitimately changes: re-run `pgn generate` in tests/fixture-project - and rsync the fresh _generated subtree plus the facade into golden (see + and copy the fresh _generated subtree plus both facades into golden (see tests/golden/README.md), then review the diff. """ - produced_root = generated_tree / GENERATED_SUBTREE - golden_root = GOLDEN_DIR / GENERATED_SUBTREE + produced_root = generated_tree / GENERATED_PACKAGE + golden_root = GOLDEN_DIR / GENERATED_PACKAGE produced = _relative_files(produced_root) - golden = _relative_files(golden_root) + golden = _relative_files(golden_root, exclude=frozenset({Path("py.typed")})) missing = sorted(str(p) for p in golden - produced) extra = sorted(str(p) for p in produced - golden) @@ -113,25 +131,19 @@ def test_generated_matches_golden(generated_tree: Path) -> None: if (produced_root / rel).read_text() != (golden_root / rel).read_text(): mismatched.append(str(rel)) - for facade in (FACADE_INIT, SYNC_FACADE_INIT): - if (generated_tree / facade).read_text() != (GOLDEN_DIR / facade).read_text(): - mismatched.append(str(facade)) - assert not mismatched, ( "generated output drifted from golden in: " + ", ".join(mismatched) - + "\nupdate via: rsync the fresh _generated subtree and the facade into golden (see tests/golden/README.md)" + + "\nupdate via: mise run golden (see tests/golden/README.md)" ) -def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: - """basedpyright strict on the FULL golden package: zero errors and warnings. +def test_generated_passes_basedpyright_strict(full_package: Path, tmp_path: Path) -> None: + """basedpyright strict on the fresh full package: zero errors and warnings. - The golden package (hand-written shell + the generated _generated subtree) - is the committed contract; test_generated_matches_golden proves the fresh - output equals golden's _generated subtree, so checking golden checks the - generator's output. psycopg resolves from the harness venv. The config scopes - the run to golden's `src` so the harness tests are not pulled in. + The full_package fixture overlays the fresh generated tree and both facades + onto the hand-written shell. psycopg resolves from the harness venv. The + config scopes the run to that package's `src` so harness tests stay excluded. """ config = tmp_path / "pyrightconfig.json" _ = config.write_text( @@ -139,7 +151,7 @@ def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: { "pythonVersion": "3.12", "typeCheckingMode": "strict", - "include": [str(GOLDEN_DIR / "src")], + "include": [str(full_package / "src")], "venvPath": str(HARNESS_ROOT), "venv": ".venv", "reportMissingModuleSource": False, @@ -159,45 +171,13 @@ def test_generated_passes_basedpyright_strict(tmp_path: Path) -> None: summary = json.loads(result.stdout)["summary"] # basedpyright exits 0 with filesAnalyzed=0 when the include path matches nothing, - # so without this the strict gate would pass vacuously if the golden src ever moved. + # so without this the strict gate would pass vacuously if the package src moved. assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files; bad include path?\n{result.stdout}" assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( f"basedpyright strict reported issues: {summary}\n{result.stdout}" ) -@pytest.fixture -def roundtrip_db(pgn_admin_url: str) -> Iterator[str]: - """A uniquely named throwaway database on pg0, dropped on teardown.""" - name = f"pgn_rt_{uuid.uuid4().hex[:12]}" - admin = psycopg.connect(pgn_admin_url, autocommit=True) - try: - # Encoding to bytes sidesteps psycopg's LiteralString-typed execute - # overload for these dynamic admin statements (the db name is a generated - # hex, not user input). - _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) - finally: - admin.close() - - # Rebuild via conninfo (not string surgery) so host/port/user/params survive, - # including a path-less admin URL the guard accepts. - target = make_conninfo(pgn_admin_url, dbname=name) - try: - yield target - finally: - admin = psycopg.connect(pgn_admin_url, autocommit=True) - try: - terminate = ( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " - "WHERE datname = %s AND pid <> pg_backend_pid()" - ) - _ = admin.execute(terminate.encode(), (name,)) - ensure_droppable(name) - _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) - finally: - admin.close() - - def _apply_migrations(db_url: str) -> None: migrations = sorted((FIXTURE_PROJECT / "migrations").glob("*.sql"), key=lambda p: int(p.stem)) with psycopg.connect(db_url, autocommit=True) as conn: @@ -205,50 +185,131 @@ def _apply_migrations(db_url: str) -> None: _ = conn.execute(migration.read_text().encode()) -def _import_client(full_package: Path): # noqa: ANN202 - dynamic module set - src = str(full_package / "src") - if src not in sys.path: - sys.path.insert(0, src) +def _clear_client_modules() -> None: for name in list(sys.modules): if name == "specimen_client" or name.startswith("specimen_client."): del sys.modules[name] - return importlib.import_module -def test_roundtrip_type_mappings(full_package: Path, roundtrip_db: str) -> None: +@contextmanager +def _client_modules(full_package: Path): + src = str(full_package / "src") + original_path = sys.path.copy() + sys.path.insert(0, src) + _clear_client_modules() + try: + root = importlib.import_module("specimen_client") + sync = importlib.import_module("specimen_client.sync") + yield root, sync, importlib.import_module + finally: + _clear_client_modules() + sys.path[:] = original_path + + +@pytest.fixture +def client_modules(full_package: Path): + with _client_modules(full_package) as loaded: + yield loaded + + +def test_combined_public_api_identity_and_signatures(full_package: Path) -> None: + with _client_modules(full_package) as (root, sync, import_module): + assert root.sync is sync + + for name in QUERY_NAMES: + statement = import_module(f"specimen_client._generated.statements.{name}") + async_function = getattr(root, name) + sync_function = getattr(sync, name) + assert async_function is getattr(statement, name) + assert sync_function is getattr(statement, f"{name}_sync") + assert inspect.iscoroutinefunction(async_function) + assert not inspect.iscoroutinefunction(sync_function) + + async_signature = inspect.signature(async_function) + sync_signature = inspect.signature(sync_function) + assert async_signature.return_annotation == sync_signature.return_annotation + async_params = list(async_signature.parameters.values()) + sync_params = list(sync_signature.parameters.values()) + assert len(async_params) == len(sync_params) + for index, (async_param, sync_param) in enumerate(zip(async_params, sync_params, strict=True)): + assert async_param.name == sync_param.name + assert async_param.kind == sync_param.kind + assert async_param.default == sync_param.default + if index == 0: + assert async_param.annotation == "AsyncConnection[object]" + assert sync_param.annotation == "Connection[object]" + else: + assert async_param.annotation == sync_param.annotation + + row_name = ROW_NAMES.get(name) + if row_name is not None: + row_class = getattr(statement, row_name) + assert getattr(root, row_name) is row_class + assert getattr(sync, row_name) is row_class + + for name in ( + "ACodecWrapper", + "JsonValue", + "Mood", + "NoRowError", + "Point2D", + "TagValue", + "ZCodecPayload", + ): + assert getattr(root, name) is getattr(sync, name) + + register = import_module("specimen_client._generated._register") + assert root.register_types is register.register_types + assert sync.register_types is register.register_types_sync + assert "register_types" in root.__all__ + assert "register_types" in sync.__all__ + + source = Path(register.__file__).read_text() + child_async = source.index("z_codec_payload_info = await CompositeInfo.fetch") + parent_async = source.index("a_codec_wrapper_info = await CompositeInfo.fetch") + child_sync = source.index("z_codec_payload_info = CompositeInfo.fetch") + parent_sync = source.index("a_codec_wrapper_info = CompositeInfo.fetch") + assert child_async < parent_async + assert child_sync < parent_sync + assert source.count("from . import types as _db_types") == 1 + assert "_dataclass_callbacks(_db_types.ZCodecPayload)" in source + assert "_dataclass_callbacks(_db_types.ACodecWrapper)" in source + + +def test_roundtrip_type_mappings(client_modules, roundtrip_db: str) -> None: """INSERT then SELECT through the generated client, asserting the mappings. Exercises every generated statement and the full type surface: enum param + enum column decoding to the generated StrEnum, composite param encode + column decode to the frozen dataclass, array param via ANY, jsonb param and column round-trip, the literal-`%` query, nullable columns as None, and the - rows-affected helper. Runs against a throwaway pg0 database. + rows-affected helper. Runs against a uniquely named scratch database on the + configured PostgreSQL server. """ _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated._register") - mood_mod = import_module("specimen_client._generated.types.mood") - point_mod = import_module("specimen_client._generated.types.point_2_d") - insert = import_module("specimen_client._generated.statements.insert_specimen") - get = import_module("specimen_client._generated.statements.get_specimen") - by_feeling = import_module("specimen_client._generated.statements.list_specimens_by_feeling") - by_ids = import_module("specimen_client._generated.statements.list_specimens_by_ids") - by_moods = import_module("specimen_client._generated.statements.list_specimens_by_moods") - by_class = import_module("specimen_client._generated.statements.list_specimens_by_class") - by_kw_col = import_module("specimen_client._generated.statements.list_specimens_keyword_column") - search = import_module("specimen_client._generated.statements.search_specimens") - bump = import_module("specimen_client._generated.statements.bump_specimen_revision") - - Mood = mood_mod.Mood - Point2D = point_mod.Point2D + facade, _, _ = client_modules + Mood = facade.Mood + Point2D = facade.Point2D + ZCodecPayload = facade.ZCodecPayload + ACodecWrapper = facade.ACodecWrapper async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) try: - await register.register_types(conn) + await facade.register_types(conn) + + codec_payload = ZCodecPayload(class_=41, pg_decode="scalar", pg_encode=None) + codec_payloads = [ + ZCodecPayload(class_=42, pg_decode="first", pg_encode="encoded"), + ZCodecPayload(class_=43, pg_decode="second", pg_encode=None), + ] + codec_wrapper = ACodecWrapper( + payload=ZCodecPayload(class_=44, pg_decode="nested", pg_encode=None), + feeling=Mood.SAD, + note=None, + ) - inserted = await insert.insert_specimen( + inserted = await facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, feeling=Mood.HAPPY, @@ -275,7 +336,11 @@ async def scenario() -> None: related_ids=None, grid=None, moods=[Mood.HAPPY, None, Mood.SAD], + codec_payload=codec_payload, + codec_payloads=codec_payloads, + codec_wrapper=codec_wrapper, ) + assert type(inserted) is facade.InsertSpecimenRow # enum column decodes to the generated StrEnum (identity, not just ==). assert isinstance(inserted.feeling, Mood) @@ -296,6 +361,20 @@ async def scenario() -> None: assert inserted.moods is not None assert inserted.moods[0] is Mood.HAPPY assert inserted.moods[2] is Mood.SAD + assert type(inserted.codec_payload) is ZCodecPayload + assert inserted.codec_payload == codec_payload + assert inserted.codec_payload.class_ == 41 + assert inserted.codec_payload.pg_decode == "scalar" + assert inserted.codec_payload.pg_encode is None + assert type(inserted.codec_payloads) is list + assert all(type(value) is ZCodecPayload for value in inserted.codec_payloads) + assert inserted.codec_payloads == codec_payloads + assert type(inserted.codec_wrapper) is ACodecWrapper + assert inserted.codec_wrapper is not None + assert type(inserted.codec_wrapper.payload) is ZCodecPayload + assert inserted.codec_wrapper == codec_wrapper + assert inserted.codec_wrapper.feeling is Mood.SAD + assert inserted.codec_wrapper.note is None # domain-backed columns map to their base Python types. assert inserted.label == "specimen" assert inserted.rev == 1 @@ -305,67 +384,67 @@ async def scenario() -> None: specimen_id = inserted.id # Optional: hit returns the row, miss returns None. - hit = await get.get_specimen(conn, id=specimen_id) + hit = await facade.get_specimen(conn, id=specimen_id) assert hit is not None assert hit.id == specimen_id assert isinstance(hit.feeling, Mood) assert isinstance(hit.origin, Point2D) assert hit.maybe_uuid is None - miss = await get.get_specimen(conn, id=specimen_id + 10_000) + assert type(hit.codec_payload) is ZCodecPayload + assert hit.codec_payload == codec_payload + assert all(type(value) is ZCodecPayload for value in hit.codec_payloads) + assert type(hit.codec_wrapper) is ACodecWrapper + assert hit.codec_wrapper is not None + assert type(hit.codec_wrapper.payload) is ZCodecPayload + assert hit.codec_wrapper.feeling is Mood.SAD + assert hit.codec_wrapper.note is None + miss = await facade.get_specimen(conn, id=specimen_id + 10_000) assert miss is None # Multiple with enum param + enum/composite column decode. - feeling_rows = await by_feeling.list_specimens_by_feeling(conn, feeling=Mood.HAPPY) + feeling_rows = await facade.list_specimens_by_feeling(conn, feeling=Mood.HAPPY) assert len(feeling_rows) == 1 assert feeling_rows[0].feeling is Mood.HAPPY assert isinstance(feeling_rows[0].origin, Point2D) - assert await by_feeling.list_specimens_by_feeling(conn, feeling=Mood.SAD) == [] - - # Array param via ANY. This exercises the nullable-element branch - # (list[T | None] | None). NOTE: the generator's non-null-element - # branch (list[T]) is not covered by this fixture because the - # single-table specimen schema produces no query shape under which pgn - # infers element_not_null:true (= ANY and unnest forms against - # specimen all yield false), and test_committed_sig_files_match_fresh_analysis - # pins every fixture sig to fresh analysis, so a true flag cannot be - # committed here. The branch ships in the real client - # (documents_have_unpublished_changes, whose unnest-over-FK-join shape - # does infer it) and is guarded by checks:pgn-generate plus call-site - # type-checking in apps/backend and apps/ingest, which pass list[UUID]. - # The generated client bodies are not strict-typechecked themselves. - id_rows = await by_ids.list_specimens_by_ids(conn, pub_ids=[inserted.pub_id]) + assert await facade.list_specimens_by_feeling(conn, feeling=Mood.SAD) == [] + + # Array param via ANY. This fixture exercises the nullable-element + # branch because pgn reports element_not_null:false for its signatures. + # Synthetic custom-shape probes exercise elementIsNullable=False, and + # the fresh generated package passes basedpyright strict above. + id_rows = await facade.list_specimens_by_ids(conn, pub_ids=[inserted.pub_id]) assert [r.pub_id for r in id_rows] == [inserted.pub_id] - assert await by_ids.list_specimens_by_ids(conn, pub_ids=[uuid.uuid4()]) == [] + assert await facade.list_specimens_by_ids(conn, pub_ids=[uuid.uuid4()]) == [] # Enum array param via ANY + enum array column decoded element-wise. - mood_rows = await by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) + mood_rows = await facade.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) assert [r.id for r in mood_rows] == [specimen_id] assert mood_rows[0].moods == [Mood.HAPPY, None, Mood.SAD] assert mood_rows[0].moods is not None assert mood_rows[0].moods[0] is Mood.HAPPY - assert await by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] + assert await facade.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] # Keyword-named param: class_ binds as %(class)s, so it must match by title. - class_rows = await by_class.list_specimens_by_class(conn, class_="alpha") + class_rows = await facade.list_specimens_by_class(conn, class_="alpha") assert [r.id for r in class_rows] == [specimen_id] - assert await by_class.list_specimens_by_class(conn, class_="nope") == [] + assert await facade.list_specimens_by_class(conn, class_="nope") == [] # Keyword-named result column: title AS "class" decodes to .class_. - kw_rows = await by_kw_col.list_specimens_keyword_column(conn) + kw_rows = await facade.list_specimens_keyword_column(conn) assert [r.id for r in kw_rows] == [specimen_id] assert kw_rows[0].class_ == "alpha" # jsonb containment param + the literal-`%` ILIKE branch (title_like=None). - search_all = await search.search_specimens(conn, title_like=None, meta_filter={}, label=None) + search_all = await facade.search_specimens(conn, title_like=None, meta_filter={}, label=None) assert [r.id for r in search_all] == [specimen_id] - search_hit = await search.search_specimens(conn, title_like="alp%", meta_filter={}, label="specimen") + search_hit = await facade.search_specimens(conn, title_like="alp%", meta_filter={}, label="specimen") assert [r.id for r in search_hit] == [specimen_id] assert isinstance(search_hit[0].meta, dict) # RowsAffected helper returns the count. - affected = await bump.bump_specimen_revision(conn, id=specimen_id) + affected = await facade.bump_specimen_revision(conn, id=specimen_id) assert affected == 1 - bumped = await get.get_specimen(conn, id=specimen_id) + bumped = await facade.get_specimen(conn, id=specimen_id) assert bumped is not None assert bumped.rev == 2 finally: @@ -374,57 +453,105 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_require_array_rejects_unregistered_enum_array(full_package: Path) -> None: - """require_array turns the unregistered enum-array form into a clear error. +def test_statement_modules_use_psycopg_row_factories(generated_tree: Path) -> None: + generated = generated_tree / GENERATED_PACKAGE / "_generated" + statements = generated / "statements" + paths = sorted(path for path in statements.glob("*.py") if path.name != "__init__.py") + assert [path.stem for path in paths] == sorted(QUERY_NAMES) + assert not (generated / "sync" / "statements").exists() + + sql_count = 0 + for path in paths: + source = path.read_text() + tree = ast.parse(source) + assignments = [ + node + for node in tree.body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "SQL" for target in node.targets) + ] + assert len(assignments) == 1 + sql_count += len(assignments) + assert "_SQL" not in source + assert "_decode_row" not in source + assert "_cast" not in source + assert "_require_array" not in source + + row_name = ROW_NAMES.get(path.stem) + row_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name.endswith("Row")] + assert [node.name for node in row_classes] == ([] if row_name is None else [row_name]) + + args_row_imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" + for alias in node.names + if alias.name == "args_row" and alias.asname == "_args_row" + ] + assert len(args_row_imports) == (0 if row_name is None else 1) + + if row_name is not None: + row_factory_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "_args_row" + ] + assert len(row_factory_calls) == 2 + assert all( + len(call.args) == 1 and isinstance(call.args[0], ast.Name) and call.args[0].id == row_name + for call in row_factory_calls + ) - Without register_types psycopg returns an enum array as the raw array text, not - a list; the generated enum-array decode wraps the value in require_array so it - fails loudly instead of iterating a string into bogus members. - """ - import_module = _import_client(full_package) - runtime = import_module("specimen_client._generated._runtime") + if "_db_types." in source: + assert source.count("from .. import types as _db_types") == 1 + assert "from ..types." not in source - assert runtime.require_array(["happy", "sad"]) == ["happy", "sad"] - with pytest.raises(RuntimeError, match="register_types"): - _ = runtime.require_array("{happy,sad}") + assert sql_count == 11 + wrapper_source = (generated / "types" / "a_codec_wrapper.py").read_text() + assert "from .z_codec_payload import ZCodecPayload" in wrapper_source + assert "from .mood import Mood" in wrapper_source -def test_roundtrip_sync_and_cross_surface_identity(full_package: Path, roundtrip_db: str) -> None: - """The sync surface decodes through the same shared Row types as async. +def test_custom_rows_use_qualified_annotations(client_modules) -> None: + _, _, import_module = client_modules + statement = import_module("specimen_client._generated.statements.insert_specimen") + source = Path(statement.__file__).read_text() - Drives the generated sync functions (psycopg.Connection, no await) end to end, - and asserts the Row dataclasses are shared across surfaces: both facades and - both statement modules expose the same class object (defined once in - _generated._rows), so a value typed against one surface is the other's too. - """ + assert "_decode_row" not in source + assert "_cast" not in source + assert "from .. import types as _db_types" in source + assert "codec_payload: _db_types.ZCodecPayload" in source + assert "codec_payloads: list[_db_types.ZCodecPayload | None]" in source + assert "_args_row(InsertSpecimenRow)" in source + assert ".pg_decode(" not in source + assert ".pg_encode(" not in source + + +def test_roundtrip_sync_surface(client_modules, roundtrip_db: str) -> None: + """Drive the additive sync public facade end to end.""" _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated.sync._register") - mood_mod = import_module("specimen_client._generated.types.mood") - point_mod = import_module("specimen_client._generated.types.point_2_d") - insert = import_module("specimen_client._generated.sync.statements.insert_specimen") - get = import_module("specimen_client._generated.sync.statements.get_specimen") - by_moods = import_module("specimen_client._generated.sync.statements.list_specimens_by_moods") - bump = import_module("specimen_client._generated.sync.statements.bump_specimen_revision") - - Mood = mood_mod.Mood - Point2D = point_mod.Point2D - - # Cross-surface identity: the async facade, the sync facade, the sync - # statement module, and the shared _rows module all expose the same object. - async_facade = import_module("specimen_client") - sync_facade = import_module("specimen_client.sync") - rows_mod = import_module("specimen_client._generated._rows") - assert async_facade.InsertSpecimenRow is sync_facade.InsertSpecimenRow - assert insert.InsertSpecimenRow is rows_mod.InsertSpecimenRow - assert async_facade.InsertSpecimenRow is rows_mod.InsertSpecimenRow + _, facade, _ = client_modules + Mood = facade.Mood + Point2D = facade.Point2D + ZCodecPayload = facade.ZCodecPayload + ACodecWrapper = facade.ACodecWrapper conn = psycopg.connect(roundtrip_db, autocommit=True) try: - register.register_types(conn) + facade.register_types(conn) + + codec_payload = ZCodecPayload(class_=51, pg_decode="sync", pg_encode=None) + codec_payloads = [ + ZCodecPayload(class_=52, pg_decode="first", pg_encode="encoded"), + ZCodecPayload(class_=53, pg_decode="second", pg_encode=None), + ] + codec_wrapper = ACodecWrapper( + payload=ZCodecPayload(class_=54, pg_decode="nested", pg_encode=None), + feeling=Mood.SAD, + note=None, + ) - inserted = insert.insert_specimen( + inserted = facade.insert_specimen( conn, doc_jsonb={"k": "v", "n": 1}, feeling=Mood.HAPPY, @@ -451,7 +578,11 @@ def test_roundtrip_sync_and_cross_surface_identity(full_package: Path, roundtrip related_ids=None, grid=None, moods=[Mood.HAPPY, None, Mood.SAD], + codec_payload=codec_payload, + codec_payloads=codec_payloads, + codec_wrapper=codec_wrapper, ) + assert type(inserted) is facade.InsertSpecimenRow assert isinstance(inserted.feeling, Mood) assert inserted.feeling is Mood.HAPPY assert isinstance(inserted.origin, Point2D) @@ -459,86 +590,91 @@ def test_roundtrip_sync_and_cross_surface_identity(full_package: Path, roundtrip assert inserted.moods == [Mood.HAPPY, None, Mood.SAD] assert inserted.moods is not None assert inserted.moods[0] is Mood.HAPPY + assert type(inserted.codec_payload) is ZCodecPayload + assert inserted.codec_payload == codec_payload + assert inserted.codec_payload.pg_encode is None + assert type(inserted.codec_payloads) is list + assert all(type(value) is ZCodecPayload for value in inserted.codec_payloads) + assert inserted.codec_payloads == codec_payloads + assert type(inserted.codec_wrapper) is ACodecWrapper + assert inserted.codec_wrapper is not None + assert type(inserted.codec_wrapper.payload) is ZCodecPayload + assert inserted.codec_wrapper.feeling is Mood.SAD + assert inserted.codec_wrapper.note is None specimen_id = inserted.id - hit = get.get_specimen(conn, id=specimen_id) + hit = facade.get_specimen(conn, id=specimen_id) assert hit is not None assert hit.id == specimen_id - assert get.get_specimen(conn, id=specimen_id + 10_000) is None - - mood_rows = by_moods.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) + assert type(hit.codec_payload) is ZCodecPayload + assert all(type(value) is ZCodecPayload for value in hit.codec_payloads) + assert type(hit.codec_wrapper) is ACodecWrapper + assert hit.codec_wrapper is not None + assert type(hit.codec_wrapper.payload) is ZCodecPayload + assert hit.codec_wrapper.feeling is Mood.SAD + assert facade.get_specimen(conn, id=specimen_id + 10_000) is None + + mood_rows = facade.list_specimens_by_moods(conn, moods=[Mood.HAPPY]) assert [r.id for r in mood_rows] == [specimen_id] - assert by_moods.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] + assert facade.list_specimens_by_moods(conn, moods=[Mood.SAD]) == [] - affected = bump.bump_specimen_revision(conn, id=specimen_id) + affected = facade.bump_specimen_revision(conn, id=specimen_id) assert affected == 1 - bumped = get.get_specimen(conn, id=specimen_id) + bumped = facade.get_specimen(conn, id=specimen_id) assert bumped is not None assert bumped.rev == 2 finally: conn.close() -def test_roundtrip_single_field_composite(full_package: Path, roundtrip_db: str) -> None: - """Regression test for compositeBind on a one-field composite. +def test_roundtrip_single_field_composite(client_modules, roundtrip_db: str) -> None: + """Regression for class-aware adaptation of a one-field composite. - concatMapSep joins a single-element field list with no separator, so an - unguarded tuple expression would render "(x.f)": a parenthesized value, not - a tuple, which psycopg would try to adapt as the bare field type instead of - the composite. Exercises the param bind (insert) and the result-column - decode (RETURNING and a plain SELECT). + The registered dataclass sequence callback must return a field-ordered + one-element tuple, not the bare field value. Exercises parameter adaptation + on insert and result adaptation through RETURNING and a plain SELECT. """ _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated._register") - tag_mod = import_module("specimen_client._generated.types.tag_value") - insert = import_module("specimen_client._generated.statements.insert_tagged_item") - get = import_module("specimen_client._generated.statements.get_tagged_item") - - TagValue = tag_mod.TagValue + facade, _, _ = client_modules + TagValue = facade.TagValue async def scenario() -> None: conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) try: - await register.register_types(conn) + await facade.register_types(conn) - inserted = await insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + inserted = await facade.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + assert type(inserted) is facade.InsertTaggedItemRow assert isinstance(inserted.tag, TagValue) assert inserted.tag == TagValue(value="blue") - hit = await get.get_tagged_item(conn, id=inserted.id) + hit = await facade.get_tagged_item(conn, id=inserted.id) assert hit is not None assert isinstance(hit.tag, TagValue) assert hit.tag == TagValue(value="blue") - assert await get.get_tagged_item(conn, id=inserted.id + 10_000) is None + assert await facade.get_tagged_item(conn, id=inserted.id + 10_000) is None finally: await conn.close() asyncio.run(scenario()) -def test_roundtrip_single_field_composite_sync(full_package: Path, roundtrip_db: str) -> None: - """Sync-surface counterpart of test_roundtrip_single_field_composite.""" +def test_roundtrip_single_field_composite_sync(client_modules, roundtrip_db: str) -> None: + """Sync public-facade counterpart of the one-field composite regression.""" _apply_migrations(roundtrip_db) - import_module = _import_client(full_package) - - register = import_module("specimen_client._generated.sync._register") - tag_mod = import_module("specimen_client._generated.types.tag_value") - insert = import_module("specimen_client._generated.sync.statements.insert_tagged_item") - get = import_module("specimen_client._generated.sync.statements.get_tagged_item") - - TagValue = tag_mod.TagValue + _, facade, _ = client_modules + TagValue = facade.TagValue conn = psycopg.connect(roundtrip_db, autocommit=True) try: - register.register_types(conn) + facade.register_types(conn) - inserted = insert.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + inserted = facade.insert_tagged_item(conn, name="widget", tag=TagValue(value="blue")) + assert type(inserted) is facade.InsertTaggedItemRow assert isinstance(inserted.tag, TagValue) assert inserted.tag == TagValue(value="blue") - hit = get.get_tagged_item(conn, id=inserted.id) + hit = facade.get_tagged_item(conn, id=inserted.id) assert hit is not None assert hit.tag == TagValue(value="blue") finally: diff --git a/tests/test_generated_quality.py b/tests/test_generated_quality.py new file mode 100644 index 0000000..ed1300e --- /dev/null +++ b/tests/test_generated_quality.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from tests._generated_quality import generated_line_length_issues + +REPO_ROOT = Path(__file__).resolve().parents[1] +RUFF_CONFIG = REPO_ROOT / "pyproject.toml" +GENERATED_MARKER = "# @generated by python.gen (pGenie); regeneration overwrites manual changes." + + +def _generated_python(root: Path) -> list[Path]: + return sorted(root.rglob("*.py")) + + +def test_fresh_generated_python_passes_ruff(full_package: Path) -> None: + generated_src = full_package / "src" + for command in (("check",), ("format", "--check")): + result = subprocess.run( + ["ruff", *command, "--config", str(RUFF_CONFIG), str(generated_src)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"ruff {' '.join(command)} failed:\n{result.stdout}{result.stderr}" + + +def test_fresh_generated_python_has_exact_marker_and_bounded_authored_lines(full_package: Path) -> None: + paths = _generated_python(full_package / "src") + assert paths, "fresh generation produced no Python files" + + authored = [] + for path in paths: + assert path.read_text().splitlines()[0] == GENERATED_MARKER + path_authored, _ = generated_line_length_issues(path) + authored.extend(path_authored) + + assert not authored, "generated authored lines exceed 120 characters:\n" + "\n".join( + issue.label() for issue in authored + ) + + +def test_line_length_helper_excludes_only_module_sql_token_interiors(tmp_path: Path) -> None: + path = tmp_path / "sample.py" + sql_body = "S" * 121 + other_body = "O" * 121 + python_value = "P" * 121 + _ = path.write_text(f'SQL = """\\\n{sql_body}\n"""\nOTHER = """\\\n{other_body}\n"""\nvalue = {python_value!r}\n') + + authored, sql = generated_line_length_issues(path) + + assert [issue.line_number for issue in sql] == [2] + assert [issue.line_number for issue in authored] == [5, 7] diff --git a/tests/test_identifier_collisions.py b/tests/test_identifier_collisions.py new file mode 100644 index 0000000..951780d --- /dev/null +++ b/tests/test_identifier_collisions.py @@ -0,0 +1,365 @@ +# The generated client is imported dynamically, so its symbols are necessarily Any. +# pyright: reportAny=false +"""Collision coverage for public query names and statement implementation globals.""" + +from __future__ import annotations + +import ast +import asyncio +import importlib +import json +import shutil +import subprocess +import sys +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import date +from pathlib import Path + +import psycopg +from psycopg.conninfo import make_conninfo + +from tests._harness import FIXTURE_PROJECT, HERE, SRC_DIR, ensure_droppable, run_pgn + +HARNESS_ROOT = HERE.parent +EXPECTED_FUNCTIONS = frozenset({"cast", "require_array", "fetch_many", "date_query"}) +HELPERS = { + "cast": "fetch_single", + "require_array": "fetch_single", + "fetch_many": "fetch_many", + "date_query": "fetch_single", +} + + +def _fresh_project(tmp_path: Path) -> tuple[Path, Path, str, str]: + root = tmp_path / "pygen" + _ = shutil.copytree(SRC_DIR, root / "src") + project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") + (project / "freeze1.pgn.yaml").unlink(missing_ok=True) + shutil.rmtree(project / "artifacts", ignore_errors=True) + shutil.rmtree(project / "queries") + (project / "queries").mkdir() + + package_name = f"identifier-collisions-{uuid.uuid4().hex[:8]}" + import_name = package_name.replace("-", "_") + _ = (project / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: identifier-collisions\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + " gen: ../../src/package.dhall\n" + " config:\n" + f" packageName: {package_name}\n" + " emitSync: true\n" + ) + + queries = { + "cast.sql": "SELECT 1::int8 AS value\n", + "require_array.sql": "SELECT ARRAY['happy'::mood] AS moods\n", + "fetch_many.sql": ("SELECT value\nFROM (VALUES (1::int8), (2::int8)) AS rows(value)\nORDER BY value\n"), + "date.sql": "SELECT DATE '2026-07-13' AS value\n", + } + for name, sql in queries.items(): + _ = (project / "queries" / name).write_text(sql) + + return root, project, package_name, import_name + + +def _assert_private_query_canaries(root: Path, project: Path, pgn_bin: str, pgn_admin_url: str) -> None: + # pgn rejects leading-underscore query filenames before invoking a generator, + # so exercise those policy inputs through a pgn-executed synthetic generator. + wrapper = r""" +let Lude = ./Deps/Lude.dhall + +let Model = ./Deps/Contract.dhall + +let Sdk = ./Deps/Sdk.dhall + +let PyIdent = ./Structures/PyIdent.dhall + +let RegisterModule = ./Templates/RegisterModule.dhall + +let Config = { packageName : Optional Text } + +let Config/default = { packageName = None Text } + +let run = + \(_ : Config) -> + \(_ : Model.Project) -> + Lude.Compiled.ok + Lude.Files.Type + ( [ { path = "query-safe-name.txt" + , content = + PyIdent.querySafeName "_db_types" + ++ "\n" + ++ PyIdent.querySafeName "_args_row" + ++ "\n" + ++ PyIdent.querySafeName "_fetch_many_sync" + ++ "\n" + ++ PyIdent.querySafeName "sync" + ++ "\n" + ++ PyIdent.querySafeName "register_types" + ++ "\n" + } + ] + # [ { path = "composite-only-register.py" + , content = + RegisterModule.run + { customTypes = + [ { typeName = "CompositeOnly" + , moduleName = "composite_only" + , pgSchema = "public" + , pgName = "composite_only" + , kind = RegisterModule.TypeKind.Composite + } + ] + , emitSync = True + } + } + ] : Lude.Files.Type + ) + +in Sdk.Sigs.generator Config Config/default run +""" + _ = (root / "src" / "query-safe-name-probe.dhall").write_text(wrapper) + + canary = shutil.copytree(project, root / "tests" / "query-safe-name-project") + shutil.rmtree(canary / "queries") + (canary / "queries").mkdir() + shutil.rmtree(canary / "artifacts", ignore_errors=True) + _ = (canary / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: query-safe-name-probe\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + " gen: ../../src/query-safe-name-probe.dhall\n" + " config:\n" + " packageName: query-safe-name-probe\n" + ) + + result = run_pgn(pgn_bin, pgn_admin_url, canary, "generate") + assert result.returncode == 0, f"query-name canary generation failed:\n{result.stdout}\n{result.stderr}" + output = canary / "artifacts" / "python" / "query-safe-name.txt" + assert output.read_text().splitlines() == [ + "_db_types_query", + "_args_row_query", + "_fetch_many_sync_query", + "sync_query", + "register_types_query", + ] + composite_register = (canary / "artifacts" / "python" / "composite-only-register.py").read_text() + assert "CompositeInfo" in composite_register + assert "register_composite" in composite_register + assert "_dataclass_callbacks" in composite_register + assert "EnumInfo" not in composite_register + assert "register_enum" not in composite_register + + +@contextmanager +def _scratch_database(admin_url: str) -> Iterator[str]: + name = f"pgn_collision_{uuid.uuid4().hex[:12]}" + admin = psycopg.connect(admin_url, autocommit=True) + try: + _ = admin.execute(f'CREATE DATABASE "{name}"'.encode()) + finally: + admin.close() + + target = make_conninfo(admin_url, dbname=name) + try: + yield target + finally: + admin = psycopg.connect(admin_url, autocommit=True) + try: + terminate = ( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()" + ) + _ = admin.execute(terminate.encode(), (name,)) + ensure_droppable(name) + _ = admin.execute(f'DROP DATABASE IF EXISTS "{name}"'.encode()) + finally: + admin.close() + + +def _apply_migrations(project: Path, db_url: str) -> None: + migrations = sorted((project / "migrations").glob("*.sql"), key=lambda path: int(path.stem)) + with psycopg.connect(db_url, autocommit=True) as conn: + for migration in migrations: + _ = conn.execute(migration.read_text().encode()) + + +def _clear_package_modules(import_name: str) -> None: + for name in list(sys.modules): + if name == import_name or name.startswith(f"{import_name}."): + del sys.modules[name] + + +def _facade_statement_exports(facade: Path, *, level: int, function_suffix: str) -> dict[str, str]: + exports: dict[str, str] = {} + tree = ast.parse(facade.read_text()) + for node in tree.body: + if not isinstance(node, ast.ImportFrom) or node.module is None: + continue + prefix = "_generated.statements." + if node.level != level or not node.module.startswith(prefix): + continue + module_name = node.module.removeprefix(prefix) + expected_name = f"{module_name}{function_suffix}" + query_imports = [alias for alias in node.names if alias.name == expected_name] + if not query_imports: + continue + assert len(query_imports) == 1 + query_import = query_imports[0] + assert query_import.asname == module_name + assert module_name not in exports + exports[module_name] = query_import.asname + return exports + + +def _assert_statement_structure(statements: Path) -> None: + modules = {path.stem for path in statements.glob("*.py") if path.name != "__init__.py"} + assert modules == EXPECTED_FUNCTIONS + + for function_name, helper in HELPERS.items(): + source = (statements / f"{function_name}.py").read_text() + tree = ast.parse(source) + async_runtime_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "_runtime" + ] + assert len(async_runtime_imports) == 1 + assert [(alias.name, alias.asname) for alias in async_runtime_imports[0].names] == [(helper, f"_{helper}")] + sync_runtime_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module == "sync._runtime" + ] + assert len(sync_runtime_imports) == 1 + assert [(alias.name, alias.asname) for alias in sync_runtime_imports[0].names] == [(helper, f"_{helper}_sync")] + assert f"from .._runtime import {helper} as _{helper}" in source + assert f"from ..sync._runtime import {helper} as _{helper}_sync" in source + row_classes = [node.name for node in tree.body if isinstance(node, ast.ClassDef)] + assert len(row_classes) == 1 + row_name = row_classes[0] + assert f"return await _{helper}(conn, SQL, params, _args_row({row_name}))" in source + assert f"return _{helper}_sync(conn, SQL, params, _args_row({row_name}))" in source + assert f"async def {function_name}(" in source + assert f"def {function_name}_sync(" in source + assert source.count("from psycopg.rows import args_row as _args_row") == 1 + assert "def decode_" not in source + assert "_SQL" not in source + assert "_decode_row" not in source + assert "_cast" not in source + assert "_require_array" not in source + + require_array_source = (statements / "require_array.py").read_text() + assert require_array_source.count("from .. import types as _db_types") == 1 + assert "moods: list[_db_types.Mood | None] | None" in require_array_source + assert "_args_row(RequireArrayRow)" in require_array_source + assert "from datetime import date" in (statements / "date_query.py").read_text() + + +def _assert_strict(package_src: Path, tmp_path: Path) -> None: + config = tmp_path / "identifier-collisions-pyright.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(package_src)], + "venvPath": str(HARNESS_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + assert result.stdout.strip(), f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}" + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files:\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) + + +def test_identifier_collisions_roundtrip(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: + root, project, package_name, import_name = _fresh_project(tmp_path) + _assert_private_query_canaries(root, project, pgn_bin, pgn_admin_url) + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + assert result.returncode == 0, f"pgn generate failed:\n{result.stdout}\n{result.stderr}" + + generated_src = project / "artifacts" / "python" / "src" + package_src = generated_src / import_name + statements = package_src / "_generated" / "statements" + assert package_src.is_dir(), f"pgn did not generate package {package_name!r}" + _assert_statement_structure(statements) + facade_exports = _facade_statement_exports(package_src / "__init__.py", level=1, function_suffix="") + sync_exports = _facade_statement_exports(package_src / "sync" / "__init__.py", level=2, function_suffix="_sync") + expected_exports = {name: name for name in EXPECTED_FUNCTIONS} + assert facade_exports == expected_exports + assert sync_exports == expected_exports + assert not (statements / "date.py").exists() + assert not (statements / "_types.py").exists() + assert not (package_src / "_generated" / "sync" / "statements").exists() + assert not (package_src / "_generated" / "sync" / "_register.py").exists() + register_source = (package_src / "_generated" / "_register.py").read_text() + assert "EnumInfo" in register_source + assert "register_enum" in register_source + assert "CompositeInfo" not in register_source + assert "register_composite" not in register_source + assert "_dataclass_callbacks" not in register_source + _assert_strict(package_src, tmp_path) + + sys.path.insert(0, str(generated_src)) + _clear_package_modules(import_name) + try: + facade = importlib.import_module(import_name) + sync_facade = importlib.import_module(f"{import_name}.sync") + Mood = facade.Mood + + with _scratch_database(pgn_admin_url) as db_url: + _apply_migrations(project, db_url) + + async def scenario() -> None: + conn = await psycopg.AsyncConnection.connect(db_url, autocommit=True) + try: + await facade.register_types(conn) + cast_row = await facade.cast(conn) + require_array_row = await facade.require_array(conn) + many_rows = await facade.fetch_many(conn) + date_row = await facade.date_query(conn) + + assert cast_row.value == 1 + assert require_array_row.moods == [Mood.HAPPY] + assert require_array_row.moods[0] is Mood.HAPPY + assert [row.value for row in many_rows] == [1, 2] + assert date_row.value == date(2026, 7, 13) + finally: + await conn.close() + + asyncio.run(scenario()) + + with psycopg.connect(db_url, autocommit=True) as conn: + sync_facade.register_types(conn) + cast_row = sync_facade.cast(conn) + require_array_row = sync_facade.require_array(conn) + many_rows = sync_facade.fetch_many(conn) + date_row = sync_facade.date_query(conn) + + assert cast_row.value == 1 + assert require_array_row.moods == [Mood.HAPPY] + assert require_array_row.moods[0] is Mood.HAPPY + assert [row.value for row in many_rows] == [1, 2] + assert date_row.value == date(2026, 7, 13) + finally: + _clear_package_modules(import_name) + sys.path.remove(str(generated_src)) diff --git a/tests/test_psycopg_adapter_contract.py b/tests/test_psycopg_adapter_contract.py new file mode 100644 index 0000000..e9484fd --- /dev/null +++ b/tests/test_psycopg_adapter_contract.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +from pathlib import Path +from typing import LiteralString + +import psycopg +import pytest +from psycopg.rows import args_row + +from tests.typing.psycopg_adapter_runtime import ( + EnumArrayRow, + EnumGridRow, + EnumRow, + Leaf, + LeafArrayRow, + LeafRow, + Mood, + SanitizedRow, + Wrapper, + WrapperRow, + fetch_one_async, + fetch_one_sync, + register_adapter_types_async, + register_adapter_types_sync, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +STRICT_FIXTURE = REPO_ROOT / "tests" / "typing" / "psycopg_adapter_runtime.py" +SCHEMA_SQL: LiteralString = """ +CREATE TYPE public.m_adapter_mood AS ENUM ('happy', 'sad'); +CREATE TYPE public.z_adapter_leaf AS ( + "class" int8, + pg_decode text, + pg_encode text +); +CREATE TYPE public.a_adapter_wrapper AS ( + leaf public.z_adapter_leaf, + mood public.m_adapter_mood, + note text +); +""" + + +@pytest.fixture(scope="module") +def _strict_adapter_contract(tmp_path_factory: pytest.TempPathFactory) -> None: + root = tmp_path_factory.mktemp("adapter-typing") + config = root / "pyrightconfig.json" + _ = config.write_text( + json.dumps( + { + "pythonVersion": "3.12", + "typeCheckingMode": "strict", + "include": [str(STRICT_FIXTURE)], + "venvPath": str(REPO_ROOT), + "venv": ".venv", + "reportMissingModuleSource": False, + } + ) + ) + result = subprocess.run( + ["basedpyright", "--project", str(config), "--outputjson"], + capture_output=True, + text=True, + ) + if not result.stdout.strip(): + pytest.fail(f"basedpyright produced no JSON (exit {result.returncode}):\n{result.stderr}") + + summary = json.loads(result.stdout)["summary"] + assert summary["filesAnalyzed"] > 0, f"basedpyright analyzed no files:\n{result.stdout}" + assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( + f"basedpyright strict reported issues: {summary}\n{result.stdout}" + ) + + +def test_async_psycopg_adapter_contract( + _strict_adapter_contract: None, + roundtrip_db: str, +) -> None: + async def scenario() -> None: + conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) + try: + _ = await conn.execute(SCHEMA_SQL) + await register_adapter_types_async(conn) + + enum_row = await fetch_one_async( + conn, + "SELECT 'happy'::public.m_adapter_mood", + {}, + args_row(EnumRow), + ) + assert type(enum_row) is EnumRow + assert type(enum_row.mood) is Mood + assert enum_row.mood is Mood.HAPPY + + enum_array_row = await fetch_one_async( + conn, + "SELECT ARRAY['happy'::public.m_adapter_mood, NULL, 'sad'::public.m_adapter_mood]", + {}, + args_row(EnumArrayRow), + ) + assert type(enum_array_row) is EnumArrayRow + assert type(enum_array_row.moods) is list + assert len(enum_array_row.moods) == 3 + assert enum_array_row.moods[0] is Mood.HAPPY + assert enum_array_row.moods[1] is None + assert enum_array_row.moods[2] is Mood.SAD + + enum_grid_row = await fetch_one_async( + conn, + "SELECT '{{happy,sad},{sad,happy}}'::public.m_adapter_mood[][]", + {}, + args_row(EnumGridRow), + ) + assert type(enum_grid_row) is EnumGridRow + assert type(enum_grid_row.moods) is list + assert len(enum_grid_row.moods) == 2 + assert all(type(row) is list for row in enum_grid_row.moods) + assert enum_grid_row.moods[0][0] is Mood.HAPPY + assert enum_grid_row.moods[0][1] is Mood.SAD + assert enum_grid_row.moods[1][0] is Mood.SAD + assert enum_grid_row.moods[1][1] is Mood.HAPPY + + leaf_row = await fetch_one_async( + conn, + "SELECT ROW(11, 'decode', NULL)::public.z_adapter_leaf", + {}, + args_row(LeafRow), + ) + assert type(leaf_row) is LeafRow + assert type(leaf_row.value) is Leaf + assert leaf_row.value.class_ == 11 + assert leaf_row.value.pg_decode == "decode" + assert leaf_row.value.pg_encode is None + + leaf_value = Leaf(class_=12, pg_decode="parameter", pg_encode=None) + leaf_parameter_row = await fetch_one_async( + conn, + "SELECT %(value)s::public.z_adapter_leaf", + {"value": leaf_value}, + args_row(LeafRow), + ) + assert type(leaf_parameter_row) is LeafRow + assert type(leaf_parameter_row.value) is Leaf + assert leaf_parameter_row.value == leaf_value + assert leaf_parameter_row.value.pg_encode is None + + leaf_values = [ + Leaf(class_=13, pg_decode="first", pg_encode="encoded"), + Leaf(class_=14, pg_decode="second", pg_encode=None), + ] + leaf_array_row = await fetch_one_async( + conn, + "SELECT %(values)s::public.z_adapter_leaf[]", + {"values": leaf_values}, + args_row(LeafArrayRow), + ) + assert type(leaf_array_row) is LeafArrayRow + assert type(leaf_array_row.values) is list + assert all(type(value) is Leaf for value in leaf_array_row.values) + assert leaf_array_row.values == leaf_values + assert leaf_array_row.values[1].pg_encode is None + + wrapper_value = Wrapper( + leaf=Leaf(class_=15, pg_decode="nested", pg_encode=None), + mood=Mood.SAD, + note=None, + ) + wrapper_row = await fetch_one_async( + conn, + "SELECT %(value)s::public.a_adapter_wrapper", + {"value": wrapper_value}, + args_row(WrapperRow), + ) + assert type(wrapper_row) is WrapperRow + assert type(wrapper_row.value) is Wrapper + assert type(wrapper_row.value.leaf) is Leaf + assert wrapper_row.value == wrapper_value + assert wrapper_row.value.mood is Mood.SAD + assert wrapper_row.value.note is None + + sanitized_row = await fetch_one_async( + conn, + "SELECT 'keyword' AS \"class\", ROW(16, 'codec', NULL)::public.z_adapter_leaf AS value", + {}, + args_row(SanitizedRow), + ) + assert type(sanitized_row) is SanitizedRow + assert sanitized_row.class_ == "keyword" + assert type(sanitized_row.value) is Leaf + assert sanitized_row.value.class_ == 16 + assert sanitized_row.value.pg_decode == "codec" + assert sanitized_row.value.pg_encode is None + finally: + await conn.close() + + asyncio.run(scenario()) + + +def test_sync_psycopg_adapter_contract( + _strict_adapter_contract: None, + roundtrip_db: str, +) -> None: + conn = psycopg.connect(roundtrip_db, autocommit=True) + try: + _ = conn.execute(SCHEMA_SQL) + register_adapter_types_sync(conn) + + enum_row = fetch_one_sync( + conn, + "SELECT 'happy'::public.m_adapter_mood", + {}, + args_row(EnumRow), + ) + assert type(enum_row) is EnumRow + assert type(enum_row.mood) is Mood + assert enum_row.mood is Mood.HAPPY + + enum_array_row = fetch_one_sync( + conn, + "SELECT ARRAY['happy'::public.m_adapter_mood, NULL, 'sad'::public.m_adapter_mood]", + {}, + args_row(EnumArrayRow), + ) + assert type(enum_array_row) is EnumArrayRow + assert type(enum_array_row.moods) is list + assert len(enum_array_row.moods) == 3 + assert enum_array_row.moods[0] is Mood.HAPPY + assert enum_array_row.moods[1] is None + assert enum_array_row.moods[2] is Mood.SAD + + enum_grid_row = fetch_one_sync( + conn, + "SELECT '{{happy,sad},{sad,happy}}'::public.m_adapter_mood[][]", + {}, + args_row(EnumGridRow), + ) + assert type(enum_grid_row) is EnumGridRow + assert type(enum_grid_row.moods) is list + assert len(enum_grid_row.moods) == 2 + assert all(type(row) is list for row in enum_grid_row.moods) + assert enum_grid_row.moods[0][0] is Mood.HAPPY + assert enum_grid_row.moods[0][1] is Mood.SAD + assert enum_grid_row.moods[1][0] is Mood.SAD + assert enum_grid_row.moods[1][1] is Mood.HAPPY + + leaf_row = fetch_one_sync( + conn, + "SELECT ROW(11, 'decode', NULL)::public.z_adapter_leaf", + {}, + args_row(LeafRow), + ) + assert type(leaf_row) is LeafRow + assert type(leaf_row.value) is Leaf + assert leaf_row.value.class_ == 11 + assert leaf_row.value.pg_decode == "decode" + assert leaf_row.value.pg_encode is None + + leaf_value = Leaf(class_=12, pg_decode="parameter", pg_encode=None) + leaf_parameter_row = fetch_one_sync( + conn, + "SELECT %(value)s::public.z_adapter_leaf", + {"value": leaf_value}, + args_row(LeafRow), + ) + assert type(leaf_parameter_row) is LeafRow + assert type(leaf_parameter_row.value) is Leaf + assert leaf_parameter_row.value == leaf_value + assert leaf_parameter_row.value.pg_encode is None + + leaf_values = [ + Leaf(class_=13, pg_decode="first", pg_encode="encoded"), + Leaf(class_=14, pg_decode="second", pg_encode=None), + ] + leaf_array_row = fetch_one_sync( + conn, + "SELECT %(values)s::public.z_adapter_leaf[]", + {"values": leaf_values}, + args_row(LeafArrayRow), + ) + assert type(leaf_array_row) is LeafArrayRow + assert type(leaf_array_row.values) is list + assert all(type(value) is Leaf for value in leaf_array_row.values) + assert leaf_array_row.values == leaf_values + assert leaf_array_row.values[1].pg_encode is None + + wrapper_value = Wrapper( + leaf=Leaf(class_=15, pg_decode="nested", pg_encode=None), + mood=Mood.SAD, + note=None, + ) + wrapper_row = fetch_one_sync( + conn, + "SELECT %(value)s::public.a_adapter_wrapper", + {"value": wrapper_value}, + args_row(WrapperRow), + ) + assert type(wrapper_row) is WrapperRow + assert type(wrapper_row.value) is Wrapper + assert type(wrapper_row.value.leaf) is Leaf + assert wrapper_row.value == wrapper_value + assert wrapper_row.value.mood is Mood.SAD + assert wrapper_row.value.note is None + + sanitized_row = fetch_one_sync( + conn, + "SELECT 'keyword' AS \"class\", ROW(16, 'codec', NULL)::public.z_adapter_leaf AS value", + {}, + args_row(SanitizedRow), + ) + assert type(sanitized_row) is SanitizedRow + assert sanitized_row.class_ == "keyword" + assert type(sanitized_row.value) is Leaf + assert sanitized_row.value.class_ == 16 + assert sanitized_row.value.pg_decode == "codec" + assert sanitized_row.value.pg_encode is None + finally: + conn.close() diff --git a/tests/test_takeover_contract.py b/tests/test_takeover_contract.py new file mode 100644 index 0000000..593fcbf --- /dev/null +++ b/tests/test_takeover_contract.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +import ast +import asyncio +import importlib +import inspect +import sys +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import date +from decimal import Decimal +from pathlib import Path +from types import ModuleType + +import psycopg + +from tests._generated_quality import generated_line_length_issues +from tests._harness import FIXTURE_PROJECT + +PACKAGE = Path("src/specimen_client") +STATEMENTS = frozenset( + { + "bump_specimen_revision", + "get_specimen", + "get_tagged_item", + "insert_specimen", + "insert_tagged_item", + "list_specimens_by_class", + "list_specimens_by_feeling", + "list_specimens_by_ids", + "list_specimens_by_moods", + "list_specimens_keyword_column", + "search_specimens", + } +) +ROWS = { + "get_specimen": "GetSpecimenRow", + "get_tagged_item": "GetTaggedItemRow", + "insert_specimen": "InsertSpecimenRow", + "insert_tagged_item": "InsertTaggedItemRow", + "list_specimens_by_class": "ListSpecimensByClassRow", + "list_specimens_by_feeling": "ListSpecimensByFeelingRow", + "list_specimens_by_ids": "ListSpecimensByIdsRow", + "list_specimens_by_moods": "ListSpecimensByMoodsRow", + "list_specimens_keyword_column": "ListSpecimensKeywordColumnRow", + "search_specimens": "SearchSpecimensRow", +} +CUSTOM_MODULES = { + "ACodecWrapper": "a_codec_wrapper", + "Mood": "mood", + "Point2D": "point_2_d", + "TagValue": "tag_value", + "ZCodecPayload": "z_codec_payload", +} +CORE_TYPES = ("JsonValue", "NoRowError") +MARKER = "# @generated by python.gen (pGenie); regeneration overwrites manual changes." +SPDX = ( + "# SPDX-FileCopyrightText: 2026 Viacheslav Shvets", + "# SPDX-License-Identifier: MIT-0", +) + + +def _generated_package(generated_tree: Path) -> Path: + return generated_tree / PACKAGE + + +def _python_paths(generated_tree: Path) -> list[Path]: + return sorted(_generated_package(generated_tree).rglob("*.py")) + + +def _statement_paths(generated_tree: Path) -> dict[str, Path]: + statements = _generated_package(generated_tree) / "_generated" / "statements" + return {path.stem: path for path in statements.glob("*.py") if path.name != "__init__.py"} + + +def _parse(path: Path) -> ast.Module: + return ast.parse(path.read_text(), filename=str(path)) + + +def _sql_assignments(tree: ast.Module) -> list[ast.expr]: + assignments: list[ast.expr] = [] + for node in tree.body: + if isinstance(node, ast.Assign): + if any(isinstance(target, ast.Name) and target.id == "SQL" for target in node.targets): + assignments.append(node.value) + elif ( + isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == "SQL" + and node.value is not None + ): + assignments.append(node.value) + return assignments + + +def _top_function(tree: ast.Module, name: str) -> ast.FunctionDef | ast.AsyncFunctionDef: + matches = [ + node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name + ] + assert len(matches) == 1, f"expected one top-level function {name}, found {len(matches)}" + return matches[0] + + +def _call_leaf(call: ast.Call) -> str | None: + if isinstance(call.func, ast.Name): + return call.func.id + if isinstance(call.func, ast.Attribute): + return call.func.attr + return None + + +def _args_row_calls(node: ast.AST) -> list[ast.Call]: + return [ + child + for child in ast.walk(node) + if isinstance(child, ast.Call) and isinstance(child.func, ast.Name) and child.func.id == "_args_row" + ] + + +def _annotations(tree: ast.Module) -> Iterator[ast.expr]: + for node in ast.walk(tree): + if isinstance(node, (ast.arg, ast.AnnAssign)) and node.annotation is not None: + yield node.annotation + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.returns is not None: + yield node.returns + + +def _remove_client_modules() -> None: + for name in list(sys.modules): + if name == "specimen_client" or name.startswith("specimen_client."): + del sys.modules[name] + + +@contextmanager +def _client_modules( + full_package: Path, +) -> Iterator[tuple[ModuleType, ModuleType, Callable[[str], ModuleType]]]: + original_path = sys.path.copy() + previous = { + name: module + for name, module in sys.modules.items() + if name == "specimen_client" or name.startswith("specimen_client.") + } + sys.path.insert(0, str(full_package / "src")) + _remove_client_modules() + try: + root = importlib.import_module("specimen_client") + sync = importlib.import_module("specimen_client.sync") + yield root, sync, importlib.import_module + finally: + _remove_client_modules() + sys.modules.update(previous) + sys.path[:] = original_path + + +def _apply_migrations(db_url: str) -> None: + migrations = sorted((FIXTURE_PROJECT / "migrations").glob("*.sql"), key=lambda path: int(path.stem)) + assert migrations, "fixture migrations are required for the registration canary" + with psycopg.connect(db_url, autocommit=True) as conn: + for migration in migrations: + _ = conn.execute(migration.read_text().encode()) + + +def test_canonical_fresh_tree(generated_tree: Path) -> None: + generated_src = generated_tree / "src" + packages = sorted(path.name for path in generated_src.iterdir() if path.is_dir()) + assert packages == ["specimen_client"] + + package = _generated_package(generated_tree) + generated = package / "_generated" + assert set(_statement_paths(generated_tree)) == STATEMENTS + assert (package / "__init__.py").is_file() + assert (package / "sync" / "__init__.py").is_file() + assert [path.relative_to(package) for path in package.rglob("_register.py")] == [Path("_generated/_register.py")] + assert [path.relative_to(package) for path in package.rglob("types") if path.is_dir()] == [Path("_generated/types")] + + sync_generated = generated / "sync" + for relative in (Path("statements"), Path("types"), Path("_register.py")): + assert not (sync_generated / relative).exists() + assert not list(generated_tree.rglob("golden_sync")) + assert not list(package.rglob("_rows.py")) + + +def test_statement_ast_contract(generated_tree: Path) -> None: + paths = _statement_paths(generated_tree) + assert set(paths) == STATEMENTS + + for stem in sorted(STATEMENTS): + tree = _parse(paths[stem]) + sql = _sql_assignments(tree) + assert len(sql) == 1 + assert isinstance(sql[0], ast.Constant) and isinstance(sql[0].value, str) + + async_function = _top_function(tree, stem) + sync_function = _top_function(tree, f"{stem}_sync") + assert isinstance(async_function, ast.AsyncFunctionDef) + assert isinstance(sync_function, ast.FunctionDef) + + expected_row = ROWS.get(stem) + row_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name.endswith("Row")] + assert [node.name for node in row_classes] == ([] if expected_row is None else [expected_row]) + + imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" and node.level == 0 + for alias in node.names + if alias.name == "args_row" and alias.asname == "_args_row" + ] + expected_count = int(expected_row is not None) + assert len(imports) == expected_count + if expected_row is None: + assert not _args_row_calls(tree) + continue + + row_class = row_classes[0] + assert len(row_class.decorator_list) == 1 + decorator = row_class.decorator_list[0] + assert isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name) + assert decorator.func.id == "dataclass" and not decorator.args + assert {keyword.arg: ast.literal_eval(keyword.value) for keyword in decorator.keywords} == { + "frozen": True, + "slots": True, + } + + for function in (async_function, sync_function): + calls = _args_row_calls(function) + assert len(calls) == 1 + assert len(calls[0].args) == 1 and not calls[0].keywords + assert isinstance(calls[0].args[0], ast.Name) and calls[0].args[0].id == expected_row + returns = [node for node in function.body if isinstance(node, ast.Return)] + assert len(returns) == 1 and returns[0].value is not None + returned = returns[0].value.value if isinstance(returns[0].value, ast.Await) else returns[0].value + assert isinstance(returned, ast.Call) and calls[0] in returned.args + + +def test_adapter_strategy_ast_contract(generated_tree: Path) -> None: + forbidden_functions = {"_decode_row", "pg_decode", "pg_encode", "_cast", "model_dump", "model_validate"} + forbidden_calls = forbidden_functions | {"cast", "parse_obj"} + + for path in _python_paths(generated_tree): + tree = _parse(path) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + assert node.name not in forbidden_functions and not node.name.startswith("decode_") + elif isinstance(node, ast.Call): + leaf = _call_leaf(node) + assert leaf not in forbidden_calls and not (leaf or "").startswith("decode_") + elif isinstance(node, ast.ImportFrom) and node.module == "typing": + assert all(alias.name != "cast" for alias in node.names) + + for stem, path in _statement_paths(generated_tree).items(): + tree = _parse(path) + row_imports = [ + (alias.name, alias.asname) + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == "psycopg.rows" and node.level == 0 + for alias in node.names + ] + assert row_imports == ([] if stem == "bump_specimen_revision" else [("args_row", "_args_row")]) + + custom_references = [ + node + for annotation in _annotations(tree) + for node in ast.walk(annotation) + if isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "_db_types" + and node.attr in CUSTOM_MODULES + ] + bare_custom = [ + node + for annotation in _annotations(tree) + for node in ast.walk(annotation) + if isinstance(node, ast.Name) and node.id in CUSTOM_MODULES + ] + assert not bare_custom + + namespace_imports = [ + alias + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.level == 2 and node.module is None + for alias in node.names + if alias.name == "types" and alias.asname == "_db_types" + ] + assert len(namespace_imports) == int(bool(custom_references)) + direct_leaf_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) + and node.level > 0 + and (node.module == "types" or (node.module or "").startswith("types.")) + ] + assert not direct_leaf_imports + + +def test_public_facade_identities_and_signatures(full_package: Path) -> None: + with _client_modules(full_package) as (root, sync, import_module): + assert root.sync is sync + for stem in sorted(STATEMENTS): + statement = import_module(f"specimen_client._generated.statements.{stem}") + async_function = getattr(root, stem) + sync_function = getattr(sync, stem) + assert async_function is getattr(statement, stem) + assert sync_function is getattr(statement, f"{stem}_sync") + assert inspect.iscoroutinefunction(async_function) + assert not inspect.iscoroutinefunction(sync_function) + + async_signature = inspect.signature(async_function) + sync_signature = inspect.signature(sync_function) + assert async_signature.return_annotation == sync_signature.return_annotation + async_parameters = list(async_signature.parameters.values()) + sync_parameters = list(sync_signature.parameters.values()) + assert len(async_parameters) == len(sync_parameters) + for index, (async_parameter, sync_parameter) in enumerate( + zip(async_parameters, sync_parameters, strict=True) + ): + assert async_parameter.name == sync_parameter.name + assert async_parameter.kind == sync_parameter.kind + assert async_parameter.default == sync_parameter.default + if index == 0: + assert async_parameter.annotation == "AsyncConnection[object]" + assert sync_parameter.annotation == "Connection[object]" + else: + assert async_parameter.annotation == sync_parameter.annotation + + row_name = ROWS.get(stem) + if row_name is not None: + canonical_row = getattr(statement, row_name) + assert getattr(root, row_name) is canonical_row + assert getattr(sync, row_name) is canonical_row + + core = import_module("specimen_client._generated._core") + for name in CORE_TYPES: + assert getattr(root, name) is getattr(sync, name) is getattr(core, name) + for name, leaf in CUSTOM_MODULES.items(): + module = import_module(f"specimen_client._generated.types.{leaf}") + assert getattr(root, name) is getattr(sync, name) is getattr(module, name) + + +def test_generated_import_allowlist(generated_tree: Path) -> None: + allowed_roots = sys.stdlib_module_names | {"__future__", "psycopg", "specimen_client"} + violations: list[str] = [] + + for path in _python_paths(generated_tree): + tree = _parse(path) + importlib_aliases: set[str] = set() + import_module_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.partition(".")[0] + if root not in allowed_roots: + violations.append(f"{path}: absolute import {alias.name}") + if alias.name == "importlib": + importlib_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + root = (node.module or "").partition(".")[0] + if root not in allowed_roots: + violations.append(f"{path}: absolute import from {node.module}") + if node.module == "importlib": + import_module_aliases.update( + alias.asname or alias.name for alias in node.names if alias.name == "import_module" + ) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Name) and node.func.id == "__import__": + violations.append(f"{path}:{node.lineno}: dynamic __import__") + if isinstance(node.func, ast.Name) and node.func.id in import_module_aliases: + violations.append(f"{path}:{node.lineno}: dynamic import_module") + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "import_module" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in importlib_aliases + ): + violations.append(f"{path}:{node.lineno}: dynamic importlib.import_module") + + assert not violations, "generated import boundary violations:\n" + "\n".join(violations) + + +def test_registration_static_and_api_contract(generated_tree: Path, full_package: Path) -> None: + package = _generated_package(generated_tree) + register_path = package / "_generated" / "_register.py" + assert list(package.rglob("_register.py")) == [register_path] + assert not (package / "_generated" / "sync" / "_register.py").exists() + + tree = _parse(register_path) + metadata: dict[str, str] = {} + callback_types: list[str] = [] + for node in tree.body: + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and target.id.endswith("_pg_name"): + assert isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) + metadata[target.id] = node.value.value + if ( + isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) + and node.value.func.id == "_dataclass_callbacks" + ): + argument = node.value.args[0] + assert isinstance(argument, ast.Attribute) and isinstance(argument.value, ast.Name) + assert argument.value.id == "_db_types" + callback_types.append(argument.attr) + + assert metadata == { + "_mood_pg_name": "public.mood", + "_point_2_d_pg_name": "public.point2d", + "_tag_value_pg_name": "public.tag_value", + "_z_codec_payload_pg_name": "public.z_codec_payload", + "_a_codec_wrapper_pg_name": "public.a_codec_wrapper", + } + assert callback_types == ["Point2D", "TagValue", "ZCodecPayload", "ACodecWrapper"] + + async_register = _top_function(tree, "register_types") + sync_register = _top_function(tree, "register_types_sync") + assert isinstance(async_register, ast.AsyncFunctionDef) + assert isinstance(sync_register, ast.FunctionDef) + expected_fetch_order = [ + "_mood_pg_name", + "_point_2_d_pg_name", + "_tag_value_pg_name", + "_z_codec_payload_pg_name", + "_a_codec_wrapper_pg_name", + ] + for function in (async_register, sync_register): + fetch_calls = sorted( + ( + node + for node in ast.walk(function) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "fetch" + ), + key=lambda node: node.lineno, + ) + fetch_metadata = [ + call.args[1].id for call in fetch_calls if len(call.args) == 2 and isinstance(call.args[1], ast.Name) + ] + assert fetch_metadata == expected_fetch_order + + with _client_modules(full_package) as (root, sync, import_module): + register = import_module("specimen_client._generated._register") + assert root.register_types is register.register_types + assert sync.register_types is register.register_types_sync + assert "register_types" in root.__all__ + assert "register_types" in sync.__all__ + assert inspect.iscoroutinefunction(root.register_types) + assert not inspect.iscoroutinefunction(sync.register_types) + + +def test_registration_roundtrip_two_surfaces(full_package: Path, roundtrip_db: str) -> None: + _apply_migrations(roundtrip_db) + with _client_modules(full_package) as (root, sync, _): + + async def write_and_read() -> tuple[int, int, int]: + conn = await psycopg.AsyncConnection.connect(roundtrip_db, autocommit=True) + try: + await root.register_types(conn) + tagged = await root.insert_tagged_item( + conn, + name="takeover-tag", + tag=root.TagValue(value="blue"), + ) + tagged_hit = await root.get_tagged_item(conn, id=tagged.id) + assert type(tagged) is root.InsertTaggedItemRow + assert type(tagged_hit) is root.GetTaggedItemRow + assert type(tagged_hit.tag) is root.TagValue + + payload = root.ZCodecPayload(class_=71, pg_decode="takeover", pg_encode=None) + wrapper = root.ACodecWrapper( + payload=root.ZCodecPayload(class_=72, pg_decode="nested", pg_encode=None), + feeling=root.Mood.SAD, + note="canary", + ) + specimen = await root.insert_specimen( + conn, + doc_jsonb={"contract": "takeover"}, + feeling=root.Mood.HAPPY, + origin=root.Point2D(x=3.0, y=4.0), + flag=True, + small=1, + medium=2, + large=3, + ratio=0.5, + precise=0.25, + title="takeover-specimen", + code="T-1", + letter="t", + born_on=date(2026, 7, 13), + amount=Decimal("7.13"), + blob=b"takeover", + doc_json=["canary"], + maybe_text=None, + maybe_int=None, + maybe_uuid=None, + maybe_ts=None, + maybe_num=None, + tags=["contract"], + related_ids=None, + grid=None, + moods=[root.Mood.HAPPY, None], + codec_payload=payload, + codec_payloads=[payload], + codec_wrapper=wrapper, + ) + specimen_hit = await root.get_specimen(conn, id=specimen.id) + assert type(specimen) is root.InsertSpecimenRow + assert type(specimen_hit) is root.GetSpecimenRow + assert type(specimen_hit.codec_payload) is root.ZCodecPayload + assert type(specimen_hit.codec_wrapper) is root.ACodecWrapper + assert type(specimen_hit.codec_wrapper.payload) is root.ZCodecPayload + return tagged.id, specimen.id, conn.info.backend_pid + finally: + await conn.close() + + tagged_id, specimen_id, async_backend_pid = asyncio.run(write_and_read()) + with psycopg.connect(roundtrip_db, autocommit=True) as conn: + assert conn.info.backend_pid != async_backend_pid + sync.register_types(conn) + tagged_hit = sync.get_tagged_item(conn, id=tagged_id) + specimen_hit = sync.get_specimen(conn, id=specimen_id) + assert type(tagged_hit) is sync.GetTaggedItemRow is root.GetTaggedItemRow + assert type(tagged_hit.tag) is sync.TagValue is root.TagValue + assert type(specimen_hit) is sync.GetSpecimenRow is root.GetSpecimenRow + assert type(specimen_hit.codec_wrapper) is sync.ACodecWrapper is root.ACodecWrapper + assert type(specimen_hit.codec_wrapper.payload) is sync.ZCodecPayload is root.ZCodecPayload + + +def test_fresh_output_h2_budget(generated_tree: Path) -> None: + paths = _python_paths(generated_tree) + statement_paths = _statement_paths(generated_tree) + sql_count = sum(len(_sql_assignments(_parse(path))) for path in statement_paths.values()) + metrics = { + "python_files": len(paths), + "total_lines": sum(len(path.read_text().splitlines()) for path in paths), + "statement_modules": len(statement_paths), + "statement_lines": sum(len(path.read_text().splitlines()) for path in statement_paths.values()), + "sql_assignments": sql_count, + } + + assert metrics["python_files"] <= 26, metrics + assert metrics["total_lines"] <= 1500, metrics + assert metrics["statement_modules"] == 11, metrics + assert metrics["statement_lines"] <= 900, metrics + assert metrics["sql_assignments"] == 11, metrics + assert set(statement_paths) == STATEMENTS + assert not (_generated_package(generated_tree) / "_generated" / "sync" / "statements").exists() + + +def test_generated_file_quality_and_required_generator(generated_tree: Path) -> None: + paths = _python_paths(generated_tree) + authored = [] + sql = [] + for path in paths: + lines = path.read_text().splitlines() + assert lines[:3] == [MARKER, *SPDX] + path_authored, path_sql = generated_line_length_issues(path) + authored.extend(path_authored) + sql.extend(path_sql) + + assert not authored, "authored generated lines exceed 120 characters:\n" + "\n".join( + issue.label() for issue in authored + ) + assert not sql, "SQL generated lines exceed 120 characters:\n" + "\n".join(issue.label() for issue in sql) + assert not list(_generated_package(generated_tree).rglob("_rows.py")) + + bypass = "s" + "kip" + assert bypass not in Path(__file__).read_text() + conftest_tree = _parse(Path(__file__).with_name("conftest.py")) + pgn_fixture = _top_function(conftest_tree, "pgn_bin") + pytest_calls = [ + node.func.attr + for node in ast.walk(pgn_fixture) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "pytest" + ] + assert pytest_calls.count("fail") == 1 + assert bypass not in pytest_calls diff --git a/tests/test_unsupported_types.py b/tests/test_unsupported_types.py index 9d99eab..b88cfb4 100644 --- a/tests/test_unsupported_types.py +++ b/tests/test_unsupported_types.py @@ -17,6 +17,7 @@ import importlib import json +import re import shutil import subprocess import sys @@ -24,17 +25,60 @@ import pytest -from tests._harness import FIXTURE_PROJECT, GEN_DIR, HERE, run_pgn +from tests._harness import ( + FIXTURE_PROJECT, + GOLDEN_DIR, + HERE, + SRC_DIR, + run_pgn, +) HARNESS_ROOT = HERE.parent -def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: +def _fresh_project(tmp_path: Path) -> tuple[Path, Path]: root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") + _ = shutil.copytree(SRC_DIR, root / "src") project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") (project / "freeze1.pgn.yaml").unlink(missing_ok=True) shutil.rmtree(project / "artifacts", ignore_errors=True) + return root, project + + +def _write_single_artifact( + project: Path, + generator: str, + package_name: str, + mode: str | None = "Fail", +) -> None: + unsupported_config = "" if mode is None else f" onUnsupported: {mode}\n" + _ = (project / "project1.pgn.yaml").write_text( + "space: python-gen\n" + "name: fixture\n" + "version: 0.0.0\n" + "postgres: 18\n" + "artifacts:\n" + " python:\n" + f" gen: {generator}\n" + " config:\n" + f" packageName: {package_name}\n" + f"{unsupported_config}" + ) + + +def _combined_output(result: subprocess.CompletedProcess[str]) -> str: + return result.stdout + result.stderr + + +def _clear_package_modules(package_name: str) -> None: + for name in list(sys.modules): + if name == package_name or name.startswith(f"{package_name}."): + del sys.modules[name] + + +def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: + _, project = _fresh_project(tmp_path) + _write_single_artifact(project, "../../src/package.dhall", "unsupported-money", None) # `money` has no Python mapping (psycopg decodes it as a locale string, not Decimal), # so the Primitive interpreter must reject it instead of guessing. @@ -50,32 +94,16 @@ def test_unsupported_pg_type_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: - root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") - project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") - (project / "freeze1.pgn.yaml").unlink(missing_ok=True) - shutil.rmtree(project / "artifacts", ignore_errors=True) + _, project = _fresh_project(tmp_path) # Explicit `onUnsupported: Fail` (vs the absent-key default the money test # covers): pins that pgn decodes the bare YAML string "Fail" into the # < Fail | Skip > union tag, mirroring the "Skip" decode the Skip test pins. - _ = (project / "project1.pgn.yaml").write_text( - "space: python-gen\n" - "name: fixture\n" - "version: 0.0.0\n" - "postgres: 18\n" - "artifacts:\n" - " python:\n" - " gen: ../../gen/Gen.dhall\n" - " config:\n" - " onUnsupported: Fail\n" - ) + _write_single_artifact(project, "../../src/package.dhall", "unsupported-json-array") # A jsonb[] param has no faithful psycopg bind (Jsonb wraps a scalar, not # element-wise); the generator must reject it, not emit an unwrapped list. - _ = (project / "queries" / "probe_json_array.sql").write_text( - "SELECT cardinality($payloads::jsonb []) AS n\n" - ) + _ = (project / "queries" / "probe_json_array.sql").write_text("SELECT cardinality($payloads::jsonb []) AS n\n") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") @@ -86,28 +114,366 @@ def test_json_array_param_fails_loudly(pgn_bin: str, pgn_admin_url: str, tmp_pat ) -def test_skip_unsupported_drops_offending_units_and_cascades( - pgn_bin: str, pgn_admin_url: str, tmp_path: Path +def _write_contract_probe( + root: Path, + *, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, +) -> None: + lookup = { + "absent": "CustomKind.TypeKind.Absent", + "enum": ('CustomKind.TypeKind.Enum { className = "ProbeValue", moduleName = "probe_value", order = 0 }'), + "composite": ( + 'CustomKind.TypeKind.Composite { className = "ProbeValue", moduleName = "probe_value", order = 0 }' + ), + }[lookup_kind] + 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 + : Model.CustomType + = { definition = + < Enum : List Model.EnumVariant + | Composite : List Model.Member + | Domain : Model.Value + >.Composite [ member ] + , name + , pgName = "outer_custom" + , pgSchema = "public" + } + """ + if nested + else "" + ) + wrapper = f""" +let Lude = ./Deps/Lude.dhall + +let Model = ./Deps/Contract.dhall + +let Sdk = ./Deps/Sdk.dhall + +let OnUnsupported = ./Structures/OnUnsupported.dhall + +let CustomKind = ./Structures/CustomKind.dhall + +let Target = ./Interpreters/{interpreter}.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 interpreterConfig = {{=}} + +let run = + \\(_ : Config) -> + \\(input : Model.Project) -> + let name = input.name + + let member + : Model.Member + = {{ isNullable = False + , name + , pgName = "probe_value" + , value = + {{ dimensionality = {dimensionality} + , elementIsNullable = False + , scalar = + Model.Scalar.Custom + {{ name + , pgSchema = "public" + , pgName = "probe_value" + , index = 0 + }} + }} + }} + +{custom_type} + let lookup + : CustomKind.Lookup + = [ {lookup} ] + + in Lude.Compiled.map + Target.Output + Lude.Files.Type + (\\(_ : Target.Output) -> [] : Lude.Files.Type) + (Target.run interpreterConfig lookup{index_argument} {target_input}) + +in Sdk.Sigs.generator Config Config/default run +""" + _ = (root / "src" / "contract-probe.dhall").write_text(wrapper) + + +# PostgreSQL flattens array rank and valid projects resolve customs, so synthetic +# inputs are required to reach the rank-2 and missing-reference contracts. +@pytest.mark.parametrize( + ("case_id", "interpreter", "lookup_kind", "dimensionality", "nested", "message", "path_tokens"), + [ + pytest.param( + "missing_custom", + "Member", + "absent", + 0, + False, + "Custom type not found in project customTypes", + ("fixture",), + id="missing-custom", + ), + pytest.param( + "custom_array_member", + "CustomType", + "composite", + 1, + True, + "Custom array fields inside a composite type are not supported", + ("fixture", "probe_value"), + id="custom-array-member", + ), + pytest.param( + "composite_rank_two_result", + "Member", + "composite", + 2, + False, + "Array of a composite type with dimensionality > 1 is not supported", + ("fixture", "probe_value"), + id="composite-rank-two-result", + ), + pytest.param( + "composite_rank_two_parameter", + "ParamsMember", + "composite", + 2, + False, + "Array of a composite type parameter with dimensionality > 1 is not supported", + ("fixture", "probe_value"), + id="composite-rank-two-parameter", + ), + pytest.param( + "enum_rank_three_result", + "Member", + "enum", + 3, + False, + "Array of an enum with dimensionality > 2 is not supported", + ("fixture", "probe_value"), + id="enum-rank-three-result", + ), + pytest.param( + "enum_rank_three_parameter", + "ParamsMember", + "enum", + 3, + False, + "Array of an enum parameter with dimensionality > 2 is not supported", + ("fixture", "probe_value"), + id="enum-rank-three-parameter", + ), + ], +) +def test_custom_shape_contracts_fail_loudly( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, + case_id: str, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, + message: str, + path_tokens: tuple[str, ...], +) -> None: + root, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + _write_contract_probe( + root, + interpreter=interpreter, + lookup_kind=lookup_kind, + dimensionality=dimensionality, + nested=nested, + ) + _write_single_artifact(project, "../../src/contract-probe.dhall", f"probe-{case_id}") + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + combined = _combined_output(result) + assert result.returncode != 0, f"expected {case_id} to fail:\n{combined}" + assert message in combined, f"missing exact diagnostic {message!r}:\n{combined}" + + plain = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", combined) + message_at = plain.index(message) + diagnostic = plain[max(0, message_at - 500) : message_at + len(message) + 1000] + stage = re.search( + r"Stage:\s*Generating\s*>\s*python\s*>\s*Compiling\s*>\s*([^\r\n]+)", + diagnostic, + ) + assert stage, f"diagnostic had no pgn Stage path:\n{plain}" + rendered_path = tuple(re.split(r"\s*>\s*", stage.group(1).strip())) + assert rendered_path == path_tokens, f"expected exact path {path_tokens}, got {rendered_path}:\n{plain}" + + +@pytest.mark.parametrize( + ("case_id", "interpreter", "lookup_kind", "dimensionality", "nested"), + [ + pytest.param("nested_scalar", "CustomType", "composite", 0, True, id="nested-scalar"), + pytest.param("enum_rank_two_result", "Member", "enum", 2, False, id="enum-rank-two-result"), + pytest.param( + "enum_rank_two_parameter", + "ParamsMember", + "enum", + 2, + False, + id="enum-rank-two-parameter", + ), + pytest.param( + "composite_array_result", + "Member", + "composite", + 1, + False, + id="composite-array-result", + ), + pytest.param( + "composite_array_parameter", + "ParamsMember", + "composite", + 1, + False, + id="composite-array-parameter", + ), + ], +) +def test_custom_shape_contracts_succeed( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, + case_id: str, + interpreter: str, + lookup_kind: str, + dimensionality: int, + nested: bool, +) -> None: + root, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + _write_contract_probe( + root, + interpreter=interpreter, + lookup_kind=lookup_kind, + dimensionality=dimensionality, + nested=nested, + ) + _write_single_artifact(project, "../../src/contract-probe.dhall", f"probe-{case_id}") + + result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") + assert result.returncode == 0, f"expected {case_id} to succeed:\n{_combined_output(result)}" + + +def test_skip_preserves_nested_registration_chain( + pgn_bin: str, + pgn_admin_url: str, + tmp_path: Path, ) -> None: + _, project = _fresh_project(tmp_path) + for query_file in (project / "queries").iterdir(): + query_file.unlink() + shutil.rmtree(project / "types") + + _ = (project / "migrations" / "1.sql").write_text( + "CREATE TYPE a_bad AS (amount money);\n" + "CREATE TYPE z_survivor_status AS ENUM ('ready');\n" + "CREATE TYPE m_survivor_leaf AS (status z_survivor_status);\n" + "CREATE TYPE n_survivor_outer AS (leaf m_survivor_leaf);\n" + ) + _ = (project / "queries" / "read_survivor_outer.sql").write_text( + "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-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"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"), + ( + "Custom type not found in project customTypes", + "Generating > python > Compiling > a_bad > value > result > ./queries/read_bad.sql", + ), + ] + + 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", + "z_survivor_status.py", + "n_survivor_outer.py", + } + + generated_sources = [path.read_text() for path in package.rglob("*.py")] + assert all("a_bad" not in source and "ABad" not in source for source in generated_sources) + assert all("read_bad" not in source for source in generated_sources) + assert not list(package.rglob("*read_bad*")) + + 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 .z_survivor_status import ZSurvivorStatus" + ] + assert "status: ZSurvivorStatus" in leaf_source + assert [line for line in outer_source.splitlines() if line.startswith("from .")] == [ + "from .m_survivor_leaf import MSurvivorLeaf" + ] + assert "leaf: MSurvivorLeaf" in outer_source + + register_source = (generated / "_register.py").read_text() + pg_name_constants = [ + line for line in register_source.splitlines() if line.startswith("_") and "_pg_name = " in line + ] + assert pg_name_constants == [ + '_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"', + ] + + statement = (generated / "statements" / "read_survivor_outer.py").read_text() + assert "from .. import types as _db_types" in statement + assert "value: _db_types.NSurvivorOuter" in statement + + +def test_skip_unsupported_drops_offending_units_and_cascades(pgn_bin: str, pgn_admin_url: str, tmp_path: Path) -> None: """onUnsupported: Skip drops only the smallest self-consistent unit. Three independent failures in one project: a money result column and a jsonb[] param each doom their own statement (Primitive.dhall / ParamsMember - .dhall); a composite nesting another composite dooms the custom type - itself (CustomType.dhall's nestedLookup is hardcoded Absent for any nested - custom-type reference) and, because the lookup built from the surviving - types resolves it to Absent, the query selecting that composite column - cascades into a skip too. Generation must still succeed, the surviving - statements and all 3 fixture types must be unaffected, no generated file + .dhall); a composite containing a custom array dooms that type, its scalar + custom parent, its grandparent, and the dependent query through the bounded + survivor closure. Generation must still succeed, the surviving statements + and all 5 fixture types must be unaffected, no generated file may reference a skipped name, the package must still import, and basedpyright strict must still pass on the result -- the same gate the golden package is held to. """ - root = tmp_path / "pygen" - _ = shutil.copytree(GEN_DIR, root / "gen") - project = shutil.copytree(FIXTURE_PROJECT, root / "tests" / "fixture-project") - (project / "freeze1.pgn.yaml").unlink(missing_ok=True) - shutil.rmtree(project / "artifacts", ignore_errors=True) + _, project = _fresh_project(tmp_path) # Skip mode evaluates every query twice (the keep/drop check plus the # final render; see Interpreters/Project.dhall), and a full-fixture Skip @@ -119,39 +485,32 @@ def test_skip_unsupported_drops_offending_units_and_cascades( if query_file.name.split(".", 1)[0] not in kept_statements: query_file.unlink() - # A single Skip artifact, package name left at its "fixture" default so it - # cannot collide with the "specimen_client" package the shared golden/ - # roundtrip suites import. - _ = (project / "project1.pgn.yaml").write_text( - "space: python-gen\n" - "name: fixture\n" - "version: 0.0.0\n" - "postgres: 18\n" - "artifacts:\n" - " python:\n" - " gen: ../../gen/Gen.dhall\n" - " config:\n" - " onUnsupported: Skip\n" - ) + # A unique package prefix prevents collisions with the golden and roundtrip + # packages imported by the shared test process. + _write_single_artifact(project, "../../src/package.dhall", "skip-cascade", "Skip") _ = (project / "queries" / "probe_unsupported.sql").write_text("SELECT 1::money AS amount\n") - _ = (project / "queries" / "probe_json_array.sql").write_text( - "SELECT cardinality($payloads::jsonb []) AS n\n" - ) + _ = (project / "queries" / "probe_json_array.sql").write_text("SELECT cardinality($payloads::jsonb []) AS n\n") + _ = (project / "queries" / "probe_custom_only.sql").write_text("SELECT 'happy'::mood AS feeling\n") _ = (project / "migrations" / "2.sql").write_text( - "create type wrapped_point as (\n" - " label text,\n" - " origin point2d\n" + "create type z_bad_leaf as (\n" + " feelings mood[]\n" + ");\n" + "\n" + "create type m_bad_parent as (\n" + " leaf z_bad_leaf\n" + ");\n" + "\n" + "create type a_bad_grandparent as (\n" + " parent m_bad_parent\n" ");\n" "\n" "create table nested_probe (\n" " id int8 primary key generated always as identity,\n" - " wrapped wrapped_point not null\n" + " wrapped a_bad_grandparent not null\n" ");\n" ) - _ = (project / "queries" / "probe_nested_composite.sql").write_text( - "SELECT wrapped FROM nested_probe LIMIT 1\n" - ) + _ = (project / "queries" / "probe_nested_composite.sql").write_text("SELECT wrapped FROM nested_probe LIMIT 1\n") result = run_pgn(pgn_bin, pgn_admin_url, project, "generate") assert result.returncode == 0, f"Skip mode must still succeed:\n{result.stdout}\n{result.stderr}" @@ -159,55 +518,64 @@ def test_skip_unsupported_drops_offending_units_and_cascades( # Since 0.7.2 pgn surfaces the generator's Compiled.warnings during # generate (pgenie-io/pgenie#67), so every skipped unit must be visible in # the combined output. - combined = (result.stdout + result.stderr).lower() - for marker in ("unsupported type", "json/jsonb array", "custom type not found"): + combined = _combined_output(result).lower() + for marker in ( + "unsupported type", + "json/jsonb array as a parameter is not supported", + "custom array fields inside a composite type are not supported", + "custom type not found in project customtypes", + ): assert marker in combined, f"expected pgn to surface the warning, {marker!r} missing from output" generated = project / "artifacts" / "python" - package_src = generated / "src" / "fixture" + package_name = "skip_cascade" + package_src = generated / "src" / package_name src = package_src / "_generated" for name in ("probe_unsupported", "probe_json_array", "probe_nested_composite"): assert not (src / "statements" / f"{name}.py").exists(), f"{name} should have been skipped" - assert not (src / "types" / "wrapped_point.py").exists(), "wrapped_point should have been skipped" + for name in ("z_bad_leaf", "m_bad_parent", "a_bad_grandparent"): + assert not (src / "types" / f"{name}.py").exists(), f"{name} should have been skipped" - for name in kept_statements: + for name in [*kept_statements, "probe_custom_only"]: assert (src / "statements" / f"{name}.py").is_file(), f"{name} should not have been skipped" - for name in ("mood", "point_2_d", "tag_value"): + for name in ("a_codec_wrapper", "mood", "point_2_d", "tag_value", "z_codec_payload"): assert (src / "types" / f"{name}.py").is_file(), f"{name} should not have been skipped" - facade = (package_src / "__init__.py").read_text() - rows = (src / "_rows.py").read_text() - register = (src / "_register.py").read_text() - types_init = (src / "types" / "__init__.py").read_text() + generated_python = {path: path.read_text() for path in package_src.rglob("*.py")} for orphan in ( "probe_unsupported", "probe_json_array", "probe_nested_composite", - "wrapped_point", - "WrappedPoint", + "z_bad_leaf", + "ZBadLeaf", + "m_bad_parent", + "MBadParent", + "a_bad_grandparent", + "ABadGrandparent", ): - assert orphan not in facade, f"facade references skipped {orphan}" - assert orphan not in rows, f"_rows references skipped {orphan}" - assert orphan not in register, f"_register references skipped {orphan}" - assert orphan not in types_init, f"types/__init__ references skipped {orphan}" + assert not any(orphan in text for text in generated_python.values()), ( + f"surviving generated output references skipped {orphan}" + ) + + custom_only = (src / "statements" / "probe_custom_only.py").read_text() + assert "from .. import types as _db_types" in custom_only + assert "_db_types.Mood" in custom_only + assert "_args_row(ProbeCustomOnlyRow)" in custom_only src_root = str(generated / "src") sys.path.insert(0, src_root) try: - for name in list(sys.modules): - if name == "fixture" or name.startswith("fixture."): - del sys.modules[name] - importlib.import_module("fixture") - importlib.import_module("fixture._generated._register") - importlib.import_module("fixture._generated._rows") - for name in kept_statements: - importlib.import_module(f"fixture._generated.statements.{name}") + _clear_package_modules(package_name) + for module_path in sorted(package_src.rglob("*.py")): + relative = module_path.relative_to(generated / "src").with_suffix("") + parts = list(relative.parts) + if parts[-1] == "__init__": + parts.pop() + importlib.import_module(".".join(parts)) finally: sys.path.remove(src_root) - for name in list(sys.modules): - if name == "fixture" or name.startswith("fixture."): - del sys.modules[name] + _clear_package_modules(package_name) config = tmp_path / "pyrightconfig.json" _ = config.write_text( @@ -234,3 +602,27 @@ def test_skip_unsupported_drops_offending_units_and_cascades( assert summary["errorCount"] == 0 and summary["warningCount"] == 0, ( f"basedpyright strict reported issues on the Skip output: {summary}\n{pyright_result.stdout}" ) + + +def test_statement_custom_types_use_one_qualified_namespace_import() -> None: + statements = GOLDEN_DIR / "src" / "specimen_client" / "_generated" / "statements" + namespace_import = "from .. import types as _db_types" + for module in statements.glob("*.py"): + source = module.read_text() + assert not re.search(r"^from \.\.types\.", source, re.MULTILINE), f"direct custom type import in {module}" + if "_db_types." in source: + assert source.count(namespace_import) == 1, f"expected one custom type namespace import in {module}" + + insert_specimen = (statements / "insert_specimen.py").read_text() + for annotation in ( + "feeling: _db_types.Mood", + "moods: list[_db_types.Mood | None] | None", + "origin: _db_types.Point2D | None", + "codec_payload: _db_types.ZCodecPayload", + "codec_payloads: list[_db_types.ZCodecPayload | None]", + "codec_wrapper: _db_types.ACodecWrapper | None", + ): + assert annotation in insert_specimen + + insert_tagged_item = (statements / "insert_tagged_item.py").read_text() + assert "tag: _db_types.TagValue" in insert_tagged_item diff --git a/tests/typing/psycopg_adapter_runtime.py b/tests/typing/psycopg_adapter_runtime.py new file mode 100644 index 0000000..5562b24 --- /dev/null +++ b/tests/typing/psycopg_adapter_runtime.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import keyword +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, fields, is_dataclass +from enum import StrEnum +from typing import Any, LiteralString + +from psycopg import AsyncConnection, Connection +from psycopg.rows import BaseRowFactory, args_row +from psycopg.types.composite import CompositeInfo, register_composite +from psycopg.types.enum import EnumInfo, register_enum + + +class Mood(StrEnum): + HAPPY = "happy" + SAD = "sad" + + +@dataclass(frozen=True, slots=True) +class Leaf: + class_: int + pg_decode: str + pg_encode: str | None + + +@dataclass(frozen=True, slots=True) +class Wrapper: + leaf: Leaf + mood: Mood + note: str | None + + +@dataclass(frozen=True, slots=True) +class EnumRow: + mood: Mood + + +@dataclass(frozen=True, slots=True) +class EnumArrayRow: + moods: list[Mood | None] + + +@dataclass(frozen=True, slots=True) +class EnumGridRow: + moods: list[list[Mood]] + + +@dataclass(frozen=True, slots=True) +class LeafRow: + value: Leaf + + +@dataclass(frozen=True, slots=True) +class LeafArrayRow: + values: list[Leaf] + + +@dataclass(frozen=True, slots=True) +class WrapperRow: + value: Wrapper + + +@dataclass(frozen=True, slots=True) +class SanitizedRow: + class_: str + value: Leaf + + +type _ObjectMaker[T] = Callable[[Sequence[Any], CompositeInfo], T] +type _SequenceMaker[T] = Callable[[T, CompositeInfo], Sequence[Any]] + + +def _python_name(name: str) -> str: + if name == "class": + assert keyword.iskeyword(name) + return "class_" + return name + + +def dataclass_callbacks[T](cls: type[T]) -> tuple[_ObjectMaker[T], _SequenceMaker[T]]: + if not is_dataclass(cls): + raise TypeError(f"{cls.__name__} must be a dataclass") + + model_fields = fields(cls) + model_names = tuple(field.name for field in model_fields) + + def make_object(values: Sequence[Any], info: CompositeInfo) -> T: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + assert len(values) == len(model_fields) + return cls(**dict(zip(names, values, strict=True))) + + def make_sequence(obj: T, info: CompositeInfo) -> Sequence[Any]: + names = tuple(_python_name(name) for name in info.field_names) + assert names == model_names + return tuple(getattr(obj, field.name) for field in model_fields) + + return make_object, make_sequence + + +_leaf_make_object, _leaf_make_sequence = dataclass_callbacks(Leaf) +_wrapper_make_object, _wrapper_make_sequence = dataclass_callbacks(Wrapper) + + +async def register_adapter_types_async(conn: AsyncConnection[object]) -> None: + mood_info = await EnumInfo.fetch(conn, "public.m_adapter_mood") + assert mood_info is not None + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + assert mood_info.enum is Mood + + # Register dependencies before the alphabetically earlier wrapper type. + leaf_info = await CompositeInfo.fetch(conn, "public.z_adapter_leaf") + assert leaf_info is not None + register_composite( + leaf_info, + conn, + Leaf, + make_object=_leaf_make_object, + make_sequence=_leaf_make_sequence, + ) + assert leaf_info.python_type is Leaf + + wrapper_info = await CompositeInfo.fetch(conn, "public.a_adapter_wrapper") + assert wrapper_info is not None + register_composite( + wrapper_info, + conn, + Wrapper, + make_object=_wrapper_make_object, + make_sequence=_wrapper_make_sequence, + ) + assert wrapper_info.python_type is Wrapper + + +def register_adapter_types_sync(conn: Connection[object]) -> None: + mood_info = EnumInfo.fetch(conn, "public.m_adapter_mood") + assert mood_info is not None + register_enum( + mood_info, + conn, + Mood, + mapping={member: member.value for member in Mood}, + ) + assert mood_info.enum is Mood + + leaf_info = CompositeInfo.fetch(conn, "public.z_adapter_leaf") + assert leaf_info is not None + register_composite( + leaf_info, + conn, + Leaf, + make_object=_leaf_make_object, + make_sequence=_leaf_make_sequence, + ) + assert leaf_info.python_type is Leaf + + wrapper_info = CompositeInfo.fetch(conn, "public.a_adapter_wrapper") + assert wrapper_info is not None + register_composite( + wrapper_info, + conn, + Wrapper, + make_object=_wrapper_make_object, + make_sequence=_wrapper_make_sequence, + ) + assert wrapper_info.python_type is Wrapper + + +async def fetch_one_async[T]( + conn: AsyncConnection[object], + sql: LiteralString, + params: Mapping[str, object], + row_factory: BaseRowFactory[T], +) -> T: + async with conn.cursor(row_factory=row_factory) as cursor: + await cursor.execute(sql, params) + row = await cursor.fetchone() + if row is None: + raise RuntimeError("query returned no row") + return row + + +def fetch_one_sync[T]( + conn: Connection[object], + sql: LiteralString, + params: Mapping[str, object], + row_factory: BaseRowFactory[T], +) -> T: + with conn.cursor(row_factory=row_factory) as cursor: + cursor.execute(sql, params) + row = cursor.fetchone() + if row is None: + raise RuntimeError("query returned no row") + return row + + +async def prove_async_row_inference(conn: AsyncConnection[object]) -> None: + enum_row: EnumRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumRow)) + enum_array_row: EnumArrayRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumArrayRow)) + enum_grid_row: EnumGridRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(EnumGridRow)) + leaf_row: LeafRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(LeafRow)) + leaf_array_row: LeafArrayRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(LeafArrayRow)) + wrapper_row: WrapperRow = await fetch_one_async(conn, "SELECT NULL", {}, args_row(WrapperRow)) + sanitized_row: SanitizedRow = await fetch_one_async(conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow)) + _ = ( + enum_row, + enum_array_row, + enum_grid_row, + leaf_row, + leaf_array_row, + wrapper_row, + sanitized_row, + ) + + +def prove_sync_row_inference(conn: Connection[object]) -> None: + enum_row: EnumRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumRow)) + enum_array_row: EnumArrayRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumArrayRow)) + enum_grid_row: EnumGridRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(EnumGridRow)) + leaf_row: LeafRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafRow)) + leaf_array_row: LeafArrayRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(LeafArrayRow)) + wrapper_row: WrapperRow = fetch_one_sync(conn, "SELECT NULL", {}, args_row(WrapperRow)) + sanitized_row: SanitizedRow = fetch_one_sync(conn, "SELECT NULL, NULL", {}, args_row(SanitizedRow)) + _ = ( + enum_row, + enum_array_row, + enum_grid_row, + leaf_row, + leaf_array_row, + wrapper_row, + sanitized_row, + ) diff --git a/uv.lock b/uv.lock index f2a9658..46c2abd 100644 --- a/uv.lock +++ b/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.12" [[package]] name = "basedpyright" -version = "1.39.7" +version = "1.39.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/e5/0d685b5808436c628ab8b9edad6810b889d11044a962bc42b128543910ea/basedpyright-1.39.7.tar.gz", hash = "sha256:688d913a19c417870c164c630ed9cdd83a8d8b484b30ab8e99f5dec4ae9604a6", size = 25503256, upload-time = "2026-06-07T11:33:27.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/f4/5b1e8ea279ce8f97a6bb1518c84fa25f5794022053ce10eab22ad3f0b51b/basedpyright-1.39.7-py3-none-any.whl", hash = "sha256:81266deb6044c9be98fb4555e4b7b1a521d8aee06b66e80858d183b0e3991140", size = 13182666, upload-time = "2026-06-07T11:33:24.119Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, ] [[package]] @@ -67,15 +67,17 @@ dev = [ { name = "basedpyright" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] [package.metadata.requires-dev] dev = [ - { name = "basedpyright", specifier = "==1.39.7" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "basedpyright", specifier = "==1.39.9" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4,<4" }, { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = "==0.15.20" }, ] [[package]] @@ -156,7 +158,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -165,9 +167,34 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] diff --git a/wheel/NOTICE b/wheel/NOTICE index c057ecc..bab453d 100644 --- a/wheel/NOTICE +++ b/wheel/NOTICE @@ -14,4 +14,4 @@ because resolved.dhall inlines the gen-sdk Dhall contract. Components: Code that this generator emits into your project is not covered by the GPL: emitted files carry an SPDX-License-Identifier: MIT-0 header and may be used under the containing project's own terms. See the python.gen README, -section License. +section Provenance and license. diff --git a/wheel/pyproject.toml b/wheel/pyproject.toml index 7f4184d..6caa40e 100644 --- a/wheel/pyproject.toml +++ b/wheel/pyproject.toml @@ -8,7 +8,8 @@ description = "The python.gen frozen Dhall generator, shipped as an installable requires-python = ">=3.12" # The wheel bundles resolved.dhall, which inlines the GPL-3.0-or-later gen-sdk, # so the artifact as a whole ships under the GPL. The python.gen sources are -# MIT and emitted code is MIT-0; see the repository README's License section. +# MIT and emitted code is MIT-0; see the repository README's +# Provenance and license section. license = "GPL-3.0-or-later" dynamic = ["version"] dependencies = []