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 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()