snake-orm 0.1.0b1__py3-none-any.whl
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.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helpers: generic utility functions reused by 2+ domains. A neutral leaf, no cycles."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Utilities over type annotations, shared by two consumers that must not know about each other:
|
|
2
|
+
the Model Compiler (which infers `nullable`) and `@snake_result` (the type to coerce an `X | None`
|
|
3
|
+
with)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import types
|
|
8
|
+
from typing import Any, Union, get_args, get_origin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
|
12
|
+
"""If the type is `X | None`, returns `(X, True)`; otherwise `(type, False)`.
|
|
13
|
+
|
|
14
|
+
It recognises both syntaxes: `Optional[X]` / `Union[X, None]` and PEP 604's `X | None`.
|
|
15
|
+
"""
|
|
16
|
+
if get_origin(annotation) in (Union, types.UnionType):
|
|
17
|
+
args = get_args(annotation)
|
|
18
|
+
non_none = tuple(arg for arg in args if arg is not type(None))
|
|
19
|
+
if len(non_none) != len(args):
|
|
20
|
+
return non_none[0], True
|
|
21
|
+
return annotation, False
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Collecting descriptors along the MRO (inheritance from abstract models).
|
|
2
|
+
|
|
3
|
+
A model inherits columns/relations from a base that is NOT a table; looking only at `vars(cls)`
|
|
4
|
+
would lose whatever was inherited, so the compiler, the generated `__init__` and the linker walk the
|
|
5
|
+
MRO with this helper.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TypeVar
|
|
11
|
+
|
|
12
|
+
D = TypeVar("D")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def collect_inherited(cls: type, descriptor_type: type[D]) -> dict[str, D]:
|
|
16
|
+
"""Collects the descriptors of the given type across the whole MRO, from BASE to CHILD.
|
|
17
|
+
|
|
18
|
+
It walks `reversed(cls.__mro__)` so that two rules fall out: the parent's columns come BEFORE
|
|
19
|
+
the child's (DDL order), and if the child redefines an attribute its descriptor WINS (same key).
|
|
20
|
+
"""
|
|
21
|
+
collected: dict[str, D] = {}
|
|
22
|
+
for klass in reversed(cls.__mro__):
|
|
23
|
+
for attr, value in vars(klass).items():
|
|
24
|
+
if isinstance(value, descriptor_type):
|
|
25
|
+
collected[attr] = value
|
|
26
|
+
return collected
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ABSTRACT_MARKER = "__snake_abstract__"
|
|
30
|
+
"""The attribute `@snake_abstract` marks a base that contributes columns and is NOT a table with."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_abstract(cls: type) -> bool:
|
|
34
|
+
"""Is this class marked as an abstract base?
|
|
35
|
+
|
|
36
|
+
It looks at the OWN dictionary (not `getattr`): the mark must not be inherited, or a
|
|
37
|
+
`@snake_model` child would still look abstract. It lives here (and not next to the decorator) to
|
|
38
|
+
avoid a cycle: `query.py` needs it and the decorators package drags in `view.py`, which imports
|
|
39
|
+
`snakeorm.query`.
|
|
40
|
+
"""
|
|
41
|
+
return cls.__dict__.get(ABSTRACT_MARKER, False) is True
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Writing a string as a SAFE Python literal. One single place, two consumers (the migration
|
|
2
|
+
renderer and the DB-first scaffolding), both generating `.py` that another process imports.
|
|
3
|
+
|
|
4
|
+
A badly escaped string breaks the literal and whatever follows runs on import; in the scaffolding
|
|
5
|
+
that is RCE, because the table/column names and comments come from the database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
_ESCAPES: dict[str, str] = {
|
|
11
|
+
"\\": "\\\\",
|
|
12
|
+
"\n": "\\n",
|
|
13
|
+
"\r": "\\r",
|
|
14
|
+
"\t": "\\t",
|
|
15
|
+
}
|
|
16
|
+
"""Characters with a FIXED escape. The quote is not here because it depends on which one is used."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _encode(value: str, quote: str) -> str:
|
|
20
|
+
"""Escapes `value` as a string literal for the given quote (the way CPython/ruff do).
|
|
21
|
+
|
|
22
|
+
It escapes the quote, the control range and SURROGATES; everything else passes through. A lone
|
|
23
|
+
surrogate (`\\ud800`) is a valid `str` but is not encodable to UTF-8, so it is emitted as
|
|
24
|
+
`\\uXXXX` to make the escaper total over ANY `str` and keep the file writable.
|
|
25
|
+
"""
|
|
26
|
+
out: list[str] = []
|
|
27
|
+
for char in value:
|
|
28
|
+
code = ord(char)
|
|
29
|
+
escaped = _ESCAPES.get(char)
|
|
30
|
+
if escaped is not None:
|
|
31
|
+
out.append(escaped)
|
|
32
|
+
elif char == quote:
|
|
33
|
+
out.append("\\" + quote)
|
|
34
|
+
elif code < 0x20 or code == 0x7F:
|
|
35
|
+
out.append(f"\\x{code:02x}")
|
|
36
|
+
elif 0xD800 <= code <= 0xDFFF:
|
|
37
|
+
# Lone surrogate: `\uXXXX`, the only way to fit it into an encodable literal.
|
|
38
|
+
out.append(f"\\u{code:04x}")
|
|
39
|
+
else:
|
|
40
|
+
out.append(char)
|
|
41
|
+
return f"{quote}{''.join(out)}{quote}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def str_lit(value: str) -> str:
|
|
45
|
+
"""A string as a valid Python literal, double quotes unless single ones save escapes.
|
|
46
|
+
|
|
47
|
+
That is `ruff format`'s rule (the generated file passes `ruff check`); it ALWAYS returns a
|
|
48
|
+
closed, correct literal, whatever the content of `value`.
|
|
49
|
+
"""
|
|
50
|
+
double = _encode(value, '"')
|
|
51
|
+
single = _encode(value, "'")
|
|
52
|
+
return single if single.count("\\") < double.count("\\") else double
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""How a `python_type` off the compiled graph is SPELLED in generated Python source.
|
|
2
|
+
|
|
3
|
+
Two generators write annotations from the same metadata — the DB-first scaffolder
|
|
4
|
+
(`introspection/models.py`) and the DTO generator (`dto/`) — and they have to spell a type the same
|
|
5
|
+
way or the mirror and the DTO of one column would disagree about what it is. That is why this lives
|
|
6
|
+
here and not inside either of them: one question, one answer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import snakeorm
|
|
12
|
+
|
|
13
|
+
PARAMETERISED: dict[type, str] = {
|
|
14
|
+
dict: "dict[str, object]",
|
|
15
|
+
list: "list[object]",
|
|
16
|
+
}
|
|
17
|
+
"""How the generic builtins are SPELLED in generated code.
|
|
18
|
+
|
|
19
|
+
`object` and not `Any`: the value really is unknown, and `object` says so while still making the
|
|
20
|
+
checker ask for a narrowing before use. `Any` would say the opposite — that anything goes — which is
|
|
21
|
+
what the bare form silently meant.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
FROM_SNAKEORM = "<snakeorm>"
|
|
25
|
+
"""Marks "this symbol goes in the `from snakeorm import ...`", not in an `import` of its own."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def type_annotation(python_type: type) -> tuple[str, str | None]:
|
|
29
|
+
"""The type's name exactly as it is written in the annotation, and its import if one is needed.
|
|
30
|
+
|
|
31
|
+
A type from the ORM ITSELF (`SnakeUtc`) is written bare and comes in through the
|
|
32
|
+
`from snakeorm import` the file already has: rendering it as `snakeorm.times.SnakeUtc` with its
|
|
33
|
+
own separate import would be exposing an internal path in a file people read, and tying it to
|
|
34
|
+
never moving again.
|
|
35
|
+
"""
|
|
36
|
+
if python_type.__module__ == "builtins":
|
|
37
|
+
# `dict` and `list` are written PARAMETERISED. A bare `dict` is `dict[Any, Any]`, so every
|
|
38
|
+
# read through it comes back `Any` and the code this ORM generates would hand the user the
|
|
39
|
+
# one thing the project forbids — in a file it wrote itself. The decision lives here and not
|
|
40
|
+
# in the caller because this is the function that decides how a type is spelled.
|
|
41
|
+
parameterised = PARAMETERISED.get(python_type)
|
|
42
|
+
if parameterised is not None:
|
|
43
|
+
return parameterised, None
|
|
44
|
+
return python_type.__qualname__, None
|
|
45
|
+
if getattr(snakeorm, python_type.__qualname__, None) is python_type:
|
|
46
|
+
return python_type.__qualname__, FROM_SNAKEORM
|
|
47
|
+
return (
|
|
48
|
+
f"{python_type.__module__}.{python_type.__qualname__}",
|
|
49
|
+
python_type.__module__,
|
|
50
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Introspection: how a database's schema is READ (the third axis, alongside `dialects` and `drivers`).
|
|
2
|
+
|
|
3
|
+
It returns the SAME graph as the Model Compiler: scaffolding and drift detection compare with no intermediate format.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from snakeorm.introspection.mysql import MySQLIntrospector as MySQLIntrospector
|
|
7
|
+
from snakeorm.introspection.base import SnakeIntrospector as SnakeIntrospector
|
|
8
|
+
from snakeorm.introspection.drift import drift as drift
|
|
9
|
+
from snakeorm.introspection.models import SnakeMirrorNames as SnakeMirrorNames
|
|
10
|
+
from snakeorm.introspection.models import render_models as render_models
|
|
11
|
+
from snakeorm.introspection.models import unrepresentable as unrepresentable
|
|
12
|
+
from snakeorm.introspection.postgres import PostgresIntrospector as PostgresIntrospector
|
|
13
|
+
from snakeorm.introspection.sqlite import SQLiteIntrospector as SQLiteIntrospector
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The INTROSPECTOR Protocol: how a database's schema is READ (the third axis, alongside Dialect and Driver).
|
|
2
|
+
|
|
3
|
+
It returns the SAME graph (`SnakeTableInfo`) as the Model Compiler: scaffolding and the drift `check` compare with no translator. It is NOT bijective: `TEXT`/`VARCHAR(50)`/`CHAR(10)` all come back as `str`, so the round trip does not reproduce the original.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
11
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class SnakeIntrospector(Protocol):
|
|
16
|
+
"""Reads a database's REAL schema and returns it as a metadata graph."""
|
|
17
|
+
|
|
18
|
+
def tables(self, schema: str = DEFAULT_SCHEMA) -> list[SnakeTableInfo]:
|
|
19
|
+
"""Every table in the schema with its columns, PK, uniqueness, indexes and comments.
|
|
20
|
+
|
|
21
|
+
The FKs come resolved in each `SnakeTableInfo` pointing at the NAME of the target table; whoever generates models translates them into class names.
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def unsupported(self, schema: str = DEFAULT_SCHEMA) -> list[str]:
|
|
26
|
+
"""Objects the ORM does NOT know how to represent (triggers, exotic types, expression indexes), described so it can WARN.
|
|
27
|
+
|
|
28
|
+
Dropping them silently would make people believe the generated model covers the whole database.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""DRIFT detection: what the difference is between what the code says and what is in the database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from snakeorm.core.exceptions import SnakeDialectError
|
|
6
|
+
from snakeorm.dialects import SnakeDialect
|
|
7
|
+
from snakeorm.metadata import SnakeColumnInfo, SnakeTableInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def drift(
|
|
11
|
+
code: list[SnakeTableInfo], database: list[SnakeTableInfo], dialect: SnakeDialect
|
|
12
|
+
) -> list[str]:
|
|
13
|
+
"""Differences between the compiled metadata and the REAL schema, as text (code vs database, not vs history like `makemigrations --check`).
|
|
14
|
+
|
|
15
|
+
It only compares what introspection reads reliably (existence of tables/columns, column type, nullability): defaults and constraints would give false positives that turn the tool into noise. Drift is about what YOU DECLARE: a table the ORM does not know about is not drift, it is somebody else's.
|
|
16
|
+
|
|
17
|
+
The `dialect` is not decoration and it is not optional. Comparing PYTHON types across this
|
|
18
|
+
boundary compares something only one side has: a `date` on SQLite is written as `TEXT`, and
|
|
19
|
+
reading `TEXT` back can only ever answer `str`. Both sides are right and they never match, so
|
|
20
|
+
drift used to report three differences against a database the ORM had just built out of the
|
|
21
|
+
very model it was comparing — noise from the one tool whose silence is the whole product.
|
|
22
|
+
|
|
23
|
+
The SQL type is what both sides genuinely share, so that is what is compared.
|
|
24
|
+
"""
|
|
25
|
+
report: list[str] = []
|
|
26
|
+
in_code = {table.name: table for table in code}
|
|
27
|
+
in_database = {table.name: table for table in database}
|
|
28
|
+
|
|
29
|
+
for name in sorted(set(in_code) - set(in_database)):
|
|
30
|
+
report.append(f"table '{name}': is in the code and NOT in the database")
|
|
31
|
+
|
|
32
|
+
for name in sorted(set(in_code) & set(in_database)):
|
|
33
|
+
report.extend(_column_drift(name, in_code[name], in_database[name], dialect))
|
|
34
|
+
return report
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sql_type(column: SnakeColumnInfo, dialect: SnakeDialect) -> str:
|
|
38
|
+
"""The SQL type this engine writes for the column, or the dialect's refusal to write one.
|
|
39
|
+
|
|
40
|
+
The STORAGE type feeds it, not the declared one: a `StrEnum` is stored as TEXT, so going
|
|
41
|
+
through `python_type` would flag every enum column as permanent drift.
|
|
42
|
+
|
|
43
|
+
A refusal is returned as text rather than raised: drift is a diagnostic, and a model this
|
|
44
|
+
engine cannot represent is exactly the kind of thing it exists to tell you about — not a
|
|
45
|
+
reason for the report to blow up half-written.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return dialect.map_type(column.storage_type, params=column.type_params)
|
|
49
|
+
except SnakeDialectError as refusal:
|
|
50
|
+
return f"<not representable: {refusal}>"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _column_drift(
|
|
54
|
+
table: str, code: SnakeTableInfo, database: SnakeTableInfo, dialect: SnakeDialect
|
|
55
|
+
) -> list[str]:
|
|
56
|
+
"""Column differences for a table present on both sides."""
|
|
57
|
+
report: list[str] = []
|
|
58
|
+
in_code = {column.name: column for column in code.columns}
|
|
59
|
+
in_database = {column.name: column for column in database.columns}
|
|
60
|
+
|
|
61
|
+
for name in sorted(set(in_code) - set(in_database)):
|
|
62
|
+
report.append(f"{table}.{name}: is in the code and NOT in the database")
|
|
63
|
+
for name in sorted(set(in_database) - set(in_code)):
|
|
64
|
+
report.append(f"{table}.{name}: is in the database and NOT in the code")
|
|
65
|
+
|
|
66
|
+
for name in sorted(set(in_code) & set(in_database)):
|
|
67
|
+
expected, actual = in_code[name], in_database[name]
|
|
68
|
+
written, found = _sql_type(expected, dialect), _sql_type(actual, dialect)
|
|
69
|
+
if written != found:
|
|
70
|
+
report.append(
|
|
71
|
+
f"{table}.{name}: the code stores {written} and the database has {found}"
|
|
72
|
+
)
|
|
73
|
+
if expected.nullable != actual.nullable:
|
|
74
|
+
state = "accepts NULL" if actual.nullable else "does NOT accept NULL"
|
|
75
|
+
report.append(
|
|
76
|
+
f"{table}.{name}: the database {state} and the code says the opposite"
|
|
77
|
+
)
|
|
78
|
+
return report
|