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
@@ -0,0 +1,194 @@
1
+ import dataclasses
2
+ import datetime
3
+ import decimal
4
+ import keyword
5
+ import sys
6
+ import types
7
+ import typing
8
+ from dataclasses import dataclass
9
+ from io import StringIO
10
+ from typing import Annotated, Any, Optional, Union
11
+ from uuid import UUID
12
+
13
+ from strong_typing.auxiliary import Length, MaxLength, Precision, TimePrecision, float32, float64, int16, int32, int64
14
+ from strong_typing.core import JsonType
15
+ from strong_typing.inspection import DataclassInstance, TypeLike
16
+
17
+ from ..model.data_types import (
18
+ SqlArrayType,
19
+ SqlBooleanType,
20
+ SqlDataType,
21
+ SqlDateType,
22
+ SqlDecimalType,
23
+ SqlDoubleType,
24
+ SqlFixedCharacterType,
25
+ SqlFloatType,
26
+ SqlIntegerType,
27
+ SqlJsonType,
28
+ SqlRealType,
29
+ SqlTimestampType,
30
+ SqlTimeType,
31
+ SqlUserDefinedType,
32
+ SqlUuidType,
33
+ SqlVariableCharacterType,
34
+ )
35
+ from ..model.id_types import SupportsQualifiedId
36
+ from ..model.key_types import PrimaryKey
37
+ from .object_types import Column, DiscriminatedConstraint, ForeignConstraint, ReferenceConstraint, Table
38
+
39
+
40
+ def sql_type_to_python(sql_type: SqlDataType) -> TypeLike:
41
+ if isinstance(sql_type, SqlBooleanType):
42
+ return bool
43
+ elif isinstance(sql_type, SqlIntegerType):
44
+ if sql_type.width == 2:
45
+ return int16
46
+ elif sql_type.width == 4:
47
+ return int32
48
+ elif sql_type.width == 8:
49
+ return int64
50
+ return int
51
+ elif isinstance(sql_type, SqlRealType):
52
+ return float32
53
+ elif isinstance(sql_type, SqlDoubleType):
54
+ return float64
55
+ elif isinstance(sql_type, SqlFloatType):
56
+ if sql_type.precision is not None:
57
+ return Annotated[float, Precision(sql_type.precision)]
58
+ return float
59
+ elif isinstance(sql_type, SqlDecimalType):
60
+ return decimal.Decimal
61
+ elif isinstance(sql_type, SqlTimestampType):
62
+ if sql_type.precision is not None:
63
+ return Annotated[datetime.datetime, TimePrecision(sql_type.precision)]
64
+ return datetime.datetime
65
+ elif isinstance(sql_type, SqlDateType):
66
+ return datetime.date
67
+ elif isinstance(sql_type, SqlTimeType):
68
+ if sql_type.precision is not None:
69
+ return Annotated[datetime.time, TimePrecision(sql_type.precision)]
70
+ return datetime.time
71
+ elif isinstance(sql_type, SqlFixedCharacterType):
72
+ if sql_type.limit is not None:
73
+ return Annotated[str, Length(sql_type.limit)]
74
+ return str
75
+ elif isinstance(sql_type, SqlVariableCharacterType):
76
+ if sql_type.limit is not None:
77
+ return Annotated[str, MaxLength(sql_type.limit)]
78
+ return str
79
+ elif isinstance(sql_type, SqlUuidType):
80
+ return UUID
81
+ elif isinstance(sql_type, SqlJsonType):
82
+ return JsonType
83
+ elif isinstance(sql_type, SqlUserDefinedType):
84
+ return sql_type.ref.compact_id
85
+ elif isinstance(sql_type, SqlArrayType):
86
+ item_type = (sql_type_to_python(sql_type.element_type),)
87
+ return list[item_type] # type: ignore
88
+
89
+ raise TypeError(f"unrecognized SQL type: {sql_type}")
90
+
91
+
92
+ def safe_id(id: str) -> str:
93
+ "Apply PEP 8: single trailing underscore to avoid conflicts with Python keyword"
94
+
95
+ return f"{id}_" if keyword.iskeyword(id) else id
96
+
97
+
98
+ @dataclass
99
+ class SqlConverterOptions:
100
+ namespaces: dict[Optional[str], types.ModuleType]
101
+
102
+
103
+ class SqlConverter:
104
+ options: SqlConverterOptions
105
+
106
+ def __init__(self, options: SqlConverterOptions) -> None:
107
+ self.options = options
108
+
109
+ def qual_to_module(self, id: SupportsQualifiedId) -> str:
110
+ return f"{self.options.namespaces[id.scope_id].__name__}.{safe_id(id.local_id)}"
111
+
112
+ def column_to_field(self, table: Table, column: Column) -> tuple[str, type, dataclasses.Field[Any]]:
113
+ """
114
+ Generates a dataclass field corresponding to a table column.
115
+
116
+ :param column: The database column from which to produce a dataclass field.
117
+ """
118
+
119
+ field_name = safe_id(column.name.local_id)
120
+
121
+ default: Any = dataclasses.MISSING
122
+ if column.default is not None:
123
+ default = column.default
124
+
125
+ field_type = sql_type_to_python(column.data_type)
126
+ if (column.name,) == table.primary_key:
127
+ field_type = PrimaryKey[(field_type,)] # type: ignore
128
+ elif column.nullable:
129
+ field_type = Optional[field_type]
130
+
131
+ for c in table.constraints.values():
132
+ if not isinstance(c, ReferenceConstraint):
133
+ continue
134
+
135
+ if column.name not in c.foreign_columns:
136
+ continue
137
+
138
+ if isinstance(c, ForeignConstraint):
139
+ field_type = self.qual_to_module(c.reference.table)
140
+ elif isinstance(c, DiscriminatedConstraint):
141
+ union_types = tuple(self.qual_to_module(r.table) for r in c.references)
142
+ field_type = Union[union_types]
143
+
144
+ # use cast to ensure compatibility with signature of `make_dataclass`
145
+ data_type = typing.cast(type, field_type)
146
+
147
+ return (field_name, data_type, dataclasses.field(default=default))
148
+
149
+ def table_to_dataclass(self, table: Table) -> type[DataclassInstance]:
150
+ """
151
+ Generates a dataclass type corresponding to a table schema.
152
+
153
+ :param table: The database table from which to produce a dataclass.
154
+ """
155
+
156
+ fields = [self.column_to_field(table, column) for column in table.columns.values()]
157
+ class_name = safe_id(table.name.local_id)
158
+
159
+ # default arguments must follow non-default arguments
160
+ fields.sort(key=lambda f: f[2].default is not dataclasses.MISSING)
161
+
162
+ # look up target module
163
+ module = self.options.namespaces[table.name.scope_id]
164
+
165
+ # produce class definition with docstring
166
+ if sys.version_info >= (3, 12):
167
+ typ = dataclasses.make_dataclass(class_name, fields, module=module.__name__)
168
+ else:
169
+ typ = dataclasses.make_dataclass(class_name, fields, namespace={"__module__": module.__name__})
170
+ with StringIO() as out:
171
+ for field in dataclasses.fields(typ):
172
+ description = field.metadata.get("description")
173
+ if description is not None:
174
+ print(f":param {field.name}: {description}", file=out)
175
+ paramstring = out.getvalue()
176
+ with StringIO() as out:
177
+ if table.description:
178
+ out.write(table.description)
179
+ if table.description and paramstring:
180
+ out.write("\n\n")
181
+ if paramstring:
182
+ out.write(paramstring)
183
+ docstring = out.getvalue()
184
+ typ.__doc__ = docstring
185
+
186
+ # assign the newly created type to the target module
187
+ setattr(sys.modules[module.__name__], class_name, typ)
188
+
189
+ return typ
190
+
191
+
192
+ def table_to_dataclass(table: Table, options: SqlConverterOptions) -> type[DataclassInstance]:
193
+ converter = SqlConverter(options)
194
+ return converter.table_to_dataclass(table)
File without changes
@@ -0,0 +1,397 @@
1
+ import datetime
2
+ import decimal
3
+ from dataclasses import dataclass
4
+ from functools import reduce
5
+ from typing import Any, Optional
6
+
7
+ from strong_typing.auxiliary import IntegerRange, MaxLength, Precision, Signed, Storage, TimePrecision
8
+ from strong_typing.inspection import dataclass_fields, is_dataclass_instance
9
+
10
+ from .id_types import LocalId, SupportsQualifiedId
11
+
12
+
13
+ def quote(s: str) -> str:
14
+ "Quotes a string to be embedded in an SQL statement."
15
+
16
+ return "'" + s.replace("'", "''") + "'"
17
+
18
+
19
+ def constant(v: Any) -> str:
20
+ "Outputs a constant value."
21
+
22
+ if isinstance(v, str):
23
+ return quote(v)
24
+ elif isinstance(v, bool):
25
+ return "TRUE" if v else "FALSE"
26
+ elif isinstance(v, (int, float)):
27
+ return str(v)
28
+ elif isinstance(v, decimal.Decimal):
29
+ return str(v)
30
+ elif isinstance(v, datetime.datetime):
31
+ if v.tzinfo is not None:
32
+ timestamp = v.astimezone(tz=datetime.timezone.utc).replace(tzinfo=None)
33
+ else:
34
+ timestamp = v
35
+ return quote(timestamp.isoformat(sep=" "))
36
+ elif isinstance(v, tuple):
37
+ values = ", ".join(constant(value) for value in v) # pyright: ignore[reportUnknownVariableType]
38
+ return f"({values})"
39
+ elif is_dataclass_instance(v):
40
+ values = ", ".join(constant(getattr(v, field.name)) for field in dataclass_fields(type(v)))
41
+ return f"({values})"
42
+ else:
43
+ raise NotImplementedError(f"unknown constant representation for value (of type): {v} ({type(v)})")
44
+
45
+
46
+ def escape_like(value: str, escape_char: str) -> str:
47
+ "Escapes a string to be embedded in an SQL LIKE '...' ESCAPE '...' expression."
48
+
49
+ return (
50
+ value.replace("'", "''")
51
+ .replace(escape_char, f"{escape_char}{escape_char}")
52
+ .replace("_", f"{escape_char}_")
53
+ .replace("%", f"{escape_char}%")
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class SqlDataType:
59
+ def parse_meta(self, meta: Any) -> None:
60
+ raise TypeError(f"unrecognized Python type annotation for {type(self).__name__}: {meta}")
61
+
62
+ def value_to_sql_literal(self, value: Any) -> str:
63
+ return constant(value)
64
+
65
+
66
+ @dataclass
67
+ class SqlArrayType(SqlDataType):
68
+ element_type: SqlDataType
69
+
70
+ def __str__(self) -> str:
71
+ return f"{self.element_type} ARRAY"
72
+
73
+
74
+ @dataclass
75
+ class SqlUuidType(SqlDataType):
76
+ def __str__(self) -> str:
77
+ return "uuid"
78
+
79
+
80
+ @dataclass
81
+ class SqlBooleanType(SqlDataType):
82
+ def __str__(self) -> str:
83
+ return "boolean"
84
+
85
+
86
+ @dataclass
87
+ class SqlIntegerType(SqlDataType):
88
+ width: int
89
+ signed: bool = True
90
+ minimum: Optional[int] = None
91
+ maximum: Optional[int] = None
92
+
93
+ def __str__(self) -> str:
94
+ if self.width == 1:
95
+ return "tinyint"
96
+ elif self.width == 2:
97
+ return "smallint"
98
+ elif self.width == 4:
99
+ return "integer"
100
+ elif self.width == 8:
101
+ return "bigint"
102
+
103
+ raise TypeError(f"invalid integer width: {self.width}")
104
+
105
+ def parse_meta(self, meta: Any) -> None:
106
+ if isinstance(meta, IntegerRange):
107
+ self.minimum = meta.minimum
108
+ self.maximum = meta.maximum
109
+ elif isinstance(meta, Signed):
110
+ self.signed = meta.is_signed
111
+ elif isinstance(meta, Storage):
112
+ self.width = meta.bytes
113
+ else:
114
+ super().parse_meta(meta)
115
+
116
+
117
+ @dataclass
118
+ class SqlFloatType(SqlDataType):
119
+ """
120
+ Floating-point numeric type.
121
+
122
+ :param precision: Numeric precision in base 2.
123
+ """
124
+
125
+ precision: Optional[int] = None
126
+
127
+ def __str__(self) -> str:
128
+ if self.precision is not None:
129
+ precision = self.precision
130
+ else:
131
+ precision = 53
132
+
133
+ return f"float({precision})"
134
+
135
+ def parse_meta(self, meta: Any) -> None:
136
+ if isinstance(meta, Precision):
137
+ self.precision = meta.significant_digits
138
+ else:
139
+ super().parse_meta(meta)
140
+
141
+
142
+ @dataclass
143
+ class SqlRealType(SqlDataType):
144
+ def __str__(self) -> str:
145
+ return "real"
146
+
147
+
148
+ @dataclass
149
+ class SqlDoubleType(SqlDataType):
150
+ def __str__(self) -> str:
151
+ return "double precision"
152
+
153
+
154
+ @dataclass
155
+ class SqlDecimalType(SqlDataType):
156
+ """
157
+ Fixed-point numeric type.
158
+
159
+ :param precision: Numeric precision in base 10.
160
+ :param scale: Scale in base 10.
161
+ """
162
+
163
+ precision: Optional[int] = None
164
+ scale: Optional[int] = None
165
+
166
+ def __str__(self) -> str:
167
+ if self.precision is not None and self.scale is not None:
168
+ return f"decimal({self.precision}, {self.scale})"
169
+ elif self.precision is not None:
170
+ return f"decimal({self.precision})"
171
+ else:
172
+ return "decimal"
173
+
174
+ def parse_meta(self, meta: Any) -> None:
175
+ if isinstance(meta, Precision):
176
+ self.precision = meta.significant_digits
177
+ self.scale = meta.decimal_digits
178
+ else:
179
+ super().parse_meta(meta)
180
+
181
+
182
+ @dataclass
183
+ class SqlFixedBinaryType(SqlDataType):
184
+ storage: Optional[int] = None
185
+
186
+ def __str__(self) -> str:
187
+ storage = f"({self.storage})" if self.storage is not None else ""
188
+ return f"binary{storage}"
189
+
190
+
191
+ @dataclass
192
+ class SqlVariableBinaryType(SqlDataType):
193
+ storage: Optional[int] = None
194
+
195
+ def __str__(self) -> str:
196
+ if self.storage is not None:
197
+ return f"varbinary({self.storage})"
198
+ else:
199
+ return "blob"
200
+
201
+ def parse_meta(self, meta: Any) -> None:
202
+ if isinstance(meta, Storage):
203
+ self.storage = meta.bytes
204
+ else:
205
+ super().parse_meta(meta)
206
+
207
+
208
+ @dataclass
209
+ class SqlFixedCharacterType(SqlDataType):
210
+ limit: Optional[int] = None
211
+
212
+ def __str__(self) -> str:
213
+ limit = f"({self.limit})" if self.limit is not None else ""
214
+ return f"char{limit}"
215
+
216
+
217
+ @dataclass
218
+ class SqlVariableCharacterType(SqlDataType):
219
+ limit: Optional[int] = None
220
+
221
+ def __str__(self) -> str:
222
+ if self.limit is not None:
223
+ return f"varchar({self.limit})"
224
+ else:
225
+ return "text"
226
+
227
+ def parse_meta(self, meta: Any) -> None:
228
+ if isinstance(meta, MaxLength):
229
+ self.limit = meta.value
230
+ else:
231
+ super().parse_meta(meta)
232
+
233
+
234
+ @dataclass
235
+ class SqlTimestampType(SqlDataType):
236
+ precision: Optional[int] = None
237
+ with_time_zone: bool = False
238
+
239
+ def __str__(self) -> str:
240
+ if self.precision is not None:
241
+ precision = f"({self.precision})"
242
+ else:
243
+ precision = ""
244
+ if self.with_time_zone:
245
+ time_zone = " with time zone"
246
+ else:
247
+ time_zone = "" # PostgreSQL: " without time zone"
248
+ return f"timestamp{precision}{time_zone}"
249
+
250
+ def parse_meta(self, meta: Any) -> None:
251
+ if isinstance(meta, TimePrecision):
252
+ self.precision = meta.decimal_digits
253
+ else:
254
+ super().parse_meta(meta)
255
+
256
+
257
+ @dataclass
258
+ class SqlDateType(SqlDataType):
259
+ def __str__(self) -> str:
260
+ return "date"
261
+
262
+
263
+ @dataclass
264
+ class SqlTimeType(SqlDataType):
265
+ precision: Optional[int] = None
266
+ with_time_zone: bool = False
267
+
268
+ def __str__(self) -> str:
269
+ if self.precision is not None:
270
+ precision = f"({self.precision})"
271
+ else:
272
+ precision = ""
273
+ if self.with_time_zone:
274
+ time_zone = " with time zone"
275
+ else:
276
+ time_zone = "" # PostgreSQL: " without time zone"
277
+ return f"time{precision}{time_zone}"
278
+
279
+ def parse_meta(self, meta: Any) -> None:
280
+ if isinstance(meta, TimePrecision):
281
+ self.precision = meta.decimal_digits
282
+ else:
283
+ super().parse_meta(meta)
284
+
285
+
286
+ @dataclass
287
+ class SqlIntervalType(SqlDataType):
288
+ def __str__(self) -> str:
289
+ return "interval"
290
+
291
+
292
+ @dataclass
293
+ class SqlJsonType(SqlDataType):
294
+ def __str__(self) -> str:
295
+ return "json"
296
+
297
+
298
+ @dataclass
299
+ class SqlEnumType(SqlDataType):
300
+ values: list[str]
301
+
302
+ def __str__(self) -> str:
303
+ values = ", ".join(quote(val) for val in self.values)
304
+ return f"ENUM ({values})"
305
+
306
+
307
+ @dataclass
308
+ class SqlStructMember:
309
+ name: LocalId
310
+ data_type: SqlDataType
311
+ nullable: bool
312
+
313
+ def __str__(self) -> str:
314
+ nullable = " NOT NULL" if not self.nullable else ""
315
+ return f"{self.name} {self.data_type}{nullable}"
316
+
317
+
318
+ @dataclass
319
+ class SqlStructType(SqlDataType):
320
+ fields: list[SqlStructMember]
321
+
322
+ def __str__(self) -> str:
323
+ field_list = ", ".join(str(f) for f in self.fields)
324
+ return f"STRUCT <{field_list}>"
325
+
326
+
327
+ @dataclass
328
+ class SqlUserDefinedType(SqlDataType):
329
+ ref: SupportsQualifiedId
330
+
331
+ def __str__(self) -> str:
332
+ return self.ref.quoted_id
333
+
334
+
335
+ class CompatibilityError(TypeError):
336
+ "Raised when a list of types cannot be reduced into a single common type."
337
+
338
+
339
+ def compatible_type(data_types: list[SqlDataType]) -> SqlDataType:
340
+ "Returns a single common type that all elements in a list of types can be converted into without data loss."
341
+
342
+ return reduce(_compatible_type, data_types)
343
+
344
+
345
+ def max_or_none(left: Optional[int], right: Optional[int]) -> Optional[int]:
346
+ if left is None or right is None:
347
+ return None
348
+ else:
349
+ return max(left, right)
350
+
351
+
352
+ def _compatible_type(left: SqlDataType, right: SqlDataType) -> SqlDataType:
353
+ """
354
+ Returns a common type that can represent instances of both source types without data loss.
355
+
356
+ The implementation makes use of `type(...)` to ensure the most specific class is instantiated when
357
+ database-specific data types are created with inheritance.
358
+ """
359
+
360
+ if left == right:
361
+ return left
362
+
363
+ # integer types
364
+ if isinstance(left, SqlIntegerType) and isinstance(right, SqlIntegerType):
365
+ return type(left)(width=max(left.width, right.width))
366
+
367
+ # floating-point types
368
+ if isinstance(left, SqlRealType):
369
+ if isinstance(right, SqlRealType):
370
+ return type(left)()
371
+ elif isinstance(right, SqlDoubleType):
372
+ return type(right)()
373
+ elif isinstance(left, SqlDoubleType):
374
+ if isinstance(right, (SqlRealType, SqlDoubleType)):
375
+ return type(left)()
376
+
377
+ # character types
378
+ if isinstance(left, SqlFixedCharacterType):
379
+ if isinstance(right, SqlFixedCharacterType):
380
+ return type(left)(limit=max_or_none(left.limit, right.limit))
381
+ elif isinstance(right, SqlVariableCharacterType):
382
+ return type(right)(limit=max_or_none(left.limit, right.limit))
383
+ elif isinstance(left, SqlVariableCharacterType):
384
+ if isinstance(right, (SqlFixedCharacterType, SqlVariableCharacterType)):
385
+ return type(left)(limit=max_or_none(left.limit, right.limit))
386
+
387
+ # binary types
388
+ if isinstance(left, SqlFixedBinaryType):
389
+ if isinstance(right, SqlFixedBinaryType):
390
+ return type(left)(storage=max_or_none(left.storage, right.storage))
391
+ elif isinstance(right, SqlVariableBinaryType):
392
+ return type(right)(storage=max_or_none(left.storage, right.storage))
393
+ elif isinstance(left, SqlVariableBinaryType):
394
+ if isinstance(right, (SqlFixedBinaryType, SqlVariableBinaryType)):
395
+ return type(left)(storage=max_or_none(left.storage, right.storage))
396
+
397
+ raise CompatibilityError()
@@ -0,0 +1,68 @@
1
+ import dataclasses
2
+ import sys
3
+ from typing import Any, Optional
4
+
5
+ from strong_typing.inspection import DataclassField, DataclassInstance, dataclass_fields
6
+
7
+ from .key_types import PrimaryKey
8
+
9
+
10
+ def key_value_fields(cls: type[DataclassInstance], key: str) -> tuple[DataclassField, list[DataclassField]]:
11
+ """
12
+ Separates the key and the value fields in a data-class definition.
13
+
14
+ :param cls: The data-class type to transform.
15
+ :param key: The field to be come the primary key.
16
+ """
17
+
18
+ source_fields = dataclass_fields(cls)
19
+ key_field: Optional[DataclassField] = None
20
+ value_fields: list[DataclassField] = []
21
+ for source_field in source_fields:
22
+ if source_field.name == key:
23
+ key_field = source_field
24
+ else:
25
+ value_fields.append(source_field)
26
+
27
+ if key_field is None:
28
+ raise ValueError(f"key field not found: {key}")
29
+
30
+ return key_field, value_fields
31
+
32
+
33
+ def make_entity(cls: type[DataclassInstance], key: str) -> type[DataclassInstance]:
34
+ """
35
+ Transforms a regular data-class type into an entity type.
36
+
37
+ :param cls: The data-class type to transform.
38
+ :param key: The field to become the primary key.
39
+ """
40
+
41
+ key_field, value_fields = key_value_fields(cls, key)
42
+ key_type = key_field.type
43
+
44
+ primary_key_type = PrimaryKey[key_type] # type: ignore
45
+ target_fields: list[tuple[str, Any, dataclasses.Field[Any]]] = [
46
+ (key_field.name, primary_key_type, dataclasses.field(default=key_field.default))
47
+ ]
48
+ for value_field in value_fields:
49
+ target_fields.append(
50
+ (
51
+ value_field.name,
52
+ value_field.type,
53
+ dataclasses.field(default=value_field.default),
54
+ )
55
+ )
56
+
57
+ class_name = cls.__name__
58
+ class_module = cls.__module__
59
+ class_doc = cls.__doc__
60
+
61
+ if sys.version_info >= (3, 12):
62
+ class_type = dataclasses.make_dataclass(class_name, target_fields, module=class_module)
63
+ else:
64
+ class_type = dataclasses.make_dataclass(class_name, target_fields, namespace={"__module__": class_module})
65
+
66
+ class_type.__doc__ = class_doc
67
+ setattr(sys.modules[class_module], class_name, class_type)
68
+ return class_type