pydoptic 0.0.post1.dev38__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 (50) hide show
  1. pydoptic-0.0.post1.dev38/.github/workflows/integration-tests.yml +57 -0
  2. pydoptic-0.0.post1.dev38/.github/workflows/release.yml +69 -0
  3. pydoptic-0.0.post1.dev38/.github/workflows/snapshot.yml +57 -0
  4. pydoptic-0.0.post1.dev38/.github/workflows/unit-tests.yml +30 -0
  5. pydoptic-0.0.post1.dev38/.gitignore +17 -0
  6. pydoptic-0.0.post1.dev38/CONTEXT.md +234 -0
  7. pydoptic-0.0.post1.dev38/LICENSE +21 -0
  8. pydoptic-0.0.post1.dev38/PKG-INFO +847 -0
  9. pydoptic-0.0.post1.dev38/README.md +825 -0
  10. pydoptic-0.0.post1.dev38/benchmarks/bench_model.py +315 -0
  11. pydoptic-0.0.post1.dev38/docker-compose.yml +46 -0
  12. pydoptic-0.0.post1.dev38/mypy.ini +7 -0
  13. pydoptic-0.0.post1.dev38/pyproject.toml +45 -0
  14. pydoptic-0.0.post1.dev38/setup.cfg +4 -0
  15. pydoptic-0.0.post1.dev38/src/pydoptic/__init__.py +3 -0
  16. pydoptic-0.0.post1.dev38/src/pydoptic/base_model.py +463 -0
  17. pydoptic-0.0.post1.dev38/src/pydoptic/py.typed +0 -0
  18. pydoptic-0.0.post1.dev38/src/pydoptic/selector.py +1351 -0
  19. pydoptic-0.0.post1.dev38/src/pydoptic/validate_types.py +104 -0
  20. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/PKG-INFO +847 -0
  21. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/SOURCES.txt +48 -0
  22. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/dependency_links.txt +1 -0
  23. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/requires.txt +13 -0
  24. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/scm_file_list.json +45 -0
  25. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/scm_version.json +8 -0
  26. pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/top_level.txt +3 -0
  27. pydoptic-0.0.post1.dev38/src/pydoptic_elastic/__init__.py +3 -0
  28. pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_model.py +88 -0
  29. pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_query.py +154 -0
  30. pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_service.py +73 -0
  31. pydoptic-0.0.post1.dev38/src/pydoptic_elastic/py.typed +0 -0
  32. pydoptic-0.0.post1.dev38/src/pydoptic_sql/__init__.py +6 -0
  33. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_computed.py +93 -0
  34. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_constraint.py +782 -0
  35. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_having.py +728 -0
  36. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_order.py +28 -0
  37. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_query.py +1144 -0
  38. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_service.py +335 -0
  39. pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_table.py +188 -0
  40. pydoptic-0.0.post1.dev38/test/pydoptic/test_base_model.py +621 -0
  41. pydoptic-0.0.post1.dev38/test/pydoptic/test_partial_model.py +548 -0
  42. pydoptic-0.0.post1.dev38/test/pydoptic/test_selector.py +730 -0
  43. pydoptic-0.0.post1.dev38/test/pydoptic_elastic/test_elastic_model.py +33 -0
  44. pydoptic-0.0.post1.dev38/test/pydoptic_elastic/test_elastic_service.py +68 -0
  45. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_computed.py +258 -0
  46. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_constraint.py +296 -0
  47. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_having.py +237 -0
  48. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_params.py +154 -0
  49. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_query.py +911 -0
  50. pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_service.py +657 -0
