asqueel 0.1.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.
- asqueel/__init__.py +82 -0
- asqueel/application.py +278 -0
- asqueel/application_table.py +401 -0
- asqueel/builder.py +133 -0
- asqueel/catalog.py +143 -0
- asqueel/catalog_provider.py +30 -0
- asqueel/common.py +22 -0
- asqueel/compiler.py +739 -0
- asqueel/configuration.py +113 -0
- asqueel/contracts.py +181 -0
- asqueel/dialects/__init__.py +1 -0
- asqueel/dialects/base.py +15 -0
- asqueel/dialects/postgres.py +241 -0
- asqueel/drivers/__init__.py +1 -0
- asqueel/drivers/base.py +26 -0
- asqueel/drivers/psycopg.py +90 -0
- asqueel/elements.py +427 -0
- asqueel/emitter.py +252 -0
- asqueel/environment.py +132 -0
- asqueel/examples/__init__.py +2 -0
- asqueel/grammar.md +355 -0
- asqueel/grammar_doc.py +258 -0
- asqueel/importers.py +225 -0
- asqueel/migration.py +251 -0
- asqueel/model.py +385 -0
- asqueel/object_renderer.py +29 -0
- asqueel/projection.py +183 -0
- asqueel/py.typed +0 -0
- asqueel/query_plan.py +110 -0
- asqueel/reader.py +417 -0
- asqueel/renderer.py +16 -0
- asqueel/runtime.py +188 -0
- asqueel/session.py +279 -0
- asqueel/triggers.py +59 -0
- asqueel/validators.py +374 -0
- asqueel-0.1.0.dist-info/METADATA +160 -0
- asqueel-0.1.0.dist-info/RECORD +39 -0
- asqueel-0.1.0.dist-info/WHEEL +4 -0
- asqueel-0.1.0.dist-info/licenses/LICENSE +201 -0
asqueel/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""SQL-model grammar, native PostgreSQL compiler and synchronous runtime.
|
|
3
|
+
|
|
4
|
+
The source tree is the pivot for migration tooling and round-tripping:
|
|
5
|
+
:class:`SqlMigrationRenderer` projects it to normalized migration JSON,
|
|
6
|
+
:class:`SqlModelReader` reads that structure back, and
|
|
7
|
+
:class:`SqlPythonEmitter` emits an editable Python recipe.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib import import_module
|
|
13
|
+
|
|
14
|
+
from .builder import SqlBuilder
|
|
15
|
+
from .application import SqlDatabase
|
|
16
|
+
from .application_table import (
|
|
17
|
+
SqlTable, SqlColumn, SqlRelation, SqlQuery, SqlRecord,
|
|
18
|
+
RecordNotFoundError, RecordMultipleRowsError,
|
|
19
|
+
)
|
|
20
|
+
from .configuration import SqlDatabaseConfig, ConfigurationView, build_database
|
|
21
|
+
from .emitter import SqlPythonEmitter
|
|
22
|
+
from .renderer import SqlRenderer
|
|
23
|
+
from .compiler import PostgresCompiler, QueryCompiler
|
|
24
|
+
from .catalog_provider import CatalogProvider, PostgresCatalogProvider
|
|
25
|
+
from .dialects.postgres import PostgresDialect
|
|
26
|
+
from .drivers.psycopg import PsycopgDriver
|
|
27
|
+
from .contracts import (
|
|
28
|
+
Column, CompiledQuery, QueryResult, Relation, ResolvedModel, ResultColumn,
|
|
29
|
+
Table, UnsupportedFeatureError, EnvironmentMismatchError, PartitionScope, RowPolicies,
|
|
30
|
+
)
|
|
31
|
+
from .environment import SqlEnvironment
|
|
32
|
+
from .importers import ImportResult, inspect_postgres
|
|
33
|
+
from .model import resolve_model
|
|
34
|
+
from .projection import to_physical_builder
|
|
35
|
+
from .runtime import (
|
|
36
|
+
DatabaseClosedError, Database, PostgresDatabase,
|
|
37
|
+
TransactionStateError,
|
|
38
|
+
)
|
|
39
|
+
from .session import DeferredCommitError
|
|
40
|
+
from .triggers import TriggerStack, TriggerStackItem
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"SqlDatabase", "SqlDatabaseConfig", "ConfigurationView", "build_database",
|
|
46
|
+
"SqlTable", "SqlColumn", "SqlRelation", "SqlQuery", "SqlRecord",
|
|
47
|
+
"RecordNotFoundError", "RecordMultipleRowsError",
|
|
48
|
+
"SqlBuilder", "SqlPythonEmitter", "SqlRenderer",
|
|
49
|
+
"Column", "CompiledQuery", "QueryResult", "Relation", "ResolvedModel",
|
|
50
|
+
"ResultColumn", "Table", "UnsupportedFeatureError", "PostgresCompiler",
|
|
51
|
+
"PostgresDatabase", "DatabaseClosedError",
|
|
52
|
+
"TransactionStateError", "resolve_model", "ImportResult", "inspect_postgres",
|
|
53
|
+
"to_physical_builder",
|
|
54
|
+
"QueryCompiler", "PostgresDialect", "PsycopgDriver", "Database",
|
|
55
|
+
"CatalogProvider", "PostgresCatalogProvider",
|
|
56
|
+
"SqlEnvironment", "EnvironmentMismatchError", "PartitionScope", "RowPolicies",
|
|
57
|
+
"TriggerStack", "TriggerStackItem",
|
|
58
|
+
"DeferredCommitError",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
_MIGRATION_EXTRA = (
|
|
62
|
+
"asqueel-migration is required for {name}: install asqueel[migration]"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_MIGRATION_NAMES = {
|
|
66
|
+
"SqlMigrationRenderer": ".migration",
|
|
67
|
+
"SqlModelReader": ".reader",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def __getattr__(name: str):
|
|
72
|
+
"""Resolve the names that need an optional dependency, on first use."""
|
|
73
|
+
module_name = _MIGRATION_NAMES.get(name)
|
|
74
|
+
if module_name is None:
|
|
75
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
76
|
+
try:
|
|
77
|
+
module = import_module(module_name, __name__)
|
|
78
|
+
except ModuleNotFoundError as error:
|
|
79
|
+
if error.name != "asqueel_migration":
|
|
80
|
+
raise
|
|
81
|
+
raise ImportError(_MIGRATION_EXTRA.format(name=name)) from error
|
|
82
|
+
return getattr(module, name)
|
asqueel/application.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Application database: one configuration, live graph and named sessions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .application_table import SqlTable
|
|
9
|
+
from .compiler import QueryCompiler
|
|
10
|
+
from .contracts import CompiledQuery, QueryResult, ResolvedModel, UnsupportedFeatureError
|
|
11
|
+
from .dialects.postgres import PostgresDialect
|
|
12
|
+
from .drivers.psycopg import PsycopgDriver
|
|
13
|
+
from .environment import ApplicationEnvironment, SqlEnvironment
|
|
14
|
+
from .runtime import DatabaseClosedError, TransactionStateError
|
|
15
|
+
from .session import Session
|
|
16
|
+
from .triggers import TriggerStack
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SqlDatabase:
|
|
20
|
+
"""A synchronous application database constructed from a configuration.
|
|
21
|
+
|
|
22
|
+
Use ``build_database(recipe)`` or a configuration's object renderer to
|
|
23
|
+
construct this class. Table operations share a lazy session until explicit
|
|
24
|
+
commit/rollback. Closing rolls back pending work; it never commits it.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, model: ResolvedModel, config, driver=None, dialect=None,
|
|
28
|
+
environment: SqlEnvironment | None = None):
|
|
29
|
+
self._ready = False
|
|
30
|
+
self._write_depth = 0
|
|
31
|
+
self.config = config
|
|
32
|
+
self.model = model
|
|
33
|
+
implementation = config('implementation', default='postgresql')
|
|
34
|
+
if implementation != 'postgresql':
|
|
35
|
+
raise UnsupportedFeatureError(f'Unsupported database implementation: {implementation!r}')
|
|
36
|
+
self.driver = driver if driver is not None else PsycopgDriver()
|
|
37
|
+
self.dialect = dialect if dialect is not None else PostgresDialect()
|
|
38
|
+
if self.dialect.name != implementation:
|
|
39
|
+
raise ValueError('Configured implementation does not match the data dialect')
|
|
40
|
+
self.environment = ApplicationEnvironment(environment)
|
|
41
|
+
self.compiler = QueryCompiler(model, self.dialect, self.driver, environment=self.environment)
|
|
42
|
+
self._sessions: dict[str, Session] = {}
|
|
43
|
+
self._trigger_stack = TriggerStack()
|
|
44
|
+
self._closed = False
|
|
45
|
+
from threading import get_ident
|
|
46
|
+
self._owner = get_ident()
|
|
47
|
+
self._tables: dict[str, SqlTable] = {}
|
|
48
|
+
for descriptor in model.tables.values():
|
|
49
|
+
table_class = descriptor.attributes.get('x_table_class', SqlTable)
|
|
50
|
+
if not isinstance(table_class, type) or not issubclass(table_class, SqlTable):
|
|
51
|
+
raise TypeError(f'{descriptor.key}: x_table_class must be a SqlTable subclass')
|
|
52
|
+
self._tables[descriptor.key] = table_class(self, descriptor)
|
|
53
|
+
self._ready = True
|
|
54
|
+
|
|
55
|
+
def _check_open(self) -> None:
|
|
56
|
+
if not self._ready:
|
|
57
|
+
raise TransactionStateError('Database object graph is not ready')
|
|
58
|
+
self._check_owner()
|
|
59
|
+
if self._closed:
|
|
60
|
+
raise DatabaseClosedError('Database is closed')
|
|
61
|
+
|
|
62
|
+
def _check_owner(self) -> None:
|
|
63
|
+
from threading import get_ident
|
|
64
|
+
if get_ident() != self._owner:
|
|
65
|
+
raise TransactionStateError('Database operations must run on the constructing thread')
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def currentConnectionName(self) -> str:
|
|
69
|
+
self._check_open()
|
|
70
|
+
name = self.environment.currentEnv.get('connectionName') or '_main_connection'
|
|
71
|
+
if not isinstance(name, str):
|
|
72
|
+
raise TypeError('connectionName must be a string')
|
|
73
|
+
return name
|
|
74
|
+
|
|
75
|
+
def usingMainConnection(self) -> bool:
|
|
76
|
+
return self.currentConnectionName == '_main_connection'
|
|
77
|
+
|
|
78
|
+
def _check_store(self) -> None:
|
|
79
|
+
store = self.environment.currentEnv.get('storename')
|
|
80
|
+
if store and store != '_main_db':
|
|
81
|
+
raise UnsupportedFeatureError('Database stores are not implemented in this profile')
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def _session(self) -> Session:
|
|
85
|
+
self._check_open()
|
|
86
|
+
self._check_store()
|
|
87
|
+
name = self.currentConnectionName
|
|
88
|
+
if name not in self._sessions:
|
|
89
|
+
self._sessions[name] = Session(
|
|
90
|
+
driver=self.driver,
|
|
91
|
+
conninfo=self.config('conninfo', default=''),
|
|
92
|
+
connect_kwargs=self.config('connect_kwargs', default=None),
|
|
93
|
+
environment=self.environment,
|
|
94
|
+
connection_name=name,
|
|
95
|
+
)
|
|
96
|
+
return self._sessions[name]
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def tables(self):
|
|
100
|
+
self._check_open()
|
|
101
|
+
return MappingProxyType(self._tables)
|
|
102
|
+
|
|
103
|
+
def table(self, name: str) -> SqlTable:
|
|
104
|
+
"""Return the registered operational table, preserving object identity."""
|
|
105
|
+
self._check_open()
|
|
106
|
+
return self._tables[self.model.table(name).key]
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def current_env(self):
|
|
110
|
+
self._check_open()
|
|
111
|
+
return self.environment.current_env
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def currentEnv(self):
|
|
115
|
+
self._check_open()
|
|
116
|
+
return self.environment.currentEnv
|
|
117
|
+
|
|
118
|
+
@currentEnv.setter
|
|
119
|
+
def currentEnv(self, values):
|
|
120
|
+
self._check_open()
|
|
121
|
+
self.environment.currentEnv = values
|
|
122
|
+
|
|
123
|
+
def updateEnv(self, _excludeNoneValues=False, **values):
|
|
124
|
+
self.currentEnv.update({key: value for key, value in values.items()
|
|
125
|
+
if not _excludeNoneValues or value is not None})
|
|
126
|
+
|
|
127
|
+
def clearCurrentEnv(self):
|
|
128
|
+
self.currentEnv = {}
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def workdate(self):
|
|
132
|
+
self._check_open()
|
|
133
|
+
return self.environment.workdate
|
|
134
|
+
|
|
135
|
+
@workdate.setter
|
|
136
|
+
def workdate(self, value):
|
|
137
|
+
self.currentEnv['workdate'] = value
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def locale(self):
|
|
141
|
+
self._check_open()
|
|
142
|
+
return self.environment.locale
|
|
143
|
+
|
|
144
|
+
@locale.setter
|
|
145
|
+
def locale(self, value):
|
|
146
|
+
self.currentEnv['locale'] = value
|
|
147
|
+
|
|
148
|
+
@contextmanager
|
|
149
|
+
def temp_env(self, **values: Any):
|
|
150
|
+
self._check_open()
|
|
151
|
+
with self.environment.temp_env(**values):
|
|
152
|
+
yield self
|
|
153
|
+
|
|
154
|
+
def tempEnv(self, **values: Any):
|
|
155
|
+
return self.temp_env(**values)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def outcome(self) -> str:
|
|
159
|
+
"""Most recent session transaction outcome, including uncertain commit."""
|
|
160
|
+
self._check_owner()
|
|
161
|
+
name = self.environment.currentEnv.get('connectionName') or '_main_connection'
|
|
162
|
+
session = self._sessions.get(name)
|
|
163
|
+
return session.outcome if session is not None else 'not_started'
|
|
164
|
+
|
|
165
|
+
def execute(self, query: CompiledQuery) -> QueryResult:
|
|
166
|
+
"""Execute a compiled statement in this DB's shared unit of work."""
|
|
167
|
+
self._check_open()
|
|
168
|
+
return self._session.execute(query)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def currentTrigger(self):
|
|
172
|
+
"""Current write operation, including its causal parent and level."""
|
|
173
|
+
self._check_open()
|
|
174
|
+
return self._trigger_stack.parentItem
|
|
175
|
+
|
|
176
|
+
@contextmanager
|
|
177
|
+
def _trigger_operation(self, event, table, record=None, old_record=None):
|
|
178
|
+
self._check_open()
|
|
179
|
+
with self._trigger_stack.operation(
|
|
180
|
+
event, table.fullname, record=record, old_record=old_record) as item:
|
|
181
|
+
yield item
|
|
182
|
+
|
|
183
|
+
def deferToCommit(self, callback, *args, **kwargs):
|
|
184
|
+
"""Run a callback before committing the selected named connection."""
|
|
185
|
+
self._check_open()
|
|
186
|
+
return self._session.defer_to_commit(callback, *args, **kwargs)
|
|
187
|
+
|
|
188
|
+
def deferAfterCommit(self, callback, *args, **kwargs):
|
|
189
|
+
"""Run a callback after committing the selected named connection."""
|
|
190
|
+
self._check_open()
|
|
191
|
+
return self._session.defer_after_commit(callback, *args, **kwargs)
|
|
192
|
+
|
|
193
|
+
def deferredRaise(self, exception):
|
|
194
|
+
"""Make the selected named connection fail at its next commit."""
|
|
195
|
+
self._check_open()
|
|
196
|
+
self._session.deferred_raise(exception)
|
|
197
|
+
|
|
198
|
+
def _check_boundary(self) -> None:
|
|
199
|
+
self._check_open()
|
|
200
|
+
if self._write_depth:
|
|
201
|
+
raise TransactionStateError('Cannot finish a transaction inside a table write or hook')
|
|
202
|
+
|
|
203
|
+
def commit(self) -> None:
|
|
204
|
+
self._check_boundary()
|
|
205
|
+
self._session.commit()
|
|
206
|
+
|
|
207
|
+
def rollback(self) -> None:
|
|
208
|
+
self._check_boundary()
|
|
209
|
+
self._session.rollback()
|
|
210
|
+
|
|
211
|
+
@contextmanager
|
|
212
|
+
def transaction(self):
|
|
213
|
+
"""Group table operations atomically; enter with no pending transaction."""
|
|
214
|
+
self._check_boundary()
|
|
215
|
+
with self._session.transaction():
|
|
216
|
+
yield self
|
|
217
|
+
|
|
218
|
+
@contextmanager
|
|
219
|
+
def _write_operation(self):
|
|
220
|
+
self._check_open()
|
|
221
|
+
session = self._session
|
|
222
|
+
session._check_usable()
|
|
223
|
+
self._write_depth += 1
|
|
224
|
+
try:
|
|
225
|
+
yield
|
|
226
|
+
except BaseException as error:
|
|
227
|
+
# execute already rolled back the failing named connection. Do not
|
|
228
|
+
# turn a recoverable SQL error into a new domain failure.
|
|
229
|
+
if not any(item._execution_error is error for item in self._sessions.values()):
|
|
230
|
+
session.mark_failed()
|
|
231
|
+
raise
|
|
232
|
+
finally:
|
|
233
|
+
self._write_depth -= 1
|
|
234
|
+
|
|
235
|
+
def closeConnection(self) -> None:
|
|
236
|
+
"""Roll back and release all named connections, keeping the DB usable."""
|
|
237
|
+
self._check_boundary()
|
|
238
|
+
for session in self._sessions.values():
|
|
239
|
+
session._check_manual_completion()
|
|
240
|
+
try:
|
|
241
|
+
self._close_sessions()
|
|
242
|
+
finally:
|
|
243
|
+
self._sessions.clear()
|
|
244
|
+
|
|
245
|
+
def _close_sessions(self) -> None:
|
|
246
|
+
error = None
|
|
247
|
+
for session in self._sessions.values():
|
|
248
|
+
try:
|
|
249
|
+
session.close()
|
|
250
|
+
except BaseException as failure:
|
|
251
|
+
if error is None:
|
|
252
|
+
error = failure
|
|
253
|
+
else:
|
|
254
|
+
error.add_note(f'Another named connection cleanup failed: {type(failure).__name__}')
|
|
255
|
+
if error is not None:
|
|
256
|
+
raise error
|
|
257
|
+
|
|
258
|
+
def close(self) -> None:
|
|
259
|
+
if self._write_depth:
|
|
260
|
+
raise TransactionStateError('Cannot close the database inside a table write or hook')
|
|
261
|
+
self._check_owner()
|
|
262
|
+
if self._closed:
|
|
263
|
+
return
|
|
264
|
+
# Preflight all scopes before closing anything; then clean every named
|
|
265
|
+
# connection even when one driver rollback/close fails.
|
|
266
|
+
for session in self._sessions.values():
|
|
267
|
+
session._check_manual_completion()
|
|
268
|
+
try:
|
|
269
|
+
self._close_sessions()
|
|
270
|
+
finally:
|
|
271
|
+
self._closed = True
|
|
272
|
+
|
|
273
|
+
def __enter__(self) -> SqlDatabase:
|
|
274
|
+
self._check_open()
|
|
275
|
+
return self
|
|
276
|
+
|
|
277
|
+
def __exit__(self, *exc: Any) -> None:
|
|
278
|
+
self.close()
|