wexample-orm 1.3.3__tar.gz → 1.4.0__tar.gz

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 (39) hide show
  1. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/PKG-INFO +4 -4
  2. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/README.md +2 -2
  3. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/pyproject.toml +2 -2
  4. {wexample_orm-1.3.3/src/wexample_orm/entity → wexample_orm-1.4.0/src/wexample_orm/const}/__init__.py +0 -0
  5. wexample_orm-1.4.0/src/wexample_orm/const/generator.py +6 -0
  6. wexample_orm-1.4.0/src/wexample_orm/const/import_export.py +17 -0
  7. wexample_orm-1.4.0/src/wexample_orm/exception/import_reference_not_found_exception.py +17 -0
  8. wexample_orm-1.4.0/src/wexample_orm/exception/import_unknown_entity_exception.py +14 -0
  9. {wexample_orm-1.3.3/src/wexample_orm/session → wexample_orm-1.4.0/src/wexample_orm/generator}/__init__.py +0 -0
  10. wexample_orm-1.4.0/src/wexample_orm/generator/entity_generator.py +354 -0
  11. wexample_orm-1.4.0/src/wexample_orm/generator/entity_schema.py +73 -0
  12. wexample_orm-1.4.0/src/wexample_orm/generator/repository_generator.py +110 -0
  13. {wexample_orm-1.3.3/tests → wexample_orm-1.4.0/src/wexample_orm/helper}/__init__.py +0 -0
  14. wexample_orm-1.4.0/src/wexample_orm/helper/doctrine.py +47 -0
  15. wexample_orm-1.4.0/src/wexample_orm/import_export/entity_exporter.py +151 -0
  16. wexample_orm-1.4.0/src/wexample_orm/import_export/entity_importer.py +305 -0
  17. wexample_orm-1.4.0/src/wexample_orm/import_export/import_references_bag.py +43 -0
  18. wexample_orm-1.4.0/src/wexample_orm/import_export/normalized_entity_hydrator.py +67 -0
  19. wexample_orm-1.4.0/src/wexample_orm/repository/__init__.py +0 -0
  20. wexample_orm-1.4.0/src/wexample_orm/session/__init__.py +0 -0
  21. wexample_orm-1.4.0/tests/.gitkeep +0 -0
  22. wexample_orm-1.4.0/tests/__init__.py +0 -0
  23. wexample_orm-1.4.0/tests/test_entity_generator.py +149 -0
  24. wexample_orm-1.4.0/tests/test_entity_importer.py +188 -0
  25. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/__init__.py +0 -0
  26. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/common/__init__.py +0 -0
  27. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/common/abstract_repositories_manager.py +0 -0
  28. {wexample_orm-1.3.3/src/wexample_orm/exception → wexample_orm-1.4.0/src/wexample_orm/entity}/__init__.py +0 -0
  29. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/entity/abstract_entity.py +0 -0
  30. {wexample_orm-1.3.3/src/wexample_orm/repository → wexample_orm-1.4.0/src/wexample_orm/exception}/__init__.py +0 -0
  31. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/exception/repository_session_missing_exception.py +0 -0
  32. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/exception/unknown_repository_exception.py +0 -0
  33. /wexample_orm-1.3.3/src/wexample_orm/py.typed → /wexample_orm-1.4.0/src/wexample_orm/import_export/__init__.py +0 -0
  34. /wexample_orm-1.3.3/tests/.gitkeep → /wexample_orm-1.4.0/src/wexample_orm/py.typed +0 -0
  35. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/repository/abstract_repository.py +0 -0
  36. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/session/abstract_session_factory.py +0 -0
  37. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/testing/__init__.py +0 -0
  38. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/testing/sqlite.py +0 -0
  39. {wexample_orm-1.3.3 → wexample_orm-1.4.0}/tests/test_abstract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-orm
3
- Version: 1.3.3
3
+ Version: 1.4.0
4
4
  Summary: Provides SQLAlchemy ORM abstractions — base entity, repository, session factory, and repository manager — with PostgreSQL-first defaults via psycopg
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -12,7 +12,7 @@ Requires-Python: >=3.10
12
12
  Requires-Dist: attrs>=23.1.0
