instructure-pysqlsync 0.8.5.dev0__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.
- instructure_pysqlsync-0.8.5.dev0.dist-info/METADATA +244 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/RECORD +102 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/WHEEL +5 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/licenses/LICENSE +21 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/top_level.txt +1 -0
- instructure_pysqlsync-0.8.5.dev0.dist-info/zip-safe +1 -0
- pysqlsync/__init__.py +15 -0
- pysqlsync/base.py +1386 -0
- pysqlsync/connection.py +101 -0
- pysqlsync/data/__init__.py +0 -0
- pysqlsync/data/exchange.py +184 -0
- pysqlsync/data/generator.py +305 -0
- pysqlsync/dialect/README.md +35 -0
- pysqlsync/dialect/__init__.py +0 -0
- pysqlsync/dialect/delta/__init__.py +0 -0
- pysqlsync/dialect/delta/data_types.py +51 -0
- pysqlsync/dialect/delta/dependency.py +7 -0
- pysqlsync/dialect/delta/engine.py +18 -0
- pysqlsync/dialect/delta/generator.py +74 -0
- pysqlsync/dialect/delta/object_types.py +87 -0
- pysqlsync/dialect/mssql/README.md +23 -0
- pysqlsync/dialect/mssql/__init__.py +0 -0
- pysqlsync/dialect/mssql/connection.py +146 -0
- pysqlsync/dialect/mssql/data_types.py +113 -0
- pysqlsync/dialect/mssql/dependency.py +9 -0
- pysqlsync/dialect/mssql/discovery.py +99 -0
- pysqlsync/dialect/mssql/engine.py +20 -0
- pysqlsync/dialect/mssql/generator.py +88 -0
- pysqlsync/dialect/mssql/mutation.py +46 -0
- pysqlsync/dialect/mssql/object_types.py +135 -0
- pysqlsync/dialect/mysql/__init__.py +0 -0
- pysqlsync/dialect/mysql/connection.py +126 -0
- pysqlsync/dialect/mysql/data_types.py +40 -0
- pysqlsync/dialect/mysql/dependency.py +10 -0
- pysqlsync/dialect/mysql/discovery.py +145 -0
- pysqlsync/dialect/mysql/engine.py +20 -0
- pysqlsync/dialect/mysql/generator.py +140 -0
- pysqlsync/dialect/mysql/mutation.py +65 -0
- pysqlsync/dialect/mysql/object_types.py +75 -0
- pysqlsync/dialect/oracle/README.md +22 -0
- pysqlsync/dialect/oracle/__init__.py +0 -0
- pysqlsync/dialect/oracle/connection.py +141 -0
- pysqlsync/dialect/oracle/data_types.py +104 -0
- pysqlsync/dialect/oracle/dependency.py +9 -0
- pysqlsync/dialect/oracle/discovery.py +231 -0
- pysqlsync/dialect/oracle/engine.py +20 -0
- pysqlsync/dialect/oracle/generator.py +93 -0
- pysqlsync/dialect/oracle/mutation.py +37 -0
- pysqlsync/dialect/oracle/object_types.py +59 -0
- pysqlsync/dialect/postgresql/__init__.py +0 -0
- pysqlsync/dialect/postgresql/connection.py +133 -0
- pysqlsync/dialect/postgresql/data_types.py +6 -0
- pysqlsync/dialect/postgresql/dependency.py +9 -0
- pysqlsync/dialect/postgresql/discovery.py +392 -0
- pysqlsync/dialect/postgresql/engine.py +20 -0
- pysqlsync/dialect/postgresql/generator.py +99 -0
- pysqlsync/dialect/postgresql/mutation.py +82 -0
- pysqlsync/dialect/postgresql/object_types.py +99 -0
- pysqlsync/dialect/redshift/__init__.py +0 -0
- pysqlsync/dialect/redshift/connection.py +156 -0
- pysqlsync/dialect/redshift/data_types.py +10 -0
- pysqlsync/dialect/redshift/dependency.py +9 -0
- pysqlsync/dialect/redshift/engine.py +20 -0
- pysqlsync/dialect/redshift/generator.py +82 -0
- pysqlsync/dialect/snowflake/__init__.py +0 -0
- pysqlsync/dialect/snowflake/data_types.py +18 -0
- pysqlsync/dialect/snowflake/dependency.py +9 -0
- pysqlsync/dialect/snowflake/engine.py +18 -0
- pysqlsync/dialect/snowflake/generator.py +64 -0
- pysqlsync/dialect/snowflake/object_types.py +84 -0
- pysqlsync/dialect/test/__init__.py +0 -0
- pysqlsync/dialect/test/dependency.py +10 -0
- pysqlsync/dialect/trino/__init__.py +0 -0
- pysqlsync/dialect/trino/connection.py +86 -0
- pysqlsync/dialect/trino/dependency.py +9 -0
- pysqlsync/dialect/trino/discovery.py +21 -0
- pysqlsync/dialect/trino/engine.py +20 -0
- pysqlsync/dialect/trino/generator.py +31 -0
- pysqlsync/factory.py +169 -0
- pysqlsync/formation/__init__.py +0 -0
- pysqlsync/formation/constraints.py +78 -0
- pysqlsync/formation/data_types.py +192 -0
- pysqlsync/formation/discovery.py +390 -0
- pysqlsync/formation/inspection.py +179 -0
- pysqlsync/formation/mutation.py +320 -0
- pysqlsync/formation/object_dict.py +79 -0
- pysqlsync/formation/object_types.py +872 -0
- pysqlsync/formation/py_to_sql.py +1134 -0
- pysqlsync/formation/sql_to_py.py +194 -0
- pysqlsync/model/__init__.py +0 -0
- pysqlsync/model/data_types.py +397 -0
- pysqlsync/model/entity_types.py +68 -0
- pysqlsync/model/id_types.py +193 -0
- pysqlsync/model/key_types.py +38 -0
- pysqlsync/model/properties.py +234 -0
- pysqlsync/py.typed +0 -0
- pysqlsync/python_types.py +192 -0
- pysqlsync/resultset.py +106 -0
- pysqlsync/util/__init__.py +0 -0
- pysqlsync/util/dataclasses.py +80 -0
- pysqlsync/util/dispatch.py +28 -0
- pysqlsync/util/typing.py +13 -0
pysqlsync/base.py
ADDED
|
@@ -0,0 +1,1386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pysqlsync: Synchronize schema and large volumes of data.
|
|
3
|
+
|
|
4
|
+
This module defines base classes to create a connection, generate SQL, discover database objects, and synchronize
|
|
5
|
+
schema and data.
|
|
6
|
+
|
|
7
|
+
:see: https://github.com/instructure-internal/pysqlsync
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import abc
|
|
11
|
+
import dataclasses
|
|
12
|
+
import enum
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import types
|
|
16
|
+
import typing
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, AsyncIterable, Callable, Iterable, Optional, Sized, TypeVar, Union, overload
|
|
19
|
+
|
|
20
|
+
from strong_typing.inspection import DataclassInstance, is_dataclass_type, is_type_enum
|
|
21
|
+
from strong_typing.name import python_type_to_str
|
|
22
|
+
|
|
23
|
+
from .connection import ConnectionParameters
|
|
24
|
+
from .formation.inspection import get_entity_types
|
|
25
|
+
from .formation.mutation import Mutator, MutatorOptions
|
|
26
|
+
from .formation.object_types import Catalog, Column, FormationError, Namespace, ObjectFactory, Table
|
|
27
|
+
from .formation.py_to_sql import ArrayMode, DataclassConverter, EnumMode, StructMode
|
|
28
|
+
from .model.data_types import SqlJsonType, SqlVariableCharacterType
|
|
29
|
+
from .model.id_types import LocalId, QualifiedId, SupportsQualifiedId
|
|
30
|
+
from .python_types import dataclass_to_code, module_to_code
|
|
31
|
+
from .util.typing import override
|
|
32
|
+
|
|
33
|
+
D = TypeVar("D", bound=DataclassInstance)
|
|
34
|
+
E = TypeVar("E", bound=enum.Enum)
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
RecordType = tuple[Any, ...]
|
|
38
|
+
RecordSource = Union[Iterable[RecordType], AsyncIterable[RecordType]]
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger("pysqlsync")
|
|
41
|
+
|
|
42
|
+
_JSON_ENCODER = json.JSONEncoder(
|
|
43
|
+
ensure_ascii=False,
|
|
44
|
+
check_circular=False,
|
|
45
|
+
allow_nan=False,
|
|
46
|
+
indent=None,
|
|
47
|
+
separators=(",", ":"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# number of records consumed from an asynchronous iterator before a batch is dispatched to a database
|
|
51
|
+
BATCH_SIZE = 100000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ClassRef:
|
|
55
|
+
"Represents a reference to a Python class in a Python module."
|
|
56
|
+
|
|
57
|
+
module_name: str
|
|
58
|
+
entity_name: str
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def __init__(self, entity_type: type[DataclassInstance]) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Creates a reference from a Python data-class type.
|
|
64
|
+
|
|
65
|
+
:param entity_type: A Python data-class type.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def __init__(self, *, module: types.ModuleType, entity_name: str) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Creates a reference from a Python class name in a Python module.
|
|
73
|
+
|
|
74
|
+
:param module: A Python module instance.
|
|
75
|
+
:param entity_name: Class name without module qualifier.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def __init__(self, *, module_name: str, entity_name: str) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Creates a reference from a Python fully-qualified module name and Python class name pair.
|
|
83
|
+
|
|
84
|
+
:param module_name: Fully-qualified module name.
|
|
85
|
+
:param entity_name: Class name without module qualifier.
|
|
86
|
+
"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
entity_type: Optional[type[DataclassInstance]] = None,
|
|
92
|
+
*,
|
|
93
|
+
module: Optional[types.ModuleType] = None,
|
|
94
|
+
module_name: Optional[str] = None,
|
|
95
|
+
entity_name: Optional[str] = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
if module_name is None:
|
|
98
|
+
if module is not None:
|
|
99
|
+
module_name = module.__name__
|
|
100
|
+
elif entity_type is not None:
|
|
101
|
+
module_name = entity_type.__module__
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError("expected: module name or type")
|
|
104
|
+
|
|
105
|
+
if entity_name is None:
|
|
106
|
+
if entity_type is not None:
|
|
107
|
+
entity_name = entity_type.__name__
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError("expected: entity class name or type")
|
|
110
|
+
|
|
111
|
+
self.module_name = module_name
|
|
112
|
+
self.entity_name = entity_name
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class GeneratorOptions:
|
|
117
|
+
"""
|
|
118
|
+
Database-agnostic generator options.
|
|
119
|
+
|
|
120
|
+
:param enum_mode: Conversion mode for enumeration types.
|
|
121
|
+
:param struct_mode: Conversion mode for composite types.
|
|
122
|
+
:param array_mode: Conversion mode for sequence types.
|
|
123
|
+
:param namespaces: Maps Python modules into SQL namespaces (a.k.a. schemas).
|
|
124
|
+
:param foreign_constraints: Whether to create foreign/primary key relationships between tables.
|
|
125
|
+
:param initialize_tables: Whether to populate special tables (e.g. enumerations) with data.
|
|
126
|
+
:param synchronization: Synchronization options.
|
|
127
|
+
:param skip_annotations: Annotation classes to ignore on table column types.
|
|
128
|
+
:param auto_default: Automatically assign a default value to non-nullable types.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
enum_mode: Optional[EnumMode] = None
|
|
132
|
+
struct_mode: Optional[StructMode] = None
|
|
133
|
+
array_mode: Optional[ArrayMode] = None
|
|
134
|
+
namespaces: dict[types.ModuleType, Optional[str]] = dataclasses.field(default_factory=dict[types.ModuleType, Optional[str]])
|
|
135
|
+
foreign_constraints: bool = True
|
|
136
|
+
initialize_tables: bool = False
|
|
137
|
+
synchronization: MutatorOptions = dataclasses.field(default_factory=MutatorOptions)
|
|
138
|
+
skip_annotations: tuple[type, ...] = ()
|
|
139
|
+
auto_default: bool = False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BaseGenerator(abc.ABC):
|
|
143
|
+
"""
|
|
144
|
+
Generates SQL statements for creating or dropping tables, and inserting, updating or deleting data.
|
|
145
|
+
|
|
146
|
+
:param options: Database-agnostic generator options.
|
|
147
|
+
:param factory: A factory object to create new column, table, struct and namespace instances.
|
|
148
|
+
:param converter: A converter that maps a set of data-class types into a database state.
|
|
149
|
+
:param state: A current database state based on which statements are generated.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
options: GeneratorOptions
|
|
153
|
+
factory: ObjectFactory
|
|
154
|
+
mutator: Mutator
|
|
155
|
+
converter: DataclassConverter
|
|
156
|
+
state: Catalog
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
options: GeneratorOptions,
|
|
161
|
+
factory: Optional[ObjectFactory] = None,
|
|
162
|
+
mutator: Optional[Mutator] = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
self.options = options
|
|
165
|
+
self.factory = factory if factory is not None else ObjectFactory()
|
|
166
|
+
self.mutator = mutator if mutator is not None else Mutator()
|
|
167
|
+
self.reset()
|
|
168
|
+
|
|
169
|
+
def reset(self) -> None:
|
|
170
|
+
self.state = Catalog([])
|
|
171
|
+
|
|
172
|
+
def _check_mode(
|
|
173
|
+
self,
|
|
174
|
+
mode: Optional[E],
|
|
175
|
+
*,
|
|
176
|
+
matches: Optional[E] = None,
|
|
177
|
+
include: Optional[list[E]] = None,
|
|
178
|
+
exclude: Optional[list[E]] = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
if mode is None:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
fail: bool = False
|
|
184
|
+
if matches is not None:
|
|
185
|
+
if mode is not matches:
|
|
186
|
+
fail = True
|
|
187
|
+
if include is not None:
|
|
188
|
+
if mode not in include:
|
|
189
|
+
fail = True
|
|
190
|
+
if exclude is not None:
|
|
191
|
+
if mode in exclude:
|
|
192
|
+
fail = True
|
|
193
|
+
if fail:
|
|
194
|
+
raise FormationError(f"unsupported {mode.__class__.__name__} for {self.__class__.__name__}: {mode}")
|
|
195
|
+
|
|
196
|
+
def check_enum_mode(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
matches: Optional[EnumMode] = None,
|
|
200
|
+
include: Optional[list[EnumMode]] = None,
|
|
201
|
+
exclude: Optional[list[EnumMode]] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
self._check_mode(self.options.enum_mode, matches=matches, include=include, exclude=exclude)
|
|
204
|
+
|
|
205
|
+
def check_struct_mode(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
matches: Optional[StructMode] = None,
|
|
209
|
+
include: Optional[list[StructMode]] = None,
|
|
210
|
+
exclude: Optional[list[StructMode]] = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
self._check_mode(self.options.struct_mode, matches=matches, include=include, exclude=exclude)
|
|
213
|
+
|
|
214
|
+
def check_array_mode(
|
|
215
|
+
self,
|
|
216
|
+
*,
|
|
217
|
+
matches: Optional[ArrayMode] = None,
|
|
218
|
+
include: Optional[list[ArrayMode]] = None,
|
|
219
|
+
exclude: Optional[list[ArrayMode]] = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
self._check_mode(self.options.array_mode, matches=matches, include=include, exclude=exclude)
|
|
222
|
+
|
|
223
|
+
@overload
|
|
224
|
+
def create(self, *, tables: list[type[DataclassInstance]]) -> Optional[str]: ...
|
|
225
|
+
|
|
226
|
+
@overload
|
|
227
|
+
def create(self, *, modules: list[types.ModuleType]) -> Optional[str]: ...
|
|
228
|
+
|
|
229
|
+
def create(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
tables: Optional[list[type[DataclassInstance]]] = None,
|
|
233
|
+
modules: Optional[list[types.ModuleType]] = None,
|
|
234
|
+
) -> Optional[str]:
|
|
235
|
+
"""
|
|
236
|
+
Creates tables, structs, enumerations, constraints, etc. corresponding to entity class definitions.
|
|
237
|
+
|
|
238
|
+
:param tables: The list of entity classes to define the SQL objects.
|
|
239
|
+
:param modules: The list of modules whose entity classes defined the SQL objects.
|
|
240
|
+
:returns: A SQL statement that creates tables, structs, enumerations, constraints, etc.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if tables is not None and modules is not None:
|
|
244
|
+
raise TypeError("arguments `tables` and `modules` are exclusive")
|
|
245
|
+
if tables is None and modules is None:
|
|
246
|
+
raise TypeError("one of arguments `tables` and `modules` is required")
|
|
247
|
+
|
|
248
|
+
if tables is not None:
|
|
249
|
+
if not tables:
|
|
250
|
+
LOGGER.warning("no tables to create")
|
|
251
|
+
|
|
252
|
+
if LOGGER.isEnabledFor(logging.DEBUG):
|
|
253
|
+
for table in tables:
|
|
254
|
+
code = dataclass_to_code(table)
|
|
255
|
+
LOGGER.debug("analyzing dataclass `%s`:\n%s", table.__name__, code)
|
|
256
|
+
|
|
257
|
+
target = self.converter.dataclasses_to_catalog(tables)
|
|
258
|
+
statement = self.get_mutate_stmt(target)
|
|
259
|
+
self.state = target
|
|
260
|
+
return statement
|
|
261
|
+
elif modules is not None:
|
|
262
|
+
if not modules:
|
|
263
|
+
LOGGER.warning("no schemas to create")
|
|
264
|
+
|
|
265
|
+
if LOGGER.isEnabledFor(logging.DEBUG):
|
|
266
|
+
for module in modules:
|
|
267
|
+
code = module_to_code(module)
|
|
268
|
+
LOGGER.debug("analyzing module `%s`:\n%s", module.__name__, code)
|
|
269
|
+
|
|
270
|
+
target = self.converter.modules_to_catalog(modules)
|
|
271
|
+
statement = self.get_mutate_stmt(target)
|
|
272
|
+
self.state = target
|
|
273
|
+
return statement
|
|
274
|
+
else:
|
|
275
|
+
# should never be triggered; either `tables` or `modules` must be defined at this point
|
|
276
|
+
raise NotImplementedError("match condition not exhaustive")
|
|
277
|
+
|
|
278
|
+
def drop(self) -> Optional[str]:
|
|
279
|
+
"""
|
|
280
|
+
Drops all objects currently synced with the generator.
|
|
281
|
+
|
|
282
|
+
(This function is mainly for unit tests.)
|
|
283
|
+
|
|
284
|
+
:returns: A SQL statement that drops tables, structs, enumerations, constraints, etc.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
target = Catalog([])
|
|
288
|
+
statement = self.get_mutate_stmt(target)
|
|
289
|
+
self.state = target
|
|
290
|
+
return statement
|
|
291
|
+
|
|
292
|
+
def get_mutate_stmt(self, target: Catalog) -> Optional[str]:
|
|
293
|
+
"Returns a SQL statement to mutate a source state into a target state."
|
|
294
|
+
|
|
295
|
+
return self.mutator.mutate_catalog_stmt(self.state, target)
|
|
296
|
+
|
|
297
|
+
@abc.abstractmethod
|
|
298
|
+
def placeholder(self, index: int) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Returns a placeholder for a positional argument in a prepared statement.
|
|
301
|
+
|
|
302
|
+
:param index: An index starting at 1 for the first position.
|
|
303
|
+
"""
|
|
304
|
+
...
|
|
305
|
+
|
|
306
|
+
def get_table_insert_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
307
|
+
"""
|
|
308
|
+
Returns a SQL statement to insert new records in a database table.
|
|
309
|
+
|
|
310
|
+
This statement omits values for identity columns.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
statements: list[str] = []
|
|
314
|
+
statements.append(f"INSERT INTO {table.name}")
|
|
315
|
+
columns = [column for column in table.get_columns(order) if not column.identity]
|
|
316
|
+
column_list = ", ".join(str(column.name) for column in columns)
|
|
317
|
+
value_list = ", ".join(self.placeholder(index) for index, _ in enumerate(columns, start=1))
|
|
318
|
+
statements.append(f"({column_list}) VALUES ({value_list})")
|
|
319
|
+
statements.append(";")
|
|
320
|
+
return "\n".join(statements)
|
|
321
|
+
|
|
322
|
+
def _get_merge_preamble(self, table: Table, columns: list[Column]) -> list[str]:
|
|
323
|
+
"Builds the match condition of a SQL MERGE statement."
|
|
324
|
+
|
|
325
|
+
statements: list[str] = []
|
|
326
|
+
|
|
327
|
+
statements.append(f"MERGE INTO {table.name} target")
|
|
328
|
+
column_list = ", ".join(str(column.name) for column in columns)
|
|
329
|
+
value_list = ", ".join(self.placeholder(index) for index, _ in enumerate(columns, start=1))
|
|
330
|
+
statements.append(f"USING (VALUES ({value_list})) source({column_list})")
|
|
331
|
+
|
|
332
|
+
match_columns = [column for column in columns if table.is_lookup_column(column)]
|
|
333
|
+
if match_columns:
|
|
334
|
+
match_condition = " OR ".join(f"target.{column.name} = source.{column.name}" for column in match_columns)
|
|
335
|
+
statements.append(f"ON ({match_condition})")
|
|
336
|
+
|
|
337
|
+
return statements
|
|
338
|
+
|
|
339
|
+
def get_table_merge_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
340
|
+
"""
|
|
341
|
+
Returns a SQL statement to insert or ignore records in a database table.
|
|
342
|
+
|
|
343
|
+
This statement omits values for identity columns.
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
columns = [column for column in table.get_columns(order) if not column.identity]
|
|
347
|
+
statements = self._get_merge_preamble(table, columns)
|
|
348
|
+
|
|
349
|
+
statements.append("WHEN NOT MATCHED THEN")
|
|
350
|
+
column_list = ", ".join(str(column.name) for column in columns)
|
|
351
|
+
insert_list = ", ".join(f"source.{column.name}" for column in columns)
|
|
352
|
+
statements.append(f"INSERT ({column_list}) VALUES ({insert_list})")
|
|
353
|
+
|
|
354
|
+
statements.append(";")
|
|
355
|
+
return "\n".join(statements)
|
|
356
|
+
|
|
357
|
+
def get_table_upsert_stmt(self, table: Table, order: Optional[tuple[str, ...]] = None) -> str:
|
|
358
|
+
"""
|
|
359
|
+
Returns a SQL statement to insert or update records in a database table.
|
|
360
|
+
|
|
361
|
+
This statement uses identity column values for lookup.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
columns = [column for column in table.get_columns(order)]
|
|
365
|
+
statements: list[str] = self._get_merge_preamble(table, columns)
|
|
366
|
+
|
|
367
|
+
insert_columns = [column for column in columns if not column.identity]
|
|
368
|
+
update_columns = [column for column in insert_columns if not table.is_lookup_column(column)]
|
|
369
|
+
if update_columns:
|
|
370
|
+
statements.append("WHEN MATCHED THEN")
|
|
371
|
+
update_list = ", ".join(f"target.{column.name} = source.{column.name}" for column in update_columns)
|
|
372
|
+
statements.append(f"UPDATE SET {update_list}")
|
|
373
|
+
if insert_columns:
|
|
374
|
+
statements.append("WHEN NOT MATCHED THEN")
|
|
375
|
+
column_list = ", ".join(str(column.name) for column in insert_columns)
|
|
376
|
+
insert_list = ", ".join(f"source.{column.name}" for column in insert_columns)
|
|
377
|
+
statements.append(f"INSERT ({column_list}) VALUES ({insert_list})")
|
|
378
|
+
|
|
379
|
+
statements.append(";")
|
|
380
|
+
return "\n".join(statements)
|
|
381
|
+
|
|
382
|
+
def get_table_delete_stmt(self, table: Table) -> str:
|
|
383
|
+
"Returns a SQL statement to delete rows from a database table."
|
|
384
|
+
|
|
385
|
+
return f"DELETE FROM {table.name} WHERE {table.get_primary_column().name} = {self.placeholder(1)}"
|
|
386
|
+
|
|
387
|
+
def get_qualified_id(self, ref: ClassRef) -> SupportsQualifiedId:
|
|
388
|
+
return self.converter.create_qualified_id(ref.module_name, ref.entity_name)
|
|
389
|
+
|
|
390
|
+
def get_current_schema_stmt(self) -> str:
|
|
391
|
+
return "CURRENT_SCHEMA()"
|
|
392
|
+
|
|
393
|
+
def get_dataclass_insert_stmt(self, table: type[DataclassInstance]) -> str:
|
|
394
|
+
"Returns a SQL statement to insert or ignore records in a database table."
|
|
395
|
+
|
|
396
|
+
table_object = self.state.get_table(self.get_qualified_id(ClassRef(table)))
|
|
397
|
+
return self.get_table_insert_stmt(table_object)
|
|
398
|
+
|
|
399
|
+
def get_dataclass_upsert_stmt(self, table: type[DataclassInstance]) -> str:
|
|
400
|
+
"Returns a SQL statement to insert or update records in a database table."
|
|
401
|
+
|
|
402
|
+
table_object = self.state.get_table(self.get_qualified_id(ClassRef(table)))
|
|
403
|
+
return self.get_table_upsert_stmt(table_object)
|
|
404
|
+
|
|
405
|
+
def get_dataclass_delete_stmt(self, table: type[DataclassInstance]) -> str:
|
|
406
|
+
"Returns a SQL statement to delete records from a database table."
|
|
407
|
+
|
|
408
|
+
table_object = self.state.get_table(self.get_qualified_id(ClassRef(table)))
|
|
409
|
+
return self.get_table_delete_stmt(table_object)
|
|
410
|
+
|
|
411
|
+
def get_dataclass_as_record(self, entity_type: type[D], item: D, *, skip_identity: bool = False) -> tuple[Any, ...]:
|
|
412
|
+
"Converts a data-class object into a record to insert into a database table."
|
|
413
|
+
|
|
414
|
+
table_object = self.state.get_table(self.get_qualified_id(ClassRef(entity_type)))
|
|
415
|
+
extractors = self._get_dataclass_extractors(table_object, entity_type, skip_identity=skip_identity)
|
|
416
|
+
return tuple(extractor(item) for extractor in extractors)
|
|
417
|
+
|
|
418
|
+
def get_dataclasses_as_records(
|
|
419
|
+
self,
|
|
420
|
+
entity_type: type[D],
|
|
421
|
+
items: Iterable[D],
|
|
422
|
+
*,
|
|
423
|
+
skip_identity: bool = False,
|
|
424
|
+
) -> Iterable[tuple[Any, ...]]:
|
|
425
|
+
"Converts a list of data-class objects into a list of records to insert into a database table."
|
|
426
|
+
|
|
427
|
+
table_object = self.state.get_table(self.get_qualified_id(ClassRef(entity_type)))
|
|
428
|
+
extractors = self._get_dataclass_extractors(table_object, entity_type, skip_identity=skip_identity)
|
|
429
|
+
return (tuple(extractor(item) for extractor in extractors) for item in items)
|
|
430
|
+
|
|
431
|
+
def _get_dataclass_extractors(
|
|
432
|
+
self, table: Table, entity_type: type[DataclassInstance], *, skip_identity: bool
|
|
433
|
+
) -> tuple[Callable[[Any], Any], ...]:
|
|
434
|
+
"Returns a tuple of callable function objects that extracts each field of a data-class."
|
|
435
|
+
|
|
436
|
+
return tuple(
|
|
437
|
+
self.get_field_extractor(table.columns[field.name], field.name, typing.cast(type, field.type))
|
|
438
|
+
for field in dataclasses.fields(entity_type)
|
|
439
|
+
if not (skip_identity and table.columns[field.name].identity)
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def get_field_extractor(self, column: Column, field_name: str, field_type: type) -> Callable[[Any], Any]:
|
|
443
|
+
"Returns a callable function object that extracts a single field of a data-class."
|
|
444
|
+
|
|
445
|
+
if is_type_enum(field_type):
|
|
446
|
+
return lambda obj: getattr(obj, field_name).value
|
|
447
|
+
else:
|
|
448
|
+
return lambda obj: getattr(obj, field_name)
|
|
449
|
+
|
|
450
|
+
def get_value_transformer(self, column: Column, field_type: type) -> Optional[Callable[[Any], Any]]:
|
|
451
|
+
"Returns a callable function object that transforms a value type into the expected column type."
|
|
452
|
+
|
|
453
|
+
if isinstance(column.data_type, SqlJsonType) or (
|
|
454
|
+
isinstance(column.data_type, SqlVariableCharacterType) and (field_type is dict or field_type is list or field_type is set)
|
|
455
|
+
):
|
|
456
|
+
return lambda field: _JSON_ENCODER.encode(field)
|
|
457
|
+
|
|
458
|
+
if is_type_enum(field_type):
|
|
459
|
+
return lambda field: field.value
|
|
460
|
+
else:
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
def get_enum_transformer(self, enum_dict: dict[str, int]) -> Callable[[Any], Any]:
|
|
464
|
+
"Returns a callable function object that looks up an assigned integer value based on the enumeration string value."
|
|
465
|
+
|
|
466
|
+
return lambda field: enum_dict[field]
|
|
467
|
+
|
|
468
|
+
def get_enum_list_transformer(self, enum_dict: dict[str, int]) -> Callable[[Any], Any]:
|
|
469
|
+
"Returns a callable function object that looks up assigned integer values based on a list of enumeration string values."
|
|
470
|
+
|
|
471
|
+
return lambda field: [enum_dict[item] for item in field]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class QueryException(RuntimeError):
|
|
475
|
+
query: str
|
|
476
|
+
|
|
477
|
+
def __init__(self, query: str) -> None:
|
|
478
|
+
super().__init__()
|
|
479
|
+
self.query = query
|
|
480
|
+
|
|
481
|
+
def __str__(self) -> str:
|
|
482
|
+
query = f"{self.query[:1000]}..." if len(self.query) > 1000 else self.query
|
|
483
|
+
return f"error executing query:\n{query}"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class BaseConnection(abc.ABC):
|
|
487
|
+
"An active connection to a database."
|
|
488
|
+
|
|
489
|
+
generator: BaseGenerator
|
|
490
|
+
params: ConnectionParameters
|
|
491
|
+
|
|
492
|
+
def __init__(
|
|
493
|
+
self,
|
|
494
|
+
generator: BaseGenerator,
|
|
495
|
+
params: ConnectionParameters,
|
|
496
|
+
) -> None:
|
|
497
|
+
self.generator = generator
|
|
498
|
+
self.params = params
|
|
499
|
+
|
|
500
|
+
async def __aenter__(self) -> "BaseContext":
|
|
501
|
+
return await self.open()
|
|
502
|
+
|
|
503
|
+
async def __aexit__(
|
|
504
|
+
self,
|
|
505
|
+
exc_type: Optional[type[BaseException]],
|
|
506
|
+
exc_val: Optional[BaseException],
|
|
507
|
+
exc_tb: Optional[types.TracebackType],
|
|
508
|
+
) -> None:
|
|
509
|
+
await self.close()
|
|
510
|
+
|
|
511
|
+
@abc.abstractmethod
|
|
512
|
+
async def open(self) -> "BaseContext": ...
|
|
513
|
+
|
|
514
|
+
@abc.abstractmethod
|
|
515
|
+
async def close(self) -> None: ...
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class RecordTransformer:
|
|
519
|
+
@abc.abstractmethod
|
|
520
|
+
def is_identity(self) -> bool:
|
|
521
|
+
"True if the transformer does nothing."
|
|
522
|
+
|
|
523
|
+
...
|
|
524
|
+
|
|
525
|
+
@abc.abstractmethod
|
|
526
|
+
async def get(self, records: Iterable[RecordType]) -> Optional[Callable[[Any], Any]]:
|
|
527
|
+
"""
|
|
528
|
+
Returns a callable function object that performs a transformation on a value.
|
|
529
|
+
|
|
530
|
+
:param records: Records in a batch to insert/update. Used for context.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
...
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class ScalarTransformer(RecordTransformer):
|
|
537
|
+
"A context-free transformer that mutates a scalar value into another."
|
|
538
|
+
|
|
539
|
+
fn: Optional[Callable[[Any], Any]]
|
|
540
|
+
|
|
541
|
+
def __init__(
|
|
542
|
+
self,
|
|
543
|
+
fn: Optional[Callable[[Any], Any]],
|
|
544
|
+
) -> None:
|
|
545
|
+
self.fn = fn
|
|
546
|
+
|
|
547
|
+
@override
|
|
548
|
+
def is_identity(self) -> bool:
|
|
549
|
+
return self.fn is None
|
|
550
|
+
|
|
551
|
+
async def get(self, records: Iterable[RecordType]) -> Optional[Callable[[Any], Any]]:
|
|
552
|
+
return self.fn
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class BaseEnumTransformer(RecordTransformer):
|
|
556
|
+
context: "BaseContext"
|
|
557
|
+
table: Table
|
|
558
|
+
generator: BaseGenerator
|
|
559
|
+
index: int
|
|
560
|
+
|
|
561
|
+
def __init__(
|
|
562
|
+
self,
|
|
563
|
+
context: "BaseContext",
|
|
564
|
+
table: Table,
|
|
565
|
+
generator: BaseGenerator,
|
|
566
|
+
index: int,
|
|
567
|
+
) -> None:
|
|
568
|
+
self.context = context
|
|
569
|
+
self.table = table
|
|
570
|
+
self.generator = generator
|
|
571
|
+
self.index = index
|
|
572
|
+
|
|
573
|
+
@override
|
|
574
|
+
def is_identity(self) -> bool:
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
async def _merge_lookup_table(self, values: set[str]) -> dict[str, int]:
|
|
578
|
+
"Merges new values into a lookup table and returns the entire updated table."
|
|
579
|
+
|
|
580
|
+
if not values:
|
|
581
|
+
return dict()
|
|
582
|
+
|
|
583
|
+
await self.context.execute_all(
|
|
584
|
+
self.context.connection.generator.get_table_merge_stmt(self.table),
|
|
585
|
+
list((value,) for value in values),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
value_name = self.table.get_value_columns()[0].name
|
|
589
|
+
index_name = self.table.get_primary_column().name
|
|
590
|
+
LOGGER.debug("adding new enumeration values: %s", values)
|
|
591
|
+
results = await self.context.query_all(
|
|
592
|
+
tuple[str, int],
|
|
593
|
+
f"SELECT {value_name}, {index_name} FROM {self.table.name}",
|
|
594
|
+
)
|
|
595
|
+
return dict(results)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
class EnumTransformer(BaseEnumTransformer):
|
|
599
|
+
"A transformer that looks up an assigned integer value based on the enumeration string value."
|
|
600
|
+
|
|
601
|
+
async def get(self, records: Iterable[RecordType]) -> Optional[Callable[[Any], Any]]:
|
|
602
|
+
"Returns a callable function object that looks up an assigned integer value based on the enumeration string value."
|
|
603
|
+
|
|
604
|
+
# a single enumeration value represented as a string
|
|
605
|
+
values: set[Optional[str]] = set()
|
|
606
|
+
for record in records:
|
|
607
|
+
values.add(record[self.index])
|
|
608
|
+
values.discard(None) # do not insert NULL into referenced table
|
|
609
|
+
enum_dict = await self._merge_lookup_table(typing.cast(set[str], values))
|
|
610
|
+
return self.generator.get_enum_transformer(enum_dict)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
class EnumListTransformer(BaseEnumTransformer):
|
|
614
|
+
"A transformer that looks up assigned integer values based on a list of enumeration string values."
|
|
615
|
+
|
|
616
|
+
async def get(self, records: Iterable[RecordType]) -> Optional[Callable[[Any], Any]]:
|
|
617
|
+
"Returns a callable function object that looks up assigned integer values based on a list of enumeration string values."
|
|
618
|
+
|
|
619
|
+
# a list of enumeration values represented as a list of strings
|
|
620
|
+
values: set[Optional[str]] = set()
|
|
621
|
+
for record in records:
|
|
622
|
+
values.update(record[self.index])
|
|
623
|
+
values.discard(None) # do not insert NULL into referenced table
|
|
624
|
+
enum_list_dict = await self._merge_lookup_table(typing.cast(set[str], values))
|
|
625
|
+
return self.generator.get_enum_list_transformer(enum_list_dict)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
class DataSource:
|
|
629
|
+
"A data source from which records can be retrieved in batches."
|
|
630
|
+
|
|
631
|
+
@abc.abstractmethod
|
|
632
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
633
|
+
"Produces a batch of records."
|
|
634
|
+
|
|
635
|
+
# yield required for mypy to properly identify return type signature in derived classes
|
|
636
|
+
yield []
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
class IterableDataSource(DataSource):
|
|
640
|
+
"""
|
|
641
|
+
A data source created from a synchronous iterable of records.
|
|
642
|
+
|
|
643
|
+
Each element in the iterator is accessed only once.
|
|
644
|
+
"""
|
|
645
|
+
|
|
646
|
+
records: Iterable[RecordType]
|
|
647
|
+
|
|
648
|
+
def __init__(self, records: Iterable[RecordType]) -> None:
|
|
649
|
+
self.records = records
|
|
650
|
+
|
|
651
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
652
|
+
rows: list[RecordType] = []
|
|
653
|
+
for record in self.records:
|
|
654
|
+
rows.append(record)
|
|
655
|
+
if len(rows) >= BATCH_SIZE:
|
|
656
|
+
yield rows
|
|
657
|
+
rows = []
|
|
658
|
+
if rows:
|
|
659
|
+
yield rows
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
class AsyncIterableDataSource(DataSource):
|
|
663
|
+
"""
|
|
664
|
+
A data source created from an asynchronous iterable of records.
|
|
665
|
+
|
|
666
|
+
Each element in the iterator is accessed only once.
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
records: AsyncIterable[RecordType]
|
|
670
|
+
|
|
671
|
+
def __init__(self, records: AsyncIterable[RecordType]) -> None:
|
|
672
|
+
self.records = records
|
|
673
|
+
|
|
674
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
675
|
+
rows: list[RecordType] = []
|
|
676
|
+
async for record in self.records:
|
|
677
|
+
rows.append(record)
|
|
678
|
+
if len(rows) >= BATCH_SIZE:
|
|
679
|
+
yield rows
|
|
680
|
+
rows = []
|
|
681
|
+
if rows:
|
|
682
|
+
yield rows
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
class CompositeDataSource(DataSource):
|
|
686
|
+
"A data source created by transforming the output of another source."
|
|
687
|
+
|
|
688
|
+
source: DataSource
|
|
689
|
+
|
|
690
|
+
def __init__(self, source: DataSource) -> None:
|
|
691
|
+
self.source = source
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class SelectorDataSource(CompositeDataSource):
|
|
695
|
+
"A data source derived from another by selecting specific columns."
|
|
696
|
+
|
|
697
|
+
indices: list[int]
|
|
698
|
+
|
|
699
|
+
def __init__(self, source: DataSource, indices: list[int]) -> None:
|
|
700
|
+
super().__init__(source)
|
|
701
|
+
self.indices = indices
|
|
702
|
+
|
|
703
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
704
|
+
indices = self.indices
|
|
705
|
+
async for batch in self.source.batches():
|
|
706
|
+
yield [tuple(record[i] for i in indices) for record in batch]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
class TransformerDataSource(CompositeDataSource):
|
|
710
|
+
"A data source derived from another by transforming specific columns."
|
|
711
|
+
|
|
712
|
+
transformers: list[RecordTransformer]
|
|
713
|
+
|
|
714
|
+
def __init__(self, source: DataSource, transformers: list[RecordTransformer]) -> None:
|
|
715
|
+
super().__init__(source)
|
|
716
|
+
self.transformers = transformers
|
|
717
|
+
|
|
718
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
719
|
+
async for batch in self.source.batches():
|
|
720
|
+
fns: list[Optional[Callable[[Any], Any]]] = []
|
|
721
|
+
for transformer in self.transformers:
|
|
722
|
+
fns.append(await transformer.get(batch))
|
|
723
|
+
yield [
|
|
724
|
+
tuple(((fn(field) if field is not None else None) if fn is not None else field) for fn, field in zip(fns, record))
|
|
725
|
+
for record in batch
|
|
726
|
+
]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
class SelectorTransformerDataSource(CompositeDataSource):
|
|
730
|
+
"A data source derived from another by selecting and/or transforming specific columns."
|
|
731
|
+
|
|
732
|
+
indices: list[int]
|
|
733
|
+
transformers: list[RecordTransformer]
|
|
734
|
+
|
|
735
|
+
def __init__(
|
|
736
|
+
self,
|
|
737
|
+
source: DataSource,
|
|
738
|
+
indices: list[int],
|
|
739
|
+
transformers: list[RecordTransformer],
|
|
740
|
+
) -> None:
|
|
741
|
+
super().__init__(source)
|
|
742
|
+
self.indices = indices
|
|
743
|
+
self.transformers = transformers
|
|
744
|
+
|
|
745
|
+
async def batches(self) -> AsyncIterable[list[RecordType]]:
|
|
746
|
+
indices = self.indices
|
|
747
|
+
async for batch in self.source.batches():
|
|
748
|
+
fns: list[Optional[Callable[[Any], Any]]] = []
|
|
749
|
+
for transformer in self.transformers:
|
|
750
|
+
fns.append(await transformer.get(batch))
|
|
751
|
+
yield [
|
|
752
|
+
tuple(
|
|
753
|
+
((fn(field) if field is not None else None) if fn is not None else field)
|
|
754
|
+
for fn, field in zip(fns, (record[i] for i in indices))
|
|
755
|
+
)
|
|
756
|
+
for record in batch
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def to_data_source(records: RecordSource) -> DataSource:
|
|
761
|
+
"Converts a synchronous or asynchronous iterable of records into a data source."
|
|
762
|
+
|
|
763
|
+
if isinstance(records, Iterable):
|
|
764
|
+
return IterableDataSource(records)
|
|
765
|
+
elif isinstance(records, AsyncIterable): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
766
|
+
return AsyncIterableDataSource(records)
|
|
767
|
+
else:
|
|
768
|
+
raise TypeError("expected: `Iterable` or `AsyncIterable` of records")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
class BaseContext(abc.ABC):
|
|
772
|
+
"Context object returned by a connection object."
|
|
773
|
+
|
|
774
|
+
connection: BaseConnection
|
|
775
|
+
|
|
776
|
+
def __init__(self, connection: BaseConnection) -> None:
|
|
777
|
+
self.connection = connection
|
|
778
|
+
|
|
779
|
+
async def execute(self, statement: str) -> None:
|
|
780
|
+
"Executes one or more SQL statements."
|
|
781
|
+
|
|
782
|
+
if not statement:
|
|
783
|
+
raise ValueError("empty statement")
|
|
784
|
+
if not statement.strip():
|
|
785
|
+
raise ValueError("blank statement")
|
|
786
|
+
|
|
787
|
+
LOGGER.debug("execute SQL:\n%s", statement)
|
|
788
|
+
try:
|
|
789
|
+
await self._execute(statement)
|
|
790
|
+
except QueryException:
|
|
791
|
+
raise
|
|
792
|
+
except Exception as e:
|
|
793
|
+
raise QueryException(statement) from e
|
|
794
|
+
|
|
795
|
+
@abc.abstractmethod
|
|
796
|
+
async def _execute(self, statement: str) -> None:
|
|
797
|
+
"Executes one or more SQL statements."
|
|
798
|
+
|
|
799
|
+
...
|
|
800
|
+
|
|
801
|
+
async def execute_all(self, statement: str, records: RecordSource) -> None:
|
|
802
|
+
"Executes a SQL statement with several records of data."
|
|
803
|
+
|
|
804
|
+
if not statement:
|
|
805
|
+
raise ValueError("empty statement")
|
|
806
|
+
if not statement.strip():
|
|
807
|
+
raise ValueError("blank statement")
|
|
808
|
+
|
|
809
|
+
if isinstance(records, Sized):
|
|
810
|
+
LOGGER.debug("execute SQL with %d rows:\n%s", len(records), statement)
|
|
811
|
+
if not len(records):
|
|
812
|
+
LOGGER.warning("no data to execute statement with")
|
|
813
|
+
return
|
|
814
|
+
else:
|
|
815
|
+
LOGGER.debug("execute SQL:\n%s", statement)
|
|
816
|
+
|
|
817
|
+
source = to_data_source(records)
|
|
818
|
+
try:
|
|
819
|
+
await self._execute_all(statement, source)
|
|
820
|
+
except QueryException:
|
|
821
|
+
raise
|
|
822
|
+
except Exception as e:
|
|
823
|
+
raise QueryException(statement) from e
|
|
824
|
+
|
|
825
|
+
@abc.abstractmethod
|
|
826
|
+
async def _execute_all(self, statement: str, source: DataSource) -> None:
|
|
827
|
+
"Executes a SQL statement with several records of data."
|
|
828
|
+
|
|
829
|
+
...
|
|
830
|
+
|
|
831
|
+
async def _execute_typed(
|
|
832
|
+
self,
|
|
833
|
+
statement: str,
|
|
834
|
+
source: DataSource,
|
|
835
|
+
table: Table,
|
|
836
|
+
order: Optional[tuple[str, ...]] = None,
|
|
837
|
+
) -> None:
|
|
838
|
+
"Executes a SQL statement with several records of data, passing type information."
|
|
839
|
+
|
|
840
|
+
await self._execute_all(statement, source)
|
|
841
|
+
|
|
842
|
+
async def query_one(self, signature: type[T], statement: str) -> T:
|
|
843
|
+
"Runs a query to produce a result-set of one or more columns, and a single row."
|
|
844
|
+
|
|
845
|
+
rows = await self.query_all(signature, statement)
|
|
846
|
+
return rows[0]
|
|
847
|
+
|
|
848
|
+
async def query_all(self, signature: type[T], statement: str) -> list[T]:
|
|
849
|
+
"Runs a query to produce a result-set of one or more columns, and multiple rows."
|
|
850
|
+
|
|
851
|
+
if LOGGER.isEnabledFor(logging.DEBUG):
|
|
852
|
+
LOGGER.debug("query SQL with into %s:\n%s", python_type_to_str(signature), statement)
|
|
853
|
+
try:
|
|
854
|
+
return await self._query_all(signature, statement)
|
|
855
|
+
except QueryException:
|
|
856
|
+
raise
|
|
857
|
+
except Exception as e:
|
|
858
|
+
raise QueryException(statement) from e
|
|
859
|
+
|
|
860
|
+
@abc.abstractmethod
|
|
861
|
+
async def _query_all(self, signature: type[T], statement: str) -> list[T]:
|
|
862
|
+
"Runs a query to produce a result-set of one or more columns, and multiple rows."
|
|
863
|
+
|
|
864
|
+
...
|
|
865
|
+
|
|
866
|
+
async def current_schema(self) -> Optional[str]:
|
|
867
|
+
func = self.connection.generator.get_current_schema_stmt()
|
|
868
|
+
return await self.query_one(str, f"SELECT {func};")
|
|
869
|
+
|
|
870
|
+
async def create_schema(self, namespace: LocalId) -> None:
|
|
871
|
+
LOGGER.debug("create schema: %s", namespace)
|
|
872
|
+
factory = self.connection.generator.factory
|
|
873
|
+
stmt = factory.namespace_class(namespace).create_schema_stmt()
|
|
874
|
+
if stmt:
|
|
875
|
+
await self.execute(stmt)
|
|
876
|
+
|
|
877
|
+
async def drop_schema(self, namespace: LocalId) -> None:
|
|
878
|
+
LOGGER.debug("drop schema: %s", namespace)
|
|
879
|
+
factory = self.connection.generator.factory
|
|
880
|
+
stmt = factory.namespace_class(namespace).drop_schema_stmt()
|
|
881
|
+
if stmt:
|
|
882
|
+
await self.execute(stmt)
|
|
883
|
+
|
|
884
|
+
def get_table_id(self, ref: ClassRef) -> SupportsQualifiedId:
|
|
885
|
+
return self.connection.generator.get_qualified_id(ref)
|
|
886
|
+
|
|
887
|
+
@overload
|
|
888
|
+
def get_table(self, __ref: ClassRef) -> Table: ...
|
|
889
|
+
|
|
890
|
+
@overload
|
|
891
|
+
def get_table(self, __table: type[DataclassInstance]) -> Table: ...
|
|
892
|
+
|
|
893
|
+
def get_table(self, __table_or_ref: Union[ClassRef, type[DataclassInstance]]) -> Table:
|
|
894
|
+
if isinstance(__table_or_ref, ClassRef):
|
|
895
|
+
table_id = self.get_table_id(__table_or_ref)
|
|
896
|
+
elif is_dataclass_type(__table_or_ref):
|
|
897
|
+
table_id = self.get_table_id(ClassRef(__table_or_ref))
|
|
898
|
+
else:
|
|
899
|
+
raise TypeError(f"expected: {ClassRef.__name__} or data-class type")
|
|
900
|
+
|
|
901
|
+
return self.connection.generator.state.get_table(table_id)
|
|
902
|
+
|
|
903
|
+
async def drop_table_if_exists(self, table: SupportsQualifiedId) -> None:
|
|
904
|
+
LOGGER.debug("drop table if exists: %s", table)
|
|
905
|
+
factory = self.connection.generator.factory
|
|
906
|
+
# column list and primary key are ignored
|
|
907
|
+
stmt = factory.table_class(table, [], primary_key=()).drop_if_exists_stmt()
|
|
908
|
+
if stmt:
|
|
909
|
+
await self.execute(stmt)
|
|
910
|
+
|
|
911
|
+
async def create_objects(self, tables: list[type[DataclassInstance]]) -> None:
|
|
912
|
+
"Creates tables, structs, enumerations, constraints, etc. corresponding to entity class definitions."
|
|
913
|
+
|
|
914
|
+
generator = self.connection.generator
|
|
915
|
+
statement = generator.create(tables=tables)
|
|
916
|
+
if statement:
|
|
917
|
+
await self.execute(statement)
|
|
918
|
+
|
|
919
|
+
async def drop_objects(self) -> None:
|
|
920
|
+
"Drops all objects currently synced with the generator. (This function is mainly for unit tests.)"
|
|
921
|
+
|
|
922
|
+
generator = self.connection.generator
|
|
923
|
+
statement = generator.drop()
|
|
924
|
+
if statement:
|
|
925
|
+
await self.execute(statement)
|
|
926
|
+
|
|
927
|
+
async def insert_data(self, table: type[D], data: Iterable[D]) -> None:
|
|
928
|
+
"Inserts data in the database table corresponding to the dataclass type."
|
|
929
|
+
|
|
930
|
+
if isinstance(data, Sized) and not len(data):
|
|
931
|
+
LOGGER.warning("no data to insert")
|
|
932
|
+
return
|
|
933
|
+
|
|
934
|
+
generator = self.connection.generator
|
|
935
|
+
statement = generator.get_dataclass_insert_stmt(table)
|
|
936
|
+
records = generator.get_dataclasses_as_records(table, data, skip_identity=True)
|
|
937
|
+
await self.execute_all(statement, records)
|
|
938
|
+
|
|
939
|
+
async def upsert_data(self, table: type[D], data: Iterable[D]) -> None:
|
|
940
|
+
"Inserts or updates data in the database table corresponding to the dataclass type."
|
|
941
|
+
|
|
942
|
+
if isinstance(data, Sized) and not len(data):
|
|
943
|
+
LOGGER.warning("no data to upsert")
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
generator = self.connection.generator
|
|
947
|
+
statement = generator.get_dataclass_upsert_stmt(table)
|
|
948
|
+
records = generator.get_dataclasses_as_records(table, data, skip_identity=False)
|
|
949
|
+
await self.execute_all(statement, records)
|
|
950
|
+
|
|
951
|
+
async def delete_data(self, table: type[DataclassInstance], keys: Iterable[Any]) -> None:
|
|
952
|
+
"Inserts or updates data in the database table corresponding to the dataclass type."
|
|
953
|
+
|
|
954
|
+
if isinstance(keys, Sized) and not len(keys):
|
|
955
|
+
LOGGER.warning("no data to delete")
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
generator = self.connection.generator
|
|
959
|
+
statement = generator.get_dataclass_delete_stmt(table)
|
|
960
|
+
await self.execute_all(statement, ((key,) for key in keys))
|
|
961
|
+
|
|
962
|
+
async def insert_rows(
|
|
963
|
+
self,
|
|
964
|
+
table: Table,
|
|
965
|
+
records: RecordSource,
|
|
966
|
+
*,
|
|
967
|
+
field_types: tuple[type, ...],
|
|
968
|
+
field_names: Optional[tuple[str, ...]] = None,
|
|
969
|
+
) -> None:
|
|
970
|
+
"""
|
|
971
|
+
Inserts rows in a database table, ignoring duplicate keys.
|
|
972
|
+
|
|
973
|
+
:param table: The table to insert into.
|
|
974
|
+
:param field_types: The data types of the items in a row record.
|
|
975
|
+
:param field_names: The field names of the items in a row record.
|
|
976
|
+
:param records: The rows to be inserted into the database table.
|
|
977
|
+
"""
|
|
978
|
+
|
|
979
|
+
if isinstance(records, Sized):
|
|
980
|
+
LOGGER.debug("insert %d rows into %s", len(records), table.name)
|
|
981
|
+
if not len(records):
|
|
982
|
+
LOGGER.warning("no rows to insert")
|
|
983
|
+
return
|
|
984
|
+
else:
|
|
985
|
+
LOGGER.debug("insert into %s", table.name)
|
|
986
|
+
|
|
987
|
+
source = to_data_source(records)
|
|
988
|
+
await self._insert_rows(table, source, field_types=field_types, field_names=field_names)
|
|
989
|
+
|
|
990
|
+
if isinstance(records, Sized):
|
|
991
|
+
LOGGER.info("%d rows have been inserted into %s", len(records), table.name)
|
|
992
|
+
|
|
993
|
+
async def _insert_rows(
|
|
994
|
+
self,
|
|
995
|
+
table: Table,
|
|
996
|
+
source: DataSource,
|
|
997
|
+
*,
|
|
998
|
+
field_types: tuple[type, ...],
|
|
999
|
+
field_names: Optional[tuple[str, ...]] = None,
|
|
1000
|
+
) -> None:
|
|
1001
|
+
"""
|
|
1002
|
+
Inserts rows in a database table, ignoring duplicate keys.
|
|
1003
|
+
|
|
1004
|
+
Override in engine-specific derived classes.
|
|
1005
|
+
"""
|
|
1006
|
+
|
|
1007
|
+
record_generator = await self._generate_records(table, source, field_types=field_types, field_names=field_names)
|
|
1008
|
+
order = tuple(name for name in field_names if name) if field_names else None
|
|
1009
|
+
statement = self.connection.generator.get_table_insert_stmt(table, order)
|
|
1010
|
+
await self._execute_typed(statement, record_generator, table, order)
|
|
1011
|
+
|
|
1012
|
+
async def upsert_rows(
|
|
1013
|
+
self,
|
|
1014
|
+
table: Table,
|
|
1015
|
+
records: RecordSource,
|
|
1016
|
+
*,
|
|
1017
|
+
field_types: tuple[type, ...],
|
|
1018
|
+
field_names: Optional[tuple[str, ...]] = None,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
"""
|
|
1021
|
+
Inserts or updates rows in a database table.
|
|
1022
|
+
|
|
1023
|
+
:param table: The table to be updated.
|
|
1024
|
+
:param field_types: The data types of the items in a row record.
|
|
1025
|
+
:param field_names: The field names of the items in a row record.
|
|
1026
|
+
:param records: The rows to be inserted into or updated in the database table.
|
|
1027
|
+
"""
|
|
1028
|
+
|
|
1029
|
+
if isinstance(records, Sized):
|
|
1030
|
+
LOGGER.debug("upsert %d rows into %s", len(records), table.name)
|
|
1031
|
+
if not len(records):
|
|
1032
|
+
LOGGER.warning("no rows to upsert")
|
|
1033
|
+
return
|
|
1034
|
+
else:
|
|
1035
|
+
LOGGER.debug("upsert into %s", table.name)
|
|
1036
|
+
|
|
1037
|
+
source = to_data_source(records)
|
|
1038
|
+
await self._upsert_rows(table, source, field_types=field_types, field_names=field_names)
|
|
1039
|
+
|
|
1040
|
+
if isinstance(records, Sized):
|
|
1041
|
+
LOGGER.info(
|
|
1042
|
+
"%d rows have been inserted or updated into %s",
|
|
1043
|
+
len(records),
|
|
1044
|
+
table.name,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
async def _upsert_rows(
|
|
1048
|
+
self,
|
|
1049
|
+
table: Table,
|
|
1050
|
+
source: DataSource,
|
|
1051
|
+
*,
|
|
1052
|
+
field_types: tuple[type, ...],
|
|
1053
|
+
field_names: Optional[tuple[str, ...]] = None,
|
|
1054
|
+
) -> None:
|
|
1055
|
+
"""
|
|
1056
|
+
Inserts or updates rows in a database table.
|
|
1057
|
+
|
|
1058
|
+
Override in engine-specific derived classes.
|
|
1059
|
+
"""
|
|
1060
|
+
|
|
1061
|
+
record_generator = await self._generate_records(table, source, field_types=field_types, field_names=field_names)
|
|
1062
|
+
order = tuple(name for name in field_names if name) if field_names else None
|
|
1063
|
+
statement = self.connection.generator.get_table_upsert_stmt(table, order)
|
|
1064
|
+
|
|
1065
|
+
await self._execute_typed(statement, record_generator, table, order)
|
|
1066
|
+
|
|
1067
|
+
async def _generate_records(
|
|
1068
|
+
self,
|
|
1069
|
+
table: Table,
|
|
1070
|
+
source: DataSource,
|
|
1071
|
+
*,
|
|
1072
|
+
field_types: tuple[type, ...],
|
|
1073
|
+
field_names: Optional[tuple[str, ...]] = None,
|
|
1074
|
+
) -> DataSource:
|
|
1075
|
+
"""
|
|
1076
|
+
Creates a record generator for a database table.
|
|
1077
|
+
|
|
1078
|
+
Pass a list of field names when the order of fields in the source data does not match the column order
|
|
1079
|
+
in the target table.
|
|
1080
|
+
|
|
1081
|
+
:param table: The database table descriptor.
|
|
1082
|
+
:param source: The records to insert or update.
|
|
1083
|
+
:param field_types: Type of each record field. Use `types.NoneType` to omit a field.
|
|
1084
|
+
:param field_names: Label for each record field. Use an empty string for an omitted field.
|
|
1085
|
+
"""
|
|
1086
|
+
|
|
1087
|
+
generator = self.connection.generator
|
|
1088
|
+
|
|
1089
|
+
if field_names is None:
|
|
1090
|
+
field_names = tuple(name for name in table.columns.keys())
|
|
1091
|
+
|
|
1092
|
+
indices: list[int] = []
|
|
1093
|
+
transformers: list[RecordTransformer] = []
|
|
1094
|
+
for index, field_type, field_name in zip(range(len(field_types)), field_types, field_names):
|
|
1095
|
+
if field_type is type(None) or not field_name:
|
|
1096
|
+
continue
|
|
1097
|
+
|
|
1098
|
+
indices.append(index)
|
|
1099
|
+
transformer = self._get_transformer(table, generator, index, field_type, field_name)
|
|
1100
|
+
transformers.append(transformer)
|
|
1101
|
+
|
|
1102
|
+
if all(transformer.is_identity() for transformer in transformers):
|
|
1103
|
+
if len(indices) == len(field_types):
|
|
1104
|
+
return source
|
|
1105
|
+
else:
|
|
1106
|
+
return SelectorDataSource(source, indices)
|
|
1107
|
+
else:
|
|
1108
|
+
if len(indices) == len(field_types):
|
|
1109
|
+
return TransformerDataSource(source, transformers)
|
|
1110
|
+
else:
|
|
1111
|
+
return SelectorTransformerDataSource(source, indices, transformers)
|
|
1112
|
+
|
|
1113
|
+
def _get_transformer(
|
|
1114
|
+
self,
|
|
1115
|
+
table: Table,
|
|
1116
|
+
generator: BaseGenerator,
|
|
1117
|
+
index: int,
|
|
1118
|
+
field_type: type,
|
|
1119
|
+
field_name: str,
|
|
1120
|
+
) -> RecordTransformer:
|
|
1121
|
+
"""
|
|
1122
|
+
Creates a transformer for a record field.
|
|
1123
|
+
|
|
1124
|
+
:param table: The database table descriptor.
|
|
1125
|
+
:param records: The records to insert or update.
|
|
1126
|
+
:param generator: The generator that produces the transformer function.
|
|
1127
|
+
:param index: Field index.
|
|
1128
|
+
:param field_type: Type of field. Use `types.NoneType` to omit a field.
|
|
1129
|
+
:param field_name: Label for field. Use an empty string for an omitted field.
|
|
1130
|
+
"""
|
|
1131
|
+
|
|
1132
|
+
column = table.columns.get(field_name)
|
|
1133
|
+
if column is None:
|
|
1134
|
+
raise ValueError(f"column {LocalId(field_name)} not found in table {table.name}")
|
|
1135
|
+
|
|
1136
|
+
transformer = generator.get_value_transformer(column, field_type)
|
|
1137
|
+
|
|
1138
|
+
if not table.is_relation(column):
|
|
1139
|
+
return ScalarTransformer(transformer)
|
|
1140
|
+
relation = generator.state.get_referenced_table(table.name, column.name)
|
|
1141
|
+
if not relation.is_lookup_table():
|
|
1142
|
+
return ScalarTransformer(transformer)
|
|
1143
|
+
|
|
1144
|
+
LOGGER.debug("found lookup table column %s in table %s", column.name, table.name)
|
|
1145
|
+
|
|
1146
|
+
if field_type is str:
|
|
1147
|
+
return EnumTransformer(
|
|
1148
|
+
self,
|
|
1149
|
+
relation,
|
|
1150
|
+
generator,
|
|
1151
|
+
index,
|
|
1152
|
+
)
|
|
1153
|
+
elif field_type is list or field_type is set:
|
|
1154
|
+
return EnumListTransformer(
|
|
1155
|
+
self,
|
|
1156
|
+
relation,
|
|
1157
|
+
generator,
|
|
1158
|
+
index,
|
|
1159
|
+
)
|
|
1160
|
+
else:
|
|
1161
|
+
return ScalarTransformer(transformer)
|
|
1162
|
+
|
|
1163
|
+
async def delete_rows(self, table: Table, key_type: type, key_values: Iterable[Any]) -> None:
|
|
1164
|
+
"""
|
|
1165
|
+
Deletes rows from a database table.
|
|
1166
|
+
|
|
1167
|
+
:param table: The table to remove records from.
|
|
1168
|
+
:param key_type: The data type of the key values such that the following is true:
|
|
1169
|
+
`all(isinstance(v, key_type) for v in key_values)`
|
|
1170
|
+
:param key_values: The key values to look up in the table.
|
|
1171
|
+
"""
|
|
1172
|
+
|
|
1173
|
+
if isinstance(key_values, Sized):
|
|
1174
|
+
LOGGER.debug("delete %d rows from %s", len(key_values), table.name)
|
|
1175
|
+
if not len(key_values):
|
|
1176
|
+
LOGGER.warning("no rows to delete")
|
|
1177
|
+
return
|
|
1178
|
+
else:
|
|
1179
|
+
LOGGER.debug("delete from %s", table.name)
|
|
1180
|
+
|
|
1181
|
+
await self._delete_rows(table, key_type, key_values)
|
|
1182
|
+
|
|
1183
|
+
if isinstance(key_values, Sized):
|
|
1184
|
+
LOGGER.info("%d rows have been deleted from %s", len(key_values), table.name)
|
|
1185
|
+
|
|
1186
|
+
async def _delete_rows(self, table: Table, key_type: type, key_values: Iterable[Any]) -> None:
|
|
1187
|
+
"""
|
|
1188
|
+
Deletes rows from a database table.
|
|
1189
|
+
|
|
1190
|
+
Override in engine-specific derived classes.
|
|
1191
|
+
"""
|
|
1192
|
+
|
|
1193
|
+
generator = self.connection.generator
|
|
1194
|
+
transformer = generator.get_value_transformer(table.get_primary_column(), key_type)
|
|
1195
|
+
if transformer is not None:
|
|
1196
|
+
source = IterableDataSource((transformer(key),) for key in key_values)
|
|
1197
|
+
else:
|
|
1198
|
+
source = IterableDataSource((key,) for key in key_values)
|
|
1199
|
+
|
|
1200
|
+
statement = generator.get_table_delete_stmt(table)
|
|
1201
|
+
order = (table.get_primary_column().name.local_id,)
|
|
1202
|
+
await self._execute_typed(statement, source, table, order)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _module_or_list(module: Optional[types.ModuleType], modules: Optional[list[types.ModuleType]]) -> list[types.ModuleType]:
|
|
1206
|
+
if module is None and modules is None:
|
|
1207
|
+
raise TypeError("required: one of parameters `module` and `modules`")
|
|
1208
|
+
if module is not None and modules is not None:
|
|
1209
|
+
raise TypeError("disallowed: both parameters `module` and `modules`")
|
|
1210
|
+
|
|
1211
|
+
if modules is not None:
|
|
1212
|
+
if not isinstance(modules, list): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1213
|
+
raise TypeError("expected: list of modules for parameter `modules`")
|
|
1214
|
+
entity_modules = modules
|
|
1215
|
+
elif module is not None:
|
|
1216
|
+
entity_modules = [module]
|
|
1217
|
+
else:
|
|
1218
|
+
# should never be triggered; either `module` or `modules` must be defined at this point
|
|
1219
|
+
raise NotImplementedError("match condition not exhaustive")
|
|
1220
|
+
|
|
1221
|
+
return entity_modules
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
class DiscoveryError(RuntimeError):
|
|
1225
|
+
pass
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
class Explorer(abc.ABC):
|
|
1229
|
+
conn: BaseContext
|
|
1230
|
+
|
|
1231
|
+
def __init__(self, conn: BaseContext) -> None:
|
|
1232
|
+
self.conn = conn
|
|
1233
|
+
|
|
1234
|
+
def get_qualified_id(self, namespace: Optional[str], id: str) -> SupportsQualifiedId:
|
|
1235
|
+
return QualifiedId(namespace, id)
|
|
1236
|
+
|
|
1237
|
+
@abc.abstractmethod
|
|
1238
|
+
async def get_table_names(self) -> list[QualifiedId]: ...
|
|
1239
|
+
|
|
1240
|
+
@abc.abstractmethod
|
|
1241
|
+
async def has_table(self, table_id: SupportsQualifiedId) -> bool: ...
|
|
1242
|
+
|
|
1243
|
+
@abc.abstractmethod
|
|
1244
|
+
async def has_column(self, table_id: SupportsQualifiedId, column_id: LocalId) -> bool: ...
|
|
1245
|
+
|
|
1246
|
+
@abc.abstractmethod
|
|
1247
|
+
async def get_table(self, table_id: SupportsQualifiedId) -> Table: ...
|
|
1248
|
+
|
|
1249
|
+
@abc.abstractmethod
|
|
1250
|
+
async def get_namespace(self, namespace_id: LocalId) -> Namespace:
|
|
1251
|
+
"Constructs a database object model of a namespace (database schema)."
|
|
1252
|
+
...
|
|
1253
|
+
|
|
1254
|
+
@abc.abstractmethod
|
|
1255
|
+
async def get_namespace_current(self) -> Namespace:
|
|
1256
|
+
"Constructs a database object model of the current namespace (database schema)."
|
|
1257
|
+
...
|
|
1258
|
+
|
|
1259
|
+
@overload
|
|
1260
|
+
async def discover(self, *, module: types.ModuleType) -> None: ...
|
|
1261
|
+
|
|
1262
|
+
@overload
|
|
1263
|
+
async def discover(self, *, modules: list[types.ModuleType]) -> None: ...
|
|
1264
|
+
|
|
1265
|
+
async def discover(
|
|
1266
|
+
self,
|
|
1267
|
+
*,
|
|
1268
|
+
module: Optional[types.ModuleType] = None,
|
|
1269
|
+
modules: Optional[list[types.ModuleType]] = None,
|
|
1270
|
+
) -> None:
|
|
1271
|
+
"""
|
|
1272
|
+
Assumes a database model based on the state of the database.
|
|
1273
|
+
|
|
1274
|
+
Discovers database objects (e.g. tables, structs, user-defined types, constraints) and builds a model
|
|
1275
|
+
representing those objects. The database model is specific to the selected dialect.
|
|
1276
|
+
|
|
1277
|
+
The list of Python modules passed as a parameter is used in mapping modules to database schemas. These modules
|
|
1278
|
+
don't need to contain Python class definitions.
|
|
1279
|
+
|
|
1280
|
+
:param module: The Python module identifying the database schema to discover.
|
|
1281
|
+
:param modules: The list of Python modules identifying database schemas to discover.
|
|
1282
|
+
"""
|
|
1283
|
+
|
|
1284
|
+
entity_modules = _module_or_list(module, modules)
|
|
1285
|
+
|
|
1286
|
+
generator = self.conn.connection.generator
|
|
1287
|
+
generator.reset()
|
|
1288
|
+
|
|
1289
|
+
for entity_module in entity_modules:
|
|
1290
|
+
ns_name = generator.converter.options.namespaces.get(entity_module.__name__)
|
|
1291
|
+
if ns_name is not None:
|
|
1292
|
+
ns = await self.get_namespace(LocalId(ns_name))
|
|
1293
|
+
else:
|
|
1294
|
+
ns = await self.get_namespace_current()
|
|
1295
|
+
generator.state.merge(Catalog([ns]))
|
|
1296
|
+
|
|
1297
|
+
LOGGER.debug("found %d namespaces", len(generator.state.namespaces))
|
|
1298
|
+
for ns in generator.state.namespaces.values():
|
|
1299
|
+
LOGGER.debug("found %d enum(s) in namespace %s", len(ns.enums), ns.name)
|
|
1300
|
+
LOGGER.debug("found %d struct(s) in namespace %s", len(ns.structs), ns.name)
|
|
1301
|
+
LOGGER.debug("found %d table(s) in namespace %s", len(ns.tables), ns.name)
|
|
1302
|
+
LOGGER.debug("discovered state:\n%s", generator.state)
|
|
1303
|
+
|
|
1304
|
+
@overload
|
|
1305
|
+
async def synchronize(self, *, module: types.ModuleType) -> None: ...
|
|
1306
|
+
|
|
1307
|
+
@overload
|
|
1308
|
+
async def synchronize(self, *, modules: list[types.ModuleType]) -> None: ...
|
|
1309
|
+
|
|
1310
|
+
async def synchronize(
|
|
1311
|
+
self,
|
|
1312
|
+
*,
|
|
1313
|
+
module: Optional[types.ModuleType] = None,
|
|
1314
|
+
modules: Optional[list[types.ModuleType]] = None,
|
|
1315
|
+
) -> None:
|
|
1316
|
+
"""
|
|
1317
|
+
Synchronizes a current source state with a desired target state.
|
|
1318
|
+
|
|
1319
|
+
First, constructs a database source model from the Python modules and the classes defined within.
|
|
1320
|
+
|
|
1321
|
+
Next, discovers database objects (e.g. tables, structs, user-defined types, constraints) and builds a target
|
|
1322
|
+
model representing those objects. The database model is specific to the selected dialect.
|
|
1323
|
+
|
|
1324
|
+
Finally, compares the source and the target models, and mutates the database state from the source state to
|
|
1325
|
+
the desired target state. Mutation involves executing SQL statements.
|
|
1326
|
+
|
|
1327
|
+
:param module: The Python module identifying the database schema to discover.
|
|
1328
|
+
:param modules: The list of Python modules identifying database schemas to discover.
|
|
1329
|
+
"""
|
|
1330
|
+
|
|
1331
|
+
entity_modules = _module_or_list(module, modules)
|
|
1332
|
+
|
|
1333
|
+
generator = self.conn.connection.generator
|
|
1334
|
+
|
|
1335
|
+
# determine target database schema
|
|
1336
|
+
generator.reset()
|
|
1337
|
+
generator.create(tables=get_entity_types(entity_modules))
|
|
1338
|
+
target_state = generator.state
|
|
1339
|
+
LOGGER.debug("desired state:\n%s", generator.state)
|
|
1340
|
+
|
|
1341
|
+
# acquire current database schema
|
|
1342
|
+
await self.discover(modules=entity_modules)
|
|
1343
|
+
|
|
1344
|
+
# mutate current state into desired state
|
|
1345
|
+
stmt = generator.get_mutate_stmt(target_state)
|
|
1346
|
+
if stmt is not None:
|
|
1347
|
+
LOGGER.info("synchronize schema with SQL:\n%s", stmt)
|
|
1348
|
+
await self.conn.execute(stmt)
|
|
1349
|
+
generator.state = target_state
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
class BaseEngine(abc.ABC):
|
|
1353
|
+
"Represents a specific database server type."
|
|
1354
|
+
|
|
1355
|
+
@property
|
|
1356
|
+
@abc.abstractmethod
|
|
1357
|
+
def name(self) -> str: ...
|
|
1358
|
+
|
|
1359
|
+
@abc.abstractmethod
|
|
1360
|
+
def get_generator_type(self) -> type[BaseGenerator]: ...
|
|
1361
|
+
|
|
1362
|
+
@abc.abstractmethod
|
|
1363
|
+
def get_connection_type(self) -> type[BaseConnection]: ...
|
|
1364
|
+
|
|
1365
|
+
@abc.abstractmethod
|
|
1366
|
+
def get_explorer_type(self) -> type[Explorer]: ...
|
|
1367
|
+
|
|
1368
|
+
def create_connection(self, params: ConnectionParameters, options: Optional[GeneratorOptions] = None) -> BaseConnection:
|
|
1369
|
+
"Opens a connection to a database server."
|
|
1370
|
+
|
|
1371
|
+
generator_options = options if options is not None else GeneratorOptions()
|
|
1372
|
+
connection_type = self.get_connection_type()
|
|
1373
|
+
return connection_type(self.create_generator(generator_options), params)
|
|
1374
|
+
|
|
1375
|
+
def create_generator(self, options: Optional[GeneratorOptions] = None) -> BaseGenerator:
|
|
1376
|
+
"Instantiates a generator that can emit SQL statements."
|
|
1377
|
+
|
|
1378
|
+
generator_options = options if options is not None else GeneratorOptions()
|
|
1379
|
+
generator_type = self.get_generator_type()
|
|
1380
|
+
return generator_type(generator_options)
|
|
1381
|
+
|
|
1382
|
+
def create_explorer(self, conn: BaseContext) -> Explorer:
|
|
1383
|
+
"Instantiates an explorer that can discover objects in a database."
|
|
1384
|
+
|
|
1385
|
+
explorer_type = self.get_explorer_type()
|
|
1386
|
+
return explorer_type(conn)
|