pyoq-sql 1.0.0__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 +7 -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 +19 -0
- pyoq/config/loader.py +204 -0
- pyoq/config/models.py +243 -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 +72 -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 +62 -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 +19 -0
- pyoq/serving/databases.py +107 -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.0.dist-info/METADATA +3034 -0
- pyoq_sql-1.0.0.dist-info/RECORD +264 -0
- pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
- pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
pyoq/config/loader.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Strict project configuration loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from pyoq.config import (
|
|
10
|
+
Configuration,
|
|
11
|
+
DatabaseDialect,
|
|
12
|
+
DatabaseProfile,
|
|
13
|
+
EnvironmentReference,
|
|
14
|
+
)
|
|
15
|
+
from pyoq.errors import (
|
|
16
|
+
ConfigurationFileError,
|
|
17
|
+
ConfigurationValidationError,
|
|
18
|
+
PathContainmentError,
|
|
19
|
+
UnknownConfigurationSettingError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_DEFAULT_CODEGEN_DIRECTORY = "pyoq_generated"
|
|
23
|
+
_DEFAULT_CODEGEN_PACKAGE = "pyoq_generated"
|
|
24
|
+
_DEFAULT_PROFILE = "default"
|
|
25
|
+
_ROOT_SETTINGS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"codegen-directory",
|
|
28
|
+
"codegen-package",
|
|
29
|
+
"profiles",
|
|
30
|
+
"schema-snapshot",
|
|
31
|
+
"selected-profile",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
_PROFILE_SETTINGS = frozenset({"database-path", "dialect", "dsn-environment"})
|
|
35
|
+
|
|
36
|
+
RawTable = dict[str, object]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_configuration(
|
|
40
|
+
project_root: Path,
|
|
41
|
+
*,
|
|
42
|
+
explicit: Configuration | None = None,
|
|
43
|
+
pyproject_path: Path | None = None,
|
|
44
|
+
selected_profile: str | None = None,
|
|
45
|
+
) -> Configuration:
|
|
46
|
+
if explicit is not None:
|
|
47
|
+
return (
|
|
48
|
+
explicit
|
|
49
|
+
if selected_profile is None
|
|
50
|
+
else explicit.select_profile(selected_profile)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
root = project_root.resolve()
|
|
54
|
+
source_path = _resolve_config_path(root, pyproject_path)
|
|
55
|
+
document = _read_toml(source_path)
|
|
56
|
+
table = _pyoq_table(document)
|
|
57
|
+
configuration = _parse_configuration(root, table)
|
|
58
|
+
return (
|
|
59
|
+
configuration
|
|
60
|
+
if selected_profile is None
|
|
61
|
+
else configuration.select_profile(selected_profile)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_toml(path: Path) -> RawTable:
|
|
66
|
+
try:
|
|
67
|
+
with path.open("rb") as stream:
|
|
68
|
+
document = tomllib.load(stream)
|
|
69
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
70
|
+
message = f"unable to read configuration: {path}"
|
|
71
|
+
raise ConfigurationFileError(message) from error
|
|
72
|
+
return cast(RawTable, document)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _pyoq_table(document: RawTable) -> RawTable:
|
|
76
|
+
tool = _require_table(document.get("tool"), "tool")
|
|
77
|
+
return _require_table(tool.get("pyoq"), "tool.pyoq")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_configuration(root: Path, table: RawTable) -> Configuration:
|
|
81
|
+
_reject_unknown(table, _ROOT_SETTINGS, "tool.pyoq")
|
|
82
|
+
profiles = _parse_profiles(table.get("profiles"))
|
|
83
|
+
return Configuration(
|
|
84
|
+
project_root=root,
|
|
85
|
+
codegen_directory=Path(
|
|
86
|
+
_string_setting(
|
|
87
|
+
table,
|
|
88
|
+
"codegen-directory",
|
|
89
|
+
_DEFAULT_CODEGEN_DIRECTORY,
|
|
90
|
+
"tool.pyoq",
|
|
91
|
+
)
|
|
92
|
+
),
|
|
93
|
+
codegen_package=_string_setting(
|
|
94
|
+
table,
|
|
95
|
+
"codegen-package",
|
|
96
|
+
_DEFAULT_CODEGEN_PACKAGE,
|
|
97
|
+
"tool.pyoq",
|
|
98
|
+
),
|
|
99
|
+
selected_profile=_string_setting(
|
|
100
|
+
table,
|
|
101
|
+
"selected-profile",
|
|
102
|
+
_DEFAULT_PROFILE,
|
|
103
|
+
"tool.pyoq",
|
|
104
|
+
),
|
|
105
|
+
profiles=profiles,
|
|
106
|
+
schema_snapshot=_optional_path_setting(table, "schema-snapshot"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _optional_path_setting(table: RawTable, key: str) -> Path | None:
|
|
111
|
+
"""A path a project may leave unset, which most projects do."""
|
|
112
|
+
written = table.get(key)
|
|
113
|
+
if written is None:
|
|
114
|
+
return None
|
|
115
|
+
if not isinstance(written, str) or not written:
|
|
116
|
+
message = f"tool.pyoq.{key} must be a non-empty string"
|
|
117
|
+
raise ConfigurationValidationError(message)
|
|
118
|
+
return Path(written)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_profiles(value: object) -> tuple[DatabaseProfile, ...]:
|
|
122
|
+
table = _require_table(value, "tool.pyoq.profiles")
|
|
123
|
+
if not table:
|
|
124
|
+
message = "tool.pyoq.profiles must contain at least one profile"
|
|
125
|
+
raise ConfigurationValidationError(message)
|
|
126
|
+
return tuple(_parse_profile(name, settings) for name, settings in table.items())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_profile(name: str, value: object) -> DatabaseProfile:
|
|
130
|
+
location = f"tool.pyoq.profiles.{name}"
|
|
131
|
+
table = _require_table(value, location)
|
|
132
|
+
_reject_unknown(table, _PROFILE_SETTINGS, location)
|
|
133
|
+
dialect_name = _required_string(table, "dialect", location)
|
|
134
|
+
try:
|
|
135
|
+
dialect = DatabaseDialect(dialect_name)
|
|
136
|
+
except ValueError as error:
|
|
137
|
+
supported = ", ".join(dialect.value for dialect in DatabaseDialect)
|
|
138
|
+
message = f"{location}.dialect must be one of: {supported}"
|
|
139
|
+
raise ConfigurationValidationError(message) from error
|
|
140
|
+
dsn_name = _optional_string(table, "dsn-environment", location)
|
|
141
|
+
dsn_environment = None if dsn_name is None else EnvironmentReference(dsn_name)
|
|
142
|
+
database_name = _optional_string(table, "database-path", location)
|
|
143
|
+
return DatabaseProfile(
|
|
144
|
+
name=name,
|
|
145
|
+
dialect=dialect,
|
|
146
|
+
dsn_environment=dsn_environment,
|
|
147
|
+
database_path=None if database_name is None else Path(database_name),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_config_path(root: Path, configured: Path | None) -> Path:
|
|
152
|
+
path = root / "pyproject.toml" if configured is None else configured
|
|
153
|
+
candidate = path if path.is_absolute() else root / path
|
|
154
|
+
resolved = candidate.resolve()
|
|
155
|
+
if not resolved.is_relative_to(root):
|
|
156
|
+
message = f"configuration path escapes project root: {path}"
|
|
157
|
+
raise PathContainmentError(message)
|
|
158
|
+
return resolved
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _require_table(value: object, location: str) -> RawTable:
|
|
162
|
+
if not isinstance(value, dict):
|
|
163
|
+
message = f"{location} must be a table"
|
|
164
|
+
raise ConfigurationValidationError(message)
|
|
165
|
+
return cast(RawTable, value)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _reject_unknown(table: RawTable, allowed: frozenset[str], location: str) -> None:
|
|
169
|
+
unknown = sorted(table.keys() - allowed)
|
|
170
|
+
if unknown:
|
|
171
|
+
names = ", ".join(unknown)
|
|
172
|
+
message = f"unknown settings in {location}: {names}"
|
|
173
|
+
raise UnknownConfigurationSettingError(message)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _required_string(table: RawTable, key: str, location: str) -> str:
|
|
177
|
+
value = table.get(key)
|
|
178
|
+
if not isinstance(value, str) or not value:
|
|
179
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
180
|
+
raise ConfigurationValidationError(message)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _string_setting(
|
|
185
|
+
table: RawTable,
|
|
186
|
+
key: str,
|
|
187
|
+
default: str,
|
|
188
|
+
location: str,
|
|
189
|
+
) -> str:
|
|
190
|
+
value = table.get(key, default)
|
|
191
|
+
if not isinstance(value, str) or not value:
|
|
192
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
193
|
+
raise ConfigurationValidationError(message)
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _optional_string(table: RawTable, key: str, location: str) -> str | None:
|
|
198
|
+
value = table.get(key)
|
|
199
|
+
if value is None:
|
|
200
|
+
return None
|
|
201
|
+
if not isinstance(value, str) or not value:
|
|
202
|
+
message = f"{location}.{key} must be a non-empty string"
|
|
203
|
+
raise ConfigurationValidationError(message)
|
|
204
|
+
return value
|
pyoq/config/models.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Immutable configuration contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pyoq.errors import (
|
|
14
|
+
ConfigurationValidationError,
|
|
15
|
+
PathContainmentError,
|
|
16
|
+
SecretResolutionError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ENVIRONMENT_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
20
|
+
_PROFILE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DatabaseDialect(StrEnum):
|
|
24
|
+
POSTGRES = "postgres"
|
|
25
|
+
MYSQL = "mysql"
|
|
26
|
+
SQLITE = "sqlite"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
30
|
+
class SecretValue:
|
|
31
|
+
_value: str
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not self._value:
|
|
35
|
+
message = "secret values cannot be empty"
|
|
36
|
+
raise ConfigurationValidationError(message)
|
|
37
|
+
|
|
38
|
+
def reveal(self) -> str:
|
|
39
|
+
return self._value
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return "SecretValue('[REDACTED]')"
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return "[REDACTED]"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class EnvironmentReference:
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if not _ENVIRONMENT_NAME.fullmatch(self.name):
|
|
54
|
+
message = f"invalid environment variable name: {self.name!r}"
|
|
55
|
+
raise ConfigurationValidationError(message)
|
|
56
|
+
|
|
57
|
+
def resolve(self, environment: Mapping[str, str] | None = None) -> SecretValue:
|
|
58
|
+
values = os.environ if environment is None else environment
|
|
59
|
+
value = values.get(self.name)
|
|
60
|
+
if not value:
|
|
61
|
+
message = f"required environment variable is unavailable: {self.name}"
|
|
62
|
+
raise SecretResolutionError(message)
|
|
63
|
+
return SecretValue(value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class DatabaseProfile:
|
|
68
|
+
name: str
|
|
69
|
+
dialect: DatabaseDialect
|
|
70
|
+
dsn_environment: EnvironmentReference | None = None
|
|
71
|
+
database_path: Path | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not _PROFILE_NAME.fullmatch(self.name):
|
|
75
|
+
message = f"invalid profile name: {self.name!r}"
|
|
76
|
+
raise ConfigurationValidationError(message)
|
|
77
|
+
if (
|
|
78
|
+
self.database_path is not None
|
|
79
|
+
and self.dialect is not DatabaseDialect.SQLITE
|
|
80
|
+
):
|
|
81
|
+
message = "database-path is only valid for SQLite profiles"
|
|
82
|
+
raise ConfigurationValidationError(message)
|
|
83
|
+
if self.database_path is not None and self.dsn_environment is not None:
|
|
84
|
+
message = "database-path and dsn-environment are mutually exclusive"
|
|
85
|
+
raise ConfigurationValidationError(message)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class Configuration:
|
|
90
|
+
project_root: Path
|
|
91
|
+
codegen_directory: Path
|
|
92
|
+
codegen_package: str
|
|
93
|
+
selected_profile: str
|
|
94
|
+
profiles: tuple[DatabaseProfile, ...]
|
|
95
|
+
schema_snapshot: Path | None = None
|
|
96
|
+
"""Where the recorded schema is kept, relative to the project root.
|
|
97
|
+
|
|
98
|
+
Generation reads it instead of a database when it is set, which is what
|
|
99
|
+
lets a check run where there are no credentials to connect with.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
root = self.project_root.resolve()
|
|
104
|
+
object.__setattr__(self, "project_root", root)
|
|
105
|
+
codegen_directory = _canonical_codegen_directory(
|
|
106
|
+
root,
|
|
107
|
+
self.codegen_directory,
|
|
108
|
+
)
|
|
109
|
+
object.__setattr__(self, "codegen_directory", codegen_directory)
|
|
110
|
+
object.__setattr__(
|
|
111
|
+
self,
|
|
112
|
+
"schema_snapshot",
|
|
113
|
+
_canonical_snapshot(root, self.schema_snapshot),
|
|
114
|
+
)
|
|
115
|
+
_validate_package_name(self.codegen_package)
|
|
116
|
+
_validate_profiles(self.profiles, self.selected_profile)
|
|
117
|
+
_validate_database_paths(root, self.profiles)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def generated_path(self) -> Path:
|
|
121
|
+
return (self.project_root / self.codegen_directory).resolve()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def schema_snapshot_path(self) -> Path | None:
|
|
125
|
+
"""The recorded schema, as an absolute path, where one is configured."""
|
|
126
|
+
if self.schema_snapshot is None:
|
|
127
|
+
return None
|
|
128
|
+
return (self.project_root / self.schema_snapshot).resolve()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def active_profile(self) -> DatabaseProfile:
|
|
132
|
+
return _find_profile(self.profiles, self.selected_profile)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def active_database_path(self) -> Path | None:
|
|
136
|
+
configured = self.active_profile.database_path
|
|
137
|
+
if configured is None:
|
|
138
|
+
return None
|
|
139
|
+
return (self.project_root / configured).resolve()
|
|
140
|
+
|
|
141
|
+
def select_profile(self, name: str) -> Configuration:
|
|
142
|
+
_find_profile(self.profiles, name)
|
|
143
|
+
return replace(self, selected_profile=name)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_configuration(
|
|
147
|
+
project_root: Path,
|
|
148
|
+
*,
|
|
149
|
+
explicit: Configuration | None = None,
|
|
150
|
+
pyproject_path: Path | None = None,
|
|
151
|
+
selected_profile: str | None = None,
|
|
152
|
+
) -> Configuration:
|
|
153
|
+
from pyoq.config.loader import load_configuration as load_from_toml
|
|
154
|
+
|
|
155
|
+
return load_from_toml(
|
|
156
|
+
project_root,
|
|
157
|
+
explicit=explicit,
|
|
158
|
+
pyproject_path=pyproject_path,
|
|
159
|
+
selected_profile=selected_profile,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _contained(root: Path, configured: Path, label: str) -> Path:
|
|
164
|
+
"""Where a configured path lands, refused if that is outside the project.
|
|
165
|
+
|
|
166
|
+
One policy for every path a project configures. It is written once because
|
|
167
|
+
three of them need it, and the one that had its own copy is the one that
|
|
168
|
+
escaped. Resolving first is what catches a symbolic link that points out
|
|
169
|
+
of the project as surely as a `..` does.
|
|
170
|
+
"""
|
|
171
|
+
if configured.is_absolute():
|
|
172
|
+
message = f"{label} must be relative to the project root"
|
|
173
|
+
raise PathContainmentError(message)
|
|
174
|
+
resolved = (root / configured).resolve()
|
|
175
|
+
if resolved == root or not resolved.is_relative_to(root):
|
|
176
|
+
message = f"{label} escapes project root: {configured}"
|
|
177
|
+
raise PathContainmentError(message)
|
|
178
|
+
return resolved
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _canonical_codegen_directory(root: Path, configured: Path) -> Path:
|
|
182
|
+
return _contained(root, configured, "codegen-directory").relative_to(root)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _canonical_snapshot(root: Path, configured: Path | None) -> Path | None:
|
|
186
|
+
"""The recorded schema is written and replaced, so it stays in the project."""
|
|
187
|
+
if configured is None:
|
|
188
|
+
return None
|
|
189
|
+
return _contained(root, configured, "schema-snapshot").relative_to(root)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_package_name(name: str) -> None:
|
|
193
|
+
parts = name.split(".")
|
|
194
|
+
invalid = any(not part.isidentifier() or keyword.iskeyword(part) for part in parts)
|
|
195
|
+
if invalid:
|
|
196
|
+
message = f"codegen-package is not a valid dotted package name: {name!r}"
|
|
197
|
+
raise ConfigurationValidationError(message)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _validate_profiles(
|
|
201
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
202
|
+
selected_profile: str,
|
|
203
|
+
) -> None:
|
|
204
|
+
if not profiles:
|
|
205
|
+
message = "configuration must contain at least one profile"
|
|
206
|
+
raise ConfigurationValidationError(message)
|
|
207
|
+
names = tuple(profile.name for profile in profiles)
|
|
208
|
+
if len(set(names)) != len(names):
|
|
209
|
+
message = "configuration profile names must be unique"
|
|
210
|
+
raise ConfigurationValidationError(message)
|
|
211
|
+
_find_profile(profiles, selected_profile)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _validate_database_paths(
|
|
215
|
+
root: Path,
|
|
216
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
217
|
+
) -> None:
|
|
218
|
+
for profile in profiles:
|
|
219
|
+
configured = profile.database_path
|
|
220
|
+
if configured is None:
|
|
221
|
+
continue
|
|
222
|
+
_contained(root, configured, "database-path")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _find_profile(
|
|
226
|
+
profiles: tuple[DatabaseProfile, ...],
|
|
227
|
+
name: str,
|
|
228
|
+
) -> DatabaseProfile:
|
|
229
|
+
for profile in profiles:
|
|
230
|
+
if profile.name == name:
|
|
231
|
+
return profile
|
|
232
|
+
message = f"selected profile does not exist: {name!r}"
|
|
233
|
+
raise ConfigurationValidationError(message)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
__all__ = (
|
|
237
|
+
"Configuration",
|
|
238
|
+
"DatabaseDialect",
|
|
239
|
+
"DatabaseProfile",
|
|
240
|
+
"EnvironmentReference",
|
|
241
|
+
"SecretValue",
|
|
242
|
+
"load_configuration",
|
|
243
|
+
)
|
pyoq/descriptors.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Runtime contracts used by generated database types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar, Generic, TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
from pyoq.query import Expression, FieldNode, ScalarFamily
|
|
9
|
+
from pyoq.relations import RelationCardinality, RelationDirection
|
|
10
|
+
from pyoq.schema import JsonScalar as SchemaJsonScalar
|
|
11
|
+
from pyoq.schema import JsonValue as SchemaJsonValue
|
|
12
|
+
from pyoq.schema import KeyKind, ReferentialAction
|
|
13
|
+
from pyoq.unset import UNSET, UnsetType
|
|
14
|
+
|
|
15
|
+
Value = TypeVar("Value")
|
|
16
|
+
Row = TypeVar("Row", covariant=True)
|
|
17
|
+
Insert = TypeVar("Insert", covariant=True)
|
|
18
|
+
Update = TypeVar("Update", covariant=True)
|
|
19
|
+
SourceRow = TypeVar("SourceRow", covariant=True)
|
|
20
|
+
TargetRow = TypeVar("TargetRow", covariant=True)
|
|
21
|
+
JsonScalar: TypeAlias = SchemaJsonScalar
|
|
22
|
+
JsonValue: TypeAlias = SchemaJsonValue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Missing:
|
|
26
|
+
__slots__ = ()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Present:
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ColumnDescriptor(Expression[Value], Generic[Value]):
|
|
35
|
+
database_name: str
|
|
36
|
+
nullable: bool
|
|
37
|
+
writable: bool
|
|
38
|
+
has_default: bool
|
|
39
|
+
generated: bool
|
|
40
|
+
scalar_family: ScalarFamily = ScalarFamily.OTHER
|
|
41
|
+
table_name: str | None = None
|
|
42
|
+
schema_name: str | None = None
|
|
43
|
+
catalog_name: str | None = None
|
|
44
|
+
field_name: str | None = None
|
|
45
|
+
value_type: type[object] | None = None
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
_require_name(self.database_name, "column")
|
|
49
|
+
self._initialize_expression(
|
|
50
|
+
FieldNode(
|
|
51
|
+
self.database_name,
|
|
52
|
+
self.table_name,
|
|
53
|
+
self.schema_name,
|
|
54
|
+
self.catalog_name,
|
|
55
|
+
self.scalar_family,
|
|
56
|
+
self.value_type,
|
|
57
|
+
self.nullable,
|
|
58
|
+
),
|
|
59
|
+
self.scalar_family,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class TableDescriptor(Generic[Row, Insert, Update]):
|
|
65
|
+
COLUMNS: ClassVar[tuple[object, ...]] = ()
|
|
66
|
+
"""Every column this table has, in the order the table declares them.
|
|
67
|
+
|
|
68
|
+
Generation fills this in. A table written by hand names no columns, and
|
|
69
|
+
so cannot be selected from without saying which.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
database_name: str
|
|
73
|
+
schema_name: str | None = None
|
|
74
|
+
catalog_name: str | None = None
|
|
75
|
+
row_type: type[Row] | None = None
|
|
76
|
+
"""The class one row of this table reads back as.
|
|
77
|
+
|
|
78
|
+
Generation fills this in, so a table constant carries the type of what it
|
|
79
|
+
holds as well as its name. A table written by hand names no row class, and
|
|
80
|
+
so a row of it can only be read as the values it came back with.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
_require_name(self.database_name, "table")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class KeyDescriptor(Generic[Value]):
|
|
89
|
+
kind: KeyKind
|
|
90
|
+
column_names: tuple[str, ...]
|
|
91
|
+
database_name: str | None = None
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
if not self.column_names:
|
|
95
|
+
message = "key descriptor requires at least one column"
|
|
96
|
+
raise ValueError(message)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class RelationshipDescriptor(Generic[SourceRow, TargetRow]):
|
|
101
|
+
source_table: str
|
|
102
|
+
source_columns: tuple[str, ...]
|
|
103
|
+
target_table: str
|
|
104
|
+
target_columns: tuple[str, ...]
|
|
105
|
+
source_schema: str | None = None
|
|
106
|
+
source_catalog: str | None = None
|
|
107
|
+
target_schema: str | None = None
|
|
108
|
+
target_catalog: str | None = None
|
|
109
|
+
"""Where each side lives, because a table name alone names more than one.
|
|
110
|
+
|
|
111
|
+
Two schemas may each hold a `parent` and a `child` joined on the same
|
|
112
|
+
columns. Without these, a constant generated for one of them describes
|
|
113
|
+
both, and reading through it answers with whichever was found first.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
database_name: str | None = None
|
|
117
|
+
on_update: ReferentialAction = ReferentialAction.NO_ACTION
|
|
118
|
+
on_delete: ReferentialAction = ReferentialAction.NO_ACTION
|
|
119
|
+
direction: RelationDirection = RelationDirection.FORWARD
|
|
120
|
+
cardinality: RelationCardinality = RelationCardinality.TO_ONE
|
|
121
|
+
optional: bool = False
|
|
122
|
+
target_row_type: type[TargetRow] | None = None
|
|
123
|
+
"""The class one row of the table this reaches reads back as.
|
|
124
|
+
|
|
125
|
+
The same fact as a table constant's own, carried here so that following a
|
|
126
|
+
relation says what it arrives at without the caller naming it again.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
_require_name(self.source_table, "relationship source table")
|
|
131
|
+
_require_name(self.target_table, "relationship target table")
|
|
132
|
+
if not self.source_columns or len(self.source_columns) != len(
|
|
133
|
+
self.target_columns
|
|
134
|
+
):
|
|
135
|
+
message = "relationship descriptor columns must be non-empty and aligned"
|
|
136
|
+
raise ValueError(message)
|
|
137
|
+
if self.cardinality is RelationCardinality.TO_MANY and self.optional:
|
|
138
|
+
message = "a to-many relationship cannot be optional"
|
|
139
|
+
raise ValueError(message)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def to_one(self) -> bool:
|
|
143
|
+
return self.cardinality is RelationCardinality.TO_ONE
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _require_name(value: str, label: str) -> None:
|
|
147
|
+
if not value:
|
|
148
|
+
message = f"{label} name cannot be empty"
|
|
149
|
+
raise ValueError(message)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = (
|
|
153
|
+
"UNSET",
|
|
154
|
+
"ColumnDescriptor",
|
|
155
|
+
"JsonScalar",
|
|
156
|
+
"JsonValue",
|
|
157
|
+
"KeyDescriptor",
|
|
158
|
+
"Missing",
|
|
159
|
+
"Present",
|
|
160
|
+
"RelationCardinality",
|
|
161
|
+
"RelationDirection",
|
|
162
|
+
"RelationshipDescriptor",
|
|
163
|
+
"TableDescriptor",
|
|
164
|
+
"UnsetType",
|
|
165
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Seeing what a scope asked a database to do."""
|
|
2
|
+
|
|
3
|
+
from pyoq.diagnostics.budget import QueryBudget, QueryScope
|
|
4
|
+
from pyoq.diagnostics.events import (
|
|
5
|
+
CollectingSink,
|
|
6
|
+
EventPolicy,
|
|
7
|
+
EventSink,
|
|
8
|
+
StatementEvent,
|
|
9
|
+
StatementFailed,
|
|
10
|
+
StatementFinished,
|
|
11
|
+
StatementStarted,
|
|
12
|
+
reportable_values,
|
|
13
|
+
)
|
|
14
|
+
from pyoq.diagnostics.fingerprint import (
|
|
15
|
+
CacheMetrics,
|
|
16
|
+
QueryShape,
|
|
17
|
+
forget_shapes,
|
|
18
|
+
normalize_sql,
|
|
19
|
+
query_shape,
|
|
20
|
+
shape_cache_metrics,
|
|
21
|
+
)
|
|
22
|
+
from pyoq.diagnostics.instrumented import (
|
|
23
|
+
AsyncInstrumentedOperations,
|
|
24
|
+
InstrumentedOperations,
|
|
25
|
+
)
|
|
26
|
+
from pyoq.diagnostics.metrics import DatabaseMetrics, metrics_of
|
|
27
|
+
from pyoq.diagnostics.observation import (
|
|
28
|
+
DEFAULT_REPEAT_THRESHOLD,
|
|
29
|
+
DEFAULT_SHAPE_LIMIT,
|
|
30
|
+
DEFAULT_SITE_LIMIT,
|
|
31
|
+
CallSite,
|
|
32
|
+
QueryObserver,
|
|
33
|
+
RepeatedQuery,
|
|
34
|
+
calling_site,
|
|
35
|
+
)
|
|
36
|
+
from pyoq.diagnostics.scoped import AsyncScopedOperations, ScopedOperations
|
|
37
|
+
|
|
38
|
+
__all__ = (
|
|
39
|
+
"DEFAULT_REPEAT_THRESHOLD",
|
|
40
|
+
"DEFAULT_SHAPE_LIMIT",
|
|
41
|
+
"DEFAULT_SITE_LIMIT",
|
|
42
|
+
"AsyncInstrumentedOperations",
|
|
43
|
+
"AsyncScopedOperations",
|
|
44
|
+
"CacheMetrics",
|
|
45
|
+
"CallSite",
|
|
46
|
+
"CollectingSink",
|
|
47
|
+
"DatabaseMetrics",
|
|
48
|
+
"EventPolicy",
|
|
49
|
+
"EventSink",
|
|
50
|
+
"InstrumentedOperations",
|
|
51
|
+
"QueryBudget",
|
|
52
|
+
"QueryObserver",
|
|
53
|
+
"QueryScope",
|
|
54
|
+
"QueryShape",
|
|
55
|
+
"RepeatedQuery",
|
|
56
|
+
"ScopedOperations",
|
|
57
|
+
"StatementEvent",
|
|
58
|
+
"StatementFailed",
|
|
59
|
+
"StatementFinished",
|
|
60
|
+
"StatementStarted",
|
|
61
|
+
"calling_site",
|
|
62
|
+
"forget_shapes",
|
|
63
|
+
"metrics_of",
|
|
64
|
+
"normalize_sql",
|
|
65
|
+
"query_shape",
|
|
66
|
+
"reportable_values",
|
|
67
|
+
"shape_cache_metrics",
|
|
68
|
+
)
|