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/__init__.py
ADDED
dclassql/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
|
|
10
|
+
from .codegen import generate_client
|
|
11
|
+
from .push import db_push
|
|
12
|
+
from .runtime.datasource import open_sqlite_connection, resolve_sqlite_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_MODEL_FILE = "model.py"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_module(module_path: Path) -> ModuleType:
|
|
19
|
+
module_path = module_path.resolve()
|
|
20
|
+
if not module_path.exists():
|
|
21
|
+
raise FileNotFoundError(f"Model file '{module_path}' does not exist")
|
|
22
|
+
module_name = module_path.stem
|
|
23
|
+
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
|
24
|
+
if spec is None or spec.loader is None:
|
|
25
|
+
raise ImportError(f"Unable to load module from '{module_path}'")
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
sys.modules[module_name] = module
|
|
28
|
+
sys.path.insert(0, str(module_path.parent))
|
|
29
|
+
try:
|
|
30
|
+
spec.loader.exec_module(module)
|
|
31
|
+
finally:
|
|
32
|
+
sys.path.pop(0)
|
|
33
|
+
return module
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def collect_models(module: ModuleType) -> list[type[Any]]:
|
|
37
|
+
from dataclasses import is_dataclass
|
|
38
|
+
|
|
39
|
+
models: list[type[Any]] = []
|
|
40
|
+
for value in vars(module).values():
|
|
41
|
+
if isinstance(value, type) and is_dataclass(value) and value.__module__ == module.__name__:
|
|
42
|
+
models.append(value)
|
|
43
|
+
if not models:
|
|
44
|
+
raise ValueError("No dataclass models were found in the provided module")
|
|
45
|
+
return models
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def push_database(models: Sequence[type[Any]]) -> None:
|
|
49
|
+
from .model_inspector import inspect_models
|
|
50
|
+
|
|
51
|
+
model_infos = inspect_models(models)
|
|
52
|
+
connections: dict[str, Any] = {}
|
|
53
|
+
opened: list[Any] = []
|
|
54
|
+
try:
|
|
55
|
+
for info in model_infos.values():
|
|
56
|
+
config = info.datasource
|
|
57
|
+
key = config.key
|
|
58
|
+
if key in connections:
|
|
59
|
+
continue
|
|
60
|
+
if config.provider != "sqlite":
|
|
61
|
+
raise ValueError(f"Unsupported provider '{config.provider}'")
|
|
62
|
+
connection = open_sqlite_connection(config.url)
|
|
63
|
+
connections[key] = connection
|
|
64
|
+
opened.append(connection)
|
|
65
|
+
db_push(models, connections)
|
|
66
|
+
finally:
|
|
67
|
+
for conn in opened:
|
|
68
|
+
try:
|
|
69
|
+
conn.close()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def command_generate(module_path: Path) -> None:
|
|
75
|
+
module = load_module(module_path)
|
|
76
|
+
models = collect_models(module)
|
|
77
|
+
generated = generate_client(models)
|
|
78
|
+
sys.stdout.write(generated.code)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def command_push_db(module_path: Path) -> None:
|
|
82
|
+
module = load_module(module_path)
|
|
83
|
+
models = collect_models(module)
|
|
84
|
+
push_database(models)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
parser = argparse.ArgumentParser(prog="typed-db", description="Typed DB utilities.")
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"-m",
|
|
91
|
+
"--module",
|
|
92
|
+
type=Path,
|
|
93
|
+
default=Path(DEFAULT_MODEL_FILE),
|
|
94
|
+
help="Path to the model module file (default: model.py)",
|
|
95
|
+
)
|
|
96
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
97
|
+
|
|
98
|
+
generate_parser = subparsers.add_parser("generate", help="Generate client code for given models")
|
|
99
|
+
generate_parser.set_defaults(handler=lambda args: command_generate(args.module))
|
|
100
|
+
|
|
101
|
+
push_parser = subparsers.add_parser("push-db", help="Apply schema and indexes to configured databases")
|
|
102
|
+
push_parser.set_defaults(handler=lambda args: command_push_db(args.module))
|
|
103
|
+
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
108
|
+
parser = build_parser()
|
|
109
|
+
args = parser.parse_args(argv)
|
|
110
|
+
handler = getattr(args, "handler", None)
|
|
111
|
+
if handler is None:
|
|
112
|
+
parser.print_help()
|
|
113
|
+
return 1
|
|
114
|
+
try:
|
|
115
|
+
handler(args)
|
|
116
|
+
return 0
|
|
117
|
+
except Exception as exc: # pragma: no cover - CLI error reporting
|
|
118
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__": # pragma: no cover
|
|
123
|
+
raise SystemExit(main())
|
dclassql/codegen.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import UnionType
|
|
7
|
+
from typing import Annotated, Any, Iterable, Mapping, Sequence, get_args, get_origin, Literal
|
|
8
|
+
|
|
9
|
+
from .model_inspector import ColumnInfo, ForeignKeyInfo, ModelInfo, inspect_models, DataSourceConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class GeneratedModule:
|
|
14
|
+
code: str
|
|
15
|
+
model_names: tuple[str, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def generate_client(models: Sequence[type[Any]]) -> GeneratedModule:
|
|
19
|
+
model_infos = inspect_models(models)
|
|
20
|
+
renderer = _TypeRenderer({info.model: name for name, info in model_infos.items()})
|
|
21
|
+
|
|
22
|
+
header_lines: list[str] = ["from __future__ import annotations", ""]
|
|
23
|
+
|
|
24
|
+
base_imports = {
|
|
25
|
+
"from dataclasses import dataclass, field",
|
|
26
|
+
"import sqlite3",
|
|
27
|
+
"from dclassql.db_pool import BaseDBPool, save_local",
|
|
28
|
+
"from dclassql.runtime.backends import BackendProtocol, RelationSpec, create_backend",
|
|
29
|
+
"from dclassql.runtime.datasource import resolve_sqlite_path",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
model_imports: dict[str, set[str]] = defaultdict(set)
|
|
33
|
+
for info in model_infos.values():
|
|
34
|
+
module = info.model.__module__
|
|
35
|
+
model_imports.setdefault(module, set()).add(info.model.__name__)
|
|
36
|
+
|
|
37
|
+
body_sections: list[str] = [_render_metadata_base()]
|
|
38
|
+
|
|
39
|
+
rendered_models: list[str] = []
|
|
40
|
+
for name in sorted(model_infos.keys()):
|
|
41
|
+
info = model_infos[name]
|
|
42
|
+
rendered_models.append(_render_model(info, renderer, model_infos))
|
|
43
|
+
|
|
44
|
+
body_sections.extend(rendered_models)
|
|
45
|
+
body_sections.append(_render_client_class(model_infos))
|
|
46
|
+
|
|
47
|
+
module_imports = renderer.build_imports()
|
|
48
|
+
combined_imports: dict[str, set[str]] = defaultdict(set)
|
|
49
|
+
for module, names in model_imports.items():
|
|
50
|
+
combined_imports[module].update(names)
|
|
51
|
+
for module, names in module_imports.items():
|
|
52
|
+
combined_imports[module].update(names)
|
|
53
|
+
|
|
54
|
+
import_lines = sorted(base_imports)
|
|
55
|
+
for module, names in sorted(combined_imports.items()):
|
|
56
|
+
names_list = ", ".join(sorted(names))
|
|
57
|
+
import_lines.append(f"from {module} import {names_list}")
|
|
58
|
+
typing_names = {"Any", "Literal", "Mapping", "Sequence", "TypedDict", "cast"}
|
|
59
|
+
typing_names.update(renderer.typing_names)
|
|
60
|
+
if typing_names:
|
|
61
|
+
import_lines.append(f"from typing import {', '.join(sorted(typing_names))}")
|
|
62
|
+
|
|
63
|
+
lines = header_lines + import_lines + [""]
|
|
64
|
+
for section in body_sections:
|
|
65
|
+
lines.append(section)
|
|
66
|
+
lines.append("")
|
|
67
|
+
if lines and lines[-1] == "":
|
|
68
|
+
lines.pop()
|
|
69
|
+
if lines and lines[-1] != "":
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append(_render_all(model_infos))
|
|
72
|
+
code = "\n".join(lines) + "\n"
|
|
73
|
+
return GeneratedModule(code=code, model_names=tuple(sorted(model_infos.keys())))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_metadata_base() -> str:
|
|
77
|
+
return """@dataclass(slots=True)
|
|
78
|
+
class DataSourceConfig:
|
|
79
|
+
provider: str
|
|
80
|
+
url: str | None
|
|
81
|
+
name: str | None = None
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def key(self) -> str:
|
|
85
|
+
return self.name or self.provider
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class ForeignKeySpec:
|
|
90
|
+
local_columns: tuple[str, ...]
|
|
91
|
+
remote_model: type[Any]
|
|
92
|
+
remote_columns: tuple[str, ...]
|
|
93
|
+
backref: str | None
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render_model(info: ModelInfo, renderer: "_TypeRenderer", model_infos: Mapping[str, ModelInfo]) -> str:
|
|
98
|
+
alias_block, include_alias, sortable_alias = _render_type_aliases(info)
|
|
99
|
+
sections: list[str] = []
|
|
100
|
+
if alias_block:
|
|
101
|
+
sections.append(alias_block)
|
|
102
|
+
sections.append(_render_insert_structures(info, renderer))
|
|
103
|
+
sections.append(_render_where_dict(info, renderer))
|
|
104
|
+
sections.append(_render_table_class(info, renderer, include_alias, sortable_alias, model_infos))
|
|
105
|
+
return "\n\n".join(sections)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _render_type_aliases(info: ModelInfo) -> tuple[str, str, str]:
|
|
109
|
+
name = info.model.__name__
|
|
110
|
+
include_literals = sorted({relation.target.__name__ for relation in info.relations})
|
|
111
|
+
sortable_literals = [col.name for col in info.columns]
|
|
112
|
+
|
|
113
|
+
lines: list[str] = []
|
|
114
|
+
include_alias = f"T{name}IncludeCol"
|
|
115
|
+
include_literal_expr = _literal_expression(include_literals)
|
|
116
|
+
lines.append(f"{include_alias} = {include_literal_expr}")
|
|
117
|
+
|
|
118
|
+
sortable_alias = f"T{name}SortableCol"
|
|
119
|
+
lines.append(f"{sortable_alias} = {_literal_expression(sortable_literals)}")
|
|
120
|
+
|
|
121
|
+
return "\n".join(lines), include_alias, sortable_alias
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_insert_structures(info: ModelInfo, renderer: "_TypeRenderer") -> str:
|
|
125
|
+
insert_fields = info.columns
|
|
126
|
+
dataclass_lines: list[str] = []
|
|
127
|
+
for col in insert_fields:
|
|
128
|
+
annotation = _format_insert_annotation(col, renderer)
|
|
129
|
+
default_fragment = _render_default_fragment(info.model.__name__, col)
|
|
130
|
+
if default_fragment is not None:
|
|
131
|
+
dataclass_lines.append(f" {col.name}: {annotation} = {default_fragment}")
|
|
132
|
+
elif col.auto_increment:
|
|
133
|
+
dataclass_lines.append(f" {col.name}: {annotation} = None")
|
|
134
|
+
else:
|
|
135
|
+
dataclass_lines.append(f" {col.name}: {annotation}")
|
|
136
|
+
if not dataclass_lines:
|
|
137
|
+
dataclass_lines.append(" pass")
|
|
138
|
+
dataclass_block = f"@dataclass(slots=True, kw_only=True)\nclass {info.model.__name__}Insert:\n" + "\n".join(dataclass_lines)
|
|
139
|
+
|
|
140
|
+
dict_lines = []
|
|
141
|
+
for col in insert_fields:
|
|
142
|
+
annotation = _format_insert_annotation(col, renderer)
|
|
143
|
+
if col.auto_increment:
|
|
144
|
+
renderer.require_typing("NotRequired")
|
|
145
|
+
base_annotation = _strip_optional_annotation(annotation)
|
|
146
|
+
dict_lines.append(f" {col.name}: NotRequired[{base_annotation}]")
|
|
147
|
+
else:
|
|
148
|
+
dict_lines.append(f" {col.name}: {annotation}")
|
|
149
|
+
if not dict_lines:
|
|
150
|
+
dict_lines.append(" pass")
|
|
151
|
+
dict_block = f"class {info.model.__name__}InsertDict(TypedDict):\n" + "\n".join(dict_lines)
|
|
152
|
+
|
|
153
|
+
return "\n\n".join([dataclass_block, dict_block])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _render_where_dict(info: ModelInfo, renderer: "_TypeRenderer") -> str:
|
|
157
|
+
name = info.model.__name__
|
|
158
|
+
lines = [f"class {name}WhereDict(TypedDict, total=False):"]
|
|
159
|
+
field_lines: list[str] = []
|
|
160
|
+
for col in info.columns:
|
|
161
|
+
annotation = renderer.render(col.python_type)
|
|
162
|
+
if "None" not in annotation:
|
|
163
|
+
annotation = f"{annotation} | None"
|
|
164
|
+
field_lines.append(f" {col.name}: {annotation}")
|
|
165
|
+
if not field_lines:
|
|
166
|
+
field_lines.append(" pass")
|
|
167
|
+
lines.extend(field_lines)
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _build_relation_entries(info: ModelInfo, model_infos: Mapping[str, ModelInfo]) -> list[dict[str, Any]]:
|
|
172
|
+
entries: list[dict[str, Any]] = []
|
|
173
|
+
if not info.relations:
|
|
174
|
+
return entries
|
|
175
|
+
|
|
176
|
+
target_index: dict[str, ModelInfo] = {name: model for name, model in model_infos.items()}
|
|
177
|
+
|
|
178
|
+
for relation in info.relations:
|
|
179
|
+
target_model = relation.target
|
|
180
|
+
target_info = target_index.get(target_model.__name__)
|
|
181
|
+
if target_info is None:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
mapping: tuple[tuple[str, str], ...] | None = None
|
|
185
|
+
if not relation.many:
|
|
186
|
+
for fk in info.foreign_keys:
|
|
187
|
+
if fk.remote_model is target_model:
|
|
188
|
+
mapping = tuple((local, remote) for local, remote in zip(fk.local_columns, fk.remote_columns))
|
|
189
|
+
break
|
|
190
|
+
if mapping is None:
|
|
191
|
+
for fk in target_info.foreign_keys:
|
|
192
|
+
if fk.remote_model is info.model and fk.backref_attribute == relation.name:
|
|
193
|
+
mapping = tuple((remote, local) for remote, local in zip(fk.remote_columns, fk.local_columns))
|
|
194
|
+
break
|
|
195
|
+
else:
|
|
196
|
+
for fk in target_info.foreign_keys:
|
|
197
|
+
if fk.remote_model is info.model and fk.backref_attribute == relation.name:
|
|
198
|
+
mapping = tuple((remote, local) for remote, local in zip(fk.remote_columns, fk.local_columns))
|
|
199
|
+
break
|
|
200
|
+
if mapping is None:
|
|
201
|
+
continue
|
|
202
|
+
if target_model.__module__ == info.model.__module__:
|
|
203
|
+
module_expr = "__name__"
|
|
204
|
+
else:
|
|
205
|
+
module_expr = repr(target_model.__module__)
|
|
206
|
+
entries.append(
|
|
207
|
+
{
|
|
208
|
+
"name": relation.name,
|
|
209
|
+
"table_name": f"{target_model.__name__}Table",
|
|
210
|
+
"many": relation.many,
|
|
211
|
+
"mapping": mapping,
|
|
212
|
+
"table_module_expr": module_expr,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
return entries
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _render_table_class(
|
|
219
|
+
info: ModelInfo,
|
|
220
|
+
renderer: "_TypeRenderer",
|
|
221
|
+
include_alias: str | None,
|
|
222
|
+
sortable_alias: str,
|
|
223
|
+
model_infos: Mapping[str, ModelInfo],
|
|
224
|
+
) -> str:
|
|
225
|
+
name = info.model.__name__
|
|
226
|
+
indent = " "
|
|
227
|
+
where_alias = f"{name}WhereDict"
|
|
228
|
+
lines = [f"class {name}Table:"]
|
|
229
|
+
lines.append(f"{indent}model = {name}")
|
|
230
|
+
lines.append(f"{indent}insert_model = {name}Insert")
|
|
231
|
+
ds = info.datasource
|
|
232
|
+
ds_url_repr = repr(ds.url)
|
|
233
|
+
lines.append(
|
|
234
|
+
f"{indent}datasource = DataSourceConfig(provider={ds.provider!r}, url={ds_url_repr}, name={repr(ds.name)})"
|
|
235
|
+
)
|
|
236
|
+
lines.append(f"{indent}columns: tuple[str, ...] = {_tuple_literal(col.name for col in info.columns)}")
|
|
237
|
+
auto_increment = tuple(col.name for col in info.columns if col.auto_increment)
|
|
238
|
+
if auto_increment:
|
|
239
|
+
lines.append(f"{indent}auto_increment_columns: tuple[str, ...] = {_tuple_literal(auto_increment)}")
|
|
240
|
+
else:
|
|
241
|
+
lines.append(f"{indent}auto_increment_columns: tuple[str, ...] = ()")
|
|
242
|
+
lines.append(f"{indent}primary_key: tuple[str, ...] = {_tuple_literal(info.primary_key)}")
|
|
243
|
+
if info.indexes:
|
|
244
|
+
lines.append(f"{indent}indexes: tuple[tuple[str, ...], ...] = {_tuple_literal(tuple(idx) for idx in info.indexes)}")
|
|
245
|
+
else:
|
|
246
|
+
lines.append(f"{indent}indexes: tuple[tuple[str, ...], ...] = ()")
|
|
247
|
+
if info.unique_indexes:
|
|
248
|
+
lines.append(f"{indent}unique_indexes: tuple[tuple[str, ...], ...] = {_tuple_literal(tuple(idx) for idx in info.unique_indexes)}")
|
|
249
|
+
else:
|
|
250
|
+
lines.append(f"{indent}unique_indexes: tuple[tuple[str, ...], ...] = ()")
|
|
251
|
+
if info.foreign_keys:
|
|
252
|
+
lines.append(f"{indent}foreign_keys: tuple[ForeignKeySpec, ...] = (")
|
|
253
|
+
for fk in info.foreign_keys:
|
|
254
|
+
lines.append(f"{indent*2}ForeignKeySpec(")
|
|
255
|
+
lines.append(f"{indent*3}local_columns={_tuple_literal(fk.local_columns)},")
|
|
256
|
+
lines.append(f"{indent*3}remote_model={fk.remote_model.__name__},")
|
|
257
|
+
lines.append(f"{indent*3}remote_columns={_tuple_literal(fk.remote_columns)},")
|
|
258
|
+
lines.append(f"{indent*3}backref={repr(fk.backref_attribute)},")
|
|
259
|
+
lines.append(f"{indent*2}),")
|
|
260
|
+
lines.append(f"{indent})")
|
|
261
|
+
else:
|
|
262
|
+
lines.append(f"{indent}foreign_keys: tuple[ForeignKeySpec, ...] = ()")
|
|
263
|
+
relation_entries = _build_relation_entries(info, model_infos)
|
|
264
|
+
if relation_entries:
|
|
265
|
+
lines.append(f"{indent}relations: tuple[RelationSpec, ...] = (")
|
|
266
|
+
for entry in relation_entries:
|
|
267
|
+
mapping_literal = _tuple_literal(entry["mapping"])
|
|
268
|
+
module_expr = entry["table_module_expr"]
|
|
269
|
+
lines.append(
|
|
270
|
+
f"{indent*2}RelationSpec(name={entry['name']!r}, table_name={entry['table_name']!r}, table_module={module_expr}, many={entry['many']}, mapping={mapping_literal}),"
|
|
271
|
+
)
|
|
272
|
+
lines.append(f"{indent})")
|
|
273
|
+
else:
|
|
274
|
+
lines.append(f"{indent}relations: tuple[RelationSpec, ...] = ()")
|
|
275
|
+
lines.append("")
|
|
276
|
+
lines.append(f"{indent}def __init__(self, backend: BackendProtocol[{name}, {name}Insert, {where_alias}]) -> None:")
|
|
277
|
+
lines.append(f"{indent*2}self._backend: BackendProtocol[{name}, {name}Insert, {where_alias}] = backend")
|
|
278
|
+
lines.append("")
|
|
279
|
+
lines.append(f"{indent}def insert(self, data: {name}Insert | {name}InsertDict) -> {name}:")
|
|
280
|
+
lines.append(f"{indent*2}return self._backend.insert(self, data)")
|
|
281
|
+
lines.append("")
|
|
282
|
+
lines.append(f"{indent}def insert_many(self, data: Sequence[{name}Insert | {name}InsertDict], *, batch_size: int | None = None) -> list[{name}]:")
|
|
283
|
+
lines.append(f"{indent*2}return self._backend.insert_many(self, data, batch_size=batch_size)")
|
|
284
|
+
lines.append("")
|
|
285
|
+
include_annotation = f"dict[{include_alias}, bool] | None"
|
|
286
|
+
lines.append(f"{indent}def find_many(self, *, where: {where_alias} | None = None, include: {include_annotation} = None, order_by: Sequence[tuple[{sortable_alias}, Literal['asc', 'desc']]] | None = None, take: int | None = None, skip: int | None = None) -> list[{name}]:")
|
|
287
|
+
lines.append(
|
|
288
|
+
f"{indent*2}return self._backend.find_many(self, where=where, include=cast(Mapping[str, bool] | None, include), order_by=order_by, take=take, skip=skip)"
|
|
289
|
+
)
|
|
290
|
+
lines.append("")
|
|
291
|
+
lines.append(f"{indent}def find_first(self, *, where: {where_alias} | None = None, include: {include_annotation} = None, order_by: Sequence[tuple[{sortable_alias}, Literal['asc', 'desc']]] | None = None, skip: int | None = None) -> {name} | None:")
|
|
292
|
+
lines.append(
|
|
293
|
+
f"{indent*2}return self._backend.find_first(self, where=where, include=cast(Mapping[str, bool] | None, include), order_by=order_by, skip=skip)"
|
|
294
|
+
)
|
|
295
|
+
return "\n".join(lines)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _format_insert_annotation(col: ColumnInfo, renderer: "_TypeRenderer") -> str:
|
|
299
|
+
annotation = renderer.render(col.python_type)
|
|
300
|
+
needs_optional = col.auto_increment
|
|
301
|
+
if needs_optional and "None" not in annotation:
|
|
302
|
+
annotation = f"{annotation} | None"
|
|
303
|
+
return annotation
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _render_default_fragment(model_name: str, col: ColumnInfo) -> str | None:
|
|
307
|
+
if col.has_default_factory and col.default_factory is not None:
|
|
308
|
+
factory_expr = f"{model_name}.__dataclass_fields__['{col.name}'].default_factory"
|
|
309
|
+
return f"field(default_factory={factory_expr})"
|
|
310
|
+
if col.has_default:
|
|
311
|
+
return repr(col.default_value)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _render_client_class(model_infos: Mapping[str, ModelInfo]) -> str:
|
|
316
|
+
indent = " "
|
|
317
|
+
lines = ["class GeneratedClient(BaseDBPool):"]
|
|
318
|
+
datasource_configs: dict[str, DataSourceConfig] = {}
|
|
319
|
+
for info in model_infos.values():
|
|
320
|
+
datasource = info.datasource
|
|
321
|
+
key = datasource.name or datasource.provider
|
|
322
|
+
existing = datasource_configs.get(key)
|
|
323
|
+
if existing is None:
|
|
324
|
+
datasource_configs[key] = datasource
|
|
325
|
+
elif existing != datasource:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"Conflicting datasource key '{key}' for providers"
|
|
328
|
+
)
|
|
329
|
+
lines.append(f"{indent}datasources = {{")
|
|
330
|
+
for key in sorted(datasource_configs.keys()):
|
|
331
|
+
ds = datasource_configs[key]
|
|
332
|
+
lines.append(
|
|
333
|
+
f"{indent*2}{key!r}: DataSourceConfig(provider={ds.provider!r}, url={repr(ds.url)}, name={repr(ds.name)}),"
|
|
334
|
+
)
|
|
335
|
+
lines.append(f"{indent}}}")
|
|
336
|
+
|
|
337
|
+
backend_methods: list[tuple[str, str]] = []
|
|
338
|
+
for key in sorted(datasource_configs.keys()):
|
|
339
|
+
method_suffix = _sanitize_identifier(key)
|
|
340
|
+
method_name = f"_backend_{method_suffix}"
|
|
341
|
+
backend_methods.append((key, method_name))
|
|
342
|
+
lines.append("")
|
|
343
|
+
lines.append(f"{indent}@classmethod")
|
|
344
|
+
lines.append(f"{indent}@save_local")
|
|
345
|
+
lines.append(
|
|
346
|
+
f"{indent}def {method_name}(cls) -> BackendProtocol[Any, Any, Mapping[str, object]]:"
|
|
347
|
+
)
|
|
348
|
+
lines.append(f"{indent*2}config = cls.datasources[{key!r}]")
|
|
349
|
+
lines.append(f"{indent*2}if config.provider == 'sqlite':")
|
|
350
|
+
lines.append(f"{indent*3}path = resolve_sqlite_path(config.url)")
|
|
351
|
+
lines.append(f"{indent*3}conn = sqlite3.connect(path, check_same_thread=False)")
|
|
352
|
+
lines.append(f"{indent*3}cls._setup_sqlite_db(conn)")
|
|
353
|
+
lines.append(f"{indent*3}return create_backend('sqlite', conn)")
|
|
354
|
+
lines.append(
|
|
355
|
+
f"{indent*2}raise ValueError(f\"Unsupported provider '{{config.provider}}' for datasource '{key}'\")"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
lines.append("")
|
|
359
|
+
lines.append(f"{indent}def __init__(self) -> None:")
|
|
360
|
+
method_map = {key: method for key, method in backend_methods}
|
|
361
|
+
|
|
362
|
+
for name in sorted(model_infos.keys()):
|
|
363
|
+
attr = _camel_to_snake(name)
|
|
364
|
+
datasource = model_infos[name].datasource
|
|
365
|
+
ds_key = datasource.name or datasource.provider
|
|
366
|
+
where_alias = f"{name}WhereDict"
|
|
367
|
+
method_name = method_map[ds_key]
|
|
368
|
+
lines.append(
|
|
369
|
+
f"{indent*2}self.{attr} = {name}Table(cast(BackendProtocol[{name}, {name}Insert, {where_alias}], self.{method_name}()))"
|
|
370
|
+
)
|
|
371
|
+
lines.append("")
|
|
372
|
+
lines.append(f"{indent}@classmethod")
|
|
373
|
+
lines.append(f"{indent}def close_all(cls, verbose: bool = False) -> None:")
|
|
374
|
+
lines.append(f"{indent*2}super().close_all(verbose=verbose)")
|
|
375
|
+
for _, method_name in backend_methods:
|
|
376
|
+
lines.append(f"{indent*2}if hasattr(cls._local, '{method_name}'):")
|
|
377
|
+
lines.append(f"{indent*3}backend = getattr(cls._local, '{method_name}')")
|
|
378
|
+
lines.append(f"{indent*3}if hasattr(backend, 'close') and callable(getattr(backend, 'close')):")
|
|
379
|
+
lines.append(f"{indent*4}backend.close()")
|
|
380
|
+
lines.append(f"{indent*3}delattr(cls._local, '{method_name}')")
|
|
381
|
+
return "\n".join(lines)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _render_all(model_infos: Mapping[str, ModelInfo]) -> str:
|
|
385
|
+
exports: list[str] = ["DataSourceConfig", "ForeignKeySpec", "GeneratedClient"]
|
|
386
|
+
for name in sorted(model_infos.keys()):
|
|
387
|
+
exports.extend([
|
|
388
|
+
f"T{name}IncludeCol",
|
|
389
|
+
f"T{name}SortableCol",
|
|
390
|
+
f"{name}Insert",
|
|
391
|
+
f"{name}InsertDict",
|
|
392
|
+
f"{name}WhereDict",
|
|
393
|
+
f"{name}Table",
|
|
394
|
+
])
|
|
395
|
+
exports_literal = ", ".join(f"\"{item}\"" for item in exports)
|
|
396
|
+
return f"__all__ = ({exports_literal},)"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _literal_expression(values: Sequence[str]) -> str:
|
|
400
|
+
unique = list(dict.fromkeys(values))
|
|
401
|
+
if not unique:
|
|
402
|
+
return "Literal[()]"
|
|
403
|
+
items = ", ".join(repr(value) for value in unique)
|
|
404
|
+
return f"Literal[{items}]"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _tuple_literal(values: Iterable[Any]) -> str:
|
|
408
|
+
items = list(values)
|
|
409
|
+
if not items:
|
|
410
|
+
return "()"
|
|
411
|
+
if all(isinstance(item, (tuple, list)) for item in items):
|
|
412
|
+
parts = []
|
|
413
|
+
for item in items:
|
|
414
|
+
parts.append(_tuple_literal(item))
|
|
415
|
+
joined = ", ".join(parts)
|
|
416
|
+
return f"({joined},)"
|
|
417
|
+
joined = ", ".join(repr(item) for item in items)
|
|
418
|
+
if len(items) == 1:
|
|
419
|
+
return f"({joined},)"
|
|
420
|
+
return f"({joined})"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _sanitize_identifier(value: str) -> str:
|
|
424
|
+
result_chars: list[str] = []
|
|
425
|
+
for char in value:
|
|
426
|
+
if char.isalnum() or char == "_":
|
|
427
|
+
result_chars.append(char.lower())
|
|
428
|
+
else:
|
|
429
|
+
result_chars.append("_")
|
|
430
|
+
identifier = "".join(result_chars).replace("__", "_")
|
|
431
|
+
if not identifier or identifier[0].isdigit():
|
|
432
|
+
identifier = f"ds_{identifier}" if identifier else "ds"
|
|
433
|
+
return identifier
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _strip_optional_annotation(annotation: str) -> str:
|
|
437
|
+
parts = [part.strip() for part in annotation.split("|")]
|
|
438
|
+
filtered = [part for part in parts if part != "None"]
|
|
439
|
+
return filtered[0] if len(filtered) == 1 else " | ".join(filtered)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _camel_to_snake(name: str) -> str:
|
|
443
|
+
pattern = re.compile(r"(?<!^)(?=[A-Z])")
|
|
444
|
+
return pattern.sub("_", name).lower()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class _TypeRenderer:
|
|
448
|
+
def __init__(self, model_map: Mapping[type[Any], str]) -> None:
|
|
449
|
+
self._model_map = dict(model_map)
|
|
450
|
+
self._module_imports: dict[str, set[str]] = defaultdict(set)
|
|
451
|
+
self._typing_imports: set[str] = set()
|
|
452
|
+
|
|
453
|
+
def render(self, tp: Any) -> str:
|
|
454
|
+
if tp is Any:
|
|
455
|
+
return "Any"
|
|
456
|
+
if tp is type(None):
|
|
457
|
+
return "None"
|
|
458
|
+
if isinstance(tp, UnionType):
|
|
459
|
+
parts = [self.render(arg) for arg in get_args(tp)]
|
|
460
|
+
return " | ".join(dict.fromkeys(parts))
|
|
461
|
+
origin = get_origin(tp)
|
|
462
|
+
if origin is Annotated:
|
|
463
|
+
return self.render(get_args(tp)[0])
|
|
464
|
+
if origin is Literal:
|
|
465
|
+
self._typing_imports.add("Literal")
|
|
466
|
+
values = ", ".join(repr(value) for value in get_args(tp))
|
|
467
|
+
return f"Literal[{values}]"
|
|
468
|
+
if origin in (list, set, frozenset):
|
|
469
|
+
args = get_args(tp) or (Any,)
|
|
470
|
+
if origin is set:
|
|
471
|
+
container = "set"
|
|
472
|
+
elif origin is frozenset:
|
|
473
|
+
container = "frozenset"
|
|
474
|
+
else:
|
|
475
|
+
container = "list"
|
|
476
|
+
return f"{container}[{self.render(args[0])}]"
|
|
477
|
+
if origin is tuple:
|
|
478
|
+
args = get_args(tp)
|
|
479
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
480
|
+
return f"tuple[{self.render(args[0])}, ...]"
|
|
481
|
+
return f"tuple[{', '.join(self.render(arg) for arg in args)}]"
|
|
482
|
+
if origin is dict:
|
|
483
|
+
key, value = get_args(tp) or (Any, Any)
|
|
484
|
+
return f"dict[{self.render(key)}, {self.render(value)}]"
|
|
485
|
+
if origin is None:
|
|
486
|
+
pass
|
|
487
|
+
if isinstance(tp, type):
|
|
488
|
+
mapped = self._model_map.get(tp)
|
|
489
|
+
if mapped is not None:
|
|
490
|
+
return mapped
|
|
491
|
+
if tp.__module__ == "builtins":
|
|
492
|
+
return tp.__name__
|
|
493
|
+
if tp.__module__ == "datetime":
|
|
494
|
+
self._module_imports.setdefault("datetime", set()).add(tp.__name__)
|
|
495
|
+
return tp.__name__
|
|
496
|
+
self._module_imports.setdefault(tp.__module__, set()).add(tp.__qualname__.split(".")[0])
|
|
497
|
+
return tp.__qualname__
|
|
498
|
+
return repr(tp)
|
|
499
|
+
|
|
500
|
+
def build_imports(self) -> Mapping[str, set[str]]:
|
|
501
|
+
return self._module_imports
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def typing_names(self) -> set[str]:
|
|
505
|
+
return set(self._typing_imports)
|
|
506
|
+
|
|
507
|
+
def require_typing(self, name: str) -> None:
|
|
508
|
+
self._typing_imports.add(name)
|