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.
Files changed (102) hide show
  1. instructure_pysqlsync-0.8.5.dev0.dist-info/METADATA +244 -0
  2. instructure_pysqlsync-0.8.5.dev0.dist-info/RECORD +102 -0
  3. instructure_pysqlsync-0.8.5.dev0.dist-info/WHEEL +5 -0
  4. instructure_pysqlsync-0.8.5.dev0.dist-info/licenses/LICENSE +21 -0
  5. instructure_pysqlsync-0.8.5.dev0.dist-info/top_level.txt +1 -0
  6. instructure_pysqlsync-0.8.5.dev0.dist-info/zip-safe +1 -0
  7. pysqlsync/__init__.py +15 -0
  8. pysqlsync/base.py +1386 -0
  9. pysqlsync/connection.py +101 -0
  10. pysqlsync/data/__init__.py +0 -0
  11. pysqlsync/data/exchange.py +184 -0
  12. pysqlsync/data/generator.py +305 -0
  13. pysqlsync/dialect/README.md +35 -0
  14. pysqlsync/dialect/__init__.py +0 -0
  15. pysqlsync/dialect/delta/__init__.py +0 -0
  16. pysqlsync/dialect/delta/data_types.py +51 -0
  17. pysqlsync/dialect/delta/dependency.py +7 -0
  18. pysqlsync/dialect/delta/engine.py +18 -0
  19. pysqlsync/dialect/delta/generator.py +74 -0
  20. pysqlsync/dialect/delta/object_types.py +87 -0
  21. pysqlsync/dialect/mssql/README.md +23 -0
  22. pysqlsync/dialect/mssql/__init__.py +0 -0
  23. pysqlsync/dialect/mssql/connection.py +146 -0
  24. pysqlsync/dialect/mssql/data_types.py +113 -0
  25. pysqlsync/dialect/mssql/dependency.py +9 -0
  26. pysqlsync/dialect/mssql/discovery.py +99 -0
  27. pysqlsync/dialect/mssql/engine.py +20 -0
  28. pysqlsync/dialect/mssql/generator.py +88 -0
  29. pysqlsync/dialect/mssql/mutation.py +46 -0
  30. pysqlsync/dialect/mssql/object_types.py +135 -0
  31. pysqlsync/dialect/mysql/__init__.py +0 -0
  32. pysqlsync/dialect/mysql/connection.py +126 -0
  33. pysqlsync/dialect/mysql/data_types.py +40 -0
  34. pysqlsync/dialect/mysql/dependency.py +10 -0
  35. pysqlsync/dialect/mysql/discovery.py +145 -0
  36. pysqlsync/dialect/mysql/engine.py +20 -0
  37. pysqlsync/dialect/mysql/generator.py +140 -0
  38. pysqlsync/dialect/mysql/mutation.py +65 -0
  39. pysqlsync/dialect/mysql/object_types.py +75 -0
  40. pysqlsync/dialect/oracle/README.md +22 -0
  41. pysqlsync/dialect/oracle/__init__.py +0 -0
  42. pysqlsync/dialect/oracle/connection.py +141 -0
  43. pysqlsync/dialect/oracle/data_types.py +104 -0
  44. pysqlsync/dialect/oracle/dependency.py +9 -0
  45. pysqlsync/dialect/oracle/discovery.py +231 -0
  46. pysqlsync/dialect/oracle/engine.py +20 -0
  47. pysqlsync/dialect/oracle/generator.py +93 -0
  48. pysqlsync/dialect/oracle/mutation.py +37 -0
  49. pysqlsync/dialect/oracle/object_types.py +59 -0
  50. pysqlsync/dialect/postgresql/__init__.py +0 -0
  51. pysqlsync/dialect/postgresql/connection.py +133 -0
  52. pysqlsync/dialect/postgresql/data_types.py +6 -0
  53. pysqlsync/dialect/postgresql/dependency.py +9 -0
  54. pysqlsync/dialect/postgresql/discovery.py +392 -0
  55. pysqlsync/dialect/postgresql/engine.py +20 -0
  56. pysqlsync/dialect/postgresql/generator.py +99 -0
  57. pysqlsync/dialect/postgresql/mutation.py +82 -0
  58. pysqlsync/dialect/postgresql/object_types.py +99 -0
  59. pysqlsync/dialect/redshift/__init__.py +0 -0
  60. pysqlsync/dialect/redshift/connection.py +156 -0
  61. pysqlsync/dialect/redshift/data_types.py +10 -0
  62. pysqlsync/dialect/redshift/dependency.py +9 -0
  63. pysqlsync/dialect/redshift/engine.py +20 -0
  64. pysqlsync/dialect/redshift/generator.py +82 -0
  65. pysqlsync/dialect/snowflake/__init__.py +0 -0
  66. pysqlsync/dialect/snowflake/data_types.py +18 -0
  67. pysqlsync/dialect/snowflake/dependency.py +9 -0
  68. pysqlsync/dialect/snowflake/engine.py +18 -0
  69. pysqlsync/dialect/snowflake/generator.py +64 -0
  70. pysqlsync/dialect/snowflake/object_types.py +84 -0
  71. pysqlsync/dialect/test/__init__.py +0 -0
  72. pysqlsync/dialect/test/dependency.py +10 -0
  73. pysqlsync/dialect/trino/__init__.py +0 -0
  74. pysqlsync/dialect/trino/connection.py +86 -0
  75. pysqlsync/dialect/trino/dependency.py +9 -0
  76. pysqlsync/dialect/trino/discovery.py +21 -0
  77. pysqlsync/dialect/trino/engine.py +20 -0
  78. pysqlsync/dialect/trino/generator.py +31 -0
  79. pysqlsync/factory.py +169 -0
  80. pysqlsync/formation/__init__.py +0 -0
  81. pysqlsync/formation/constraints.py +78 -0
  82. pysqlsync/formation/data_types.py +192 -0
  83. pysqlsync/formation/discovery.py +390 -0
  84. pysqlsync/formation/inspection.py +179 -0
  85. pysqlsync/formation/mutation.py +320 -0
  86. pysqlsync/formation/object_dict.py +79 -0
  87. pysqlsync/formation/object_types.py +872 -0
  88. pysqlsync/formation/py_to_sql.py +1134 -0
  89. pysqlsync/formation/sql_to_py.py +194 -0
  90. pysqlsync/model/__init__.py +0 -0
  91. pysqlsync/model/data_types.py +397 -0
  92. pysqlsync/model/entity_types.py +68 -0
  93. pysqlsync/model/id_types.py +193 -0
  94. pysqlsync/model/key_types.py +38 -0
  95. pysqlsync/model/properties.py +234 -0
  96. pysqlsync/py.typed +0 -0
  97. pysqlsync/python_types.py +192 -0
  98. pysqlsync/resultset.py +106 -0
  99. pysqlsync/util/__init__.py +0 -0
  100. pysqlsync/util/dataclasses.py +80 -0
  101. pysqlsync/util/dispatch.py +28 -0
  102. 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)