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.
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/PKG-INFO +4 -4
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/README.md +2 -2
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/pyproject.toml +2 -2
- {wexample_orm-1.3.3/src/wexample_orm/entity → wexample_orm-1.4.0/src/wexample_orm/const}/__init__.py +0 -0
- wexample_orm-1.4.0/src/wexample_orm/const/generator.py +6 -0
- wexample_orm-1.4.0/src/wexample_orm/const/import_export.py +17 -0
- wexample_orm-1.4.0/src/wexample_orm/exception/import_reference_not_found_exception.py +17 -0
- wexample_orm-1.4.0/src/wexample_orm/exception/import_unknown_entity_exception.py +14 -0
- {wexample_orm-1.3.3/src/wexample_orm/session → wexample_orm-1.4.0/src/wexample_orm/generator}/__init__.py +0 -0
- wexample_orm-1.4.0/src/wexample_orm/generator/entity_generator.py +354 -0
- wexample_orm-1.4.0/src/wexample_orm/generator/entity_schema.py +73 -0
- wexample_orm-1.4.0/src/wexample_orm/generator/repository_generator.py +110 -0
- {wexample_orm-1.3.3/tests → wexample_orm-1.4.0/src/wexample_orm/helper}/__init__.py +0 -0
- wexample_orm-1.4.0/src/wexample_orm/helper/doctrine.py +47 -0
- wexample_orm-1.4.0/src/wexample_orm/import_export/entity_exporter.py +151 -0
- wexample_orm-1.4.0/src/wexample_orm/import_export/entity_importer.py +305 -0
- wexample_orm-1.4.0/src/wexample_orm/import_export/import_references_bag.py +43 -0
- wexample_orm-1.4.0/src/wexample_orm/import_export/normalized_entity_hydrator.py +67 -0
- wexample_orm-1.4.0/src/wexample_orm/repository/__init__.py +0 -0
- wexample_orm-1.4.0/src/wexample_orm/session/__init__.py +0 -0
- wexample_orm-1.4.0/tests/.gitkeep +0 -0
- wexample_orm-1.4.0/tests/__init__.py +0 -0
- wexample_orm-1.4.0/tests/test_entity_generator.py +149 -0
- wexample_orm-1.4.0/tests/test_entity_importer.py +188 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/__init__.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/common/__init__.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/common/abstract_repositories_manager.py +0 -0
- {wexample_orm-1.3.3/src/wexample_orm/exception → wexample_orm-1.4.0/src/wexample_orm/entity}/__init__.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/entity/abstract_entity.py +0 -0
- {wexample_orm-1.3.3/src/wexample_orm/repository → wexample_orm-1.4.0/src/wexample_orm/exception}/__init__.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/exception/repository_session_missing_exception.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/exception/unknown_repository_exception.py +0 -0
- /wexample_orm-1.3.3/src/wexample_orm/py.typed → /wexample_orm-1.4.0/src/wexample_orm/import_export/__init__.py +0 -0
- /wexample_orm-1.3.3/tests/.gitkeep → /wexample_orm-1.4.0/src/wexample_orm/py.typed +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/repository/abstract_repository.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/session/abstract_session_factory.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/testing/__init__.py +0 -0
- {wexample_orm-1.3.3 → wexample_orm-1.4.0}/src/wexample_orm/testing/sqlite.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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
|
+
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.
|
|
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.
|
|
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.
|
|
24
|
+
"wexample-helpers>=20.1.0",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.readme]
|
{wexample_orm-1.3.3/src/wexample_orm/entity → wexample_orm-1.4.0/src/wexample_orm/const}/__init__.py
RENAMED
|
File without changes
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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)))
|
|
File without changes
|