cygnet-orm 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. cygnet_orm-1.0.0/.github/workflows/ci.yml +311 -0
  2. cygnet_orm-1.0.0/.github/workflows/publish.yml +55 -0
  3. cygnet_orm-1.0.0/.gitignore +39 -0
  4. cygnet_orm-1.0.0/ARCHITECTURE.md +97 -0
  5. cygnet_orm-1.0.0/LICENSE +21 -0
  6. cygnet_orm-1.0.0/PKG-INFO +1090 -0
  7. cygnet_orm-1.0.0/README.md +1056 -0
  8. cygnet_orm-1.0.0/THEORY.md +297 -0
  9. cygnet_orm-1.0.0/bench/__init__.py +0 -0
  10. cygnet_orm-1.0.0/bench/_compare.py +98 -0
  11. cygnet_orm-1.0.0/bench/_summary.py +37 -0
  12. cygnet_orm-1.0.0/bench/comparison/__init__.py +0 -0
  13. cygnet_orm-1.0.0/bench/comparison/apps.py +16 -0
  14. cygnet_orm-1.0.0/bench/comparison/models.py +36 -0
  15. cygnet_orm-1.0.0/bench/comparison/test_comparison.py +352 -0
  16. cygnet_orm-1.0.0/bench/conftest.py +190 -0
  17. cygnet_orm-1.0.0/bench/test_e2e.py +139 -0
  18. cygnet_orm-1.0.0/bench/test_overhead.py +152 -0
  19. cygnet_orm-1.0.0/bench/test_render.py +146 -0
  20. cygnet_orm-1.0.0/cygnet/__init__.py +521 -0
  21. cygnet_orm-1.0.0/cygnet/annotations.py +86 -0
  22. cygnet_orm-1.0.0/cygnet/arrays.py +111 -0
  23. cygnet_orm-1.0.0/cygnet/builders.py +1070 -0
  24. cygnet_orm-1.0.0/cygnet/cte.py +321 -0
  25. cygnet_orm-1.0.0/cygnet/executor.py +1442 -0
  26. cygnet_orm-1.0.0/cygnet/expression.py +611 -0
  27. cygnet_orm-1.0.0/cygnet/fts.py +125 -0
  28. cygnet_orm-1.0.0/cygnet/functions.py +101 -0
  29. cygnet_orm-1.0.0/cygnet/jsonb.py +112 -0
  30. cygnet_orm-1.0.0/cygnet/meta.py +264 -0
  31. cygnet_orm-1.0.0/cygnet/predicate.py +263 -0
  32. cygnet_orm-1.0.0/cygnet/proxy.py +168 -0
  33. cygnet_orm-1.0.0/cygnet/psycopg_db.py +185 -0
  34. cygnet_orm-1.0.0/cygnet/py.typed +0 -0
  35. cygnet_orm-1.0.0/cygnet/stubs.py +170 -0
  36. cygnet_orm-1.0.0/justfile +134 -0
  37. cygnet_orm-1.0.0/pyproject.toml +98 -0
  38. cygnet_orm-1.0.0/tests/__init__.py +0 -0
  39. cygnet_orm-1.0.0/tests/conftest.py +144 -0
  40. cygnet_orm-1.0.0/tests/integration/__init__.py +4 -0
  41. cygnet_orm-1.0.0/tests/integration/conftest.py +36 -0
  42. cygnet_orm-1.0.0/tests/integration/test_advanced_queries.py +285 -0
  43. cygnet_orm-1.0.0/tests/integration/test_column_defaults.py +95 -0
  44. cygnet_orm-1.0.0/tests/integration/test_pg_helpers.py +333 -0
  45. cygnet_orm-1.0.0/tests/integration/test_pg_types.py +818 -0
  46. cygnet_orm-1.0.0/tests/integration/test_roundtrip.py +802 -0
  47. cygnet_orm-1.0.0/tests/test_bench_compare.py +139 -0
  48. cygnet_orm-1.0.0/tests/test_bench_summary.py +55 -0
  49. cygnet_orm-1.0.0/tests/test_builders.py +1665 -0
  50. cygnet_orm-1.0.0/tests/test_cross_table_dml.py +230 -0
  51. cygnet_orm-1.0.0/tests/test_cte.py +218 -0
  52. cygnet_orm-1.0.0/tests/test_error_messages.py +143 -0
  53. cygnet_orm-1.0.0/tests/test_expression.py +195 -0
  54. cygnet_orm-1.0.0/tests/test_functions.py +141 -0
  55. cygnet_orm-1.0.0/tests/test_lateral.py +165 -0
  56. cygnet_orm-1.0.0/tests/test_locking.py +214 -0
  57. cygnet_orm-1.0.0/tests/test_mapping.py +188 -0
  58. cygnet_orm-1.0.0/tests/test_meta.py +335 -0
  59. cygnet_orm-1.0.0/tests/test_on_conflict.py +267 -0
  60. cygnet_orm-1.0.0/tests/test_pg_helpers.py +207 -0
  61. cygnet_orm-1.0.0/tests/test_predicate.py +166 -0
  62. cygnet_orm-1.0.0/tests/test_set_ops.py +182 -0
  63. cygnet_orm-1.0.0/tests/test_streaming.py +115 -0
  64. cygnet_orm-1.0.0/tests/test_stubs.py +95 -0
  65. cygnet_orm-1.0.0/tests/test_subquery.py +196 -0
  66. cygnet_orm-1.0.0/tests/test_transaction.py +255 -0
  67. cygnet_orm-1.0.0/tests/test_windows.py +145 -0
  68. cygnet_orm-1.0.0/uv.lock +613 -0
