dclassql 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dclassql/__init__.py +12 -0
- dclassql/cli.py +123 -0
- dclassql/codegen.py +508 -0
- dclassql/db_pool.py +76 -0
- dclassql/model_inspector.py +383 -0
- dclassql/push/__init__.py +60 -0
- dclassql/push/base.py +417 -0
- dclassql/push/sqlite.py +195 -0
- dclassql/runtime/backends.py +669 -0
- dclassql/runtime/datasource.py +39 -0
- dclassql/table_spec.py +142 -0
- dclassql/unwarp.py +12 -0
- dclassql-0.1.0.dist-info/METADATA +164 -0
- dclassql-0.1.0.dist-info/RECORD +16 -0
- dclassql-0.1.0.dist-info/WHEEL +4 -0
- dclassql-0.1.0.dist-info/entry_points.txt +3 -0
dclassql/push/base.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Iterable, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
from pypika import Query, Table
|
|
8
|
+
from pypika.queries import Column
|
|
9
|
+
from pypika.terms import Index as PypikaIndex
|
|
10
|
+
|
|
11
|
+
from ..model_inspector import ColumnInfo, ModelInfo
|
|
12
|
+
from ..table_spec import TableInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True, frozen=True)
|
|
16
|
+
class ColumnDeclaration:
|
|
17
|
+
name: str
|
|
18
|
+
type_sql: str
|
|
19
|
+
definition_sql: str
|
|
20
|
+
not_null: bool
|
|
21
|
+
primary_key: bool
|
|
22
|
+
auto_increment: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True, frozen=True)
|
|
26
|
+
class IndexDefinition:
|
|
27
|
+
name: str
|
|
28
|
+
columns: tuple[str, ...]
|
|
29
|
+
unique: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class SchemaPlan:
|
|
34
|
+
create_sql: str
|
|
35
|
+
columns: tuple[ColumnDeclaration, ...]
|
|
36
|
+
indexes: tuple[IndexDefinition, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True, frozen=True)
|
|
40
|
+
class ExistingColumn:
|
|
41
|
+
name: str
|
|
42
|
+
type_sql: str
|
|
43
|
+
not_null: bool
|
|
44
|
+
primary_key: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True, frozen=True)
|
|
48
|
+
class ColumnChange:
|
|
49
|
+
name: str
|
|
50
|
+
current: ExistingColumn
|
|
51
|
+
expected: ColumnDeclaration
|
|
52
|
+
reasons: tuple[str, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True, frozen=True)
|
|
56
|
+
class SchemaDiff:
|
|
57
|
+
added: tuple[ColumnDeclaration, ...]
|
|
58
|
+
removed: tuple[ExistingColumn, ...]
|
|
59
|
+
changed: tuple[ColumnChange, ...]
|
|
60
|
+
|
|
61
|
+
def is_empty(self) -> bool:
|
|
62
|
+
return not self.added and not self.removed and not self.changed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SchemaBuilder(ABC):
|
|
66
|
+
quote_char: str = '"'
|
|
67
|
+
|
|
68
|
+
def __init__(self, info: ModelInfo) -> None:
|
|
69
|
+
self.info = info
|
|
70
|
+
self.table_info = TableInfo.from_dc(info.model)
|
|
71
|
+
self._column_declarations: list[ColumnDeclaration] = []
|
|
72
|
+
self._table_name_override: str | None = None
|
|
73
|
+
|
|
74
|
+
def build(self, *, table_name: str | None = None) -> SchemaPlan:
|
|
75
|
+
previous = self._table_name_override
|
|
76
|
+
self._table_name_override = table_name
|
|
77
|
+
try:
|
|
78
|
+
create_sql = self.render_create_table_sql()
|
|
79
|
+
index_definitions = self.render_index_definitions()
|
|
80
|
+
return SchemaPlan(
|
|
81
|
+
create_sql=create_sql,
|
|
82
|
+
columns=tuple(self._column_declarations),
|
|
83
|
+
indexes=tuple(index_definitions),
|
|
84
|
+
)
|
|
85
|
+
finally:
|
|
86
|
+
self._table_name_override = previous
|
|
87
|
+
|
|
88
|
+
def render_create_table_sql(self) -> str:
|
|
89
|
+
self._column_declarations = []
|
|
90
|
+
builder = Query.create_table(self.table_name).if_not_exists()
|
|
91
|
+
|
|
92
|
+
pk_cols = self._normalize_col_names(self.table_info.primary_key.col_name())
|
|
93
|
+
pk_set = set(pk_cols)
|
|
94
|
+
single_inline_pk = self._has_inline_primary_key(pk_cols)
|
|
95
|
+
|
|
96
|
+
for column in self.info.columns:
|
|
97
|
+
declaration = self.render_column_declaration(
|
|
98
|
+
column=column,
|
|
99
|
+
pk_columns=pk_cols,
|
|
100
|
+
pk_members=pk_set,
|
|
101
|
+
single_inline_pk=single_inline_pk,
|
|
102
|
+
)
|
|
103
|
+
self._column_declarations.append(declaration)
|
|
104
|
+
builder = builder.columns(self.make_column(column.name, declaration.definition_sql))
|
|
105
|
+
|
|
106
|
+
seen_unique: set[tuple[str, ...]] = set()
|
|
107
|
+
for spec in self.table_info.unique_index:
|
|
108
|
+
columns = self._normalize_col_names(spec.col_name())
|
|
109
|
+
if columns in seen_unique:
|
|
110
|
+
continue
|
|
111
|
+
seen_unique.add(columns)
|
|
112
|
+
builder = builder.unique(*columns)
|
|
113
|
+
|
|
114
|
+
if pk_cols:
|
|
115
|
+
if len(pk_cols) == 1 and not single_inline_pk:
|
|
116
|
+
builder = builder.primary_key(*pk_cols)
|
|
117
|
+
elif len(pk_cols) > 1:
|
|
118
|
+
builder = builder.primary_key(*pk_cols)
|
|
119
|
+
|
|
120
|
+
return builder.get_sql(quote_char=self.quote_char) + ';'
|
|
121
|
+
|
|
122
|
+
def render_index_definitions(self) -> list[IndexDefinition]:
|
|
123
|
+
definitions: list[IndexDefinition] = []
|
|
124
|
+
seen_unique: set[tuple[str, ...]] = set()
|
|
125
|
+
for spec in self.table_info.index:
|
|
126
|
+
columns = self._normalize_col_names(spec.col_name())
|
|
127
|
+
unique = spec.is_unique_index
|
|
128
|
+
if unique:
|
|
129
|
+
if columns in seen_unique:
|
|
130
|
+
continue
|
|
131
|
+
seen_unique.add(columns)
|
|
132
|
+
definitions.append(
|
|
133
|
+
IndexDefinition(
|
|
134
|
+
name=self.make_index_name(columns, unique=unique),
|
|
135
|
+
columns=columns,
|
|
136
|
+
unique=unique,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return definitions
|
|
140
|
+
|
|
141
|
+
def make_column(self, name: str, definition: str) -> Column:
|
|
142
|
+
return Column(name, definition)
|
|
143
|
+
|
|
144
|
+
def render_column_declaration(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
column: ColumnInfo,
|
|
148
|
+
pk_columns: tuple[str, ...],
|
|
149
|
+
pk_members: set[str],
|
|
150
|
+
single_inline_pk: bool,
|
|
151
|
+
) -> ColumnDeclaration:
|
|
152
|
+
sql_type = self.resolve_column_type(column.python_type)
|
|
153
|
+
if self.use_inline_primary_key(
|
|
154
|
+
column=column,
|
|
155
|
+
pk_columns=pk_columns,
|
|
156
|
+
sql_type=sql_type,
|
|
157
|
+
):
|
|
158
|
+
definition_sql = self.inline_primary_key_definition(sql_type)
|
|
159
|
+
return ColumnDeclaration(
|
|
160
|
+
name=column.name,
|
|
161
|
+
type_sql=sql_type,
|
|
162
|
+
definition_sql=definition_sql,
|
|
163
|
+
not_null=False,
|
|
164
|
+
primary_key=True,
|
|
165
|
+
auto_increment=True,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
not_null = self.include_not_null(
|
|
169
|
+
column,
|
|
170
|
+
pk_members=pk_members,
|
|
171
|
+
single_inline_pk=single_inline_pk,
|
|
172
|
+
)
|
|
173
|
+
definition_sql = sql_type
|
|
174
|
+
if not_null:
|
|
175
|
+
definition_sql = self.append_not_null(definition_sql)
|
|
176
|
+
primary_key = column.name in pk_members
|
|
177
|
+
return ColumnDeclaration(
|
|
178
|
+
name=column.name,
|
|
179
|
+
type_sql=sql_type,
|
|
180
|
+
definition_sql=definition_sql,
|
|
181
|
+
not_null=not_null,
|
|
182
|
+
primary_key=primary_key,
|
|
183
|
+
auto_increment=False,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def include_not_null(
|
|
187
|
+
self,
|
|
188
|
+
column: ColumnInfo,
|
|
189
|
+
*,
|
|
190
|
+
pk_members: set[str],
|
|
191
|
+
single_inline_pk: bool,
|
|
192
|
+
) -> bool:
|
|
193
|
+
if column.name in pk_members and single_inline_pk and column.auto_increment:
|
|
194
|
+
return False
|
|
195
|
+
if column.name in pk_members:
|
|
196
|
+
return False
|
|
197
|
+
return not column.optional
|
|
198
|
+
|
|
199
|
+
def make_index_name(self, columns: tuple[str, ...], *, unique: bool) -> str:
|
|
200
|
+
suffix = '_'.join(columns)
|
|
201
|
+
prefix = 'uq' if unique else 'idx'
|
|
202
|
+
return f'{prefix}_{self.table_name}_{suffix}'
|
|
203
|
+
|
|
204
|
+
def create_index_sql(self, definition: IndexDefinition) -> str:
|
|
205
|
+
table = Table(self.table_name)
|
|
206
|
+
index = PypikaIndex(definition.name)
|
|
207
|
+
columns_sql = ', '.join(
|
|
208
|
+
table.field(column).get_sql(quote_char=self.quote_char) for column in definition.columns
|
|
209
|
+
)
|
|
210
|
+
unique_keyword = 'UNIQUE ' if definition.unique else ''
|
|
211
|
+
index_sql = (
|
|
212
|
+
f'CREATE {unique_keyword}INDEX IF NOT EXISTS '
|
|
213
|
+
f'{index.get_sql(quote_char=self.quote_char)} '
|
|
214
|
+
f'ON {table.get_sql(quote_char=self.quote_char)} ({columns_sql});'
|
|
215
|
+
)
|
|
216
|
+
return index_sql
|
|
217
|
+
|
|
218
|
+
def drop_index_sql(self, index_name: str) -> str:
|
|
219
|
+
index = PypikaIndex(index_name)
|
|
220
|
+
return f'DROP INDEX IF EXISTS {index.get_sql(quote_char=self.quote_char)};'
|
|
221
|
+
|
|
222
|
+
def _has_inline_primary_key(self, pk_columns: tuple[str, ...]) -> bool:
|
|
223
|
+
if len(pk_columns) != 1:
|
|
224
|
+
return False
|
|
225
|
+
pk_name = pk_columns[0]
|
|
226
|
+
for column in self.info.columns:
|
|
227
|
+
if column.name != pk_name:
|
|
228
|
+
continue
|
|
229
|
+
sql_type = self.resolve_column_type(column.python_type)
|
|
230
|
+
return self.use_inline_primary_key(
|
|
231
|
+
column=column,
|
|
232
|
+
pk_columns=pk_columns,
|
|
233
|
+
sql_type=sql_type,
|
|
234
|
+
)
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def _normalize_col_names(self, spec_cols: Any) -> tuple[str, ...]:
|
|
238
|
+
if isinstance(spec_cols, tuple):
|
|
239
|
+
return tuple(spec_cols)
|
|
240
|
+
if isinstance(spec_cols, list):
|
|
241
|
+
return tuple(spec_cols)
|
|
242
|
+
return (spec_cols,)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def table_name(self) -> str:
|
|
246
|
+
if self._table_name_override is not None:
|
|
247
|
+
return self._table_name_override
|
|
248
|
+
return self.info.model.__name__
|
|
249
|
+
|
|
250
|
+
@abstractmethod
|
|
251
|
+
def resolve_column_type(self, annotation: Any) -> str:
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
def use_inline_primary_key(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
column: ColumnInfo,
|
|
258
|
+
pk_columns: tuple[str, ...],
|
|
259
|
+
sql_type: str,
|
|
260
|
+
) -> bool:
|
|
261
|
+
if len(pk_columns) != 1:
|
|
262
|
+
return False
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def inline_primary_key_definition(self, sql_type: str) -> str:
|
|
266
|
+
raise NotImplementedError('Inline primary key definition not supported for this backend')
|
|
267
|
+
|
|
268
|
+
def append_not_null(self, definition: str) -> str:
|
|
269
|
+
return f'{definition} NOT NULL'
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class DatabasePusher(ABC):
|
|
273
|
+
schema_builder_cls: type[SchemaBuilder]
|
|
274
|
+
|
|
275
|
+
@abstractmethod
|
|
276
|
+
def fetch_existing_indexes(self, conn: Any, info: ModelInfo) -> set[str]:
|
|
277
|
+
...
|
|
278
|
+
|
|
279
|
+
@abstractmethod
|
|
280
|
+
def execute_statements(self, conn: Any, statements: Iterable[str]) -> None:
|
|
281
|
+
...
|
|
282
|
+
|
|
283
|
+
@abstractmethod
|
|
284
|
+
def table_exists(self, conn: Any, info: ModelInfo) -> bool:
|
|
285
|
+
...
|
|
286
|
+
|
|
287
|
+
@abstractmethod
|
|
288
|
+
def inspect_existing_schema(self, conn: Any, info: ModelInfo) -> tuple[ExistingColumn, ...] | None:
|
|
289
|
+
...
|
|
290
|
+
|
|
291
|
+
def calculate_diff(self, existing: tuple[ExistingColumn, ...], expected: SchemaPlan) -> SchemaDiff:
|
|
292
|
+
existing_map = {column.name: column for column in existing}
|
|
293
|
+
expected_map = {column.name: column for column in expected.columns}
|
|
294
|
+
|
|
295
|
+
added = [column for column in expected.columns if column.name not in existing_map]
|
|
296
|
+
removed = [column for column in existing if column.name not in expected_map]
|
|
297
|
+
|
|
298
|
+
changed: list[ColumnChange] = []
|
|
299
|
+
for name, current in existing_map.items():
|
|
300
|
+
target = expected_map.get(name)
|
|
301
|
+
if target is None:
|
|
302
|
+
continue
|
|
303
|
+
reasons: list[str] = []
|
|
304
|
+
if current.type_sql.upper() != target.type_sql.upper():
|
|
305
|
+
reasons.append(f"type {current.type_sql} -> {target.type_sql}")
|
|
306
|
+
if current.not_null != target.not_null:
|
|
307
|
+
reasons.append(f"not_null {current.not_null} -> {target.not_null}")
|
|
308
|
+
if current.primary_key != target.primary_key:
|
|
309
|
+
reasons.append(f"primary_key {current.primary_key} -> {target.primary_key}")
|
|
310
|
+
if reasons:
|
|
311
|
+
changed.append(
|
|
312
|
+
ColumnChange(
|
|
313
|
+
name=name,
|
|
314
|
+
current=current,
|
|
315
|
+
expected=target,
|
|
316
|
+
reasons=tuple(reasons),
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return SchemaDiff(
|
|
321
|
+
added=tuple(added),
|
|
322
|
+
removed=tuple(removed),
|
|
323
|
+
changed=tuple(changed),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def format_diff_message(self, info: ModelInfo, diff: SchemaDiff) -> str:
|
|
327
|
+
parts: list[str] = [f"模型 {info.model.__name__} 需要重建表"]
|
|
328
|
+
if diff.added:
|
|
329
|
+
added_desc = ", ".join(f"+{col.name}:{col.type_sql}" for col in diff.added)
|
|
330
|
+
parts.append(f"新增列: {added_desc}")
|
|
331
|
+
if diff.removed:
|
|
332
|
+
removed_desc = ", ".join(f"-{col.name}:{col.type_sql}" for col in diff.removed)
|
|
333
|
+
parts.append(f"删除列: {removed_desc}")
|
|
334
|
+
if diff.changed:
|
|
335
|
+
changed_desc = ", ".join(
|
|
336
|
+
f"~{change.name}({'; '.join(change.reasons)})" for change in diff.changed
|
|
337
|
+
)
|
|
338
|
+
parts.append(f"变更列: {changed_desc}")
|
|
339
|
+
return "; ".join(parts)
|
|
340
|
+
|
|
341
|
+
@abstractmethod
|
|
342
|
+
def rebuild_table(
|
|
343
|
+
self,
|
|
344
|
+
conn: Any,
|
|
345
|
+
info: ModelInfo,
|
|
346
|
+
builder: SchemaBuilder,
|
|
347
|
+
plan: SchemaPlan,
|
|
348
|
+
existing_schema: tuple[ExistingColumn, ...] | None,
|
|
349
|
+
diff: SchemaDiff,
|
|
350
|
+
) -> None:
|
|
351
|
+
...
|
|
352
|
+
|
|
353
|
+
def is_system_index(self, name: str) -> bool:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def validate_connection(self, conn: Any) -> None:
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def push(
|
|
360
|
+
self,
|
|
361
|
+
conn: Any,
|
|
362
|
+
infos: Sequence[ModelInfo],
|
|
363
|
+
*,
|
|
364
|
+
sync_indexes: bool = False,
|
|
365
|
+
confirm_rebuild: Callable[[ModelInfo, SchemaPlan, tuple[ExistingColumn, ...] | None, SchemaDiff], bool] | None = None,
|
|
366
|
+
) -> None:
|
|
367
|
+
self.validate_connection(conn)
|
|
368
|
+
for info in infos:
|
|
369
|
+
builder = self.schema_builder_cls(info)
|
|
370
|
+
plan = builder.build()
|
|
371
|
+
|
|
372
|
+
if not self.table_exists(conn, info):
|
|
373
|
+
self.execute_statements(conn, [plan.create_sql])
|
|
374
|
+
else:
|
|
375
|
+
existing_schema = self.inspect_existing_schema(conn, info)
|
|
376
|
+
if existing_schema is None:
|
|
377
|
+
diff = SchemaDiff(added=plan.columns, removed=tuple(), changed=tuple())
|
|
378
|
+
else:
|
|
379
|
+
diff = self.calculate_diff(existing_schema, plan)
|
|
380
|
+
|
|
381
|
+
if existing_schema is None or not diff.is_empty():
|
|
382
|
+
if confirm_rebuild is None:
|
|
383
|
+
raise RuntimeError(self.format_diff_message(info, diff))
|
|
384
|
+
if not confirm_rebuild(info, plan, existing_schema, diff):
|
|
385
|
+
raise RuntimeError(self.format_diff_message(info, diff))
|
|
386
|
+
self.rebuild_table(conn, info, builder, plan, existing_schema, diff)
|
|
387
|
+
|
|
388
|
+
self._sync_indexes(conn, info, builder, plan.indexes, sync_indexes)
|
|
389
|
+
|
|
390
|
+
def _sync_indexes(
|
|
391
|
+
self,
|
|
392
|
+
conn: Any,
|
|
393
|
+
info: ModelInfo,
|
|
394
|
+
builder: SchemaBuilder,
|
|
395
|
+
index_definitions: Tuple[IndexDefinition, ...],
|
|
396
|
+
sync_indexes: bool,
|
|
397
|
+
) -> None:
|
|
398
|
+
existing_indexes = self.fetch_existing_indexes(conn, info)
|
|
399
|
+
expected_names = {definition.name for definition in index_definitions}
|
|
400
|
+
|
|
401
|
+
statements: list[str] = []
|
|
402
|
+
|
|
403
|
+
if sync_indexes:
|
|
404
|
+
for index_name in sorted(existing_indexes):
|
|
405
|
+
if self.is_system_index(index_name):
|
|
406
|
+
continue
|
|
407
|
+
if index_name in expected_names:
|
|
408
|
+
continue
|
|
409
|
+
statements.append(builder.drop_index_sql(index_name))
|
|
410
|
+
|
|
411
|
+
for definition in index_definitions:
|
|
412
|
+
if definition.name in existing_indexes:
|
|
413
|
+
continue
|
|
414
|
+
statements.append(builder.create_index_sql(definition))
|
|
415
|
+
|
|
416
|
+
if statements:
|
|
417
|
+
self.execute_statements(conn, statements)
|
dclassql/push/sqlite.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import Annotated, Any, Callable, Iterable, Mapping, Sequence, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
from pypika import Query, Table
|
|
9
|
+
from pypika.utils import format_quotes
|
|
10
|
+
|
|
11
|
+
from ..model_inspector import ColumnInfo, ModelInfo
|
|
12
|
+
from .base import DatabasePusher, ExistingColumn, SchemaBuilder, SchemaDiff, SchemaPlan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TYPE_MAP: Mapping[type[Any], str] = {
|
|
16
|
+
int: "INTEGER",
|
|
17
|
+
bool: "INTEGER",
|
|
18
|
+
float: "REAL",
|
|
19
|
+
str: "TEXT",
|
|
20
|
+
datetime: "TEXT",
|
|
21
|
+
date: "TEXT",
|
|
22
|
+
bytes: "BLOB",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _infer_sqlite_type(annotation: Any) -> str:
|
|
27
|
+
origin = get_origin(annotation)
|
|
28
|
+
if origin is UnionType or isinstance(annotation, UnionType):
|
|
29
|
+
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
30
|
+
if len(args) == 1:
|
|
31
|
+
return _infer_sqlite_type(args[0])
|
|
32
|
+
if not args:
|
|
33
|
+
return "TEXT"
|
|
34
|
+
if origin is Annotated:
|
|
35
|
+
inner_args = get_args(annotation)
|
|
36
|
+
if inner_args:
|
|
37
|
+
return _infer_sqlite_type(inner_args[0])
|
|
38
|
+
if origin is None and isinstance(annotation, type):
|
|
39
|
+
if annotation in TYPE_MAP:
|
|
40
|
+
return TYPE_MAP[annotation]
|
|
41
|
+
if issubclass(annotation, str):
|
|
42
|
+
return "TEXT"
|
|
43
|
+
if issubclass(annotation, bytes):
|
|
44
|
+
return "BLOB"
|
|
45
|
+
if issubclass(annotation, int):
|
|
46
|
+
return "INTEGER"
|
|
47
|
+
if issubclass(annotation, float):
|
|
48
|
+
return "REAL"
|
|
49
|
+
if origin in (list, set, tuple):
|
|
50
|
+
return "TEXT"
|
|
51
|
+
return "TEXT"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SQLiteSchemaBuilder(SchemaBuilder):
|
|
55
|
+
quote_char = '"'
|
|
56
|
+
|
|
57
|
+
def resolve_column_type(self, annotation: Any) -> str:
|
|
58
|
+
return _infer_sqlite_type(annotation)
|
|
59
|
+
|
|
60
|
+
def use_inline_primary_key(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
column: ColumnInfo,
|
|
64
|
+
pk_columns: tuple[str, ...],
|
|
65
|
+
sql_type: str,
|
|
66
|
+
) -> bool:
|
|
67
|
+
if len(pk_columns) != 1:
|
|
68
|
+
return False
|
|
69
|
+
if column.name != pk_columns[0]:
|
|
70
|
+
return False
|
|
71
|
+
if not column.auto_increment:
|
|
72
|
+
return False
|
|
73
|
+
return sql_type.upper() == "INTEGER"
|
|
74
|
+
|
|
75
|
+
def inline_primary_key_definition(self, sql_type: str) -> str:
|
|
76
|
+
return f"{sql_type} PRIMARY KEY AUTOINCREMENT"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SQLitePusher(DatabasePusher):
|
|
80
|
+
schema_builder_cls = SQLiteSchemaBuilder
|
|
81
|
+
|
|
82
|
+
def validate_connection(self, conn: Any) -> None:
|
|
83
|
+
if not isinstance(conn, sqlite3.Connection):
|
|
84
|
+
raise TypeError("SQLite connections must be sqlite3.Connection")
|
|
85
|
+
|
|
86
|
+
def table_exists(self, conn: sqlite3.Connection, info: ModelInfo) -> bool:
|
|
87
|
+
cur = conn.execute(
|
|
88
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
89
|
+
(info.model.__name__,),
|
|
90
|
+
)
|
|
91
|
+
return cur.fetchone() is not None
|
|
92
|
+
|
|
93
|
+
def inspect_existing_schema(self, conn: sqlite3.Connection, info: ModelInfo) -> tuple[ExistingColumn, ...] | None:
|
|
94
|
+
table_name = info.model.__name__
|
|
95
|
+
quoted = format_quotes(table_name, '"')
|
|
96
|
+
rows = conn.execute(f"PRAGMA table_info({quoted})").fetchall()
|
|
97
|
+
if not rows:
|
|
98
|
+
return None
|
|
99
|
+
columns = [
|
|
100
|
+
ExistingColumn(
|
|
101
|
+
name=row[1],
|
|
102
|
+
type_sql=row[2],
|
|
103
|
+
not_null=bool(row[3]),
|
|
104
|
+
primary_key=bool(row[5]),
|
|
105
|
+
)
|
|
106
|
+
for row in rows
|
|
107
|
+
]
|
|
108
|
+
return tuple(columns)
|
|
109
|
+
|
|
110
|
+
def fetch_existing_indexes(self, conn: sqlite3.Connection, info: ModelInfo) -> set[str]:
|
|
111
|
+
cur = conn.execute(
|
|
112
|
+
"SELECT name FROM sqlite_master WHERE type IN ('index','unique') AND tbl_name = ?",
|
|
113
|
+
(info.model.__name__,),
|
|
114
|
+
)
|
|
115
|
+
return {name for (name,) in cur.fetchall()}
|
|
116
|
+
|
|
117
|
+
def execute_statements(self, conn: sqlite3.Connection, statements: Iterable[str]) -> None:
|
|
118
|
+
for sql in statements:
|
|
119
|
+
conn.execute(sql)
|
|
120
|
+
conn.commit()
|
|
121
|
+
|
|
122
|
+
def is_system_index(self, name: str) -> bool:
|
|
123
|
+
return name.startswith("sqlite_")
|
|
124
|
+
|
|
125
|
+
def rebuild_table(
|
|
126
|
+
self,
|
|
127
|
+
conn: sqlite3.Connection,
|
|
128
|
+
info: ModelInfo,
|
|
129
|
+
builder: SchemaBuilder,
|
|
130
|
+
plan: SchemaPlan,
|
|
131
|
+
existing_schema: tuple[ExistingColumn, ...] | None,
|
|
132
|
+
diff: SchemaDiff,
|
|
133
|
+
) -> None:
|
|
134
|
+
table_name = builder.table_name
|
|
135
|
+
temp_table = f"{table_name}__tmp"
|
|
136
|
+
|
|
137
|
+
temp_plan = builder.build(table_name=temp_table)
|
|
138
|
+
|
|
139
|
+
statements: list[str] = []
|
|
140
|
+
drop_temp_sql = Query.drop_table(temp_table).if_exists().get_sql(quote_char=builder.quote_char) + ';'
|
|
141
|
+
statements.append(drop_temp_sql)
|
|
142
|
+
statements.append(temp_plan.create_sql)
|
|
143
|
+
|
|
144
|
+
existing_columns = {column.name for column in existing_schema or ()}
|
|
145
|
+
transfer_columns = [
|
|
146
|
+
declaration.name
|
|
147
|
+
for declaration in plan.columns
|
|
148
|
+
if declaration.name in existing_columns
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
if transfer_columns:
|
|
152
|
+
temp_ref = Table(temp_table)
|
|
153
|
+
original_ref = Table(table_name)
|
|
154
|
+
insert_sql = (
|
|
155
|
+
Query.into(temp_ref)
|
|
156
|
+
.columns(*transfer_columns)
|
|
157
|
+
.from_(original_ref)
|
|
158
|
+
.select(*(original_ref.field(name) for name in transfer_columns))
|
|
159
|
+
.get_sql(quote_char=builder.quote_char)
|
|
160
|
+
+ ';'
|
|
161
|
+
)
|
|
162
|
+
statements.append(insert_sql)
|
|
163
|
+
|
|
164
|
+
drop_original_sql = Query.drop_table(table_name).if_exists().get_sql(quote_char=builder.quote_char) + ';'
|
|
165
|
+
statements.append(drop_original_sql)
|
|
166
|
+
|
|
167
|
+
rename_sql = (
|
|
168
|
+
f"ALTER TABLE {format_quotes(temp_table, builder.quote_char)} "
|
|
169
|
+
f"RENAME TO {format_quotes(table_name, builder.quote_char)};"
|
|
170
|
+
)
|
|
171
|
+
statements.append(rename_sql)
|
|
172
|
+
|
|
173
|
+
self.execute_statements(conn, statements)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
SQLITE_PUSHER = SQLitePusher()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _build_sqlite_schema(info: ModelInfo) -> tuple[str, list[tuple[str, str]]]:
|
|
180
|
+
builder = SQLiteSchemaBuilder(info)
|
|
181
|
+
plan = builder.build()
|
|
182
|
+
index_entries: list[tuple[str, str]] = [
|
|
183
|
+
(definition.name, builder.create_index_sql(definition)) for definition in plan.indexes
|
|
184
|
+
]
|
|
185
|
+
return plan.create_sql, index_entries
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def push_sqlite(
|
|
189
|
+
conn: sqlite3.Connection,
|
|
190
|
+
infos: Sequence[ModelInfo],
|
|
191
|
+
*,
|
|
192
|
+
sync_indexes: bool = False,
|
|
193
|
+
confirm_rebuild: Callable[[ModelInfo, SchemaPlan, tuple[ExistingColumn, ...] | None, SchemaDiff], bool] | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
SQLITE_PUSHER.push(conn, infos, sync_indexes=sync_indexes, confirm_rebuild=confirm_rebuild)
|