snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1/.gitignore +71 -0
- snake_orm-0.1.0b1/CHANGELOG.md +166 -0
- snake_orm-0.1.0b1/LICENSE +21 -0
- snake_orm-0.1.0b1/PKG-INFO +366 -0
- snake_orm-0.1.0b1/README.es.md +334 -0
- snake_orm-0.1.0b1/README.md +328 -0
- snake_orm-0.1.0b1/pyproject.toml +200 -0
- snake_orm-0.1.0b1/src/snakeorm/__init__.py +214 -0
- snake_orm-0.1.0b1/src/snakeorm/advisor.py +109 -0
- snake_orm-0.1.0b1/src/snakeorm/cli/__init__.py +3 -0
- snake_orm-0.1.0b1/src/snakeorm/cli/app.py +992 -0
- snake_orm-0.1.0b1/src/snakeorm/cli/discovery.py +179 -0
- snake_orm-0.1.0b1/src/snakeorm/cli/hooks.py +86 -0
- snake_orm-0.1.0b1/src/snakeorm/compiler/__init__.py +3 -0
- snake_orm-0.1.0b1/src/snakeorm/compiler/compiler.py +330 -0
- snake_orm-0.1.0b1/src/snakeorm/connection.py +228 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/__init__.py +17 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/asgi.py +192 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/config.py +264 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/deliver.py +256 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/django.py +245 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/sidecar.py +44 -0
- snake_orm-0.1.0b1/src/snakeorm/contrib/wsgi.py +176 -0
- snake_orm-0.1.0b1/src/snakeorm/core/__init__.py +1 -0
- snake_orm-0.1.0b1/src/snakeorm/core/config.py +173 -0
- snake_orm-0.1.0b1/src/snakeorm/core/converters.py +172 -0
- snake_orm-0.1.0b1/src/snakeorm/core/exceptions.py +175 -0
- snake_orm-0.1.0b1/src/snakeorm/core/placement.py +15 -0
- snake_orm-0.1.0b1/src/snakeorm/core/sentinels.py +37 -0
- snake_orm-0.1.0b1/src/snakeorm/core/signals.py +114 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/__init__.py +42 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/css/panel.css +616 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/img/icon.svg +5 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/js/history.js +425 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/js/language.js +283 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/js/panel.js +241 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/pages/cli.html +28 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/pages/config.html +34 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/pages/database_first.html +29 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/pages/help.html +21 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/assets/pages/history.html +27 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/capture.py +234 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/channel.py +131 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/collector.py +144 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/config.py +149 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/html.py +578 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/matrix.py +60 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/origin.py +42 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/__init__.py +68 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/context.py +43 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/exporter.py +277 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/payload.py +97 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/report.py +29 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/spans.py +393 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/otel/summary.py +59 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/record.py +54 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/report.py +311 -0
- snake_orm-0.1.0b1/src/snakeorm/debug/testing.py +23 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/__init__.py +13 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/abstract.py +26 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/db_first.py +104 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/function.py +28 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/model.py +268 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/polymorphic.py +140 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/result.py +167 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/row.py +90 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/trigger.py +47 -0
- snake_orm-0.1.0b1/src/snakeorm/decorators/view.py +149 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/__init__.py +6 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/base.py +373 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/capabilities.py +571 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/literals.py +46 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/matrix.py +384 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/mysql.py +519 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/postgres.py +504 -0
- snake_orm-0.1.0b1/src/snakeorm/dialects/sqlite.py +417 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/__init__.py +18 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncbase.py +73 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncdecorators.py +244 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncpool.py +228 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncpsycopg.py +124 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncpymysql.py +50 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/asyncsqlite.py +30 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/base.py +76 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/failures.py +181 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/logging.py +167 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/pool.py +352 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/psycopg.py +188 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/pymysql.py +176 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/savepoints.py +46 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/sqlite.py +170 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/threaded.py +143 -0
- snake_orm-0.1.0b1/src/snakeorm/drivers/timeout.py +87 -0
- snake_orm-0.1.0b1/src/snakeorm/dto/__init__.py +55 -0
- snake_orm-0.1.0b1/src/snakeorm/dto/read.py +231 -0
- snake_orm-0.1.0b1/src/snakeorm/dto/region.py +326 -0
- snake_orm-0.1.0b1/src/snakeorm/dto/resolve.py +354 -0
- snake_orm-0.1.0b1/src/snakeorm/dto/spec.py +201 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/__init__.py +88 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/conditional.py +178 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/expression.py +810 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/functions.py +71 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/keys.py +299 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/paths.py +43 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/scalar.py +293 -0
- snake_orm-0.1.0b1/src/snakeorm/expressions/window.py +248 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/__init__.py +68 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/check.py +79 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/column.py +316 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/enum.py +64 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/index.py +46 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/relationship.py +738 -0
- snake_orm-0.1.0b1/src/snakeorm/fields/typed.py +736 -0
- snake_orm-0.1.0b1/src/snakeorm/helpers/__init__.py +1 -0
- snake_orm-0.1.0b1/src/snakeorm/helpers/annotations.py +21 -0
- snake_orm-0.1.0b1/src/snakeorm/helpers/inheritance.py +41 -0
- snake_orm-0.1.0b1/src/snakeorm/helpers/pyliteral.py +52 -0
- snake_orm-0.1.0b1/src/snakeorm/helpers/pytype.py +50 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/__init__.py +13 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/base.py +30 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/drift.py +78 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/models.py +633 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/mysql.py +367 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/postgres.py +397 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/sqlite.py +217 -0
- snake_orm-0.1.0b1/src/snakeorm/introspection/unsupported.py +94 -0
- snake_orm-0.1.0b1/src/snakeorm/linker/__init__.py +3 -0
- snake_orm-0.1.0b1/src/snakeorm/linker/linker.py +521 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/__init__.py +41 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/check.py +37 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/column.py +201 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/enum_storage.py +33 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/fk_action.py +19 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/foreign_key.py +26 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/index.py +43 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/index_method.py +19 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/int_size.py +20 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/json_storage.py +21 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/polymorphic.py +30 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/primary_key.py +23 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/relationship.py +60 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/relationship_kind.py +43 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/routine.py +26 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/server_default.py +20 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/table.py +97 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/table_kind.py +23 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/trigger.py +62 -0
- snake_orm-0.1.0b1/src/snakeorm/metadata/type_params.py +348 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/__init__.py +88 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/asyncrunner.py +168 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/autodetect.py +96 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/ddl.py +1172 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/diff.py +701 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/loader.py +77 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/operations.py +952 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/planner.py +78 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/realize.py +420 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/renames.py +153 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/render.py +1095 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/runner.py +314 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/squash.py +60 -0
- snake_orm-0.1.0b1/src/snakeorm/migration/state.py +107 -0
- snake_orm-0.1.0b1/src/snakeorm/model.py +162 -0
- snake_orm-0.1.0b1/src/snakeorm/py.typed +0 -0
- snake_orm-0.1.0b1/src/snakeorm/query/__init__.py +9 -0
- snake_orm-0.1.0b1/src/snakeorm/query/compound.py +288 -0
- snake_orm-0.1.0b1/src/snakeorm/query/join_kind.py +19 -0
- snake_orm-0.1.0b1/src/snakeorm/query/joined.py +143 -0
- snake_orm-0.1.0b1/src/snakeorm/query/query.py +1001 -0
- snake_orm-0.1.0b1/src/snakeorm/query/recursive.py +244 -0
- snake_orm-0.1.0b1/src/snakeorm/registry/__init__.py +5 -0
- snake_orm-0.1.0b1/src/snakeorm/registry/by_module.py +163 -0
- snake_orm-0.1.0b1/src/snakeorm/registry/registry.py +166 -0
- snake_orm-0.1.0b1/src/snakeorm/session/__init__.py +8 -0
- snake_orm-0.1.0b1/src/snakeorm/session/asyncsession.py +638 -0
- snake_orm-0.1.0b1/src/snakeorm/session/coercion.py +306 -0
- snake_orm-0.1.0b1/src/snakeorm/session/factory.py +29 -0
- snake_orm-0.1.0b1/src/snakeorm/session/guards.py +269 -0
- snake_orm-0.1.0b1/src/snakeorm/session/isolation.py +23 -0
- snake_orm-0.1.0b1/src/snakeorm/session/mapper.py +213 -0
- snake_orm-0.1.0b1/src/snakeorm/session/planning.py +815 -0
- snake_orm-0.1.0b1/src/snakeorm/session/retry.py +96 -0
- snake_orm-0.1.0b1/src/snakeorm/session/session.py +871 -0
- snake_orm-0.1.0b1/src/snakeorm/session/shared.py +355 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/__init__.py +18 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/adapt.py +104 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/aggregate.py +232 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/condition.py +359 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/delete.py +46 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/insert.py +149 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/joins.py +158 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/pk_subquery.py +54 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/refs.py +19 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/resolver.py +28 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/select.py +175 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/update.py +82 -0
- snake_orm-0.1.0b1/src/snakeorm/sql/value.py +552 -0
- snake_orm-0.1.0b1/src/snakeorm/times.py +276 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Venv / UV
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
|
|
5
|
+
# Python cache / compile
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.pyc
|
|
8
|
+
*.pyo
|
|
9
|
+
*.pyd
|
|
10
|
+
.Python
|
|
11
|
+
|
|
12
|
+
# Python packaging, ANCHORED to the root with a leading slash. Without it these are not "the build/
|
|
13
|
+
# directory of the project": a pattern with no `/` matches at ANY depth, and `lib/` swallowed
|
|
14
|
+
# `frameworks/react_front/src/core/lib/` — two real modules that CI could not find and this machine
|
|
15
|
+
# could, because they were on disk and never in the index.
|
|
16
|
+
/build/
|
|
17
|
+
/develop-eggs/
|
|
18
|
+
/dist/
|
|
19
|
+
/downloads/
|
|
20
|
+
/eggs/
|
|
21
|
+
/.eggs/
|
|
22
|
+
/env/
|
|
23
|
+
/lib/
|
|
24
|
+
/lib64/
|
|
25
|
+
/parts/
|
|
26
|
+
/sdist/
|
|
27
|
+
/var/
|
|
28
|
+
/wheels/
|
|
29
|
+
/share/python-wheels/
|
|
30
|
+
*.egg-info/
|
|
31
|
+
.installed.cfg
|
|
32
|
+
*.egg
|
|
33
|
+
|
|
34
|
+
# Testing & Linting
|
|
35
|
+
.mypy_cache/
|
|
36
|
+
.pytest_cache/
|
|
37
|
+
.hypothesis/
|
|
38
|
+
.ruff_cache/
|
|
39
|
+
.coverage
|
|
40
|
+
.coverage.*
|
|
41
|
+
htmlcov/
|
|
42
|
+
|
|
43
|
+
# IDE / Editors
|
|
44
|
+
.vscode/
|
|
45
|
+
.idea/
|
|
46
|
+
*.swp
|
|
47
|
+
*.swo
|
|
48
|
+
|
|
49
|
+
# OS files
|
|
50
|
+
.DS_Store
|
|
51
|
+
Thumbs.db
|
|
52
|
+
|
|
53
|
+
# Environment variables
|
|
54
|
+
.env
|
|
55
|
+
|
|
56
|
+
# Built, never versioned. These names ARE meant to match at any depth: a `node_modules` is a
|
|
57
|
+
# `node_modules` wherever it turns up.
|
|
58
|
+
site/
|
|
59
|
+
node_modules/
|
|
60
|
+
*.tsbuildinfo
|
|
61
|
+
|
|
62
|
+
# The Vite bundle. Named in full because `/dist/` above is anchored and this one is not at the root.
|
|
63
|
+
frameworks/react_front/dist/
|
|
64
|
+
|
|
65
|
+
# Databases the docs and the demos leave behind when somebody RUNS them
|
|
66
|
+
*.sqlite
|
|
67
|
+
*.db
|
|
68
|
+
|
|
69
|
+
# Plans and internal notes: local only. `docs/features.md` is the feature index, not a plan.
|
|
70
|
+
docs/planning/
|
|
71
|
+
docs/interno/
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
4
|
+
|
|
5
|
+
**No dates yet, and that is the convention rather than an omission.** `Unreleased` carries no date
|
|
6
|
+
because the date is what a release stamps on it. The repository holds no tag, the package is not on
|
|
7
|
+
PyPI, and `docs/contributors/release.md` says what the first release would still take. When the first
|
|
8
|
+
tag lands, this section gets a heading with its version and its day, and a new `Unreleased` opens
|
|
9
|
+
above it.
|
|
10
|
+
|
|
11
|
+
**One language, and it is English.** The prose under `docs/` is mirrored in Spanish because it
|
|
12
|
+
teaches; this file records what changed, and it is read beside a tag and a package page — the same
|
|
13
|
+
audience the code, the docstrings and every message the ORM emits already speak to.
|
|
14
|
+
|
|
15
|
+
**What this is not.** Not a list of commits. What survived here is what changed for somebody USING
|
|
16
|
+
the ORM: the defects that produced wrong data or wrong SQL, and the reversals of decisions already
|
|
17
|
+
made. Ordered by weight inside each group, because that is how somebody reads a changelog they have
|
|
18
|
+
never seen before.
|
|
19
|
+
|
|
20
|
+
## 0.1.0b1
|
|
21
|
+
|
|
22
|
+
The first published version, and a beta on purpose: `pip install snake-orm` does not
|
|
23
|
+
install it — a preliminary is only picked up when it is asked for by name or with `--pre`.
|
|
24
|
+
Everything below is what the repository already carried before it had a version number.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
**Remote code execution in the scaffolder.** `scaffold create` reads table names, column names and
|
|
29
|
+
comments FROM THE DATABASE and writes them into a `models.py` the user imports; they went in raw
|
|
30
|
+
through f-strings. Confirmed with a real payload: a `COMMENT ON COLUMN` that closes the
|
|
31
|
+
`db_comment="..."`, puts an `__import__(...).system(...)` on its line and reopens a literal. The file
|
|
32
|
+
compiled and ran the command on import — whoever controlled a legacy schema controlled the machine of
|
|
33
|
+
whoever scaffolded it. The migration renderer was already escaping the same field correctly.
|
|
34
|
+
|
|
35
|
+
### Fixed — wrong data
|
|
36
|
+
|
|
37
|
+
**A JOIN resolved by CLASS NAME and went to the wrong table.** Two models with the same class name in
|
|
38
|
+
different modules resolved to whichever the global index happened to hold. The query ran and returned
|
|
39
|
+
rows. It took three passes to close: first a foreign key pointing at another model's table, then a
|
|
40
|
+
`through=` chain that still went to the global index, and finally the relationship resolution itself.
|
|
41
|
+
A class name does not identify a model.
|
|
42
|
+
|
|
43
|
+
**A `NULL` in a column with a converter became `False`, and was persisted.**
|
|
44
|
+
|
|
45
|
+
**A UNION with narrowed branches put values in the wrong field.** SQLite REGROUPED a compound's
|
|
46
|
+
operators and nobody noticed, and a compound's `ORDER BY` lost the relation jump and sorted by
|
|
47
|
+
another column.
|
|
48
|
+
|
|
49
|
+
**The fifteen paths where the ORM stayed quiet and returned wrong data.** Reported as one sweep
|
|
50
|
+
because they shared a shape: a guard that answered instead of refusing.
|
|
51
|
+
|
|
52
|
+
**`col == None` emitted `= NULL`** instead of `IS NULL`.
|
|
53
|
+
|
|
54
|
+
**Row values were not coerced to the declared `python_type`** — psycopg2 handed back a UUID as a
|
|
55
|
+
string, a `time` column came back from MySQL as a `timedelta`, and `annotate()` scalars were not
|
|
56
|
+
coerced at all.
|
|
57
|
+
|
|
58
|
+
**Four knobs fell silently, `refresh` degraded a `Decimal`, and `add_all` dropped data.** Same pass:
|
|
59
|
+
the debug panel rendered in production and the async pool did not clean up.
|
|
60
|
+
|
|
61
|
+
**The same DSN named two databases depending on which door you came in by**, and a `file:` DSN was
|
|
62
|
+
read as a FILENAME at both ends.
|
|
63
|
+
|
|
64
|
+
### Fixed — SQLite and MySQL
|
|
65
|
+
|
|
66
|
+
**SQLite had no transactions.** `rollback()` was a no-op and everything ran in autocommit. Foreign
|
|
67
|
+
keys did not exist either, migrations died on the first statement, `WITH RECURSIVE` was broken
|
|
68
|
+
entirely, and `.any()`, `.count()` and the four collection aggregates were broken too.
|
|
69
|
+
|
|
70
|
+
**MySQL ignored the declared date precision** and said nothing. An enum knows its own width and the
|
|
71
|
+
ORM threw it away, so MySQL stored TEXT. It has no partial indexes, and the ORM sent it the `WHERE`
|
|
72
|
+
all the same. `nulls_last()` emitted syntax it rejects — and that was the guide's own example.
|
|
73
|
+
|
|
74
|
+
**A model with only an autoincrement PK could not be inserted on ANY engine.**
|
|
75
|
+
|
|
76
|
+
**`datetime` mapped to a column without a zone**, which drops `tzinfo`.
|
|
77
|
+
|
|
78
|
+
### Fixed — migrations
|
|
79
|
+
|
|
80
|
+
**Unique constraints were created and dropped under different names.** The index diff never ran, so
|
|
81
|
+
adding an index migrated nothing. `db_comment` was dead metadata that nothing ever emitted. Foreign
|
|
82
|
+
keys were diffed by name rather than by definition.
|
|
83
|
+
|
|
84
|
+
**A `DropForeignKey` vanished silently and tables were dropped in alphabetical order.** A rebuild ate
|
|
85
|
+
its table's trigger, and `BIGSERIAL` — which exists only in `CREATE TABLE` — was written into the
|
|
86
|
+
`ALTER` too.
|
|
87
|
+
|
|
88
|
+
**The diff compared `python_type` by IDENTITY** and demanded an empty table rewrite. Only `dict` is
|
|
89
|
+
normalised: `list[int]` and `list[str]` land on different SQL types, so unwrapping every origin would
|
|
90
|
+
have hidden a real change.
|
|
91
|
+
|
|
92
|
+
**`snakeorm fresh` worked by luck**, and the pragmas that claimed to save it did nothing.
|
|
93
|
+
|
|
94
|
+
### Fixed — the scaffolder
|
|
95
|
+
|
|
96
|
+
**It stopped parsing at a non-English table name** and lost columns without saying so. It mirrored
|
|
97
|
+
against `public` and threw away half of what it had already read. It validated the name that comes IN
|
|
98
|
+
and wrote the one it DERIVES.
|
|
99
|
+
|
|
100
|
+
### Changed — breaking
|
|
101
|
+
|
|
102
|
+
**Each engine gets the DDL grammar it actually speaks.** A capability catalogue answers for the whole
|
|
103
|
+
thing, and what an engine cannot do is declared and said out loud rather than stored worse in
|
|
104
|
+
silence. A type the engine lacks falls back to TEXT, and the value knows how to come home.
|
|
105
|
+
|
|
106
|
+
**`snake_column` splits into per-type specifiers**, with two date declarators so the model says which
|
|
107
|
+
column it creates, and `SnakeUtc`: an instant that cannot be built wrong.
|
|
108
|
+
|
|
109
|
+
**A nullable FK demands an optional relationship**, a relation points at ONE model, and a collection
|
|
110
|
+
is never optional. `SnakeToOne[Card | Transfer | None]` used to discard `Transfer` in silence.
|
|
111
|
+
|
|
112
|
+
**Assigning a relation no longer fails silently**, and a missing value shouts instead of vanishing.
|
|
113
|
+
|
|
114
|
+
**Async reaches the three engines**, with the same protocol and the same composition root. Both
|
|
115
|
+
sessions consume the same Plan and the same words, and the drift between them got a test.
|
|
116
|
+
|
|
117
|
+
**The repository speaks English** — the package, the test suite, and the ORM's own messages to the
|
|
118
|
+
user. The prose under `docs/` is bilingual; nothing else is.
|
|
119
|
+
|
|
120
|
+
### Changed
|
|
121
|
+
|
|
122
|
+
**The demos declare their shapes and the generator writes them.** Migrating one module surfaced nine
|
|
123
|
+
discrepancies between what the dict built and what the model declares. The injection of the model
|
|
124
|
+
graph into the globals of eleven foreign modules is gone: the linker reads the `TYPE_CHECKING` block
|
|
125
|
+
instead.
|
|
126
|
+
|
|
127
|
+
**The annotation quoting rule climbs the WHOLE tree, and there are four situations.** Only what does
|
|
128
|
+
not exist yet gets quoted, and nothing else. A relative import inside a `TYPE_CHECKING` block is read
|
|
129
|
+
now, and six more spellings besides.
|
|
130
|
+
|
|
131
|
+
**SQL literal formatting moved to the dialect**, which is what made a second engine possible at all.
|
|
132
|
+
|
|
133
|
+
**The `src/` layout**, zero cycles between packages, and the loose modules at the root grouped into
|
|
134
|
+
`core/` and `helpers/`.
|
|
135
|
+
|
|
136
|
+
### Performance
|
|
137
|
+
|
|
138
|
+
**The hot path 4.9x**, hydration roughly 2x, and the async streaming that had been crossing the
|
|
139
|
+
thread PER ROW.
|
|
140
|
+
|
|
141
|
+
**The select-in splits by the engine's placeholder ceiling.**
|
|
142
|
+
|
|
143
|
+
### Fixed — the gates
|
|
144
|
+
|
|
145
|
+
Listed apart because they break something else: not the user, but the reason to trust a green run.
|
|
146
|
+
|
|
147
|
+
**`make audit` had been red for pyright** since an unrelated fix, unnoticed. **The gate switch was a
|
|
148
|
+
blacklist**, so `off` turned the gates ON. **The MySQL gate sent you to look at Postgres' variables.**
|
|
149
|
+
**The type-check of the demos' shared layer measured `Any` and reported Success.** **The integration
|
|
150
|
+
tests were skipping silently, and pyright never ran at all.**
|
|
151
|
+
|
|
152
|
+
## Writing an entry
|
|
153
|
+
|
|
154
|
+
One line per change somebody using the ORM would notice, under `Added`, `Changed`, `Deprecated`,
|
|
155
|
+
`Removed`, `Fixed` or `Security` — only the groups with something in them. Two rules this repository
|
|
156
|
+
already applies everywhere else apply here too:
|
|
157
|
+
|
|
158
|
+
- **Say what changed for the reader, not what was edited.** "`count()` no longer drops `limit()`"
|
|
159
|
+
beats "refactored the query planner".
|
|
160
|
+
- **No counts of what the repository IS** — tests, files, supported features. A number nobody
|
|
161
|
+
re-reads goes stale the same day and then lies with authority. A number the entry MEASURED stays,
|
|
162
|
+
because a past measurement does not drift: "the hot path 4.9x" is a fact and will read the same in
|
|
163
|
+
a decade.
|
|
164
|
+
|
|
165
|
+
A change that alters SQL on one engine and not the others names the engine, because that is the
|
|
166
|
+
difference somebody will hit.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthony Velez Tapia
|
|
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.
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: snake-orm
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: Fully typed deep relationship navigation in Python. No codegen, no type-checker plugin.
|
|
5
|
+
Project-URL: Homepage, https://github.com/velezanthony/snake-orm
|
|
6
|
+
Project-URL: Documentation, https://velezanthony.github.io/snake-orm/
|
|
7
|
+
Project-URL: Source, https://github.com/velezanthony/snake-orm
|
|
8
|
+
Project-URL: Issues, https://github.com/velezanthony/snake-orm/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/velezanthony/snake-orm/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Anthony Velez Tapia <velezanthony2000@gmail.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: dataclasses,mysql,orm,postgresql,sql,sqlite,typing
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
24
|
+
Classifier: Topic :: Database
|
|
25
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: psycopg2-binary>=2.9.9
|
|
30
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
31
|
+
Provides-Extra: async
|
|
32
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'async'
|
|
33
|
+
Provides-Extra: mysql
|
|
34
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
35
|
+
Provides-Extra: otel
|
|
36
|
+
Requires-Dist: opentelemetry-api>=1.27; extra == 'otel'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# 🐍 SnakeORM
|
|
40
|
+
|
|
41
|
+
[](https://github.com/velezanthony/snake-orm/actions/workflows/ci.yml)
|
|
42
|
+

|
|
43
|
+

|
|
44
|
+

|
|
45
|
+
[](LICENSE)
|
|
46
|
+
|
|
47
|
+
**Fully typed deep relationship navigation in Python. No codegen. No type-checker plugin.**
|
|
48
|
+
|
|
49
|
+
Docs (English and Spanish): <https://velezanthony.github.io/snake-orm/>
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
Truck.maker.nation.name == "España" # SnakeExpr[str] -> SnakeCondition
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```sql
|
|
56
|
+
SELECT t0."id", t0."model", t0."maker_id" FROM "public"."trucks" AS t0
|
|
57
|
+
JOIN "public"."makers" AS t1 ON t0."maker_id" = t1."id"
|
|
58
|
+
JOIN "public"."nations" AS t2 ON t1."nation_id" = t2."id"
|
|
59
|
+
WHERE t2."name" = %s
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Mypy checks it. Pyright checks it. Pylance autocompletes it. The Django equivalent,
|
|
63
|
+
`filter(maker__nation__name="España")`, is a string: no autocomplete, no check, and renaming
|
|
64
|
+
`nation` fails in production.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
Requires Python 3.11+. SQLite ships with the standard library, so nothing needs to be running to
|
|
71
|
+
start.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install snake-orm==0.1.0b1 # or: pip install --pre snake-orm
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The distribution is `snake-orm` and the package is `snakeorm`: `import snakeorm`.
|
|
78
|
+
|
|
79
|
+
The version is a **beta**, and the pin is the point: a preliminary is not picked up by a plain
|
|
80
|
+
`pip install snake-orm`, so nobody upgrades into it by accident while the API is still moving.
|
|
81
|
+
|
|
82
|
+
From a checkout, to work on the ORM itself:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv sync --all-extras --all-groups
|
|
86
|
+
uv run pytest # suite
|
|
87
|
+
uv run mypy . # must pass
|
|
88
|
+
uv run ruff check . # must pass
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
[Installation](docs/users/getting-started/installation.md) →
|
|
92
|
+
[first model](docs/users/getting-started/first-model.md) →
|
|
93
|
+
[migrations](docs/users/getting-started/migrations.md).
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@snake_model(table="makers")
|
|
97
|
+
class Maker(SnakeModel):
|
|
98
|
+
id: SnakeColumn[int] = snake_auto()
|
|
99
|
+
name: SnakeColumn[str] = snake_str(unique=True)
|
|
100
|
+
nation_id: SnakeColumn[int] = snake_int()
|
|
101
|
+
nation: SnakeToOne[Nation] = snake_to_one(nation_id)
|
|
102
|
+
trucks: SnakeToMany[Truck] = snake_to_many("maker")
|
|
103
|
+
|
|
104
|
+
snake_link() # once, after importing every model
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The type always comes from the annotation. `snake_column()` only adds SQL information.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
uv run snakeorm makemigrations --models myapp.models --name initial
|
|
111
|
+
uv run snakeorm migrate --models myapp.models --dsn "host=... dbname=..."
|
|
112
|
+
uv run snakeorm rollback --models myapp.models --dsn "..."
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## The mechanism
|
|
118
|
+
|
|
119
|
+
**Recursive descriptors** whose `__get__` returns a different type depending on the access:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
user.car.name # instance -> the value -> str
|
|
123
|
+
User.car.brand.name == "x" # class -> a SQL expression -> SnakeCondition
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`User.car` returns `type[Car]`, so `.brand.name` re-triggers the class overload of `Car`'s
|
|
127
|
+
descriptors. `@dataclass_transform` on the decorator types `__init__`. It is the manual equivalent
|
|
128
|
+
of TypeScript's mapped types.
|
|
129
|
+
|
|
130
|
+
The type system is the single source of truth: the class is compiled **once** into an immutable
|
|
131
|
+
metadata graph, and the runtime never reflects on it again.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Why Django returns `Any`
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
User.objects.annotate(num_posts=Count("posts"))
|
|
139
|
+
user.num_posts # -> Any
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`annotate` returns "a `User` **plus** a `num_posts: int`". That is an intersection type. TypeScript
|
|
143
|
+
has it; Python does not. So there are three paths, and only three:
|
|
144
|
+
|
|
145
|
+
| Path | Dynamic names | Real typing | IntelliSense |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `__getattr__ -> Any` | ✅ | ❌ | ❌ |
|
|
148
|
+
| Declared names | ❌ | ✅ | ✅ |
|
|
149
|
+
| Type-checker plugin | ✅ | ✅ | partial |
|
|
150
|
+
|
|
151
|
+
Django took the first and patched it with the third (`django-stubs`). SnakeORM forbids the third by
|
|
152
|
+
thesis and takes the second, with a typed escape hatch.
|
|
153
|
+
|
|
154
|
+
`Any` is not typing, it is switching the checker off:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
def __getattr__(self, name: str) -> Any: ...
|
|
158
|
+
u.agg.count_children * 2 # mypy: 0 errors
|
|
159
|
+
other: str = u.agg.count_children # mypy: 0 errors <- same value, as a str
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`object` keeps the dynamic name and wakes the checker up:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
def __getattr__(self, name: str) -> object: ...
|
|
166
|
+
u.agg.count_children * 2 # error: unsupported operand types for *
|
|
167
|
+
count: int = cast("int", u.agg.count_children) # explicit, and signed with your name
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Illegal states you cannot write
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
SnakeQuery(Nation).filter(Nation.makers.name == "SEAT")
|
|
176
|
+
# error: "SnakeCollection[Maker]" has no attribute "name" [attr-defined]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
In Django that compiles, runs and silently duplicates rows — which is what `.distinct()` is for. A
|
|
180
|
+
to-many exposes **collection operations**, not the child's columns:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
q.filter(Nation.makers.any(Maker.name == "SEAT")) # any? -> correlated EXISTS
|
|
184
|
+
q.filter(~Nation.makers.any()) # none? -> NOT EXISTS
|
|
185
|
+
q.filter(Nation.makers.count() > 3) # how many? -> scalar subquery
|
|
186
|
+
q.include(Nation.makers) # load them -> select-in, 2 queries
|
|
187
|
+
|
|
188
|
+
q.include(SnakePrefetch(Nation.makers).then(Maker.trucks))
|
|
189
|
+
# one query per LEVEL (root + makers + trucks = 3), never one per parent
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
One row per parent. `DISTINCT` is never needed.
|
|
193
|
+
|
|
194
|
+
```sql
|
|
195
|
+
-- Nation.makers.any(Maker.trucks.any(Truck.model == "Ibiza"))
|
|
196
|
+
SELECT "id", "name" FROM "public"."nations" WHERE EXISTS (
|
|
197
|
+
SELECT 1 FROM "public"."makers" AS e0 WHERE e0."nation_id" = "nations"."id" AND EXISTS (
|
|
198
|
+
SELECT 1 FROM "public"."trucks" AS e1 WHERE e1."maker_id" = e0."id" AND e1."model" = %s))
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
> **Implicit when the answer is unique. Explicit when there is more than one correct answer.**
|
|
202
|
+
|
|
203
|
+
A to-one never changes the row count, so the `JOIN` is inferred. A to-many does, so the ORM does not
|
|
204
|
+
guess.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## What the thesis gives for free
|
|
209
|
+
|
|
210
|
+
**No silent N+1.** An unloaded relation raises instead of querying:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
truck.maker
|
|
214
|
+
# SnakeRelationshipNotLoaded: Relation 'maker' was not loaded.
|
|
215
|
+
# Use .include(Truck.maker) in the query.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**No `F()`.** Class access already is an expression:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
session.update_where(query, [(Counter.views, Counter.views + 1)])
|
|
222
|
+
# UPDATE "counters" SET "views" = ("views" + %s) WHERE ...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Pairs, not a dict: `SnakeExpr` is unhashable because its `__eq__` returns a `SnakeCondition`.
|
|
226
|
+
|
|
227
|
+
**Many-to-many crosses a real model**, never an implicit table:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
tags: SnakeToMany["Tag"] = snake_to_many_through(through="PostTag", via="post", to="tag")
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The bridge is an ordinary model, so extra columns on it are ordinary fields from day one.
|
|
234
|
+
|
|
235
|
+
**A bulk `UPDATE`/`DELETE` uses the filter and nothing else.** With no `WHERE` it is refused, and
|
|
236
|
+
so is any other knob you set on the same query — `limit()`, `order_by()`, `only()`. Dropping what
|
|
237
|
+
you asked for would answer a different question without saying so: select the rows first, then
|
|
238
|
+
write by primary key.
|
|
239
|
+
|
|
240
|
+
**`annotate()` counts with a correlated subquery, not a `LEFT JOIN`** — a parent with no children
|
|
241
|
+
comes out `0` instead of disappearing.
|
|
242
|
+
|
|
243
|
+
**A view is a read-only model, and navigable.** `@snake_view` maps a `VIEW` with typed columns,
|
|
244
|
+
navigable both ways; `session.add/update/delete` reject it in the **type**, because writing requires
|
|
245
|
+
a `SnakeModel`. `CreateView`/`AlterView`/`DropView` live in the migrations.
|
|
246
|
+
|
|
247
|
+
**Migration history is `.py`**, because `python_type` is a Python `type`. JSON would need a
|
|
248
|
+
name↔type registry: a second type system, parallel to Python's and worse.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Typed annotations
|
|
253
|
+
|
|
254
|
+
Declare the result class — you choose the name, Python chooses the type:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
@snake_result
|
|
258
|
+
class RealmStats(SnakeResult[Realm]):
|
|
259
|
+
realm: Realm
|
|
260
|
+
forge_count: int
|
|
261
|
+
|
|
262
|
+
rows = session.annotate(query, RealmStats, forge_count=Realm.forges.count())
|
|
263
|
+
rows[0].forge_count # int, with IntelliSense
|
|
264
|
+
rows[0].realm.name # str, navigation intact
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
For genuinely dynamic names, the escape hatch is explicit:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
count = cast("int", realm.aggregate.forge_count) # object -> the cast is mandatory
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Without the cast it does not compile. Without annotating, it raises `SnakeAggregateNotLoaded` naming
|
|
274
|
+
the aggregates it does have.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## What is inside
|
|
279
|
+
|
|
280
|
+
| | |
|
|
281
|
+
|---|---|
|
|
282
|
+
| **Queries** | filter · order/limit/offset · group by/having · aggregates · `annotate` · explicit joins · `include` (to-one and to-many) · deep navigation · `.any()` · correlated subqueries · composite `IN` · `only`/`defer` · `iterate` (server cursor) · `for_update` · `raw` |
|
|
283
|
+
| **SQL** | window functions with frame · `UNION`/`INTERSECT`/`EXCEPT` · `WITH RECURSIVE` · `CASE`/`COALESCE`/`NULLIF` · text, date and math functions · `json_get` · `ILIKE` |
|
|
284
|
+
| **Writes** | insert/update/delete · upsert · bulk · `RETURNING` · savepoints · isolation levels · retry on transient conflict · `refresh` |
|
|
285
|
+
| **Schema** | composite PK and FK · polymorphic inheritance · views · triggers · indexes (partial, functional, `GIN`/`GIST`/`BRIN`) · checks · comments · enums · custom converters |
|
|
286
|
+
| **Engines** | PostgreSQL · MySQL/MariaDB · SQLite, all first class · `Cap` catalogue (`Full`/`Degraded`/`Nope`) · sync and async drivers · pool with `pre_ping`/`recycle`/timeout · statement timeout · `EXPLAIN` |
|
|
287
|
+
| **Migrations** | autodetected diff · atomic runner · `RebuildTable` for SQLite · `RunPython` with reverse · squash · cross-app dependencies · drift detection against the live database |
|
|
288
|
+
| **Tooling** | introspection and scaffold for the three engines · debug panel (`ssr`, `envelope`, `timing`, `sidecar`, `otel`) · index advisor · WSGI/ASGI/Django contrib · CLI |
|
|
289
|
+
|
|
290
|
+
Row by row, with links to the code, the test and the page: [feature index](docs/features.md).
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Architecture
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
Python class → Model Compiler → immutable metadata graph
|
|
298
|
+
↓
|
|
299
|
+
SQL · migrations · query · session · CLI
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
decorators/ metadata/ compiler/ registry/ linker/
|
|
304
|
+
query/ expressions/ sql/ dialects/ drivers/ session/ migration/ cli/
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Two axes that never mix: the **dialect** decides how SQL is *written* (placeholders, quoting,
|
|
308
|
+
`RETURNING`, `ON CONFLICT`); the **driver** decides how it is *executed*. Models and graph are
|
|
309
|
+
engine-agnostic — anything Postgres-specific reaching the model is a bug.
|
|
310
|
+
|
|
311
|
+
SQL is always parameterised: emission returns `(sql, params)` and values never enter the string.
|
|
312
|
+
That kills injection, and it is what makes multi-engine possible.
|
|
313
|
+
|
|
314
|
+
Async reuses the whole core: SQL generation does not execute, so it has no colour.
|
|
315
|
+
|
|
316
|
+
Details in [architecture](docs/contributors/architecture.md); how to work here in
|
|
317
|
+
[CONTRIBUTING](CONTRIBUTING.md).
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## The type contract is a test
|
|
322
|
+
|
|
323
|
+
In `test/typing/`:
|
|
324
|
+
|
|
325
|
+
- `cases_positive.py` — what **must** type, with `assert_type`.
|
|
326
|
+
- `cases_negative.py` — what **must not compile**, each line carrying its `# EXPECT: <code>`.
|
|
327
|
+
- The runner requires mypy to report exactly those errors on exactly those lines, and pyright to
|
|
328
|
+
reject the same ones.
|
|
329
|
+
|
|
330
|
+
Break `Truck.maker.nation.name` and the suite fails.
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## Deliberately not built
|
|
335
|
+
|
|
336
|
+
- **Identity map and unit of work.** Two queries to the same row return two objects. Writes are
|
|
337
|
+
explicit; nothing is flushed behind your back.
|
|
338
|
+
- **Lazy loading.** Touching an unloaded relation raises. This is what makes N+1 impossible by
|
|
339
|
+
default.
|
|
340
|
+
- **Joined-table inheritance.** Single table with a discriminator covers the polymorphism, and its
|
|
341
|
+
price is one rule: a child's own columns must allow `NULL`.
|
|
342
|
+
- **Model default ordering.** A hidden `ORDER BY` you did not write.
|
|
343
|
+
|
|
344
|
+
## Known limits
|
|
345
|
+
|
|
346
|
+
- `storage=NATIVE` in `snake_enum` (Postgres `CREATE TYPE`) is not built: `ALTER TYPE ... ADD VALUE`
|
|
347
|
+
has no inverse, so its `down_sql` would be a lie. The default `CHECK` is reversible.
|
|
348
|
+
- CHECKs are declared outside the class body (`snake_checks(User, ...)`). Inside it, `__set_name__`
|
|
349
|
+
has not run and the column does not know its own name.
|
|
350
|
+
- An expression does not carry its owning model in the type: `Maker.id` and `Truck.id` are twins to
|
|
351
|
+
the checker. Encoding the owner would break deep navigation and condition composition; where it
|
|
352
|
+
matters (aggregates, `.any()`) it is validated at runtime.
|
|
353
|
+
- No lazy loading, full-text search, JSON containment operators or array operators with a typed API.
|
|
354
|
+
|
|
355
|
+
The complete, current list is [known limits](docs/users/reference/limits.md) — part of the contract,
|
|
356
|
+
not a list of apologies.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Status
|
|
361
|
+
|
|
362
|
+
Not published on PyPI. The distribution is named `snake-orm`, the import name is
|
|
363
|
+
`snakeorm`; both are explained in [release](docs/contributors/release.md).
|
|
364
|
+
|
|
365
|
+
Everything above is implemented and tested against real PostgreSQL, MySQL/MariaDB and SQLite. It has
|
|
366
|
+
not run in production yet, and that is the one thing a repository cannot give itself.
|