pyoq-sql 1.0.2__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.
- pyoq/__init__.py +10 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +31 -0
- pyoq/config/connection.py +161 -0
- pyoq/config/loader.py +289 -0
- pyoq/config/models.py +245 -0
- pyoq/config/values.py +142 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +73 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +63 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +23 -0
- pyoq/serving/databases.py +107 -0
- pyoq/serving/opening.py +331 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
- pyoq_sql-1.0.2.dist-info/RECORD +267 -0
- pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Running the shared generation pipeline for a Django project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
8
|
+
|
|
9
|
+
from pyoq.cli.defaults import default_generation_pipeline
|
|
10
|
+
from pyoq.config import DatabaseDialect
|
|
11
|
+
from pyoq.django.schema import MigrationStateSchemaSource
|
|
12
|
+
from pyoq.django.settings import django_configuration
|
|
13
|
+
from pyoq.errors import OperationUnavailableError
|
|
14
|
+
from pyoq.generation import GenerationMode
|
|
15
|
+
|
|
16
|
+
_DIALECTS = {
|
|
17
|
+
"sqlite": "sqlite",
|
|
18
|
+
"postgresql": "postgres",
|
|
19
|
+
"mysql": "mysql",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class GenerationRequest:
|
|
25
|
+
"""One run of generation, described before anything is written."""
|
|
26
|
+
|
|
27
|
+
alias: str = DEFAULT_DB_ALIAS
|
|
28
|
+
app_labels: tuple[str, ...] = ()
|
|
29
|
+
mode: GenerationMode = GenerationMode.WRITE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate(request: GenerationRequest, /) -> str:
|
|
33
|
+
"""The pipeline every other entry point uses, pointed at Django.
|
|
34
|
+
|
|
35
|
+
Generation is not reimplemented here. Staging, validation, the manifest,
|
|
36
|
+
drift, and the atomic replacement all belong to the shared pipeline, and a
|
|
37
|
+
second copy of them would be a second set of answers.
|
|
38
|
+
"""
|
|
39
|
+
dialect = DatabaseDialect(_dialect_name(request.alias))
|
|
40
|
+
source = MigrationStateSchemaSource(
|
|
41
|
+
alias=request.alias, app_labels=request.app_labels
|
|
42
|
+
)
|
|
43
|
+
pipeline = default_generation_pipeline(source)
|
|
44
|
+
return pipeline.execute(django_configuration(dialect), request.mode)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dialect_name(alias: str) -> str:
|
|
48
|
+
vendor = connections[alias].vendor
|
|
49
|
+
name = _DIALECTS.get(vendor)
|
|
50
|
+
if name is None:
|
|
51
|
+
message = (
|
|
52
|
+
f"PyOQ cannot generate for the Django backend {vendor!r}; "
|
|
53
|
+
f"supported backends: {', '.join(sorted(_DIALECTS))}"
|
|
54
|
+
)
|
|
55
|
+
raise OperationUnavailableError(message)
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ("GenerationRequest", "generate")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Django's own `makemigrations`, with generation after it.
|
|
2
|
+
|
|
3
|
+
A project opts in to this by putting PyOQ ahead of the app that supplies the
|
|
4
|
+
original command, and by setting `codegen_after_makemigrations`. Overriding a
|
|
5
|
+
command a project already runs is not something to do to somebody quietly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from django.core.management.base import CommandError
|
|
13
|
+
from django.core.management.commands.makemigrations import (
|
|
14
|
+
Command as MakeMigrations,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from pyoq.django.generation import GenerationRequest, generate
|
|
18
|
+
from pyoq.django.settings import codegen_after_makemigrations
|
|
19
|
+
from pyoq.errors import PyOQError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Command(MakeMigrations):
|
|
23
|
+
def handle(self, *app_labels: str, **options: Any) -> None:
|
|
24
|
+
super().handle(*app_labels, **options)
|
|
25
|
+
if not codegen_after_makemigrations():
|
|
26
|
+
return
|
|
27
|
+
if _writes_nothing(options):
|
|
28
|
+
self.stdout.write(
|
|
29
|
+
"PyOQ: migrations were not written, so nothing was generated"
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
self.stdout.write(f"PyOQ: {_generated(app_labels, options)}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _writes_nothing(options: dict[str, Any]) -> bool:
|
|
36
|
+
"""Generation reads migration files, so it must not run before they exist.
|
|
37
|
+
|
|
38
|
+
A run asked to check or to report leaves the files as they were. Generating
|
|
39
|
+
then would describe the schema before this change rather than after it, and
|
|
40
|
+
would look like it had worked.
|
|
41
|
+
"""
|
|
42
|
+
return bool(options.get("dry_run") or options.get("check_changes"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _generated(app_labels: tuple[str, ...], options: dict[str, Any]) -> str:
|
|
46
|
+
request = GenerationRequest(
|
|
47
|
+
alias=str(options.get("database") or "default"),
|
|
48
|
+
app_labels=app_labels,
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
return generate(request)
|
|
52
|
+
except PyOQError as error:
|
|
53
|
+
raise CommandError(str(error)) from error
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Generate PyOQ types from the migrations this project has written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
8
|
+
from django.db import DEFAULT_DB_ALIAS
|
|
9
|
+
|
|
10
|
+
from pyoq.django.generation import GenerationRequest, generate
|
|
11
|
+
from pyoq.errors import PyOQError
|
|
12
|
+
from pyoq.generation import GenerationMode
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from argparse import ArgumentParser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Command(BaseCommand):
|
|
19
|
+
help = "Generate typed PyOQ code from this project's migration state."
|
|
20
|
+
|
|
21
|
+
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"app_label",
|
|
24
|
+
nargs="*",
|
|
25
|
+
help="Limit generation to these apps. Defaults to all of them.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--database",
|
|
29
|
+
default=DEFAULT_DB_ALIAS,
|
|
30
|
+
help="The alias whose backend decides the column types.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--dry-run",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Report what would be written without writing it.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--check",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Fail when the generated package is not up to date.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
44
|
+
del args
|
|
45
|
+
request = GenerationRequest(
|
|
46
|
+
alias=str(options["database"]),
|
|
47
|
+
app_labels=tuple(options["app_label"]),
|
|
48
|
+
mode=_mode(check=options["check"], dry_run=options["dry_run"]),
|
|
49
|
+
)
|
|
50
|
+
self.stdout.write(_report(request))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mode(*, check: bool, dry_run: bool) -> GenerationMode:
|
|
54
|
+
"""Checking and reporting are different questions, so asking both is not.
|
|
55
|
+
|
|
56
|
+
One answers whether the package is current and the other describes what a
|
|
57
|
+
run would produce. A command that quietly picked one would give an answer
|
|
58
|
+
to a question that was not asked.
|
|
59
|
+
"""
|
|
60
|
+
if check and dry_run:
|
|
61
|
+
message = "--check and --dry-run cannot be combined"
|
|
62
|
+
raise CommandError(message)
|
|
63
|
+
if check:
|
|
64
|
+
return GenerationMode.CHECK
|
|
65
|
+
if dry_run:
|
|
66
|
+
return GenerationMode.DRY_RUN
|
|
67
|
+
return GenerationMode.WRITE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _report(request: GenerationRequest) -> str:
|
|
71
|
+
"""A PyOQ failure is the operator's problem, not a traceback."""
|
|
72
|
+
try:
|
|
73
|
+
return generate(request)
|
|
74
|
+
except PyOQError as error:
|
|
75
|
+
raise CommandError(str(error)) from error
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Binding values the way Django binds them on the connection it owns.
|
|
2
|
+
|
|
3
|
+
A dialect adapter writes what its driver accepts. That is not enough here,
|
|
4
|
+
because the rows are shared: Django reads back what PyOQ writes and filters on
|
|
5
|
+
it, so a value has to reach the column in the form Django's own fields put
|
|
6
|
+
there. That form differs per backend and per type, and Django already decides
|
|
7
|
+
it, so each value is handed to the field Django would have used rather than to
|
|
8
|
+
a rule restated here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import date, datetime, time, timedelta
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import TypeAlias, cast
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from django.db import models
|
|
20
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
21
|
+
from django.db.models import Field
|
|
22
|
+
|
|
23
|
+
from pyoq.errors import ParameterBindingError
|
|
24
|
+
|
|
25
|
+
DjangoValue: TypeAlias = (
|
|
26
|
+
bool
|
|
27
|
+
| int
|
|
28
|
+
| float
|
|
29
|
+
| Decimal
|
|
30
|
+
| str
|
|
31
|
+
| bytes
|
|
32
|
+
| date
|
|
33
|
+
| datetime
|
|
34
|
+
| time
|
|
35
|
+
| timedelta
|
|
36
|
+
| UUID
|
|
37
|
+
| None
|
|
38
|
+
)
|
|
39
|
+
"""What a Django backend accepts as a bound value.
|
|
40
|
+
|
|
41
|
+
Django declares this only in its stubs and under a private name, so PyOQ states
|
|
42
|
+
it rather than reaching for one.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_FIELDS: tuple[tuple[type[object], Field[object, object]], ...] = (
|
|
46
|
+
(bool, models.BooleanField()),
|
|
47
|
+
(UUID, models.UUIDField()),
|
|
48
|
+
(timedelta, models.DurationField()),
|
|
49
|
+
(datetime, models.DateTimeField()),
|
|
50
|
+
(date, models.DateField()),
|
|
51
|
+
(time, models.TimeField()),
|
|
52
|
+
(Decimal, models.DecimalField()),
|
|
53
|
+
(dict, models.JSONField()),
|
|
54
|
+
(list, models.JSONField()),
|
|
55
|
+
)
|
|
56
|
+
"""The field Django would have used, for each type it stores its own way.
|
|
57
|
+
|
|
58
|
+
Order matters where one type is another: a datetime is a date, and a boolean
|
|
59
|
+
is an integer, so the narrower entry comes first. Each field prepares a value
|
|
60
|
+
without reading state of its own, so one instance serves every statement.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def adapt_parameters(
|
|
65
|
+
connection: BaseDatabaseWrapper,
|
|
66
|
+
values: tuple[object, ...],
|
|
67
|
+
/,
|
|
68
|
+
) -> tuple[DjangoValue, ...]:
|
|
69
|
+
"""Every bound value, in the form this backend stores it."""
|
|
70
|
+
return tuple(_adapted(connection, value) for value in values)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _adapted(connection: BaseDatabaseWrapper, value: object) -> DjangoValue:
|
|
74
|
+
if isinstance(value, Enum):
|
|
75
|
+
return _adapted(connection, value.value)
|
|
76
|
+
if isinstance(value, memoryview):
|
|
77
|
+
return value.tobytes()
|
|
78
|
+
for value_type, field in _FIELDS:
|
|
79
|
+
if isinstance(value, value_type):
|
|
80
|
+
return _prepared(field, connection, value)
|
|
81
|
+
return _plain(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _prepared(
|
|
85
|
+
field: Field[object, object],
|
|
86
|
+
connection: BaseDatabaseWrapper,
|
|
87
|
+
value: object,
|
|
88
|
+
) -> DjangoValue:
|
|
89
|
+
"""Every field above prepares a value into one a backend already binds."""
|
|
90
|
+
return cast("DjangoValue", field.get_db_prep_save(value, connection))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _plain(value: object) -> DjangoValue:
|
|
94
|
+
"""A value every backend stores alike, which no field has to prepare."""
|
|
95
|
+
if value is None or isinstance(value, (int, float, str, bytes)):
|
|
96
|
+
return value
|
|
97
|
+
message = f"unsupported Django parameter type: {type(value).__name__}"
|
|
98
|
+
raise ParameterBindingError(message)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ("DjangoValue", "adapt_parameters")
|
pyoq/django/schema.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The schema a Django project's migrations describe.
|
|
2
|
+
|
|
3
|
+
Read from migration state rather than from a database, because migrations are
|
|
4
|
+
the authority on what the schema will be and a developer generating types has
|
|
5
|
+
usually not applied them yet. Nothing here opens a connection: Django answers
|
|
6
|
+
what type a field takes on a backend without consulting the server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol
|
|
13
|
+
|
|
14
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
15
|
+
from django.db.migrations.loader import MigrationLoader
|
|
16
|
+
from django.db.models import CheckConstraint as DjangoCheckConstraint
|
|
17
|
+
from django.db.models import ForeignKey
|
|
18
|
+
|
|
19
|
+
from pyoq.config import Configuration, DatabaseDialect
|
|
20
|
+
from pyoq.errors import OperationUnavailableError
|
|
21
|
+
from pyoq.schema import (
|
|
22
|
+
Catalog,
|
|
23
|
+
CheckConstraint,
|
|
24
|
+
Column,
|
|
25
|
+
GeneratedKind,
|
|
26
|
+
GeneratedValue,
|
|
27
|
+
Identifier,
|
|
28
|
+
Index,
|
|
29
|
+
IndexTerm,
|
|
30
|
+
Key,
|
|
31
|
+
KeyKind,
|
|
32
|
+
MetadataEntry,
|
|
33
|
+
ObjectReference,
|
|
34
|
+
Relation,
|
|
35
|
+
Schema,
|
|
36
|
+
SchemaSnapshot,
|
|
37
|
+
SqlExpression,
|
|
38
|
+
SqlType,
|
|
39
|
+
Table,
|
|
40
|
+
TypeKind,
|
|
41
|
+
)
|
|
42
|
+
from pyoq.schema.mysql.types import mysql_type
|
|
43
|
+
from pyoq.schema.postgres.types import postgres_type
|
|
44
|
+
from pyoq.schema.sqlite.types import sqlite_type
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Iterable, Sequence
|
|
48
|
+
|
|
49
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
50
|
+
from django.db.models import Field, Model
|
|
51
|
+
from django.db.models.options import Options
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TypeParser(Protocol):
|
|
55
|
+
def __call__(self, declared_type: str, /) -> SqlType: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_PARSERS: dict[str, tuple[DatabaseDialect, TypeParser]] = {
|
|
59
|
+
"sqlite": (DatabaseDialect.SQLITE, sqlite_type),
|
|
60
|
+
"postgresql": (DatabaseDialect.POSTGRES, postgres_type),
|
|
61
|
+
"mysql": (DatabaseDialect.MYSQL, mysql_type),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_AUTO_FIELDS = frozenset({"AutoField", "BigAutoField", "SmallAutoField"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class MigrationStateSchemaSource:
|
|
69
|
+
"""Every table the migrations on disk would create, and nothing else."""
|
|
70
|
+
|
|
71
|
+
alias: str = DEFAULT_DB_ALIAS
|
|
72
|
+
app_labels: tuple[str, ...] = ()
|
|
73
|
+
|
|
74
|
+
def load(self, configuration: Configuration) -> SchemaSnapshot:
|
|
75
|
+
del configuration
|
|
76
|
+
connection = connections[self.alias]
|
|
77
|
+
dialect, parse = _parser_for(connection)
|
|
78
|
+
models = _selected_models(self.app_labels)
|
|
79
|
+
tables = [_table(model, connection, parse) for model in models]
|
|
80
|
+
tables.extend(_join_tables(models, connection, parse))
|
|
81
|
+
schema = Schema(name=None, tables=tuple(_ordered(tables)))
|
|
82
|
+
return SchemaSnapshot(
|
|
83
|
+
dialect=dialect,
|
|
84
|
+
catalogs=(Catalog(name=None, schemas=(schema,)),),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parser_for(
|
|
89
|
+
connection: BaseDatabaseWrapper,
|
|
90
|
+
) -> tuple[DatabaseDialect, TypeParser]:
|
|
91
|
+
parser = _PARSERS.get(connection.vendor)
|
|
92
|
+
if parser is None:
|
|
93
|
+
message = (
|
|
94
|
+
f"PyOQ cannot read a schema for the Django backend "
|
|
95
|
+
f"{connection.vendor!r}; supported backends: "
|
|
96
|
+
f"{', '.join(sorted(_PARSERS))}"
|
|
97
|
+
)
|
|
98
|
+
raise OperationUnavailableError(message)
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _selected_models(app_labels: tuple[str, ...]) -> tuple[type[Model], ...]:
|
|
103
|
+
"""The models the migrations describe, with no database consulted.
|
|
104
|
+
|
|
105
|
+
A loader given no connection reads the migration files alone and never asks
|
|
106
|
+
which of them have been applied, which is the question that needs a server.
|
|
107
|
+
"""
|
|
108
|
+
state = MigrationLoader(None).project_state()
|
|
109
|
+
wanted = frozenset(app_labels)
|
|
110
|
+
chosen = [
|
|
111
|
+
state.apps.get_model(label, name)
|
|
112
|
+
for label, name in sorted(state.models)
|
|
113
|
+
if not wanted or label in wanted
|
|
114
|
+
]
|
|
115
|
+
_require_known_labels(wanted, frozenset(label for label, _ in state.models))
|
|
116
|
+
return tuple(model for model in chosen if model._meta.managed)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _require_known_labels(
|
|
120
|
+
wanted: frozenset[str],
|
|
121
|
+
available: frozenset[str],
|
|
122
|
+
) -> None:
|
|
123
|
+
missing = sorted(wanted - available)
|
|
124
|
+
if not missing:
|
|
125
|
+
return
|
|
126
|
+
message = (
|
|
127
|
+
f"no migrations describe the app {', '.join(missing)!r}; "
|
|
128
|
+
f"apps with migrations: {', '.join(sorted(available))}"
|
|
129
|
+
)
|
|
130
|
+
raise OperationUnavailableError(message)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _table(
|
|
134
|
+
model: type[Model],
|
|
135
|
+
connection: BaseDatabaseWrapper,
|
|
136
|
+
parse: TypeParser,
|
|
137
|
+
) -> Table:
|
|
138
|
+
meta = model._meta
|
|
139
|
+
columns = tuple(
|
|
140
|
+
_column(field, declared, parse)
|
|
141
|
+
for field, declared in _declared_types(meta, connection)
|
|
142
|
+
)
|
|
143
|
+
return Table(
|
|
144
|
+
name=Identifier(meta.db_table),
|
|
145
|
+
columns=columns,
|
|
146
|
+
keys=_keys(meta),
|
|
147
|
+
relations=_relations(meta),
|
|
148
|
+
indexes=_indexes(meta),
|
|
149
|
+
checks=_checks(meta),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _declared_types(
|
|
154
|
+
meta: Options[Model],
|
|
155
|
+
connection: BaseDatabaseWrapper,
|
|
156
|
+
) -> Iterable[tuple[Field[object, object], str]]:
|
|
157
|
+
"""A field without a type of its own has no column to describe."""
|
|
158
|
+
for field in meta.local_fields:
|
|
159
|
+
declared = field.db_type(connection)
|
|
160
|
+
if declared is not None:
|
|
161
|
+
yield field, declared
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_MEANT_KINDS: dict[str, TypeKind] = {
|
|
165
|
+
"JSONField": TypeKind.JSON,
|
|
166
|
+
"UUIDField": TypeKind.UUID,
|
|
167
|
+
"DurationField": TypeKind.INTERVAL,
|
|
168
|
+
}
|
|
169
|
+
"""What a field means, where the column it asks for does not say so.
|
|
170
|
+
|
|
171
|
+
Django declares a JSON column as text, a UUID as a fixed width string, and a
|
|
172
|
+
duration as an integer, so reading the column back would hand a caller the
|
|
173
|
+
stored form rather than the value. The field is the one that knows.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _data_type(
|
|
178
|
+
field: Field[object, object],
|
|
179
|
+
declared: str,
|
|
180
|
+
parse: TypeParser,
|
|
181
|
+
) -> SqlType:
|
|
182
|
+
"""The declared column, named by what the field puts in it."""
|
|
183
|
+
stored = parse(declared)
|
|
184
|
+
meant = _MEANT_KINDS.get(field.get_internal_type())
|
|
185
|
+
if meant is None or stored.kind is meant:
|
|
186
|
+
return stored
|
|
187
|
+
return replace(stored, kind=meant, element_type=None, named_type=None)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _column(
|
|
191
|
+
field: Field[object, object],
|
|
192
|
+
declared: str,
|
|
193
|
+
parse: TypeParser,
|
|
194
|
+
) -> Column:
|
|
195
|
+
return Column(
|
|
196
|
+
name=Identifier(_column_of(field)),
|
|
197
|
+
data_type=_data_type(field, declared, parse),
|
|
198
|
+
nullable=bool(field.null),
|
|
199
|
+
default=None,
|
|
200
|
+
generated=_generated(field),
|
|
201
|
+
metadata=_column_metadata(field),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _generated(field: Field[object, object]) -> GeneratedValue | None:
|
|
206
|
+
"""Only a field the database fills in counts as generated here.
|
|
207
|
+
|
|
208
|
+
A Django default is applied in Python before the row is sent, so it is not
|
|
209
|
+
a fact about the column. Recording it as one would tell a caller they may
|
|
210
|
+
omit a value that the database will then refuse.
|
|
211
|
+
"""
|
|
212
|
+
if type(field).__name__ not in _AUTO_FIELDS:
|
|
213
|
+
return None
|
|
214
|
+
return GeneratedValue(kind=GeneratedKind.IDENTITY_BY_DEFAULT)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _column_metadata(
|
|
218
|
+
field: Field[object, object],
|
|
219
|
+
) -> tuple[MetadataEntry, ...]:
|
|
220
|
+
entries = [MetadataEntry(key="django-field", value=type(field).__name__)]
|
|
221
|
+
if field.has_default():
|
|
222
|
+
entries.append(MetadataEntry(key="django-default", value="python"))
|
|
223
|
+
if isinstance(field, ForeignKey):
|
|
224
|
+
entries.append(MetadataEntry(key="django-on-delete", value=_on_delete(field)))
|
|
225
|
+
return tuple(entries)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _on_delete(field: ForeignKey[object]) -> str:
|
|
229
|
+
"""What the model asked for, beside what the database was told.
|
|
230
|
+
|
|
231
|
+
Django deletes related rows itself, so the constraint carries no action at
|
|
232
|
+
all. Keeping the model's choice visible costs nothing and stops a reader
|
|
233
|
+
concluding the database will do it.
|
|
234
|
+
"""
|
|
235
|
+
return str(getattr(field.remote_field.on_delete, "__name__", "unknown"))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _keys(meta: Options[Model]) -> tuple[Key, ...]:
|
|
239
|
+
keys: list[Key] = []
|
|
240
|
+
keys.append(Key(kind=KeyKind.PRIMARY, columns=(Identifier(_column_of(meta.pk)),)))
|
|
241
|
+
keys.extend(
|
|
242
|
+
Key(kind=KeyKind.UNIQUE, columns=(Identifier(_column_of(field)),))
|
|
243
|
+
for field in meta.local_fields
|
|
244
|
+
if field.unique and not field.primary_key
|
|
245
|
+
)
|
|
246
|
+
keys.extend(
|
|
247
|
+
Key(kind=KeyKind.UNIQUE, columns=_columns(meta, group))
|
|
248
|
+
for group in meta.unique_together
|
|
249
|
+
)
|
|
250
|
+
keys.extend(_constraint_keys(meta))
|
|
251
|
+
return tuple(keys)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _constraint_keys(meta: Options[Model]) -> Iterable[Key]:
|
|
255
|
+
"""A unique constraint counts only when it holds for every row.
|
|
256
|
+
|
|
257
|
+
A constraint carrying a condition is unique among the rows it matches and
|
|
258
|
+
silently not unique among the rest. Treating one as a key would let a
|
|
259
|
+
relation be described as reaching a single row when it reaches many.
|
|
260
|
+
"""
|
|
261
|
+
for constraint in meta.constraints:
|
|
262
|
+
fields = getattr(constraint, "fields", None)
|
|
263
|
+
if not fields or _is_partial(constraint):
|
|
264
|
+
continue
|
|
265
|
+
yield Key(
|
|
266
|
+
kind=KeyKind.UNIQUE,
|
|
267
|
+
columns=_columns(meta, fields),
|
|
268
|
+
name=Identifier(constraint.name),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_partial(constraint: object) -> bool:
|
|
273
|
+
return any(
|
|
274
|
+
getattr(constraint, attribute, None)
|
|
275
|
+
for attribute in ("condition", "expressions", "nulls_distinct")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _relations(meta: Options[Model]) -> tuple[Relation, ...]:
|
|
280
|
+
"""Django enforces deletion in Python, so the database is told nothing.
|
|
281
|
+
|
|
282
|
+
The referential action recorded here is the one the database will actually
|
|
283
|
+
take. What the model asked for is kept beside it as a fact about Django.
|
|
284
|
+
"""
|
|
285
|
+
return tuple(
|
|
286
|
+
Relation(
|
|
287
|
+
columns=(Identifier(_column_of(field)),),
|
|
288
|
+
target=ObjectReference(name=Identifier(_target_table(field))),
|
|
289
|
+
target_columns=(Identifier(_column_of(field.target_field)),),
|
|
290
|
+
)
|
|
291
|
+
for field in _foreign_keys(meta)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _foreign_keys(meta: Options[Model]) -> Iterable[ForeignKey[object]]:
|
|
296
|
+
for field in meta.local_fields:
|
|
297
|
+
if isinstance(field, ForeignKey):
|
|
298
|
+
yield field
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _target_table(field: ForeignKey[object]) -> str:
|
|
302
|
+
return str(field.related_model._meta.db_table)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _column_of(field: Field[object, object]) -> str:
|
|
306
|
+
return str(field.column)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _indexes(meta: Options[Model]) -> tuple[Index, ...]:
|
|
310
|
+
"""Only indexes the project named itself.
|
|
311
|
+
|
|
312
|
+
Django names the indexes it adds for itself inside a schema editor, which
|
|
313
|
+
reaches the server. Guessing those names here would put a name in the
|
|
314
|
+
schema that the database may not agree with.
|
|
315
|
+
"""
|
|
316
|
+
return tuple(
|
|
317
|
+
Index(
|
|
318
|
+
name=Identifier(index.name),
|
|
319
|
+
terms=tuple(_terms(meta, index.fields)),
|
|
320
|
+
)
|
|
321
|
+
for index in meta.indexes
|
|
322
|
+
if index.fields
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _terms(meta: Options[Model], fields: Sequence[str]) -> Iterable[IndexTerm]:
|
|
327
|
+
for name in fields:
|
|
328
|
+
descending = name.startswith("-")
|
|
329
|
+
column = _column_name(meta, name.lstrip("-"))
|
|
330
|
+
yield IndexTerm(column=Identifier(column), descending=descending)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _checks(meta: Options[Model]) -> tuple[CheckConstraint, ...]:
|
|
334
|
+
return tuple(
|
|
335
|
+
CheckConstraint(
|
|
336
|
+
expression=SqlExpression(text=str(constraint.condition)),
|
|
337
|
+
name=Identifier(constraint.name),
|
|
338
|
+
)
|
|
339
|
+
for constraint in meta.constraints
|
|
340
|
+
if isinstance(constraint, DjangoCheckConstraint)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _columns(
|
|
345
|
+
meta: Options[Model],
|
|
346
|
+
names: Iterable[str],
|
|
347
|
+
) -> tuple[Identifier, ...]:
|
|
348
|
+
return tuple(Identifier(_column_name(meta, name)) for name in names)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _column_name(meta: Options[Model], name: str) -> str:
|
|
352
|
+
field = meta.get_field(name)
|
|
353
|
+
column = getattr(field, "column", None)
|
|
354
|
+
return column if isinstance(column, str) else name
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _join_tables(
|
|
358
|
+
models: tuple[type[Model], ...],
|
|
359
|
+
connection: BaseDatabaseWrapper,
|
|
360
|
+
parse: TypeParser,
|
|
361
|
+
) -> Iterable[Table]:
|
|
362
|
+
"""A many-to-many field owns a table that migration state does not list.
|
|
363
|
+
|
|
364
|
+
Django creates it from the field rather than from a model of its own, so it
|
|
365
|
+
has to be described here or the schema would be missing a real table. A
|
|
366
|
+
through model the project wrote is already a model and is left alone.
|
|
367
|
+
"""
|
|
368
|
+
for model in models:
|
|
369
|
+
for field in model._meta.local_many_to_many:
|
|
370
|
+
through = field.remote_field.through
|
|
371
|
+
if through is not None and through._meta.auto_created:
|
|
372
|
+
yield _table(through, connection, parse)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _ordered(tables: Iterable[Table]) -> list[Table]:
|
|
376
|
+
return sorted(tables, key=lambda table: table.name.value)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
__all__ = ("MigrationStateSchemaSource",)
|