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.
- pydoptic-0.0.post1.dev38/.github/workflows/integration-tests.yml +57 -0
- pydoptic-0.0.post1.dev38/.github/workflows/release.yml +69 -0
- pydoptic-0.0.post1.dev38/.github/workflows/snapshot.yml +57 -0
- pydoptic-0.0.post1.dev38/.github/workflows/unit-tests.yml +30 -0
- pydoptic-0.0.post1.dev38/.gitignore +17 -0
- pydoptic-0.0.post1.dev38/CONTEXT.md +234 -0
- pydoptic-0.0.post1.dev38/LICENSE +21 -0
- pydoptic-0.0.post1.dev38/PKG-INFO +847 -0
- pydoptic-0.0.post1.dev38/README.md +825 -0
- pydoptic-0.0.post1.dev38/benchmarks/bench_model.py +315 -0
- pydoptic-0.0.post1.dev38/docker-compose.yml +46 -0
- pydoptic-0.0.post1.dev38/mypy.ini +7 -0
- pydoptic-0.0.post1.dev38/pyproject.toml +45 -0
- pydoptic-0.0.post1.dev38/setup.cfg +4 -0
- pydoptic-0.0.post1.dev38/src/pydoptic/__init__.py +3 -0
- pydoptic-0.0.post1.dev38/src/pydoptic/base_model.py +463 -0
- pydoptic-0.0.post1.dev38/src/pydoptic/py.typed +0 -0
- pydoptic-0.0.post1.dev38/src/pydoptic/selector.py +1351 -0
- pydoptic-0.0.post1.dev38/src/pydoptic/validate_types.py +104 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/PKG-INFO +847 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/SOURCES.txt +48 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/dependency_links.txt +1 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/requires.txt +13 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/scm_file_list.json +45 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/scm_version.json +8 -0
- pydoptic-0.0.post1.dev38/src/pydoptic.egg-info/top_level.txt +3 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_elastic/__init__.py +3 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_model.py +88 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_query.py +154 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_elastic/elastic_service.py +73 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_elastic/py.typed +0 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/__init__.py +6 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_computed.py +93 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_constraint.py +782 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_having.py +728 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_order.py +28 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_query.py +1144 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_service.py +335 -0
- pydoptic-0.0.post1.dev38/src/pydoptic_sql/sql_table.py +188 -0
- pydoptic-0.0.post1.dev38/test/pydoptic/test_base_model.py +621 -0
- pydoptic-0.0.post1.dev38/test/pydoptic/test_partial_model.py +548 -0
- pydoptic-0.0.post1.dev38/test/pydoptic/test_selector.py +730 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_elastic/test_elastic_model.py +33 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_elastic/test_elastic_service.py +68 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_computed.py +258 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_constraint.py +296 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_having.py +237 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_params.py +154 -0
- pydoptic-0.0.post1.dev38/test/pydoptic_sql/test_sql_query.py +911 -0
- 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,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.
|