@@ -0,0 +1,57 @@
1
+ name: Integration Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ concurrency:
9
+ group: integration-tests-${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ services:
16
+ # Mirrors docker-compose.yml's `db` service; connection details match test/pydoptic_sql/test_sql_service.py's _DB_DSN.
17
+ postgres:
18
+ image: postgres
19
+ env:
20
+ POSTGRES_DB: pydoptic
21
+ POSTGRES_PASSWORD: password
22
+ ports:
23
+ - 5432:5432
24
+ options: >-
25
+ --health-cmd pg_isready
26
+ --health-interval 10s
27
+ --health-timeout 5s
28
+ --health-retries 5
29
+
30
+ # Mirrors docker-compose.yml's `elastic` service; heap sized down from the compose file's
31
+ # -Xms2G -Xmx10G to fit a GitHub-hosted runner.
32
+ elasticsearch:
33
+ image: elasticsearch:8.14.3
34
+ env:
35
+ discovery.type: single-node
36
+ xpack.security.enabled: 'false'
37
+ ES_JAVA_OPTS: -Xms512m -Xmx512m
38
+ ports:
39
+ - 9200:9200
40
+ options: >-
41
+ --health-cmd "curl --silent --fail localhost:9200/_cluster/health || exit 1"
42
+ --health-interval 30s
43
+ --health-timeout 30s
44
+ --health-retries 5
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+
48
+ - uses: actions/setup-python@v5
49
+ with:
50
+ python-version: '3.12'
51
+ cache: 'pip'
52
+
53
+ - name: Install dependencies
54
+ run: pip install -e .[test]
55
+
56
+ - name: Run integration tests
57
+ run: PYTHONPATH=src python -m pytest test -q -m integration
@@ -0,0 +1,69 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*.*.*'
7
+
8
+ jobs:
9
+ build:
10
+ name: Build distribution
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ with:
15
+ # setuptools-scm derives the version from git tags; it needs full history to see them.
16
+ fetch-depth: 0
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+
22
+ - name: Install build tooling
23
+ run: pip install build
24
+
25
+ - name: Build sdist and wheel
26
+ run: python -m build
27
+
28
+ - name: Upload build artifacts
29
+ uses: actions/upload-artifact@v4
30
+ with:
31
+ name: dist
32
+ path: dist/
33
+
34
+ publish-to-pypi:
35
+ name: Publish to PyPI
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ environment:
39
+ name: pypi
40
+ url: https://pypi.org/p/pydoptic
41
+ permissions:
42
+ id-token: write # required for PyPI trusted publishing (OIDC)
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ - uses: pypa/gh-action-pypi-publish@release/v1
50
+
51
+ github-release:
52
+ name: Create GitHub Release
53
+ needs: publish-to-pypi
54
+ runs-on: ubuntu-latest
55
+ permissions:
56
+ contents: write # required to create the release
57
+ steps:
58
+ - uses: actions/download-artifact@v4
59
+ with:
60
+ name: dist
61
+ path: dist/
62
+
63
+ - name: Create GitHub Release
64
+ env:
65
+ GH_TOKEN: ${{ github.token }}
66
+ run: |
67
+ gh release create "${{ github.ref_name }}" dist/* \
68
+ --repo "${{ github.repository }}" \
69
+ --generate-notes
@@ -0,0 +1,57 @@
1
+ name: Snapshot
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ concurrency:
9
+ group: snapshot-${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ build:
14
+ name: Build distribution
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ # setuptools-scm derives the version from git tags/distance; it needs full history.
20
+ fetch-depth: 0
21
+
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.12'
25
+
26
+ - name: Install build tooling
27
+ run: pip install build
28
+
29
+ - name: Build sdist and wheel
30
+ # No tag on this commit, so setuptools-scm (with version_scheme = "no-guess-dev" in
31
+ # pyproject.toml) produces a PEP 440 pre-release version like "0.1.0.dev5" -- unique per
32
+ # commit distance from the last tag, and excluded from `pip install pydoptic` by default
33
+ # (requires --pre to opt in), so this can't be mistaken for a real release.
34
+ run: python -m build
35
+
36
+ - name: Upload build artifacts
37
+ uses: actions/upload-artifact@v4
38
+ with:
39
+ name: dist
40
+ path: dist/
41
+
42
+ publish-to-pypi:
43
+ name: Publish snapshot to PyPI
44
+ needs: build
45
+ runs-on: ubuntu-latest
46
+ environment:
47
+ name: pypi
48
+ url: https://pypi.org/p/pydoptic
49
+ permissions:
50
+ id-token: write # required for PyPI trusted publishing (OIDC)
51
+ steps:
52
+ - uses: actions/download-artifact@v4
53
+ with:
54
+ name: dist
55
+ path: dist/
56
+
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,30 @@
1
+ name: Unit Tests
2
+
3
+ on:
4
+ push:
5
+ branches-ignore:
6
+ - main
7
+ pull_request:
8
+
9
+ concurrency:
10
+ group: unit-tests-${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: '3.12'
22
+ cache: 'pip'
23
+
24
+ - name: Install dependencies
25
+ run: pip install -e .[test]
26
+
27
+ - name: Run unit tests
28
+ # Integration tests are excluded by default (see pyproject.toml addopts);
29
+ # they require live services and run only in integration-tests.yml.
30
+ run: PYTHONPATH=src python -m pytest test -q
@@ -0,0 +1,17 @@
1
+ *.json
2
+ !geo_data/*.json
3
+ !location_dataset.json
4
+ *.egg*
5
+ venv*
6
+ __pycache__
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .env
11
+ .vscode
12
+ .coverage
13
+ .pytest_cache
14
+ .idea
15
+ esdata
16
+ data
17
+ .DS_Store
@@ -0,0 +1,234 @@
1
+ # CONTEXT.md
2
+
3
+ Working notes for an ongoing feature-development session on `pydoptic_sql`, written because the
4
+ conversation that produced this work is about to run out of context. Read this before continuing
5
+ the work — it captures *why* things are shaped the way they are, which the code alone won't tell you
6
+ and which took real back-and-forth to arrive at. Skipping it risks re-litigating settled design
7
+ questions or reintroducing bugs that were already found and fixed.
8
+
9
+ ## Where the work actually lives
10
+
11
+ **Do the work in `/Users/johnhungerford/projects/personal/pydoptic` on branch `main` directly.**
12
+ That's where every commit below actually landed. If you're reading this from a git worktree under
13
+ `.claude/worktrees/...`, that worktree is stale (last touched near the start of this effort) and is
14
+ *not* where this work happened — don't use it as a source of truth for current file contents.
15
+
16
+ Live Postgres integration tests connect to `host=localhost port=5432 dbname=pydoptic user=postgres
17
+ password=password` (see `docker-compose.yml`); bring it up with `docker compose up -d db` if it's not
18
+ already running. Elasticsearch (also in `docker-compose.yml`) is unrelated to this work.
19
+
20
+ ## What `pydoptic_sql` is
21
+
22
+ A type-safe SQL query builder layered on `pydoptic` (the base package's "reified optics" / selector
23
+ system — `Select`, `PropSelect`, `Prop`, `PropOpt`, etc. in `src/pydoptic/selector.py`). Its
24
+ distinguishing feature versus mainstream options (SQLAlchemy, peewee, etc.): join arity is tracked in
25
+ the type system itself, so a WHERE/ON/HAVING clause can't reference a table that isn't actually in
26
+ scope at that point in a join chain — mypy catches it, not just Postgres at runtime.
27
+
28
+ ## Commands
29
+
30
+ ```bash
31
+ # from repo root
32
+ venv/bin/python -m mypy . --config-file mypy.ini
33
+ PYTHONPATH=src venv/bin/python -m pytest test -q
34
+ ```
35
+ `venv/` at the repo root already has mypy and pytest installed — use it rather than system Python
36
+ (which is too old for this codebase's `match` statements etc.).
37
+
38
+ ## Current state
39
+
40
+ - Everything through **"Add HAVING support to the SqlQuery DSL"** (commit `61c212a`) is committed on
41
+ `main`.
42
+ - Since then, two more features landed in the same session, **built, tested, and verified, but not
43
+ yet committed**:
44
+ 1. **`Constraint.incr_arity()` / `HavingConstraint.incr_arity()`** (arities 1→2, 2→3, 3→4) on every
45
+ concrete subclass (`Or`/`And`/`Not`/`Comp`/`Between`/`In` and their `Having` counterparts) in
46
+ `sql_constraint.py`/`sql_having.py` — rewraps the same operands into the next arity's class.
47
+ 2. **Collapsed the builder/terminal split** in `sql_query.py`: `SelectQuery`/`JoinQuery2..4`
48
+ (plain and `Computed`) are gone, merged into `Query1..4`/`ComputedQuery1..4`, each directly
49
+ executable (`to_sql`/`to_sql_params`) at every stage. `join_inner`/`join_left` now call
50
+ `incr_arity()` on `_where` (and `_having`, for `Computed` queries) instead of requiring them
51
+ unset. Added `where_and`/`where_or` (mirroring the existing `having_and`/`having_or`). This is
52
+ exactly the redesign flagged as pending in the previous version of this doc — see "The two arity
53
+ strategies" below for the corrected picture.
54
+ Modified files: `src/pydoptic_sql/sql_constraint.py`, `src/pydoptic_sql/sql_having.py`,
55
+ `src/pydoptic_sql/sql_query.py`, `src/pydoptic_sql/sql_service.py` (updated to call the new
56
+ `query._resolved_selection()` instead of reading `query._selection` directly, since selection
57
+ resolution is now lazy — see below), plus matching test updates across
58
+ `test/pydoptic_sql/test_sql_{constraint,having,query,computed,service}.py`. Commit these (with the
59
+ user's go-ahead) before starting anything new, or the next session will have to rediscover this same
60
+ context.
61
+ - mypy is clean (27 source files). Full test suite is 299 passed (unit tests + live-Postgres
62
+ integration tests — the latter require the `db` container above to be running).
63
+
64
+ ## Module map (`src/pydoptic_sql/`)
65
+
66
+ | File | Purpose |
67
+ |---|---|
68
+ | `sql_table.py` | `SqlTable` base, `ColumnType`, `column()`, DDL column constraints (`PrimaryKey`, `Unique`, `AutoIncrement`, `ForeignKey`, `Check`, `Default`) |
69
+ | `sql_constraint.py` | `Constraint`/`Constraint2`/`Constraint3`/`Constraint4` — WHERE and JOIN-ON conditions, one arity-ladder class family per number of tables a single constraint can span. Every concrete subclass at arities 1-3 has `incr_arity()` rewrapping it into the next arity's class (no `Constraint5`, so arity 4 has none) |
70
+ | `sql_order.py` | `Direction`, `OrderBy` — ORDER BY. Deliberately **not** an arity ladder (see below) |
71
+ | `sql_computed.py` | `AggregateFunction`, `ComputedResult`, `Computed[TC, A]` — aggregate/computed columns (SUM/COUNT/AVG/MIN/MAX) |
72
+ | `sql_having.py` | `HavingConstraint`/`2`/`3`/`4` — HAVING conditions; a genuinely separate hierarchy from `Constraint*`, not reused, so `Computed` values stay statically unusable in WHERE/ON. Same `incr_arity()` treatment (arities 1-3) as `Constraint*` |
73
+ | `sql_query.py` | The big one (~1150 lines): `SqlQuery` base + one query class per join arity, both plain (`Query1..4`) and `Computed` (`ComputedQuery1..4`) variants — no separate builder/terminal classes (see below). |
74
+ | `sql_service.py` | `PsycoPgSqlClient`/`Transaction`/`*Response` — executes queries against Postgres via `psycopg`, decodes rows back into `PartialModel`/`ComputedResult` |
75
+
76
+ Tests mirror this 1:1 under `test/pydoptic_sql/` (`test_sql_query.py`, `test_sql_constraint.py`,
77
+ `test_sql_params.py`, `test_sql_computed.py`, `test_sql_having.py`, `test_sql_service.py` for the
78
+ live-DB integration tests).
79
+
80
+ ## The two arity strategies — the single most important thing to understand here
81
+
82
+ There are **two different solutions** in this codebase for "how does a thing scoped to one table
83
+ work once you've joined several," and picking the wrong one for a new feature is the most likely way
84
+ to waste a lot of effort. The rule:
85
+
86
+ - **If a single instance can only ever reference one table** (`OrderBy`, `Computed`, a plain
87
+ group-by column): use **one class**, and let it widen across arities via a **union type** at the
88
+ point of storage — e.g. a 2-table join's `_order_by` field is
89
+ `Sequence[OrderBy[TC] | OrderBy[TC1]]`. Qualification (`table.column` vs. bare `column`) is decided
90
+ *externally*, by whichever query class ends up rendering it (`_order_by_sql(seq, qualify=...)` in
91
+ `sql_query.py`), never baked into the object itself. This means an entry set before a `.join_inner()`
92
+ call is still exactly correct after it — no re-wrapping needed. `Computed` and `OrderBy` both work
93
+ this way on purpose.
94
+ - **If a single instance can reference multiple tables at once** (`Constraint.eq(Worker.age,
95
+ Department.min_age)` — one constraint spanning two tables): you need a real **arity ladder**
96
+ (`Constraint`, `Constraint2`, `Constraint3`, `Constraint4`, ..., each with its own
97
+ `Comp/Between/InConstraint`). Qualification is baked into which class you used (arity-1 renders
98
+ unqualified, arity-2+ always qualified) — these are structurally different renderings, not the same
99
+ class with a flag. `HavingConstraint*` mirrors this exact shape for the same reason (a HAVING clause
100
+ can compare `SUM(worker.age) > department.min_age`).
101
+
102
+ **Historical note, now resolved:** an arity-ladder value (`Constraint2[TC,TC1]`,
103
+ `HavingConstraint2[TC,TC1]`, ...) used to be unable to carry across a `.join_inner()` call the way
104
+ `OrderBy`/`Computed` can, since `HavingConstraint2` and `HavingConstraint3` are just unrelated classes
105
+ with no union relationship — this was *the* reason the old builder/terminal split existed (`.where()`
106
+ was only available on a pre-join "builder" as the one-time transition into a "terminal" class with no
107
+ further `.join_inner()`/`.join_left()`, and `join_inner`/`join_left` on the "Computed" builder classes
108
+ `assert self._having is None`, i.e. joining after `having()` was set was rejected outright).
109
+
110
+ **That limitation is gone.** Every concrete `Constraint`/`HavingConstraint` subclass at arities 1-3 now
111
+ has `.incr_arity()`, which rewraps the exact same operands into the next arity's class (e.g.
112
+ `CompConstraint[TC, A].incr_arity() -> CompConstraint2[TC, TC1, A]`, unifying the fresh `TC1` from
113
+ context the same way `join_inner`'s own `next: Type[TC1]` parameter already did). So:
114
+ - `QueryN`/`ComputedQueryN` are no longer split into builder/terminal classes — one class per arity,
115
+ directly executable (`to_sql`/`to_sql_params`) at every stage, whether or not `.where()`/`.having()`
116
+ was ever called.
117
+ - `join_inner`/`join_left` carry `_where` (and `_having`, on `Computed` queries) across by calling
118
+ `.incr_arity()` on them when set, instead of requiring them unset. The old `assert self._having is
119
+ None` is gone.
120
+ - `where_and`/`where_or` were added alongside the existing `having_and`/`having_or`, same AND/OR-compose-
121
+ or-just-set semantics.
122
+ - One real behavior change from removing the builder/terminal split: `.where()` used to have a side
123
+ effect of eagerly resolving an unset selection to "every column" (since it was the one-time
124
+ transition to a terminal class with a resolved `_selection` field). Now selection resolution is
125
+ fully lazy — done in a `_resolved_selection()` method called from `to_sql`/`to_sql_params()` (and by
126
+ `sql_service.py`, which used to read the terminal's already-resolved `_selection` field directly and
127
+ now calls `query._resolved_selection()` instead) — so calling `.where()` before `.select_computed()`
128
+ no longer changes what an unset selection defaults to.
129
+
130
+ ## Other load-bearing design decisions
131
+
132
+ - **Parameterization**: every query type has both `.to_sql()` (values interpolated into the string —
133
+ for display/debugging only, and what all the plain string-equality unit tests check) and
134
+ `.to_sql_params()` (returns `(sql_with_%s_placeholders, params_list)` — what `PsycoPgSqlTransaction.
135
+ execute()` actually sends to psycopg). **Never use `.to_sql()`'s output for real execution** — that
136
+ was the original SQL-injection bug this session fixed early on. `Computed`/`OrderBy`/plain group-by
137
+ columns never contribute params (they're identifiers, not data); `CREATE TABLE` DDL is also
138
+ deliberately left unparameterized (its literal values, e.g. a `Default` constraint, come from the
139
+ model definition in code, not runtime data).
140
+ - **`Computed[TC, A]`** is a genuine `SelectVal[ComputedResult, A]` subclass (not a `PropSelect`), so
141
+ `get_val`/`get_val_safe` extract an aggregate's value from a `ComputedResult` exactly like a `Prop`
142
+ extracts a value from a model. `TC` is phantom bookkeeping (not part of the `Select` shape at all —
143
+ origin is always `ComputedResult`), used purely so `select_computed(_more)` can be scoped per join
144
+ arity the same way `OrderBy`'s `TC` is. `Select`'s "structural" methods (`set`, `update`, `clear`,
145
+ `copy_to`, `__call__`/composition, `__hash__`, `.properties`) are all implemented in the *base*
146
+ `Select` class via `match self: case PropSelect(): ... case MatchSelect(): ... case LinkedSelect(...):
147
+ ... case _: raise ValueError()` — a closed set that doesn't include `Computed`. Only the *read* side
148
+ (`get`/`get_unsafe`, genuinely-abstract "implemented per-subtype" hooks) works for `Computed`, which
149
+ is fine since setting/mutating a computed aggregate value isn't meaningful anyway.
150
+ - **Computed columns require a separate class per arity** (`ComputedQuery1..4` alongside `Query1..4`)
151
+ because the result type `R` can't conditionally vary based on runtime state — it's fixed at
152
+ class-definition time (`class Query1(..., SqlQuery[PartialModel[TC]])` literally hardcodes `R`).
153
+ This used to mean 8 *extra* classes on top of the builder/terminal split (`SelectQueryComputed`/
154
+ `ComputedQuery1`, `JoinQuery2Computed`/`ComputedQuery2`, ...); now that the builder/terminal split is
155
+ gone, it's just the 4 `ComputedQueryN` classes alongside the 4 `QueryN` ones — 8 total, not 16.
156
+ `select_computed`/`select_computed_more` are available at every stage (not terminal-only, which was
157
+ tried and explicitly rejected) since computed columns need to be referenceable from constraints
158
+ (HAVING).
159
+ - **Default aliasing for `Computed`**: `SqlQuery.sum/avg/min/max/count_col` default their alias to
160
+ `table_column_function` (e.g. `worker_age_sum`), deliberately including the function name — two
161
+ aggregates over the same column (`SUM` and `AVG` of `worker.age`) would otherwise collide on both
162
+ the SQL alias and the `ComputedResult` key. `SqlQuery.count(table)` (no column, `COUNT(*)`) defaults
163
+ to `table_count`.
164
+ - **`HavingConstraint*`'s operand rendering**: a `Computed` operand inside a `HavingConstraint` renders
165
+ as its bare function call (`SUM(age)`), never its alias (`worker_age_sum`) — Postgres doesn't make
166
+ SELECT-list aliases visible inside HAVING (they *are* visible in GROUP BY/ORDER BY, a
167
+ Postgres-specific extension, but not WHERE/HAVING).
168
+ - **List/dataclass ordering convention** established across `OrderBy`, `Computed`, group-by, and
169
+ `HavingConstraint`: the "replace whole thing" method is named plainly (`order_by`, `group_by`,
170
+ `select_computed`, `having`) and is **fully variadic including zero args to clear** (except
171
+ `select_computed`, which requires at least one — there's nothing sensible to compute with zero
172
+ aggregates); the "append" method has a `_more` suffix (`order_by_more`, `group_by_more`,
173
+ `select_computed_more`) and requires at least one argument.
174
+ - **`having_and`/`having_or`** (and now `where_and`/`where_or`, added alongside them): AND/OR-compose
175
+ with the existing `_having`/`_where` if set, else just set it — avoids the caller having to check
176
+ `if self._having is None`/`if self._where is None` themselves.
177
+
178
+ ## Chronological feature log (for orientation, not a full changelog — see `git log` for that)
179
+
180
+ 1. Fixed three latent bugs found while writing tests for the original WIP `pydoptic_sql` integration:
181
+ `CREATE TABLE` silently dropped column constraints, `IN` didn't quote string values, `INSERT`
182
+ rendered missing optional values as the Python literal `"None"` instead of SQL `NULL`.
183
+ 2. Split `Constraint`-family tests out of `test_sql_query.py` into `test_sql_constraint.py`.
184
+ 3. Changed `.join_inner()`/`.join_left()`'s `on` parameter from variadic `*on: ConstraintN` to a
185
+ single optional constraint (compose multiple conditions explicitly via `.AND()`/`.OR()`/`.all()`/
186
+ `.any()` beforehand) — for consistency with `.where()`'s single-constraint shape.
187
+ 4. **Parameterized every query by default** (`to_sql_params()`) — this was a real SQL injection fix,
188
+ not just a nice-to-have; verified by actually sending a `'; DROP TABLE ...` payload through and
189
+ confirming it round-tripped as inert data.
190
+ 5. **ORDER BY** (`Direction`, `OrderBy`, `order_by`/`order_by_more`) — established the "single class +
191
+ externally-controlled qualification, widens via union across join arities" pattern described above.
192
+ 6. **Computed/aggregate columns** (`sql_computed.py`, `select_computed`/`select_computed_more`,
193
+ `group_by`/`group_by_more`) — established that `Computed` follows `OrderBy`'s pattern (no arity
194
+ ladder needed), but required doubling every builder/terminal class since it changes `R`.
195
+ 7. **HAVING** (`sql_having.py`, `having`/`having_and`/`having_or`) — established that `HavingConstraint`
196
+ needs `Constraint`'s arity-ladder pattern instead (a HAVING clause can span multiple tables), which
197
+ in turn surfaced the `join_inner`-after-`having()` restriction.
198
+ 8. **`Constraint.incr_arity()`/`HavingConstraint.incr_arity()`** (arities 1→2, 2→3, 3→4) — rewraps a
199
+ constraint's operands into the next arity's class. Removed the structural reason the
200
+ builder/terminal split existed.
201
+ 9. **Collapsed the builder/terminal split, added `where_and`/`where_or`** — `QueryN`/`ComputedQueryN`
202
+ now carry `_where`/`_having` across `join_inner`/`join_left` via `incr_arity()` instead of requiring
203
+ them unset; one class per arity instead of two. See "The two arity strategies" above for the
204
+ corrected picture and the one real behavior change (lazy selection resolution).
205
+
206
+ ## Known gaps / explicitly flagged, not fixed
207
+
208
+ - **`AVG` returns `Decimal`, not `float`, at runtime** — Postgres maps `NUMERIC`/`AVG` results to
209
+ Python `Decimal` via psycopg's default adapters, but `Computed.avg()`'s declared static type is
210
+ `float`. Observed directly in a live-Postgres check, not theoretical. Flagged to the user, not fixed
211
+ — fixing it would mean either changing the declared type (losing precision info) or coercing at the
212
+ response layer (extra runtime cost/complexity for a fairly narrow case). No decision made yet.
213
+ - ~~The builder/terminal split itself is slated for reconsideration~~ — **done**: see "The two arity
214
+ strategies" and chronological log items 8-9 above. `incr_arity()` was the mechanism; the split is
215
+ gone.
216
+
217
+ ## Working conventions established this session (not just for this codebase, general preferences)
218
+
219
+ - Every new query-building feature gets: mypy-clean unit tests covering the string-rendering (`to_sql`)
220
+ and parameterized (`to_sql_params`) paths, *and* at least one live-Postgres integration test in
221
+ `test_sql_service.py` actually verifying behavior (not just SQL text) against the real database —
222
+ several bugs in this session were only caught by actually running against Postgres (e.g. the
223
+ `SELECT *`-plus-aggregate default producing invalid SQL, the `Decimal`-vs-`float` mismatch).
224
+ Building a feature and only unit-testing the string output is not considered complete.
225
+ Prefer writing a small throwaway script for the live check, deleting it afterward (`/tmp/*_check.py`
226
+ or the scratchpad dir), rather than skipping live verification.
227
+ - For any multi-step design with real forks (not just "which name"), talk through the design and get
228
+ explicit agreement before writing code — this session's features (ORDER BY, Computed, HAVING) each
229
+ started with an architecture discussion, sometimes multiple rounds, before any code was written.
230
+ Don't silently narrow an agreed-upon scope without flagging it (this happened once — a
231
+ terminal-only scoping of `select_computed` was quietly substituted for the agreed
232
+ builder-and-terminal scope, and had to be walked back after the user caught it).
233
+ - Commit only when explicitly asked, with a message explaining *why*, and only after re-verifying
234
+ mypy + full test suite pass (don't trust an earlier check if anything changed since).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Hungerford
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.