13
13
  Requires-Dist: psycopg[binary]>=3.2
14
14
  Requires-Dist: sqlalchemy<3,>=2
15
- Requires-Dist: wexample-helpers>=20.0.0
15
+ Requires-Dist: wexample-helpers>=20.1.0
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: pytest; extra == "dev"
18
18
  Requires-Dist: pytest-cov; extra == "dev"
@@ -20,7 +20,7 @@ Description-Content-Type: text/markdown
20
20
 
21
21
  # orm
22
22
 
23
- Version: 1.3.3
23
+ Version: 1.4.0
24
24
 
25
25
  `wexample-orm` provides four SQLAlchemy base classes — `AbstractEntity`, `AbstractRepository`, `AbstractSessionFactory`, and `AbstractRepositoriesManager` — that impose a Doctrine-style repository pattern on top of SQLAlchemy 2. It targets Python developers in the Wexample stack who connect to PostgreSQL via psycopg and want a consistent, opinionated foundation without rewriting engine, session, and repository boilerplate for every project.
26
26
 
@@ -291,7 +291,7 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
291
291
  - attrs: >=23.1.0
292
292
  - psycopg: >=3.2
293
293
  - sqlalchemy: <3,>=2
294
- - wexample-helpers: >=20.0.0
294
+ - wexample-helpers: >=20.1.0
295
295
 
296
296
  ## Versioning & Compatibility Policy
297
297
 
@@ -1,6 +1,6 @@
1
1
  # orm
2
2
 
3
- Version: 1.3.3
3
+ Version: 1.4.0
4
4
 
5
5
  `wexample-orm` provides four SQLAlchemy base classes — `AbstractEntity`, `AbstractRepository`, `AbstractSessionFactory`, and `AbstractRepositoriesManager` — that impose a Doctrine-style repository pattern on top of SQLAlchemy 2. It targets Python developers in the Wexample stack who connect to PostgreSQL via psycopg and want a consistent, opinionated foundation without rewriting engine, session, and repository boilerplate for every project.
6
6
 
@@ -271,7 +271,7 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
271
271
  - attrs: >=23.1.0
272
272
  - psycopg: >=3.2
273
273
  - sqlalchemy: <3,>=2
274
- - wexample-helpers: >=20.0.0
274
+ - wexample-helpers: >=20.1.0
275
275
 
276
276
  ## Versioning & Compatibility Policy
277
277
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-orm"
9
- version = "1.3.3"
9
+ version = "1.4.0"
10
10
  description = "Provides SQLAlchemy ORM abstractions — base entity, repository, session factory, and repository manager — with PostgreSQL-first defaults via psycopg"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "attrs>=23.1.0",
22
22
  "psycopg[binary]>=3.2",
23
23
  "sqlalchemy>=2,<3",
24
- "wexample-helpers>=20.0.0",
24
+ "wexample-helpers>=20.1.0",
25
25
  ]
26
26
 
27
27
  [project.readme]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ # Property names that clash with SQLAlchemy declarative attributes.
