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/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)
@@ -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)