exdrf 0.0.1.dev0__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.
- exdrf/__init__.py +0 -0
- exdrf/__version__.py +24 -0
- exdrf/api.py +51 -0
- exdrf/constants.py +30 -0
- exdrf/dataset.py +197 -0
- exdrf/field.py +554 -0
- exdrf/field_types/__init__.py +0 -0
- exdrf/field_types/api.py +78 -0
- exdrf/field_types/blob_field.py +44 -0
- exdrf/field_types/bool_field.py +47 -0
- exdrf/field_types/date_field.py +49 -0
- exdrf/field_types/date_time.py +52 -0
- exdrf/field_types/dur_field.py +44 -0
- exdrf/field_types/enum_field.py +41 -0
- exdrf/field_types/filter_field.py +11 -0
- exdrf/field_types/float_field.py +85 -0
- exdrf/field_types/float_list.py +18 -0
- exdrf/field_types/formatted.py +39 -0
- exdrf/field_types/int_field.py +70 -0
- exdrf/field_types/int_list.py +18 -0
- exdrf/field_types/ref_base.py +105 -0
- exdrf/field_types/ref_m2m.py +39 -0
- exdrf/field_types/ref_m2o.py +23 -0
- exdrf/field_types/ref_o2m.py +36 -0
- exdrf/field_types/ref_o2o.py +32 -0
- exdrf/field_types/sort_field.py +18 -0
- exdrf/field_types/str_field.py +77 -0
- exdrf/field_types/str_list.py +18 -0
- exdrf/field_types/time_field.py +49 -0
- exdrf/filter.py +653 -0
- exdrf/filter_dsl.py +950 -0
- exdrf/filter_op_catalog.py +222 -0
- exdrf/label_dsl.py +691 -0
- exdrf/moment.py +496 -0
- exdrf/py.typed +0 -0
- exdrf/py_support.py +21 -0
- exdrf/resource.py +901 -0
- exdrf/sa_fi_item.py +69 -0
- exdrf/sa_filter_op.py +324 -0
- exdrf/utils.py +17 -0
- exdrf/validator.py +45 -0
- exdrf/var_bag.py +328 -0
- exdrf/visitor.py +58 -0
- exdrf-0.0.1.dev0.dist-info/METADATA +42 -0
- exdrf-0.0.1.dev0.dist-info/RECORD +57 -0
- exdrf-0.0.1.dev0.dist-info/WHEEL +5 -0
- exdrf-0.0.1.dev0.dist-info/top_level.txt +3 -0
- exdrf_tests/__init__.py +0 -0
- exdrf_tests/test_dataset.py +422 -0
- exdrf_tests/test_field.py +109 -0
- exdrf_tests/test_filter.py +425 -0
- exdrf_tests/test_filter_dsl.py +556 -0
- exdrf_tests/test_label_dsl.py +234 -0
- exdrf_tests/test_resource.py +107 -0
- exdrf_tests/test_utils.py +43 -0
- exdrf_tests/test_visitor.py +31 -0
- exdrf_tests/var_bag_test.py +502 -0
exdrf/resource.py
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from collections import OrderedDict as OrDict
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Dict,
|
|
9
|
+
List,
|
|
10
|
+
Optional,
|
|
11
|
+
OrderedDict,
|
|
12
|
+
Set,
|
|
13
|
+
Tuple,
|
|
14
|
+
Union,
|
|
15
|
+
cast,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from attrs import define, field
|
|
19
|
+
from pydantic import BaseModel, Field, field_validator
|
|
20
|
+
|
|
21
|
+
from exdrf.constants import FIELD_TYPE_INTEGER, FIELD_TYPE_REF_ONE_TO_MANY
|
|
22
|
+
from exdrf.label_dsl import (
|
|
23
|
+
generate_python_code,
|
|
24
|
+
generate_typescript_code,
|
|
25
|
+
get_used_fields,
|
|
26
|
+
parse_expr,
|
|
27
|
+
)
|
|
28
|
+
from exdrf.utils import doc_lines, inflect_e
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from exdrf.dataset import ExDataset
|
|
32
|
+
from exdrf.field import ExField
|
|
33
|
+
from exdrf.field_types.ref_base import RefBaseField
|
|
34
|
+
from exdrf.field_types.ref_m2m import RefManyToManyField
|
|
35
|
+
from exdrf.field_types.ref_o2m import RefOneToManyField
|
|
36
|
+
from exdrf.field_types.str_field import StrField
|
|
37
|
+
from exdrf.label_dsl import ASTNode
|
|
38
|
+
from exdrf.visitor import ExVisitor
|
|
39
|
+
|
|
40
|
+
CATEGORY_SEGREGATION_LIMIT = 6
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@define
|
|
44
|
+
class ExResource:
|
|
45
|
+
"""The resource consists of a list of fields and is part of a dataset.
|
|
46
|
+
|
|
47
|
+
You can retrieve a field using the `resource[key]` syntax, where key is
|
|
48
|
+
either the name of the field or its index.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
name: The name of the resource.
|
|
52
|
+
dataset: The dataset that the resource is part of.
|
|
53
|
+
fields: The fields that are part of this resource.
|
|
54
|
+
categories: The categories of the resource.
|
|
55
|
+
description: The description of the resource.
|
|
56
|
+
src: The source of the resource. For sqlalchemy models this is the
|
|
57
|
+
SQLAlchemy model class. For pydantic models this is the pydantic
|
|
58
|
+
model class.
|
|
59
|
+
label_ast: describes how to construct the label of a record.
|
|
60
|
+
provides: The concepts that the resource provides.
|
|
61
|
+
depends_on: The concepts that the resource depends on.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
dataset: "ExDataset" = field(default=None, repr=False)
|
|
66
|
+
fields: List["ExField"] = field(factory=list)
|
|
67
|
+
categories: List[str] = field(factory=list)
|
|
68
|
+
description: str = ""
|
|
69
|
+
src: Any = field(default=None)
|
|
70
|
+
label_ast: "ASTNode" = field(default=None)
|
|
71
|
+
provides: List[str] = field(factory=list)
|
|
72
|
+
depends_on: List[Tuple[str, str]] = field(factory=list)
|
|
73
|
+
|
|
74
|
+
def __attrs_post_init__(self):
|
|
75
|
+
out = self.fields
|
|
76
|
+
self.fields = []
|
|
77
|
+
for fld in out:
|
|
78
|
+
self.add_field(fld)
|
|
79
|
+
field_map = {f.name: f for f in out}
|
|
80
|
+
for fld in out:
|
|
81
|
+
self.post_process_field(fld, field_map)
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str:
|
|
84
|
+
return self.__repr__()
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"<Resource {self.name} ({len(self.fields)} fields)>"
|
|
88
|
+
|
|
89
|
+
def __hash__(self):
|
|
90
|
+
return hash(f"{self.name}.{'.'.join(self.categories)}")
|
|
91
|
+
|
|
92
|
+
def __contains__(self, key: Union[int, str]) -> bool:
|
|
93
|
+
if isinstance(key, int):
|
|
94
|
+
return 0 <= key < len(self.fields)
|
|
95
|
+
return any(f.name == key for f in self.fields)
|
|
96
|
+
|
|
97
|
+
def __iter__(self):
|
|
98
|
+
"""Make the resource iterable over its fields."""
|
|
99
|
+
return iter(self.fields)
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
"""Return the number of fields in the resource."""
|
|
103
|
+
return len(self.fields)
|
|
104
|
+
|
|
105
|
+
def __in__(self, key: Union[int, str]) -> bool:
|
|
106
|
+
"""Check if a field exists in the resource.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
key: The key to check for. Can be either an index or field name.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if the field exists, False otherwise.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(key, int):
|
|
115
|
+
return key < len(self.fields)
|
|
116
|
+
return any(f.name == key for f in self.fields)
|
|
117
|
+
|
|
118
|
+
def __getitem__(self, key: Union[int, str]) -> "ExField":
|
|
119
|
+
# If it is an index, return the field at that index.
|
|
120
|
+
if isinstance(key, int):
|
|
121
|
+
return self.fields[key]
|
|
122
|
+
|
|
123
|
+
# Locate the field by name.
|
|
124
|
+
for m in self.fields:
|
|
125
|
+
if m.name == key:
|
|
126
|
+
return m
|
|
127
|
+
|
|
128
|
+
# If the field is not found, raise an error.
|
|
129
|
+
raise KeyError(f"No field found for key `{key}` in model `{self.name}`")
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def ref_fields(self) -> List["RefBaseField"]:
|
|
133
|
+
"""Get the fields that are references to other resources.
|
|
134
|
+
|
|
135
|
+
Note that `get_dependencies` will return all related resources, even
|
|
136
|
+
if they are not referenced in the fields of the resource.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The fields that are references to other resources.
|
|
140
|
+
"""
|
|
141
|
+
return cast(
|
|
142
|
+
List["RefBaseField"],
|
|
143
|
+
[fld for fld in self.fields if fld.is_ref_type],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@cached_property
|
|
147
|
+
def derived_fields(self) -> List["ExField"]:
|
|
148
|
+
"""Get the fields that are derived from other fields."""
|
|
149
|
+
return [fld for fld in self.fields if fld.is_derived]
|
|
150
|
+
|
|
151
|
+
@cached_property
|
|
152
|
+
def pascal_case_name(self) -> str:
|
|
153
|
+
"""Return the name of the resource in PascalCase."""
|
|
154
|
+
return self.name
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def snake_case_name(self) -> str:
|
|
158
|
+
"""Return the name of the resource in snake_case.
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
If self.name == "ContractProposal", then:
|
|
162
|
+
- snake_case_name -> "contract_proposal"
|
|
163
|
+
If self.name == "IssItem", then:
|
|
164
|
+
- snake_case_name -> "iss_item"
|
|
165
|
+
"""
|
|
166
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower()
|
|
167
|
+
|
|
168
|
+
@cached_property
|
|
169
|
+
def snake_case_name_plural(self) -> str:
|
|
170
|
+
"""Return the name of the resource in snake_case.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
If self.name == "ContractProposal", then:
|
|
174
|
+
- snake_case_name_plural -> "contract_proposals"
|
|
175
|
+
If self.name == "IssItem", then:
|
|
176
|
+
- snake_case_name_plural -> "iss_items"
|
|
177
|
+
"""
|
|
178
|
+
return inflect_e.plural(
|
|
179
|
+
re.sub(r"(?<!^)(?=[A-Z])", "_", self.name).lower() # type: ignore
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@cached_property
|
|
183
|
+
def camel_case_name(self) -> str:
|
|
184
|
+
"""Return the name of the resource in camelCase."""
|
|
185
|
+
return self.name[0].lower() + self.name[1:]
|
|
186
|
+
|
|
187
|
+
@cached_property
|
|
188
|
+
def text_name(self) -> str:
|
|
189
|
+
"""Return the name of the resource in `Text case`."""
|
|
190
|
+
tmp = re.sub(r"(?<!^)(?=[A-Z])", " ", self.name).lower()
|
|
191
|
+
return tmp[0].upper() + tmp[1:]
|
|
192
|
+
|
|
193
|
+
@cached_property
|
|
194
|
+
def doc_lines(self) -> List[str]:
|
|
195
|
+
"""Get the docstring of the field as a set of lines.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The docstring of the field as a set of lines.
|
|
199
|
+
"""
|
|
200
|
+
return doc_lines(self.description)
|
|
201
|
+
|
|
202
|
+
def resource_properties(self, explicit: bool = False) -> Dict[str, Any]:
|
|
203
|
+
"""Build a JSON-friendly metadata dict for this resource.
|
|
204
|
+
|
|
205
|
+
Mirrors :meth:`exdrf.field.ExField.field_properties` for use in
|
|
206
|
+
emitters (e.g. Pydantic ``json_schema_extra``).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
explicit: If True, include all keys, including empty strings and
|
|
210
|
+
empty collections where applicable.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Serializable resource-level properties.
|
|
214
|
+
"""
|
|
215
|
+
if explicit:
|
|
216
|
+
return {
|
|
217
|
+
"name": self.name,
|
|
218
|
+
"categories": list(self.categories),
|
|
219
|
+
"description": self.description,
|
|
220
|
+
"text_name": self.text_name,
|
|
221
|
+
"provides": list(self.provides),
|
|
222
|
+
"depends_on": [list(pair) for pair in self.depends_on],
|
|
223
|
+
}
|
|
224
|
+
result: Dict[str, Any] = {"name": self.name}
|
|
225
|
+
if self.categories:
|
|
226
|
+
result["categories"] = list(self.categories)
|
|
227
|
+
if self.description:
|
|
228
|
+
result["description"] = self.description
|
|
229
|
+
if self.provides:
|
|
230
|
+
result["provides"] = list(self.provides)
|
|
231
|
+
if self.depends_on:
|
|
232
|
+
result["depends_on"] = [list(pair) for pair in self.depends_on]
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
def add_field(self, fld: "ExField") -> None:
|
|
236
|
+
"""Add a field to the resource.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
field: The field to add.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
assert fld.name, "Field name must be set"
|
|
243
|
+
assert fld.type_name, f"Field type must be set in {fld.name}"
|
|
244
|
+
|
|
245
|
+
self.fields.append(fld)
|
|
246
|
+
fld.resource = self # type: ignore
|
|
247
|
+
|
|
248
|
+
if not fld.category:
|
|
249
|
+
fld.category = self.get_default_field_category(fld)
|
|
250
|
+
|
|
251
|
+
def post_process_field(
|
|
252
|
+
self, fld: "ExField", field_map: Dict[str, "ExField"]
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Tie fields together.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
fld: The field to post-process.
|
|
258
|
+
field_map: A dictionary that maps field names to fields.
|
|
259
|
+
"""
|
|
260
|
+
from exdrf.constants import FIELD_TYPE_STRING
|
|
261
|
+
from exdrf.field import NO_DIACRITICS
|
|
262
|
+
|
|
263
|
+
if fld.derived:
|
|
264
|
+
other_name, kind = fld.derived
|
|
265
|
+
if kind == NO_DIACRITICS:
|
|
266
|
+
if fld.type_name != FIELD_TYPE_STRING:
|
|
267
|
+
raise ValueError("Only string types supports NO_DIACRITICS")
|
|
268
|
+
else:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
other = field_map.get(other_name, None)
|
|
272
|
+
if other is None:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f"The field {fld.name} depends on the field "
|
|
275
|
+
f"{other_name}, which was not found in the current "
|
|
276
|
+
"resource."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if other.type_name != FIELD_TYPE_STRING:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
"Only string types supports NO_DIACRITICS. "
|
|
282
|
+
f"The target field {other_name} is a {other.type_name}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
other_str = cast("StrField", other)
|
|
286
|
+
other_str.no_dia_field = fld
|
|
287
|
+
|
|
288
|
+
def get_default_field_category(self, fld: "ExField") -> str:
|
|
289
|
+
"""Get the default category for a field.
|
|
290
|
+
|
|
291
|
+
When adding a new field with an empty category the `add_field()`
|
|
292
|
+
method will call this method to get the default category. Reimplement
|
|
293
|
+
it if you want to assign categories to fields automatically.
|
|
294
|
+
"""
|
|
295
|
+
return "keys" if fld.primary else "general"
|
|
296
|
+
|
|
297
|
+
def get_fields_for_ref_filtering(self) -> List["ExField"]:
|
|
298
|
+
"""Get the fields that are going to be used with other models that
|
|
299
|
+
reference this model when the user searches for text.
|
|
300
|
+
"""
|
|
301
|
+
lst = self.minium_field_set_wo_primaries() or self.minimum_field_set
|
|
302
|
+
return [self[n] for n in lst if not self[n].is_ref_type]
|
|
303
|
+
|
|
304
|
+
def visit(
|
|
305
|
+
self,
|
|
306
|
+
visitor: "ExVisitor",
|
|
307
|
+
omit_fields: Optional[bool] = False,
|
|
308
|
+
) -> bool:
|
|
309
|
+
"""Visit the resource and its fields.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
visitor: The visitor to use.
|
|
313
|
+
omit_fields: If True, resource fields will not be visited.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
bool: True if the visit should continue, False otherwise.
|
|
317
|
+
"""
|
|
318
|
+
if not visitor.visit_resource(self): # type: ignore
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
if not omit_fields:
|
|
322
|
+
for fld in self.fields:
|
|
323
|
+
if not fld.visit(visitor):
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
return True
|
|
327
|
+
|
|
328
|
+
def get_dependencies(self, fk_only: bool = False) -> Set["ExResource"]:
|
|
329
|
+
"""Get the set of resources that this resource depends on.
|
|
330
|
+
|
|
331
|
+
The method interrogates the fields of the resource and checks if any of
|
|
332
|
+
them are references to other resources. If so, it adds them to the set
|
|
333
|
+
of dependencies. This is useful for generating import statements or for
|
|
334
|
+
determining the order in which resources should be processed.
|
|
335
|
+
|
|
336
|
+
Note that only the first level of dependencies is considered so, if
|
|
337
|
+
resource A depends on resource B, and resource B depends on resource C,
|
|
338
|
+
resource C will not be reported by this method.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
fk_only: If True, only dependencies that have their foreign key
|
|
342
|
+
in the current resources are returned (ManyToOne and OneToOne).
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
The set of resources that this resource depends on.
|
|
346
|
+
"""
|
|
347
|
+
deps = set()
|
|
348
|
+
for fld in self.fields:
|
|
349
|
+
if fk_only:
|
|
350
|
+
if fld.is_many_to_one_type or fld.is_one_to_one_type:
|
|
351
|
+
fld = cast("RefBaseField", fld)
|
|
352
|
+
if fld.ref is not self:
|
|
353
|
+
deps.add(fld.ref)
|
|
354
|
+
|
|
355
|
+
# Extra dependencies are not included when fk_only is True.
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
fld = cast("RefBaseField", fld)
|
|
359
|
+
if fld.is_ref_type and fld.ref is not self:
|
|
360
|
+
deps.add(fld.ref)
|
|
361
|
+
for extra in fld.extra_ref(self.dataset):
|
|
362
|
+
if extra is not self:
|
|
363
|
+
deps.add(extra)
|
|
364
|
+
return deps
|
|
365
|
+
|
|
366
|
+
def get_dep_fields(self, dep: "ExResource") -> List["ExField"]:
|
|
367
|
+
"""Get the fields that references a particular dependency.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
dep: The dependency to look for.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The fields that references the dependency.
|
|
374
|
+
"""
|
|
375
|
+
return [fld for fld in self.ref_fields if fld.ref is dep]
|
|
376
|
+
|
|
377
|
+
@cached_property
|
|
378
|
+
def minimum_field_set(self) -> List[str]:
|
|
379
|
+
"""Get the minimum set of fields that are used to represent the
|
|
380
|
+
resource.
|
|
381
|
+
|
|
382
|
+
This set includes all the fields that are used in the label definition
|
|
383
|
+
and all the primary-key fields (fields that contribute to computing
|
|
384
|
+
the identity of the resource).
|
|
385
|
+
"""
|
|
386
|
+
names: Set[str] = set(get_used_fields(self.label_ast))
|
|
387
|
+
for f in self.fields:
|
|
388
|
+
if f.primary:
|
|
389
|
+
names.add(f.name)
|
|
390
|
+
return sorted(names)
|
|
391
|
+
|
|
392
|
+
def minium_field_set_wo_primaries(self) -> List[str]:
|
|
393
|
+
"""Get the minimum set of fields that are used to represent the
|
|
394
|
+
resource except those fields that are also primary keys.
|
|
395
|
+
"""
|
|
396
|
+
names: Set[str] = set(
|
|
397
|
+
[n.split(".")[0] for n in get_used_fields(self.label_ast)]
|
|
398
|
+
)
|
|
399
|
+
return sorted(n for n in names if self.__in__(n) and not self[n].primary)
|
|
400
|
+
|
|
401
|
+
def primary_fields(self) -> List[str]:
|
|
402
|
+
"""Get the fields that are primary keys of the resource."""
|
|
403
|
+
names: Set[str] = set()
|
|
404
|
+
for f in self.fields:
|
|
405
|
+
if f.primary:
|
|
406
|
+
names.add(f.name)
|
|
407
|
+
return sorted(names)
|
|
408
|
+
|
|
409
|
+
def primary_inst_fields(self) -> List["ExField"]:
|
|
410
|
+
"""Get the fields that are primary keys of the resource."""
|
|
411
|
+
names: Set[str] = set()
|
|
412
|
+
for f in self.fields:
|
|
413
|
+
if f.primary:
|
|
414
|
+
names.add(f.name)
|
|
415
|
+
return [self[n] for n in sorted(names)]
|
|
416
|
+
|
|
417
|
+
@cached_property
|
|
418
|
+
def is_primary_simple(self) -> bool:
|
|
419
|
+
"""Check if the resource has a simple primary key.
|
|
420
|
+
|
|
421
|
+
A simple primary key is a single field that is used to identify the
|
|
422
|
+
resource. If the resource has no primary key or more than one primary
|
|
423
|
+
key, it is not a simple primary key.
|
|
424
|
+
"""
|
|
425
|
+
return len(self.primary_fields()) == 1
|
|
426
|
+
|
|
427
|
+
@cached_property
|
|
428
|
+
def is_primary_simple_id(self) -> bool:
|
|
429
|
+
"""Check if the resource has a single primary key called `id`."""
|
|
430
|
+
pf = self.primary_fields()
|
|
431
|
+
return len(pf) == 1 and pf[0] == "id"
|
|
432
|
+
|
|
433
|
+
@cached_property
|
|
434
|
+
def is_connection_resource(self) -> bool:
|
|
435
|
+
"""Check if the resource is a connection resource.
|
|
436
|
+
|
|
437
|
+
A connection resource is a resource that is used to connect two other
|
|
438
|
+
resources. It is not a real resource and should not be included in the
|
|
439
|
+
UI.
|
|
440
|
+
"""
|
|
441
|
+
return all(f.primary for f in self.fields)
|
|
442
|
+
|
|
443
|
+
@cached_property
|
|
444
|
+
def is_join_table(self) -> bool:
|
|
445
|
+
"""A table that only contains two foreign key fields."""
|
|
446
|
+
candidates = []
|
|
447
|
+
for f in self.fields:
|
|
448
|
+
if f.is_ref_type:
|
|
449
|
+
continue
|
|
450
|
+
if not f.primary:
|
|
451
|
+
return False
|
|
452
|
+
if f.type_name != FIELD_TYPE_INTEGER:
|
|
453
|
+
return False
|
|
454
|
+
if not f.name.endswith("_id"):
|
|
455
|
+
return False
|
|
456
|
+
candidates.append(f)
|
|
457
|
+
if len(candidates) != 2:
|
|
458
|
+
return False
|
|
459
|
+
return True
|
|
460
|
+
|
|
461
|
+
def rel_import(
|
|
462
|
+
self,
|
|
463
|
+
other: Union["ExResource", List[str]],
|
|
464
|
+
path_up: str = "..",
|
|
465
|
+
path_sep: str = "/",
|
|
466
|
+
) -> str:
|
|
467
|
+
"""Compute the import path for a resource relative to another resource.
|
|
468
|
+
|
|
469
|
+
Resources are assumed to live at the end of the path indicated by the
|
|
470
|
+
`categories` list. The import path is computed by finding the common
|
|
471
|
+
prefix between the two resources and then computing the relative path
|
|
472
|
+
from the other resource to this one.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
other: The resource to import from or a path as a list of strings.
|
|
476
|
+
path_up: The string to use to go up in the path. Defaults to '..'.
|
|
477
|
+
path_sep: The string to use to separate the elements of the path.
|
|
478
|
+
Defaults to '/'.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
The relative import path, with elements separated by slashes.
|
|
482
|
+
"""
|
|
483
|
+
if isinstance(other, ExResource):
|
|
484
|
+
other_categories = other.categories
|
|
485
|
+
else:
|
|
486
|
+
other_categories = other
|
|
487
|
+
|
|
488
|
+
# Find the common prefix.
|
|
489
|
+
i = 0
|
|
490
|
+
while (
|
|
491
|
+
i < len(self.categories)
|
|
492
|
+
and i < len(other_categories)
|
|
493
|
+
and self.categories[i] == other_categories[i]
|
|
494
|
+
):
|
|
495
|
+
i += 1
|
|
496
|
+
|
|
497
|
+
# Compute the relative path.
|
|
498
|
+
path = [path_up] * (len(other_categories) - i)
|
|
499
|
+
path.extend(other_categories[i:])
|
|
500
|
+
|
|
501
|
+
return path_sep.join(path)
|
|
502
|
+
|
|
503
|
+
def ensure_path(self, path: str, extension: str, name: Optional[str] = None):
|
|
504
|
+
"""Ensure that a path exists and computes file path.
|
|
505
|
+
|
|
506
|
+
The final path is computed by joining the base `path` with the
|
|
507
|
+
categories of the resource and the name of the resource, and appending
|
|
508
|
+
the `extension`.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
path: The base path to write the file to.
|
|
512
|
+
extension: The extension of the file without a dot.
|
|
513
|
+
name: override the name of the resource; the resource name is
|
|
514
|
+
stored as a Pascal-case string and you may want to use a
|
|
515
|
+
different case convention for the file; also, there's nothing
|
|
516
|
+
preventing you from including a prefix path with the name
|
|
517
|
+
that will be applied at the end of the categories path.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
The full path to the file.
|
|
521
|
+
"""
|
|
522
|
+
# Create the output file path.
|
|
523
|
+
file_path = os.path.join(
|
|
524
|
+
path, *self.categories, f"{name or self.name}.{extension}"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Create the output directory if it doesn't exist.
|
|
528
|
+
dir_path = os.path.dirname(file_path)
|
|
529
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
530
|
+
|
|
531
|
+
return file_path
|
|
532
|
+
|
|
533
|
+
def parse_pos_hint(
|
|
534
|
+
self, pos_hint: Optional[str]
|
|
535
|
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
536
|
+
"""Parse a pos_hint into sort value and relationships.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
pos_hint: The positional hint string to parse.
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
A tuple of (sort_value, after_name, before_name).
|
|
543
|
+
"""
|
|
544
|
+
if not pos_hint:
|
|
545
|
+
return None, None, None
|
|
546
|
+
|
|
547
|
+
# Extract relative positioning rules from the hint.
|
|
548
|
+
after_match = re.search(r"\[after:([^\]]+)\]", pos_hint)
|
|
549
|
+
before_match = re.search(r"\[before:([^\]]+)\]", pos_hint)
|
|
550
|
+
after_name = after_match.group(1).strip() if after_match else None
|
|
551
|
+
before_name = before_match.group(1).strip() if before_match else None
|
|
552
|
+
|
|
553
|
+
# Remove relationship tokens to keep the sort value intact.
|
|
554
|
+
sort_value = re.sub(r"\[(?:after|before):[^\]]+\]", "", pos_hint).strip()
|
|
555
|
+
|
|
556
|
+
return sort_value, after_name, before_name
|
|
557
|
+
|
|
558
|
+
def apply_pos_hint_relations(self, fields: List["ExField"]) -> List["ExField"]:
|
|
559
|
+
"""Apply [after]/[before] relations to a sorted field list.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
fields: The list of fields sorted by the base sort key.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
A reordered list that respects [after]/[before] hints.
|
|
566
|
+
"""
|
|
567
|
+
ordered = list(fields)
|
|
568
|
+
|
|
569
|
+
# Reposition fields according to relative hints.
|
|
570
|
+
for fld in fields:
|
|
571
|
+
if not fld.pos_hint:
|
|
572
|
+
continue
|
|
573
|
+
|
|
574
|
+
_, after_name, before_name = self.parse_pos_hint(fld.pos_hint)
|
|
575
|
+
if not after_name and not before_name:
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
name_to_index = {f.name: idx for idx, f in enumerate(ordered)}
|
|
579
|
+
current_index = name_to_index.get(fld.name)
|
|
580
|
+
if current_index is None:
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
# Resolve the target index while keeping categories aligned.
|
|
584
|
+
after_index = None
|
|
585
|
+
before_index = None
|
|
586
|
+
if after_name in name_to_index:
|
|
587
|
+
after_fld = ordered[name_to_index[after_name]]
|
|
588
|
+
if after_fld.category == fld.category:
|
|
589
|
+
after_index = name_to_index[after_name]
|
|
590
|
+
if before_name in name_to_index:
|
|
591
|
+
before_fld = ordered[name_to_index[before_name]]
|
|
592
|
+
if before_fld.category == fld.category:
|
|
593
|
+
before_index = name_to_index[before_name]
|
|
594
|
+
|
|
595
|
+
if after_index is not None and before_index is not None:
|
|
596
|
+
if after_index < before_index:
|
|
597
|
+
target_index = after_index + 1
|
|
598
|
+
if target_index > before_index:
|
|
599
|
+
target_index = before_index
|
|
600
|
+
else:
|
|
601
|
+
target_index = before_index
|
|
602
|
+
elif after_index is not None:
|
|
603
|
+
target_index = after_index + 1
|
|
604
|
+
elif before_index is not None:
|
|
605
|
+
target_index = before_index
|
|
606
|
+
else:
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
if target_index == current_index:
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
ordered.pop(current_index)
|
|
613
|
+
if target_index > current_index:
|
|
614
|
+
target_index -= 1
|
|
615
|
+
ordered.insert(target_index, fld)
|
|
616
|
+
|
|
617
|
+
return ordered
|
|
618
|
+
|
|
619
|
+
def field_sort_key(self, fld: "ExField") -> str:
|
|
620
|
+
"""Get the sort key for a field.
|
|
621
|
+
|
|
622
|
+
The sort key is used to sort the fields in the resource. By default it
|
|
623
|
+
is computed by joining the categories of the resource with the name of
|
|
624
|
+
the field.
|
|
625
|
+
|
|
626
|
+
You may want to reimplement this method in a subclass if you want to
|
|
627
|
+
the fields ranked before the alphabetical sort.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
fld: The field to get the sort key for.
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
The sort key for the field.
|
|
634
|
+
"""
|
|
635
|
+
label_fields = set(self.minimum_field_set)
|
|
636
|
+
|
|
637
|
+
category = fld.category or ""
|
|
638
|
+
sort_value = fld.pos_hint
|
|
639
|
+
if sort_value is None:
|
|
640
|
+
if fld.is_one_to_one_type or fld.is_many_to_one_type:
|
|
641
|
+
particle = "T"
|
|
642
|
+
elif fld.is_many_to_many_type or fld.is_one_to_many_type:
|
|
643
|
+
particle = "U"
|
|
644
|
+
elif fld.fk_to or fld.fk_from:
|
|
645
|
+
particle = "V"
|
|
646
|
+
elif fld.primary:
|
|
647
|
+
particle = "X"
|
|
648
|
+
elif fld.name == "deleted":
|
|
649
|
+
particle = "Y"
|
|
650
|
+
elif fld.name in ("created_on", "updated_on"):
|
|
651
|
+
particle = "Z"
|
|
652
|
+
elif fld.name in label_fields:
|
|
653
|
+
particle = "A"
|
|
654
|
+
else:
|
|
655
|
+
particle = "B"
|
|
656
|
+
sort_value = f"{particle}.{fld.name}"
|
|
657
|
+
return f"{category}.{sort_value}".lower()
|
|
658
|
+
|
|
659
|
+
@cached_property
|
|
660
|
+
def sorted_fields(self) -> List["ExField"]:
|
|
661
|
+
"""Get a sorted list of fields.
|
|
662
|
+
|
|
663
|
+
You can customize the order of the fields by reimplementing the
|
|
664
|
+
`field_sort_key` method.
|
|
665
|
+
"""
|
|
666
|
+
sorted_fields = sorted(
|
|
667
|
+
self.fields,
|
|
668
|
+
key=self.field_sort_key,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Apply higher-precedence relative ordering hints.
|
|
672
|
+
return self.apply_pos_hint_relations(sorted_fields)
|
|
673
|
+
|
|
674
|
+
def fields_by_category(
|
|
675
|
+
self,
|
|
676
|
+
exclude_names: Optional[Set[str]] = None,
|
|
677
|
+
exclude_derived: Optional[bool] = False,
|
|
678
|
+
exclude_ref_fields: Optional[bool] = False,
|
|
679
|
+
exclude_fk_to: Optional[bool] = False,
|
|
680
|
+
exclude_fk_from: Optional[bool] = False,
|
|
681
|
+
exclude_many_to_many: Optional[bool] = False,
|
|
682
|
+
exclude_many_to_one: Optional[bool] = False,
|
|
683
|
+
exclude_one_to_many: Optional[bool] = False,
|
|
684
|
+
exclude_one_to_one: Optional[bool] = False,
|
|
685
|
+
exclude_bridge: Optional[bool] = False,
|
|
686
|
+
exclude_one_to_many_use_rel: Optional[bool] = False,
|
|
687
|
+
) -> Dict[str, List["ExField"]]:
|
|
688
|
+
"""Get a dictionary that maps categories to fields.
|
|
689
|
+
|
|
690
|
+
The keys of the dictionary are the categories and the values are lists
|
|
691
|
+
of sorted fields in that category. The fields are sorted using the
|
|
692
|
+
`field_sort_key()` key.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
exclude_names: The names of the fields to exclude.
|
|
696
|
+
exclude_derived: If True, derived fields will not be included.
|
|
697
|
+
exclude_ref_fields: If True, reference fields will not be included.
|
|
698
|
+
exclude_one_to_many_use_rel: If True, omit non-bridge OneToMany
|
|
699
|
+
fields that have ``use_rel`` set (handled on editor rel tabs).
|
|
700
|
+
"""
|
|
701
|
+
categories: Dict[str, List["ExField"]] = {}
|
|
702
|
+
|
|
703
|
+
def is_included(f: "ExField") -> bool:
|
|
704
|
+
if exclude_names and f.name in exclude_names:
|
|
705
|
+
return False
|
|
706
|
+
if exclude_derived and f.is_derived:
|
|
707
|
+
return False
|
|
708
|
+
if exclude_ref_fields and f.is_ref_type:
|
|
709
|
+
return False
|
|
710
|
+
if exclude_fk_to and f.fk_to is not None:
|
|
711
|
+
return False
|
|
712
|
+
if exclude_fk_from and f.fk_from is not None:
|
|
713
|
+
return False
|
|
714
|
+
if exclude_many_to_many and f.is_many_to_many_type:
|
|
715
|
+
return False
|
|
716
|
+
if exclude_many_to_one and f.is_many_to_one_type:
|
|
717
|
+
return False
|
|
718
|
+
if exclude_one_to_many and f.is_one_to_many_type:
|
|
719
|
+
return False
|
|
720
|
+
if exclude_one_to_one and f.is_one_to_one_type:
|
|
721
|
+
return False
|
|
722
|
+
if exclude_bridge and hasattr(f, "bridge") and bool(getattr(f, "bridge")):
|
|
723
|
+
return False
|
|
724
|
+
if exclude_one_to_many_use_rel:
|
|
725
|
+
if (
|
|
726
|
+
f.is_one_to_many_type
|
|
727
|
+
and getattr(f, "use_rel", False)
|
|
728
|
+
and not (hasattr(f, "bridge") and bool(f.bridge))
|
|
729
|
+
):
|
|
730
|
+
return False
|
|
731
|
+
return True
|
|
732
|
+
|
|
733
|
+
included_fields = [f for f in self.sorted_fields if is_included(f)]
|
|
734
|
+
if len(included_fields) < CATEGORY_SEGREGATION_LIMIT:
|
|
735
|
+
return {
|
|
736
|
+
"general": included_fields,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
for f in included_fields:
|
|
740
|
+
category = f.category
|
|
741
|
+
lst = categories.get(category, None)
|
|
742
|
+
if lst is None:
|
|
743
|
+
categories[category] = lst = []
|
|
744
|
+
lst.append(f)
|
|
745
|
+
return categories
|
|
746
|
+
|
|
747
|
+
def category_sort_key(self, cat: str) -> str:
|
|
748
|
+
"""Get the sort key for a category.
|
|
749
|
+
|
|
750
|
+
The sort key is used to sort the categories in the resource. By
|
|
751
|
+
default it is the category itself.
|
|
752
|
+
|
|
753
|
+
You may want to reimplement this method in a subclass if you want to
|
|
754
|
+
the categories ranked before the alphabetical sort.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
cat: The category to get the sort key for.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
The sort key for the category.
|
|
761
|
+
"""
|
|
762
|
+
if cat == "general":
|
|
763
|
+
return "a-" + cat
|
|
764
|
+
if cat == "management":
|
|
765
|
+
return "z-" + cat
|
|
766
|
+
if cat == "comments":
|
|
767
|
+
return "y-" + cat
|
|
768
|
+
return "p-" + cat
|
|
769
|
+
|
|
770
|
+
def sorted_fields_and_categories(
|
|
771
|
+
self,
|
|
772
|
+
exclude_names: Optional[Set[str]] = None,
|
|
773
|
+
exclude_derived: Optional[bool] = False,
|
|
774
|
+
exclude_ref_fields: Optional[bool] = False,
|
|
775
|
+
exclude_fk_to: Optional[bool] = False,
|
|
776
|
+
exclude_fk_from: Optional[bool] = False,
|
|
777
|
+
exclude_many_to_many: Optional[bool] = False,
|
|
778
|
+
exclude_many_to_one: Optional[bool] = False,
|
|
779
|
+
exclude_one_to_many: Optional[bool] = False,
|
|
780
|
+
exclude_one_to_one: Optional[bool] = False,
|
|
781
|
+
exclude_bridge: Optional[bool] = False,
|
|
782
|
+
exclude_one_to_many_use_rel: Optional[bool] = False,
|
|
783
|
+
) -> OrderedDict[str, List["ExField"]]:
|
|
784
|
+
"""Get a dictionary that maps categories to fields.
|
|
785
|
+
|
|
786
|
+
Both the fields and the categories are sorted:
|
|
787
|
+
- the fields are sorted using the `field_sort_key()` key.
|
|
788
|
+
- the categories are sorted using the `category_sort_key()` function.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
exclude_names: The names of the fields to exclude.
|
|
792
|
+
exclude_derived: If True, derived fields will not be included.
|
|
793
|
+
exclude_ref_fields: If True, reference fields will not be included.
|
|
794
|
+
exclude_one_to_many_use_rel: Passed to ``fields_by_category``.
|
|
795
|
+
"""
|
|
796
|
+
categories = self.fields_by_category(
|
|
797
|
+
exclude_names=exclude_names,
|
|
798
|
+
exclude_derived=exclude_derived,
|
|
799
|
+
exclude_ref_fields=exclude_ref_fields,
|
|
800
|
+
exclude_fk_to=exclude_fk_to,
|
|
801
|
+
exclude_fk_from=exclude_fk_from,
|
|
802
|
+
exclude_many_to_many=exclude_many_to_many,
|
|
803
|
+
exclude_many_to_one=exclude_many_to_one,
|
|
804
|
+
exclude_one_to_many=exclude_one_to_many,
|
|
805
|
+
exclude_one_to_one=exclude_one_to_one,
|
|
806
|
+
exclude_bridge=exclude_bridge,
|
|
807
|
+
exclude_one_to_many_use_rel=exclude_one_to_many_use_rel,
|
|
808
|
+
)
|
|
809
|
+
result = OrDict()
|
|
810
|
+
for k in sorted(categories.keys(), key=self.category_sort_key):
|
|
811
|
+
result[k] = categories[k]
|
|
812
|
+
return result
|
|
813
|
+
|
|
814
|
+
def label_to_python(self) -> str:
|
|
815
|
+
"""Convert a label to python code."""
|
|
816
|
+
return generate_python_code(self.label_ast)
|
|
817
|
+
|
|
818
|
+
def label_to_typescript(self) -> str:
|
|
819
|
+
"""Convert a label to typescript code."""
|
|
820
|
+
return generate_typescript_code(self.label_ast)
|
|
821
|
+
|
|
822
|
+
def get_no_dia_map(self) -> Dict[str, str]:
|
|
823
|
+
"""Get a dictionary that maps field names to fields that are used to
|
|
824
|
+
compute the value of the field without diacritics.
|
|
825
|
+
"""
|
|
826
|
+
result = {}
|
|
827
|
+
for fld in self.fields:
|
|
828
|
+
if (
|
|
829
|
+
hasattr(fld, "no_dia_field") and fld.no_dia_field is not None # type: ignore
|
|
830
|
+
):
|
|
831
|
+
result[fld.name] = fld.no_dia_field.name # type: ignore
|
|
832
|
+
return result
|
|
833
|
+
|
|
834
|
+
def iter_many(self, include_bridge: bool = False):
|
|
835
|
+
from exdrf.constants import FIELD_TYPE_REF_MANY_TO_MANY
|
|
836
|
+
|
|
837
|
+
for other_r in self.dataset.resources:
|
|
838
|
+
if other_r is self:
|
|
839
|
+
continue
|
|
840
|
+
for fld in other_r.fields:
|
|
841
|
+
if fld.type_name == FIELD_TYPE_REF_MANY_TO_MANY:
|
|
842
|
+
fld_m = cast("RefManyToManyField", fld)
|
|
843
|
+
if fld_m.ref is self:
|
|
844
|
+
yield other_r, fld_m, fld_m.ref_intermediate
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
if include_bridge and fld.type_name == FIELD_TYPE_REF_ONE_TO_MANY:
|
|
848
|
+
fld_o = cast("RefOneToManyField", fld)
|
|
849
|
+
if fld_o.bridge is self:
|
|
850
|
+
yield other_r, fld_o, fld_o.ref
|
|
851
|
+
continue
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class ResExtraInfo(BaseModel):
|
|
855
|
+
"""The layout of the dictionary associated with resources in the model.
|
|
856
|
+
|
|
857
|
+
Attributes:
|
|
858
|
+
label: The string definition of the layer composition function using
|
|
859
|
+
layer_dsl syntax.
|
|
860
|
+
provides: The concepts that the resource provides. This can be used
|
|
861
|
+
to indicate that the control's value has a certain meaning.
|
|
862
|
+
This can be a hint that other resources that depend on this one
|
|
863
|
+
should be updated when the value representing this resource changes.
|
|
864
|
+
depends_on: The concepts that the resource depends on. A change
|
|
865
|
+
in a resource listed here would change the meaning of this
|
|
866
|
+
resource's value.
|
|
867
|
+
"""
|
|
868
|
+
|
|
869
|
+
label: Optional[str] = None
|
|
870
|
+
provides: List[str] = Field(default_factory=list)
|
|
871
|
+
depends_on: List[Tuple[str, str]] = Field(default_factory=list)
|
|
872
|
+
|
|
873
|
+
def get_layer_ast(self) -> "ASTNode":
|
|
874
|
+
"""Return the layer composition function using layer_dsl syntax."""
|
|
875
|
+
if not self.label:
|
|
876
|
+
return []
|
|
877
|
+
return parse_expr(self.label)
|
|
878
|
+
|
|
879
|
+
@field_validator("provides", mode="before")
|
|
880
|
+
@classmethod
|
|
881
|
+
def parse_provides(cls, v):
|
|
882
|
+
if v is None:
|
|
883
|
+
return []
|
|
884
|
+
if isinstance(v, str):
|
|
885
|
+
return [item.strip() for item in v.split(",") if item.strip()]
|
|
886
|
+
return v
|
|
887
|
+
|
|
888
|
+
@field_validator("depends_on", mode="before")
|
|
889
|
+
@classmethod
|
|
890
|
+
def parse_depends_on(cls, v):
|
|
891
|
+
if v is None:
|
|
892
|
+
return []
|
|
893
|
+
if isinstance(v, str):
|
|
894
|
+
result = []
|
|
895
|
+
for part in v.split(","):
|
|
896
|
+
if not part.strip():
|
|
897
|
+
continue
|
|
898
|
+
concept, target = part.strip().split(":", maxsplit=1)
|
|
899
|
+
result.append((concept.strip(), target.strip()))
|
|
900
|
+
return result
|
|
901
|
+
return v
|