4
+ GENERATOR_RESERVED_ATTRIBUTE_NAMES: dict[str, str] = {
5
+ "metadata": "entity_metadata",
6
+ }
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ # Envelope of an exported document: {"version", "type", "entity", "data"}.
4
+ IMPORT_EXPORT_KEY_DATA: str = "data"
5
+ IMPORT_EXPORT_KEY_ENTITY: str = "entity"
6
+ IMPORT_EXPORT_KEY_TYPE: str = "type"
7
+ IMPORT_EXPORT_KEY_VERSION: str = "version"
8
+
9
+ IMPORT_EXPORT_TYPE_COLLECTION: str = "collection"
10
+ IMPORT_EXPORT_TYPE_SINGLE: str = "single"
11
+
12
+ # Relation keys: "<relation>", "<relation>_id", "<relation>_ref" or
13
+ # "<relation>_<unique field>" (e.g. "project_secureId").
14
+ IMPORT_EXPORT_FIELD_SEPARATOR: str = "_"
15
+ IMPORT_EXPORT_SUFFIX_ID: str = "id"
16
+ IMPORT_EXPORT_SUFFIX_REF: str = "ref"
17
+ IMPORT_EXPORT_REFERENCE_KEY: str = IMPORT_EXPORT_FIELD_SEPARATOR + IMPORT_EXPORT_SUFFIX_REF
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from wexample_helpers.exception.undefined_exception import UndefinedException
4
+
5
+
6
+ class ImportReferenceNotFoundException(UndefinedException):
7
+ error_code = "ORM_IMPORT_REFERENCE_NOT_FOUND"
8
+
9
+ @classmethod
10
+ def for_reference(
11
+ cls, reference: str, entity_name: str | None
12
+ ) -> ImportReferenceNotFoundException:
13
+ target = entity_name or "any entity"
14
+ return cls(
15
+ message=f"Import reference '{reference}' not found for {target}",
16
+ data={"reference": reference, "entity_name": entity_name},
17
+ )
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from wexample_helpers.exception.undefined_exception import UndefinedException
4
+
5
+
6
+ class ImportUnknownEntityException(UndefinedException):
7
+ error_code = "ORM_IMPORT_UNKNOWN_ENTITY"
8
+
9
+ @classmethod
10
+ def for_entity_name(cls, entity_name: str) -> ImportUnknownEntityException:
11
+ return cls(
12
+ message=f"No entity class registered for imported entity: {entity_name}",
13
+ data={"entity_name": entity_name},
14
+ )
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from wexample_helpers.classes.base_class import BaseClass
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_orm.generator.entity_schema import EntitySchema
12
+
13
+
14
+ @base_class
15
+ class EntityGenerator(BaseClass):
16
+ """Generate SQLAlchemy entities from the entity schemas of a registry export.
17
+
18
+ The package gets, per schema of its `data/entity` directory:
19
+ `entity/generated/<entity>_generated.py`, rewritten on every run, and
20
+ `entity/<entity>.py`, a wrapper created once, where custom code goes.
21
+ `common/generated_entities.py` lists the classes: `generated_entities`
22
+ for this package, `all_entities` with the external package's ones first.
23
+
24
+ An entity only sees its own package and the external one. A relation to
25
+ an entity out of sight keeps its id column, without foreign key. Both
26
+ sides of a relation are linked (`back_populates`) only within one
27
+ package; a collection over an external entity is read-only.
28
+
29
+ The relations the external entities have towards this package, which they
30
+ cannot see, are added to them by `common/generated_external_relations.py`,
31
+ imported with the manifest: loading this package completes the graph.
32
+ """
33
+
34
+ external_package_name: str | None = public_field(
35
+ description="Package whose entities this one's relations may target, e.g. "
36
+ "the public client for the internal one.",
37
+ default=None,
38
+ )
39
+ package_dir: Path = public_field(description="Directory of the generated package.")
40
+ package_name: str = public_field(description="Import name of the generated package.")
41
+
42
+ def generate(self) -> dict[str, int]:
43
+ from wexample_orm.generator.entity_schema import EntitySchema
44
+
45
+ own = EntitySchema.load_directory(self._data_dir(self.package_dir), self.package_name)
46
+ external = (
47
+ EntitySchema.load_directory(
48
+ self._data_dir(self._external_package_dir()), self.external_package_name
49
+ )
50
+ if self.external_package_name
51
+ else []
52
+ )
53
+ schemas = {schema.module_name: schema for schema in external + own}
54
+
55
+ generated_dir = self.package_dir / "entity" / "generated"
56
+ generated_dir.mkdir(parents=True, exist_ok=True)
57
+ (self.package_dir / "common").mkdir(parents=True, exist_ok=True)
58
+
59
+ counts = {"created": 0, "removed": 0, "unchanged": 0, "updated": 0, "wrappers": 0}
60
+ for schema in own:
61
+ path = generated_dir / f"{schema.module_name}_generated.py"
62
+ content = self._build_entity(schema, schemas)
63
+ if not path.exists():
64
+ counts["created"] += 1
65
+ elif path.read_text(encoding="utf-8") != content:
66
+ counts["updated"] += 1
67
+ else:
68
+ counts["unchanged"] += 1
69
+ path.write_text(content, encoding="utf-8")
70
+
71
+ wrapper = self.package_dir / "entity" / f"{schema.module_name}.py"
72
+ if not wrapper.exists():
73
+ wrapper.write_text(self._build_wrapper(schema), encoding="utf-8")
74
+ counts["wrappers"] += 1
75
+
76
+ expected = {f"{schema.module_name}_generated.py" for schema in own}
77
+ for path in generated_dir.glob("*_generated.py"):
78
+ if path.name not in expected:
79
+ path.unlink()
80
+ counts["removed"] += 1
81
+
82
+ (self.package_dir / "common" / "generated_entities.py").write_text(
83
+ self._build_manifest(own), encoding="utf-8"
84
+ )
85
+ if self.external_package_name:
86
+ (self.package_dir / "common" / "generated_external_relations.py").write_text(
87
+ self._build_external_relations(external, schemas), encoding="utf-8"
88
+ )
89
+ return counts
90
+
91
+ def _attribute(self, name: str, annotation: str, expression: str) -> str:
92
+ return (
93
+ f" @declared_attr\n"
94
+ f" def {name}(cls) -> Mapped[{annotation}]:\n"
95
+ f" return {expression}"
96
+ )
97
+
98
+ def _build_association(
99
+ self,
100
+ schema: EntitySchema,
101
+ attribute: str,
102
+ orm: dict[str, Any],
103
+ schemas: dict[str, EntitySchema],
104
+ field_names: set[str],
105
+ ) -> tuple[list[str], set[str]]:
106
+ from wexample_orm.helper.doctrine import doctrine_join_column_attribute
107
+
108
+ association_type = str(orm.get("association_type") or "").lower()
109
+ target = schemas.get(self._target_module(orm, attribute))
110
+ lines: list[str] = []
111
+ imports: set[str] = set()
112
+
113
+ if association_type in {"many_to_one", "one_to_one"}:
114
+ foreign_key = doctrine_join_column_attribute(attribute, orm)
115
+ if foreign_key not in field_names:
116
+ join_column = (orm.get("join_columns") or [{}])[0]
117
+ nullable = bool(join_column.get("nullable", True))
118
+ constraint = ""
119
+ if target is not None:
120
+ reference = join_column.get("referenced_column_name") or "id"
121
+ constraint = f"ForeignKey({target.module_name + '.' + reference!r}), "
122
+ imports.add("ForeignKey")
123
+ lines.append(
124
+ self._attribute(
125
+ foreign_key,
126
+ "object",
127
+ f"mapped_column(Integer, {constraint}nullable={nullable})",
128
+ )
129
+ )
130
+ imports.add("Integer")
131
+ field_names.add(foreign_key)
132
+
133
+ if target is not None:
134
+ arguments = [
135
+ repr(target.class_name),
136
+ f"foreign_keys='{schema.class_name}.{foreign_key}'",
137
+ ]
138
+ mirror = self._find_mirror_collection(schema, attribute, target)
139
+ if mirror is not None:
140
+ arguments.append(f"back_populates={mirror!r}")
141
+ if target.module_name == schema.module_name:
142
+ # A self reference must name its parent side, or both
143
+ # ends read as one-to-many.
144
+ arguments.append(f"remote_side='{schema.class_name}.id'")
145
+ lines.append(self._relationship(attribute, "object", arguments))
146
+ return lines, imports
147
+
148
+ if association_type == "one_to_many" and target is not None:
149
+ from wexample_orm.generator.entity_schema import EntitySchema
150
+
151
+ owner_attribute = EntitySchema.to_attribute_name(orm.get("mapped_by") or "")
152
+ owner_orm = dict(target.get_mappings("association")).get(owner_attribute)
153
+ if owner_orm is None:
154
+ return [], set()
155
+
156
+ owner_foreign_key = doctrine_join_column_attribute(owner_attribute, owner_orm)
157
+ if target.package_name == schema.package_name:
158
+ arguments = [
159
+ repr(target.class_name),
160
+ f"foreign_keys='{target.class_name}.{owner_foreign_key}'",
161
+ f"back_populates={owner_attribute!r}",
162
+ ]
163
+ else:
164
+ # The owning side lives in another package, which does not see
165
+ # this entity: its column carries no foreign key to join on.
166
+ arguments = [
167
+ repr(target.class_name),
168
+ f"primaryjoin='{schema.class_name}.id == "
169
+ f"foreign({target.class_name}.{owner_foreign_key})'",
170
+ "viewonly=True",
171
+ ]
172
+ lines.append(
173
+ self._relationship(attribute, f"list[{target.class_name!r}]", arguments)
174
+ )
175
+
176
+ return lines, imports
177
+
178
+ def _build_entity(self, schema: EntitySchema, schemas: dict[str, EntitySchema]) -> str:
179
+ from wexample_orm.helper.doctrine import doctrine_type_to_sqlalchemy
180
+
181
+ sqlalchemy_imports: set[str] = set()
182
+ field_lines = []
183
+ field_names = {"id"}
184
+ for attribute, orm in schema.get_mappings("field"):
185
+ if orm.get("id"):
186
+ continue
187
+ column_type, imports = doctrine_type_to_sqlalchemy(orm)
188
+ sqlalchemy_imports |= imports
189
+ arguments = []
190
+ column = orm.get("column")
191
+ if isinstance(column, str) and column and column != attribute:
192
+ arguments.append(repr(column))
193
+ arguments += [column_type, f"nullable={bool(orm.get('nullable', False))}"]
194
+ if orm.get("unique"):
195
+ arguments.append("unique=True")
196
+ field_lines.append(
197
+ self._attribute(attribute, "object", f"mapped_column({', '.join(arguments)})")
198
+ )
199
+ field_names.add(attribute)
200
+
201
+ association_lines = []
202
+ for attribute, orm in schema.get_mappings("association"):
203
+ lines, imports = self._build_association(
204
+ schema, attribute, orm, schemas, field_names
205
+ )
206
+ association_lines += lines
207
+ sqlalchemy_imports |= imports
208
+
209
+ orm_imports = {"Mapped", "declared_attr", "mapped_column"}
210
+ if any("relationship(" in line for line in association_lines):
211
+ orm_imports.add("relationship")
212
+
213
+ header = []
214
+ if sqlalchemy_imports:
215
+ header.append(f"from sqlalchemy import {', '.join(sorted(sqlalchemy_imports))}")
216
+ header.append(f"from sqlalchemy.orm import {', '.join(sorted(orm_imports))}")
217
+
218
+ body = [
219
+ f"class {schema.class_name}Generated:",
220
+ " @classmethod",
221
+ " def get_entity_name(cls) -> str:",
222
+ f" return {schema.module_name!r}",
223
+ ]
224
+ for section in (field_lines, association_lines):
225
+ if section:
226
+ body += [""] + section
227
+
228
+ return "\n".join(header + ["", ""] + body) + "\n"
229
+
230
+ def _build_external_relations(
231
+ self, external: list[EntitySchema], schemas: dict[str, EntitySchema]
232
+ ) -> str:
233
+ """Assignments adding to the external entities their relations towards
234
+ this package. Joins are explicit: the external columns carry no foreign
235
+ key towards entities they cannot see."""
236
+ from wexample_orm.generator.entity_schema import EntitySchema
237
+ from wexample_orm.helper.doctrine import doctrine_join_column_attribute
238
+
239
+ imports = {"from sqlalchemy.orm import relationship"}
240
+ assignments = []
241
+ for schema in external:
242
+ for attribute, orm in schema.get_mappings("association"):
243
+ target = schemas.get(self._target_module(orm, attribute))
244
+ if target is None or target.package_name != self.package_name:
245
+ continue
246
+
247
+ association_type = str(orm.get("association_type") or "").lower()
248
+ if association_type in {"many_to_one", "one_to_one"}:
249
+ foreign_key = doctrine_join_column_attribute(attribute, orm)
250
+ arguments = (
251
+ f"{target.class_name!r}, primaryjoin='foreign({schema.class_name}."
252
+ f"{foreign_key}) == {target.class_name}.id'"
253
+ )
254
+ elif association_type == "one_to_many":
255
+ owner_attribute = EntitySchema.to_attribute_name(orm.get("mapped_by") or "")
256
+ owner_orm = dict(target.get_mappings("association")).get(owner_attribute)
257
+ if owner_orm is None:
258
+ continue
259
+ owner_foreign_key = doctrine_join_column_attribute(owner_attribute, owner_orm)
260
+ arguments = (
261
+ f"{target.class_name!r}, primaryjoin='{schema.class_name}.id == "
262
+ f"foreign({target.class_name}.{owner_foreign_key})', viewonly=True"
263
+ )
264
+ else:
265
+ continue
266
+
267
+ imports.add(
268
+ f"from {schema.package_name}.entity.{schema.module_name} import {schema.class_name}"
269
+ )
270
+ assignments.append(
271
+ f"{schema.class_name}.{attribute} = relationship({arguments}, lazy='select')"
272
+ )
273
+
274
+ return "\n".join(
275
+ ["from __future__ import annotations", "", *sorted(imports), "", "", *assignments]
276
+ ) + "\n"
277
+
278
+ def _build_manifest(self, own: list[EntitySchema]) -> str:
279
+ imports = [
280
+ f"from {schema.package_name}.entity.{schema.module_name} import {schema.class_name}"
281
+ for schema in own
282
+ ]
283
+ classes = "".join(f" {schema.class_name},\n" for schema in own)
284
+ lines = ["from __future__ import annotations", ""]
285
+ if self.external_package_name:
286
+ lines.append(
287
+ f"from {self.external_package_name}.common.generated_entities import (\n"
288
+ f" generated_entities as external_generated_entities,\n)"
289
+ )
290
+ lines += imports
291
+ if self.external_package_name:
292
+ lines.append(f"from {self.package_name}.common import generated_external_relations # noqa: F401")
293
+ lines += ["", ""]
294
+ lines.append(f"generated_entities = [\n{classes}]")
295
+ lines.append(
296
+ "all_entities = [*external_generated_entities, *generated_entities]"
297
+ if self.external_package_name
298
+ else "all_entities = list(generated_entities)"
299
+ )
300
+ return "\n".join(lines) + "\n"
301
+
302
+ def _build_wrapper(self, schema: EntitySchema) -> str:
303
+ return (
304
+ "from __future__ import annotations\n\n"
305
+ "from wexample_orm.entity.abstract_entity import AbstractEntity\n\n"
306
+ f"from {schema.package_name}.entity.generated.{schema.module_name}_generated import (\n"
307
+ f" {schema.class_name}Generated,\n)\n\n"
308
+ f'__all__ = ["{schema.class_name}", "{schema.class_name}Generated"]\n\n\n'
309
+ f"class {schema.class_name}({schema.class_name}Generated, AbstractEntity):\n"
310
+ " pass\n"
311
+ )
312
+
313
+ def _data_dir(self, package_dir: Path) -> Path:
314
+ return package_dir / "data" / "entity"
315
+
316
+ def _external_package_dir(self) -> Path:
317
+ import importlib.util
318
+
319
+ spec = importlib.util.find_spec(self.external_package_name)
320
+ return Path(next(iter(spec.submodule_search_locations)))
321
+
322
+ def _find_mirror_collection(
323
+ self, schema: EntitySchema, attribute: str, target: EntitySchema
324
+ ) -> str | None:
325
+ """The collection of the target mapped by this many-to-one, when both
326
+ are generated in the same package."""
327
+ from wexample_orm.generator.entity_schema import EntitySchema
328
+
329
+ if target.package_name != schema.package_name:
330
+ return None
331
+ for name, orm in target.get_mappings("association"):
332
+ if (
333
+ str(orm.get("association_type") or "").lower() == "one_to_many"
334
+ and self._target_module(orm, name) == schema.module_name
335
+ and EntitySchema.to_attribute_name(orm.get("mapped_by") or "") == attribute
336
+ ):
337
+ return name
338
+ return None
339
+
340
+ def _relationship(self, name: str, annotation: str, arguments: list[str]) -> str:
341
+ arguments = ", ".join([*arguments, "lazy='select'"])
342
+ return self._attribute(name, annotation, f"relationship({arguments})")
343
+
344
+ def _target_module(self, orm: dict[str, Any], attribute: str) -> str:
345
+ from wexample_helpers.helper.string import string_to_snake_case
346
+
347
+ target = orm.get("target")
348
+ if isinstance(target, str) and target:
349
+ return target
350
+ target_entity = orm.get("target_entity")
351
+ if isinstance(target_entity, str) and target_entity:
352
+ return string_to_snake_case(target_entity.split("\\")[-1])
353
+ return string_to_snake_case(attribute)
354
+
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from wexample_helpers.classes.base_class import BaseClass
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+
11
+ @base_class
12
+ class EntitySchema(BaseClass):
13
+ """One entity of a registry export: its Doctrine-flavored properties, and
14
+ the Python package its class is generated in."""
15
+
16
+ module_name: str = public_field(description="Snake case entity name, also its table.")
17
+ package_name: str = public_field(description="Package the entity class lives in.")
18
+ properties: list[dict[str, Any]] = public_field(
19
+ description="Properties as exported, each with its `orm` mapping.",
20
+ factory=list,
21
+ )
22
+
23
+ @classmethod
24
+ def from_file(cls, path: Path, package_name: str) -> EntitySchema:
25
+ import json
26
+
27
+ from wexample_helpers.helper.string import string_to_snake_case
28
+
29
+ data = json.loads(path.read_text(encoding="utf-8"))
30
+ return cls(
31
+ module_name=string_to_snake_case(str(data.get("name") or path.stem)),
32
+ package_name=package_name,
33
+ properties=[
34
+ item for item in data.get("properties") or [] if isinstance(item, dict)
35
+ ],
36
+ )
37
+
38
+ @classmethod
39
+ def load_directory(cls, directory: Path, package_name: str) -> list[EntitySchema]:
40
+ return [
41
+ cls.from_file(path, package_name)
42
+ for path in sorted(directory.iterdir())
43
+ if path.suffix == ".json"
44
+ ]
45
+
46
+ @staticmethod
47
+ def to_attribute_name(property_name: str) -> str:
48
+ import keyword
49
+
50
+ from wexample_orm.const.generator import GENERATOR_RESERVED_ATTRIBUTE_NAMES
51
+
52
+ if property_name in GENERATOR_RESERVED_ATTRIBUTE_NAMES:
53
+ return GENERATOR_RESERVED_ATTRIBUTE_NAMES[property_name]
54
+ if keyword.iskeyword(property_name):
55
+ return f"{property_name}_"
56
+ return property_name
57
+
58
+ @property
59
+ def class_name(self) -> str:
60
+ from wexample_helpers.helper.string import string_to_pascal_case
61
+
62
+ return string_to_pascal_case(self.module_name)
63
+
64
+ def get_mappings(self, kind: str) -> list[tuple[str, dict[str, Any]]]:
65
+ """(attribute name, orm mapping) of the properties of that kind:
66
+ "field" or "association"."""
67
+ return [
68
+ (self.to_attribute_name(item["name"]), item["orm"])
69
+ for item in self.properties
70
+ if isinstance(item.get("name"), str)
71
+ and isinstance(item.get("orm"), dict)
72
+ and item["orm"].get("kind") == kind
73
+ ]
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from wexample_helpers.classes.base_class import BaseClass
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_orm.generator.entity_schema import EntitySchema
12
+
13
+
14
+ @base_class
15
+ class RepositoryGenerator(BaseClass):
16
+ """Generate one database repository per entity of the given packages, in
17
+ `database/repository/`: `generated/<entity>_repository_generated.py`,
18
+ rewritten on every run, and a wrapper created once for custom queries.
19
+ `database/generated_repositories.py` lists them."""
20
+
21
+ entity_package_names: list[str] = public_field(
22
+ description="Packages whose entities get a repository, e.g. the public "
23
+ "then the internal client.",
24
+ )
25
+ package_dir: Path = public_field(description="Directory of the package receiving them.")
26
+ package_name: str = public_field(description="Import name of that package.")
27
+
28
+ def generate(self) -> dict[str, int]:
29
+ from wexample_orm.generator.entity_schema import EntitySchema
30
+
31
+ repository_dir = self.package_dir / "database" / "repository"
32
+ generated_dir = repository_dir / "generated"
33
+ generated_dir.mkdir(parents=True, exist_ok=True)
34
+ for directory in (self.package_dir / "database", repository_dir, generated_dir):
35
+ (directory / "__init__.py").touch()
36
+
37
+ schemas = [
38
+ schema
39
+ for package_name in self.entity_package_names
40
+ for schema in EntitySchema.load_directory(
41
+ self._package_dir(package_name) / "data" / "entity", package_name
42
+ )
43
+ ]
44
+
45
+ counts = {"removed": 0, "repositories": 0, "wrappers": 0}
46
+ for schema in schemas:
47
+ (generated_dir / f"{schema.module_name}_repository_generated.py").write_text(
48
+ self._build_repository(schema), encoding="utf-8"
49
+ )
50
+ counts["repositories"] += 1
51
+
52
+ wrapper = repository_dir / f"{schema.module_name}_repository.py"
53
+ if not wrapper.exists():
54
+ wrapper.write_text(self._build_wrapper(schema), encoding="utf-8")
55
+ counts["wrappers"] += 1
56
+
57
+ expected = {f"{schema.module_name}_repository_generated.py" for schema in schemas}
58
+ for path in generated_dir.glob("*_repository_generated.py"):
59
+ if path.name not in expected:
60
+ path.unlink()
61
+ counts["removed"] += 1
62
+
63
+ (self.package_dir / "database" / "generated_repositories.py").write_text(
64
+ self._build_manifest(schemas), encoding="utf-8"
65
+ )
66
+ return counts
67
+
68
+ def _build_manifest(self, schemas: list[EntitySchema]) -> str:
69
+ imports = "".join(
70
+ f"from {self.package_name}.database.repository.{schema.module_name}_repository "
71
+ f"import {schema.class_name}Repository\n"
72
+ for schema in schemas
73
+ )
74
+ classes = "".join(f" {schema.class_name}Repository,\n" for schema in schemas)
75
+ return (
76
+ "from __future__ import annotations\n\n"
77
+ f"{imports}\n\n"
78
+ f"generated_repositories = [\n{classes}]\n"
79
+ )
80
+
81
+ def _build_repository(self, schema: EntitySchema) -> str:
82
+ return (
83
+ "from __future__ import annotations\n\n"
84
+ "from wexample_orm.repository.abstract_repository import AbstractRepository\n\n"
85
+ f"from {schema.package_name}.entity.{schema.module_name} import {schema.class_name}\n\n\n"
86
+ f"class {schema.class_name}RepositoryGenerated(AbstractRepository):\n"
87
+ " @classmethod\n"
88
+ f" def get_entity_type(cls) -> type[{schema.class_name}]:\n"
89
+ f" return {schema.class_name}\n"
90
+ )
91
+
92
+ def _build_wrapper(self, schema: EntitySchema) -> str:
93
+ module = f"{schema.module_name}_repository"
94
+ return (
95
+ "from __future__ import annotations\n\n"
96
+ f"from {self.package_name}.database.repository.generated.{module}_generated import (\n"
97
+ f" {schema.class_name}RepositoryGenerated,\n)\n\n"
98
+ f'__all__ = ["{schema.class_name}Repository"]\n\n\n'
99
+ f"class {schema.class_name}Repository({schema.class_name}RepositoryGenerated):\n"
100
+ " pass\n"
101
+ )
102
+
103
+ def _package_dir(self, package_name: str) -> Path:
104
+ if package_name == self.package_name:
105
+ return self.package_dir
106
+
107
+ import importlib.util
108
+
109
+ spec = importlib.util.find_spec(package_name)
110
+ return Path(next(iter(spec.submodule_search_locations)))