@@ -0,0 +1,311 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ # Weekly audit run (S17). Monday 04:00 UTC is a low-contention slot
8
+ # — post-weekend, before East-Coast business hours — so CVE findings
9
+ # land in inboxes before any reviewer is on shift. The audit job
10
+ # runs on this trigger; the other jobs are gated below with
11
+ # `if: github.event_name != 'schedule'` so the cron doesn't burn CI
12
+ # minutes re-running unit / integration / build / bench against
13
+ # unchanged code.
14
+ schedule:
15
+ - cron: "0 4 * * 1"
16
+
17
+ # Opt every JS-based action into Node 24 ahead of the September 2026
18
+ # Node 20 removal. GitHub honours this env var on the runner and
19
+ # applies it to actions/checkout, actions/setup-python, codecov, etc.
20
+ # without us needing to bump each action's major version individually.
21
+ # Once the actions ship Node-24-native versions, this can come out.
22
+ env:
23
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
24
+
25
+ jobs:
26
+
27
+ # ── Unit tests (no database) ──────────────────────────────────────────────
28
+
29
+ unit:
30
+ name: Unit tests — Python ${{ matrix.python-version }}
31
+ runs-on: ubuntu-latest
32
+ # Skip on the weekly schedule (audit-only trigger — see top-level
33
+ # `on:`). Code hasn't changed since the last push, so re-running
34
+ # unit tests would be pure CI cost.
35
+ if: github.event_name != 'schedule'
36
+ strategy:
37
+ fail-fast: false
38
+ matrix:
39
+ python-version: ["3.12", "3.13"]
40
+
41
+ steps:
42
+ - uses: actions/checkout@v4
43
+
44
+ # OQ3 migration: uv is now canonical (justfile + ci.yml both run
45
+ # through it). setup-uv@v6 installs uv, installs the matrix
46
+ # Python via uv, and caches the global uv download cache keyed on
47
+ # uv.lock so re-runs against unchanged deps are near-instant.
48
+ - uses: astral-sh/setup-uv@v6
49
+ with:
50
+ python-version: ${{ matrix.python-version }}
51
+ enable-cache: true
52
+
53
+ - name: Install dependencies
54
+ # --locked: fail loudly if uv.lock drifted from pyproject.toml
55
+ # (CI should never silently resolve a different graph than what
56
+ # was committed). --extra dev pulls in pytest / ruff / mypy /
57
+ # psycopg.
58
+ run: uv sync --locked --extra dev
59
+
60
+ - name: Format check
61
+ run: uv run ruff format --check cygnet tests
62
+
63
+ - name: Lint
64
+ run: uv run ruff check cygnet tests
65
+
66
+ - name: Type check
67
+ run: uv run mypy cygnet
68
+
69
+ - name: Unit tests
70
+ run: |
71
+ uv run pytest tests/ --ignore=tests/integration \
72
+ -v \
73
+ --cov=cygnet \
74
+ --cov-report=xml \
75
+ --cov-report=term-missing
76
+
77
+ - name: Upload coverage
78
+ uses: codecov/codecov-action@v4
79
+ if: matrix.python-version == '3.12'
80
+ with:
81
+ files: coverage.xml
82
+ fail_ci_if_error: false
83
+
84
+ # ── Integration tests (live PostgreSQL via Docker service) ────────────────
85
+
86
+ integration:
87
+ name: Integration tests — PG ${{ matrix.pg-version }}
88
+ runs-on: ubuntu-latest
89
+ if: github.event_name != 'schedule'
90
+ strategy:
91
+ fail-fast: false
92
+ matrix:
93
+ pg-version: ["14", "15", "16", "17", "18"]
94
+
95
+ services:
96
+ postgres:
97
+ image: postgres:${{ matrix.pg-version }}-alpine
98
+ env:
99
+ POSTGRES_USER: cygnet
100
+ POSTGRES_PASSWORD: cygnet
101
+ POSTGRES_DB: cygnet_test
102
+ ports:
103
+ - 5432:5432
104
+ options: >-
105
+ --health-cmd "pg_isready -U cygnet"
106
+ --health-interval 5s
107
+ --health-timeout 3s
108
+ --health-retries 10
109
+
110
+ steps:
111
+ - uses: actions/checkout@v4
112
+
113
+ - uses: astral-sh/setup-uv@v6
114
+ with:
115
+ python-version: "3.12"
116
+ enable-cache: true
117
+
118
+ - name: Install dependencies
119
+ run: uv sync --locked --extra dev
120
+
121
+ - name: Integration tests
122
+ env:
123
+ CYGNET_TEST_DSN: postgresql://cygnet:cygnet@localhost:5432/cygnet_test
124
+ run: |
125
+ uv run pytest tests/integration \
126
+ -v \
127
+ -m integration \
128
+ --cov=cygnet \
129
+ --cov-report=xml
130
+
131
+ - name: Upload coverage
132
+ uses: codecov/codecov-action@v4
133
+ if: matrix.pg-version == '16'
134
+ with:
135
+ files: coverage.xml
136
+ fail_ci_if_error: false
137
+
138
+ # ── Build check ───────────────────────────────────────────────────────────
139
+
140
+ build:
141
+ name: Build
142
+ runs-on: ubuntu-latest
143
+ needs: [unit, integration]
144
+ if: github.event_name != 'schedule'
145
+
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+
149
+ - uses: astral-sh/setup-uv@v6
150
+ with:
151
+ python-version: "3.12"
152
+ enable-cache: true
153
+
154
+ # `uvx --from hatch hatch build` runs hatch in an ephemeral
155
+ # isolated environment — no need to install hatch into the
156
+ # project venv (the build job doesn't need the project's deps).
157
+ - name: Build wheel and sdist
158
+ run: uvx --from hatch hatch build
159
+
160
+ - name: Upload artifacts
161
+ uses: actions/upload-artifact@v4
162
+ with:
163
+ name: dist
164
+ path: dist/
165
+
166
+ # ── Dependency audit ──────────────────────────────────────────────────────
167
+ # pip-audit scans installed dependencies (psycopg[binary] and dev tools)
168
+ # against the PyPI Advisory Database. Runs on every push/PR so a CVE
169
+ # surface is caught before release. Non-blocking exit so an advisory
170
+ # doesn't immediately wedge unrelated PRs — review the job log instead.
171
+
172
+ audit:
173
+ name: Dependency audit
174
+ runs-on: ubuntu-latest
175
+
176
+ steps:
177
+ - uses: actions/checkout@v4
178
+
179
+ - uses: astral-sh/setup-uv@v6
180
+ with:
181
+ python-version: "3.12"
182
+ enable-cache: true
183
+
184
+ - name: Install dependencies
185
+ # pip-audit scans the project venv, so it needs to be installed
186
+ # alongside the project deps (not via uvx, which would isolate
187
+ # it from what we want to audit).
188
+ run: |
189
+ uv sync --locked --extra dev
190
+ uv pip install pip-audit
191
+
192
+ - name: Run pip-audit
193
+ # --skip-editable skips the local cygnet-orm editable install (it
194
+ # isn't on PyPI, so it can't be audited). Without --strict the
195
+ # skipped editable is a notice rather than a hard failure, so the
196
+ # job exits non-zero ONLY when pip-audit actually finds a CVE in
197
+ # a dep — which is the point. The previous `--strict || true`
198
+ # combo was contradictory: --strict failed on the editable skip,
199
+ # and `|| true` swallowed both that AND any real CVE.
200
+ run: uv run pip-audit --skip-editable
201
+
202
+ # ── Benchmarks (advisory) ─────────────────────────────────────────────────
203
+ # Runs the perf suite against a fresh PG instance and uploads the
204
+ # JSON output as an artifact. Advisory-only: a regression doesn't
205
+ # block merge — the artifact (and the in-job summary table) are the
206
+ # signal. PR comparisons against main's baseline are a separate
207
+ # follow-up; today the artifact is the source of truth.
208
+
209
+ bench:
210
+ name: Benchmarks (advisory)
211
+ runs-on: ubuntu-latest
212
+ needs: [unit, integration]
213
+ if: github.event_name != 'schedule'
214
+ # Job continues even if benchmarks regress or hit a transient
215
+ # variance issue; the artifact upload still happens via if: always().
216
+ continue-on-error: true
217
+
218
+ services:
219
+ postgres:
220
+ image: postgres:16-alpine
221
+ env:
222
+ POSTGRES_USER: cygnet
223
+ POSTGRES_PASSWORD: cygnet
224
+ POSTGRES_DB: cygnet_test
225
+ ports:
226
+ - 5432:5432
227
+ options: >-
228
+ --health-cmd "pg_isready -U cygnet"
229
+ --health-interval 5s
230
+ --health-timeout 3s
231
+ --health-retries 10
232
+
233
+ steps:
234
+ - uses: actions/checkout@v4
235
+
236
+ - uses: astral-sh/setup-uv@v6
237
+ with:
238
+ python-version: "3.12"
239
+ enable-cache: true
240
+
241
+ - name: Install dependencies
242
+ run: uv sync --locked --extra dev --extra bench
243
+
244
+ - name: Run benchmarks
245
+ env:
246
+ CYGNET_TEST_DSN: postgresql://cygnet:cygnet@localhost:5432/cygnet_test
247
+ run: |
248
+ uv run pytest bench/ \
249
+ --benchmark-only \
250
+ --benchmark-json=bench-result.json \
251
+ --benchmark-columns=median,min,max,iqr,ops \
252
+ --benchmark-sort=name
253
+
254
+ - name: Summary table in job output
255
+ if: always()
256
+ run: |
257
+ if [ -f bench-result.json ]; then
258
+ echo "## Benchmark results" >> $GITHUB_STEP_SUMMARY
259
+ uv run python -m bench._summary bench-result.json >> $GITHUB_STEP_SUMMARY
260
+ fi
261
+
262
+ # ── PR-only: pull main's last bench artifact and diff ──────────────
263
+ # On pull_request events, fetch the most recent successful main-branch
264
+ # bench-results artifact and emit a per-benchmark delta against the
265
+ # current run. Graceful when the baseline is missing (first PR after
266
+ # a fresh repo, retention expired, etc.) — the `|| true` guarantees
267
+ # the step doesn't fail the job, in keeping with the advisory-only
268
+ # contract.
269
+
270
+ - name: Download main baseline (PR only)
271
+ if: github.event_name == 'pull_request'
272
+ env:
273
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
274
+ run: |
275
+ LATEST=$(gh run list \
276
+ --branch main \
277
+ --workflow CI \
278
+ --status success \
279
+ --limit 1 \
280
+ --json databaseId \
281
+ --jq '.[0].databaseId' || echo "")
282
+ if [ -n "$LATEST" ]; then
283
+ mkdir -p baseline
284
+ gh run download "$LATEST" --name bench-results --dir baseline \
285
+ --repo "${{ github.repository }}" \
286
+ || echo "no baseline artifact found"
287
+ fi
288
+
289
+ - name: Compare against baseline (PR only)
290
+ if: github.event_name == 'pull_request'
291
+ run: |
292
+ if [ -f baseline/bench-result.json ] && [ -f bench-result.json ]; then
293
+ echo "" >> $GITHUB_STEP_SUMMARY
294
+ echo "## Δ vs main baseline" >> $GITHUB_STEP_SUMMARY
295
+ echo "" >> $GITHUB_STEP_SUMMARY
296
+ uv run python -m bench._compare \
297
+ baseline/bench-result.json \
298
+ bench-result.json \
299
+ >> $GITHUB_STEP_SUMMARY
300
+ echo "" >> $GITHUB_STEP_SUMMARY
301
+ echo "_Bold deltas indicate >15% slower than baseline; CI runner noise typically falls within ±10%._" >> $GITHUB_STEP_SUMMARY
302
+ else
303
+ echo "_No main baseline available for comparison (first run, expired artifact, or upstream missing)._" >> $GITHUB_STEP_SUMMARY
304
+ fi
305
+
306
+ - name: Upload benchmark JSON
307
+ if: always()
308
+ uses: actions/upload-artifact@v4
309
+ with:
310
+ name: bench-results
311
+ path: bench-result.json
@@ -0,0 +1,55 @@
1
+ name: Publish to PyPI
2
+
3
+ # Trusted publishing (OIDC): no API token is stored anywhere. The job mints a
4
+ # short-lived OIDC identity token that PyPI exchanges for a one-shot upload
5
+ # credential, and PEP 740 Sigstore attestations linking the artifacts to this
6
+ # exact workflow run are generated automatically.
7
+ #
8
+ # Publishing a GitHub Release is the deliberate human gate on an irreversible
9
+ # step: PyPI is append-only, so a given name==version (cygnet-orm==1.0.0) can
10
+ # never be re-uploaded or replaced — only yanked. Creating the release is the
11
+ # trigger; nothing publishes on a plain push.
12
+ on:
13
+ release:
14
+ types: [published]
15
+
16
+ # Match ci.yml: opt JS-based actions into Node 24 ahead of the Node 20 removal.
17
+ env:
18
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
19
+
20
+ jobs:
21
+ publish:
22
+ name: Build and publish to PyPI
23
+ runs-on: ubuntu-latest
24
+
25
+ # The `pypi` environment must match the one named in the PyPI pending
26
+ # publisher. Scoping the job to an environment also lets you add required
27
+ # reviewers / branch rules to gate the publish in repo settings.
28
+ environment:
29
+ name: pypi
30
+ url: https://pypi.org/p/cygnet-orm
31
+
32
+ permissions:
33
+ # REQUIRED for trusted publishing — lets the job request the OIDC token.
34
+ # Without it there is no credential to exchange and the upload fails.
35
+ id-token: write
36
+
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ - uses: astral-sh/setup-uv@v6
41
+ with:
42
+ python-version: "3.12"
43
+ enable-cache: true
44
+
45
+ # Build with the project's own backend (hatchling) via uv, so the release
46
+ # artifacts are produced exactly like `uv build` locally and the CI build
47
+ # job — sdist + wheel into dist/.
48
+ - name: Build sdist + wheel
49
+ run: uv build
50
+
51
+ # The blessed PyPA action consumes dist/ and uploads via trusted
52
+ # publishing. No `with: password:` — the OIDC token is the credential,
53
+ # and attestations are on by default.
54
+ - name: Publish to PyPI
55
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ *.egg
10
+
11
+ # Environment
12
+ .env
13
+
14
+ # Test / coverage
15
+ .pytest_cache/
16
+ .coverage
17
+ coverage.xml
18
+ htmlcov/
19
+
20
+ # Mypy
21
+ .mypy_cache/
22
+
23
+ # Ruff
24
+ .ruff_cache/
25
+
26
+ # macOS
27
+ .DS_Store
28
+
29
+ # Editor
30
+ .idea/
31
+ .vscode/
32
+ *.swp
33
+
34
+ # Codebase-memory graph artifacts (ADR notes; graph DB lives in central store)
35
+ .codebase-memory/
36
+
37
+ # Local working artifacts: code-review outputs and superpowers plans/specs.
38
+ # Design rationale worth keeping has been folded into THEORY.md.
39
+ docs/
@@ -0,0 +1,97 @@
1
+ # Architecture
2
+
3
+ Cygnet is a small, async, **PostgreSQL-only** ORM that maps plain dataclasses to
4
+ tables and keeps SQL visible. The one fact to hold first: there is no query DSL
5
+ behind which SQL hides — every query verb builds an AST of **`SQLRenderable`**
6
+ nodes that render, in a single left-to-right pass over one shared params list, to
7
+ parameterised PG SQL (`$1, $2, …`). Almost everything else falls out of that.
8
+
9
+ For *why* any of this is shaped the way it is, see [THEORY.md](THEORY.md). For
10
+ install/build/test/usage, see [README.md](README.md). Open issues are tracked in the
11
+ project's [GitHub issues](https://github.com/Xof/cygnet/issues).
12
+
13
+ ## Component map
14
+
15
+ Authoritative module enumeration. Dependency direction is strictly downward at
16
+ module load: `__init__` → `builders` → `executor` →
17
+ `proxy`/`cte`/`predicate`/`expression` → `meta` → `annotations`, with no cycles.
18
+ Would-be cycles are broken by *deferred* (in-function) imports instead of
19
+ module-load ones — notably `executor.run_save` → `builders` (the save→builder
20
+ edge) and `predicate.__invert__` → `expression.PrefixOp` (the reverse of
21
+ `expression`'s module-load import of `Predicate`, needed only for `~`).
22
+
23
+ | Module | Responsibility | Key symbols |
24
+ |---|---|---|
25
+ | `__init__.py` | Public API surface; query-verb factories | `Table`, `SELECT`/`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE`, `get`/`save`/`create`/`follow`, `lit`/`op`/`ops`/`exists`, `transaction`, `cte`/`recursive_cte`/`lateral` |
26
+ | `annotations.py` | Passive metadata markers, introspected by `meta` | `DBKey`, `AppKey`, `Column`, `ForeignKey`, `@table` |
27
+ | `meta.py` | Dataclass → `TableMeta`/`FieldMeta` introspection | `TableMeta` (WeakValueDict-cached per class) |
28
+ | `proxy.py` | Attribute access → predicate AST | `TableProxy[T]`, `ColumnProxy[FT]` (per-class singletons; `.AS()` bypasses cache) |
29
+ | `predicate.py` | Comparison/predicate AST + the operator-overload menu | `Predicate`, `Literal`, `_All`, **`_InfixOps`** mixin |
30
+ | `expression.py` | `SQLRenderable` protocol + expression nodes | `SQLRenderable`, `op`/`ops`, `PrefixOp`/`SuffixOp`, `FunctionCall`, `WindowExpression`, `_Exists`, `exists`/`not_exists` |
31
+ | `builders.py` | Fluent, awaitable query builders | `SelectBuilder`, `InsertBuilder`, `UpdateBuilder`, `DeleteBuilder`, `_LockClause` |
32
+ | `executor.py` | Render + execute each verb; row→object mapping | `render_*`/`run_*` per verb, hydration |
33
+ | `cte.py` | WITH-clause / lateral sources that duck-type `TableProxy` | `CTE`, `RecursiveCTE`, `Lateral` |
34
+ | `functions.py` | Curated PG function wrappers | `count`, `sum`, `coalesce`, `row_number`, `lag`/`lead`, … + `fn(name)` escape hatch |
35
+ | `jsonb.py` / `arrays.py` / `fts.py` | Operator/function helpers for JSONB, arrays, full-text | `->`/`->>`/`@>`, `&&`/`ANY`/`ALL`, `to_tsvector`/`@@`/`ts_rank` |
36
+ | `psycopg_db.py` | Reference psycopg3 adapter | `PsycopgDB` (the **only** module that imports psycopg; gated behind `[psycopg]` extra) |
37
+ | `stubs.py` | `python -m cygnet.stubs` codegen for IDE autocomplete | — |
38
+ | `py.typed` | PEP 561 typed-library marker | — |
39
+
40
+ Tests: `tests/` unit suite against `tests/conftest.py:FakeDB` (no DB);
41
+ `tests/integration/` real-PG round-trips (CI matrix PG 14–18); `bench/` advisory
42
+ pytest-benchmark + cross-ORM comparison.
43
+
44
+ ## Invariants
45
+
46
+ Load-bearing rules. THEORY.md §"Invariants, and why they hold" supplies the *why*;
47
+ this is the enumeration.
48
+
49
+ - **`UPDATE` and `DELETE` must call `.WHERE()`.** Breaks: raises `ValueError` at render *and* `.sql()` time. Opt out of the rail with `WHERE(cygnet.all)`; mixing `cygnet.all` with a real predicate also raises.
50
+ - **`ColumnProxy.__eq__` (and `__ne__`/`__lt__`/arithmetic) return a `Predicate`, not a `bool`; `__hash__` is `None`.** Breaks: a proxy comparison used in a boolean/`if` context is always truthy (it's an AST node) — a silent logic bug, not an error. Proxies are unhashable on purpose (can't go in a set/dict key).
51
+ - **One shared `params: list` threads through the whole render, numbered monotonically in document order (`$N = len(params)` after append).** Breaks: every renderable must append in left-to-right textual order; any out-of-order or two-pass rendering corrupts `$N` numbering and the adapter's `$N`→`%s` translation.
52
+ - **Anything in a WHERE/SELECT-list/ORDER BY/HAVING/SET-RHS position must implement `render_sql(self, params) -> str`.** Breaks: non-renderables don't compose; this protocol is the only contract the executor relies on.
53
+ - **`TableProxy`/`TableMeta` are per-class singletons (WeakValueDictionary); code compares them by identity (`b._table is X`).** Breaks: a non-singleton proxy fails identity checks in the executor. `.AS(alias)` deliberately returns a *fresh* proxy (self-joins need two); the canonical `Table(cls)` stays singleton.
54
+ - **Exactly one `DBKey` or `AppKey` field per model.** Breaks: `meta._introspect` raises; composite PKs are unsupported by design.
55
+ - **`DBKey` + `frozen=True` is rejected at introspection time.** Breaks: post-INSERT the executor `setattr`s the generated PK; a frozen instance would raise `FrozenInstanceError` deep in insert — caught early so the error names the model.
56
+ - **`AppKey` + `None` at INSERT raises; empty `UPDATE … SET` raises; unknown `SET`/INSERT/`DO UPDATE` kwargs raise.** Breaks: these are anti-silent-no-op rails — a typo'd field name must fail loudly, not emit a no-op.
57
+ - **The `db` object satisfies a duck-typed protocol — `execute`, `execute_one`, optional `stream`, and a `_in_transaction: bool` flag — and is per-task.** Breaks: `_in_transaction` is per-`db`-instance, not task-local; sharing one connection across `asyncio` tasks corrupts SAVEPOINT nesting.
58
+ - **Core never imports a driver; only `psycopg_db.py` imports psycopg.** Breaks: pulling psycopg into core contradicts the bring-your-own-adapter design and the driver-free core install.
59
+
60
+ ## Landmines
61
+
62
+ Non-obvious things a cold worker gets wrong. Each prevents a specific wrong action.
63
+
64
+ - **`cygnet.lit(sql)` is raw, trusted, and *still* passes through the adapter's `$N`→`%s` regex.** A literal containing `$1` (or a `%`) gets rewritten. No parameter substitution happens inside `lit()` — it is a SQL-injection surface; never build it from untrusted input.
65
+ - **Awaiting a builder twice re-renders and re-executes it.** Builders hold no rendered SQL/params between calls — intentional, so `.sql()`-then-`await` is safe, but a reused builder is a fresh query each time.
66
+ - **`save()` does NOT refresh non-PK columns on the upsert branch.** `INSERT … ON CONFLICT DO UPDATE` emits no `RETURNING`; the in-memory object diverges from the row if the table has triggers / generated columns / `DEFAULT` on update. The *fresh-INSERT* branch does refresh. (Deferred tradeoff; OQ1 in the ADR.)
67
+ - **`GROUP_BY` requires explicit columns: `SELECT(db, col1, …)`.** Bare `SELECT(db)` + `GROUP_BY` raises `ValueError`.
68
+ - **Window-frame strings are interpolated verbatim** (e.g. `ROWS BETWEEN …`) — trusted, not parameterised. Another injection surface.
69
+ - **`CTE`/`Lateral` duck-type `TableProxy`** by stamping `ColumnProxy` attrs on themselves with `# type: ignore`; they are not yet a formal `TableSourceProtocol` (OQ4). Treat `_sql_name`/`_meta`/`_alias` as the surface the executor reads.
70
+ - **`_Exists` is dedicated, not `PrefixOp("EXISTS", …)`** — because `SelectBuilder.render_sql` already wraps itself in parens, reusing `PrefixOp` would emit `EXISTS ((SELECT …))`. `~exists(b)` toggles `EXISTS ↔ NOT EXISTS` rather than wrapping in `NOT (…)`.
71
+
72
+ ## Flow
73
+
74
+ `cygnet.SELECT(db, …)` returns a `SelectBuilder`; fluent methods (`FROM`, `WHERE`,
75
+ `JOIN`, `ORDER_BY`, …) mutate it and return `self`. The terminal action is
76
+ `await builder` → `__await__` → `_execute()` → `Executor.render_<verb>(b, params)`
77
+ (single pass, document order) → `db.execute(sql, params)` → row tuples →
78
+ `Executor` maps rows back to dataclass instances (single-source) or
79
+ `(left, right, …)` tuples (multi-source JOIN; `None` on a side signals an
80
+ outer-join miss). `.sql()` renders without executing; `.stream()` async-iterates a
81
+ server-side portal cursor instead of buffering. A `SelectBuilder` is itself a
82
+ `SQLRenderable`, so it drops into any expression position as a subquery with no
83
+ wrapper.
84
+
85
+ ## Where to change X
86
+
87
+ - **Add a curated PG function** → `functions.py` (or reach `fn("name")` at call site).
88
+ - **Add a JSONB / array / FTS operator** → `jsonb.py` / `arrays.py` / `fts.py`.
89
+ - **Add or alter a SQL verb / clause** → fluent method in `builders.py` + render branch in `executor.py`.
90
+ - **Change row→object hydration** → `executor.py` mapping path.
91
+ - **Add a new expression node** → implement `render_sql(self, params)`; it composes everywhere automatically (the operator-overload menu is the `_InfixOps` mixin in `predicate.py`).
92
+ - **Add PK/FK/column semantics** → marker in `annotations.py` + introspection in `meta.py`.
93
+ - **Add a `db` adapter** → satisfy the four-method protocol; `psycopg_db.py` is the reference.
94
+
95
+ ---
96
+
97
+ For the reasoning behind all of the above, see [THEORY.md](THEORY.md).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christophe Pettus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.