lorebase-core 0.2.3__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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,122 @@
1
+ """Output of schema inference: the proposed ``Schema`` with evidence and issues to review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import JsonValue
10
+
11
+ from lorebase_core.model.schema import Schema
12
+ from lorebase_core.validate.issues import Severity
13
+
14
+
15
+ class InferenceIssueCode(StrEnum):
16
+ """Stable codes of what a reviewer should look at."""
17
+
18
+ AMBIGUOUS_PRIMARY_KEY = "infer.ambiguous_primary_key"
19
+ NO_PRIMARY_KEY = "infer.no_primary_key"
20
+ MIXED_TYPES = "infer.mixed_types"
21
+ AMBIGUOUS_REFERENCE = "infer.ambiguous_reference"
22
+ UNSUPPORTED_REFERENCE = "infer.unsupported_reference"
23
+ EMPTY_TABLE = "infer.empty_table"
24
+ EMPTY_COLUMN = "infer.empty_column"
25
+ NAME_ADJUSTED = "infer.name_adjusted"
26
+ SAMPLED = "infer.sampled"
27
+ SCHEMA_INVALID = "infer.schema_invalid"
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Evidence:
32
+ """Why one element was proposed.
33
+
34
+ ``element`` is a path into the schema document (``kinds.Supplier.attributes.country``,
35
+ ``relations.supplier``); ``source`` names the table or ``table.column`` it came from.
36
+ """
37
+
38
+ element: str
39
+ element_id: str
40
+ source: str
41
+ confidence: float
42
+ reasons: tuple[str, ...] = ()
43
+ samples: tuple[str, ...] = ()
44
+
45
+ def document(self) -> dict[str, JsonValue]:
46
+ return {
47
+ "element": self.element,
48
+ "element_id": self.element_id,
49
+ "source": self.source,
50
+ "confidence": self.confidence,
51
+ "reasons": list(self.reasons),
52
+ "samples": list(self.samples),
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class InferenceIssue:
58
+ """An ambiguity or a gap the reviewer must settle."""
59
+
60
+ code: InferenceIssueCode
61
+ message: str
62
+ severity: Severity = Severity.WARNING
63
+ element: str | None = None
64
+ source: str | None = None
65
+ details: dict[str, JsonValue] = field(default_factory=dict)
66
+
67
+ def document(self) -> dict[str, JsonValue]:
68
+ return {
69
+ "code": self.code.value,
70
+ "severity": self.severity.value,
71
+ "message": self.message,
72
+ "element": self.element,
73
+ "source": self.source,
74
+ "details": dict(self.details),
75
+ }
76
+
77
+
78
+ class TableRole(StrEnum):
79
+ KIND = "kind"
80
+ RELATION = "relation"
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class TableOutcome:
85
+ """What a source table became."""
86
+
87
+ table: str
88
+ role: TableRole
89
+ element: str
90
+ rows: int
91
+ values_read: int
92
+
93
+ def document(self) -> dict[str, JsonValue]:
94
+ return {
95
+ "table": self.table,
96
+ "role": self.role.value,
97
+ "element": self.element,
98
+ "rows": self.rows,
99
+ "values_read": self.values_read,
100
+ }
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class SchemaProposal:
105
+ """A proposed schema to review; never committed by inference itself."""
106
+
107
+ schema: Schema
108
+ evidence: tuple[Evidence, ...]
109
+ issues: tuple[InferenceIssue, ...]
110
+ tables: tuple[TableOutcome, ...]
111
+
112
+ def evidence_for(self, element: str) -> Evidence | None:
113
+ return next((e for e in self.evidence if e.element == element), None)
114
+
115
+ def document(self) -> dict[str, Any]:
116
+ """JSON form: the schema content plus evidence, issues and table outcomes."""
117
+ return {
118
+ "schema": self.schema.content(),
119
+ "evidence": [e.document() for e in self.evidence],
120
+ "issues": [i.document() for i in self.issues],
121
+ "tables": [t.document() for t in self.tables],
122
+ }
@@ -0,0 +1,314 @@
1
+ """Foreign keys by value inclusion, and junction tables.
2
+
3
+ A column references a table when at least ``min_inclusion_ratio`` of its distinct values are
4
+ values of that table's single-column key, the value families match (integer to integer, text to
5
+ text) and, for integers, the name looks like a reference. Declared foreign keys need no
6
+ evidence.
7
+
8
+ Tables whose key is itself included in another table's key are *dependent* (extension or link
9
+ tables) and are never targets. When two keys hold the same values (``purchase_order.po_number``
10
+ and ``delivered_to.from_key``), the table with more columns owns them and the other depends on
11
+ it. Among several targets the best inclusion wins, then the stronger name hint, then the wider
12
+ table, then the name.
13
+
14
+ A junction table has exactly two references, both required, and its key is made of those
15
+ reference columns. It becomes a Relation between the two referenced Kinds, with the other
16
+ columns as link Attributes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass
23
+ from enum import IntEnum
24
+
25
+ from lorebase_core.infer.columns import ColumnAnalysis, support
26
+ from lorebase_core.infer.names import strip_suffix
27
+ from lorebase_core.infer.options import InferenceOptions
28
+ from lorebase_core.infer.proposal import InferenceIssue, InferenceIssueCode
29
+ from lorebase_core.infer.tables import TableAnalysis
30
+ from lorebase_core.model.relation import Cardinality
31
+
32
+ #: Column name prefixes giving the direction of a junction table.
33
+ _FROM_PREFIXES = ("from_", "source_", "src_", "parent_")
34
+ _TO_PREFIXES = ("to_", "target_", "dst_", "child_")
35
+
36
+
37
+ class NameHint(IntEnum):
38
+ """How strongly a column name points at a referenced table (lower is stronger)."""
39
+
40
+ DECLARED = 0
41
+ TARGET = 1
42
+ SUFFIX = 2
43
+ NONE = 3
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Reference:
48
+ """Column ``column`` of table ``table`` holds keys of table ``target``."""
49
+
50
+ table: int
51
+ column: int
52
+ target: int
53
+ inclusion: float
54
+ hint: NameHint
55
+ confidence: float
56
+ reasons: tuple[str, ...]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Junction:
61
+ """Table ``table`` links ``source`` (from) to ``destination`` (to) Records."""
62
+
63
+ table: int
64
+ source: Reference
65
+ destination: Reference
66
+ cardinality: Cardinality
67
+ reasons: tuple[str, ...]
68
+
69
+
70
+ def inclusion(column: ColumnAnalysis, key: ColumnAnalysis) -> float:
71
+ """Share of ``column``'s distinct values that are values of ``key``."""
72
+ if not column.distinct_keys:
73
+ return 0.0
74
+ return len(column.distinct_keys & key.distinct_keys) / len(column.distinct_keys)
75
+
76
+
77
+ def name_hint(column: ColumnAnalysis, target: TableAnalysis, options: InferenceOptions) -> NameHint:
78
+ """``TARGET`` when the name names the target table or its key, ``SUFFIX`` when it only
79
+ has a reference suffix, ``NONE`` otherwise."""
80
+ stem = strip_suffix(column.snake, options.foreign_key_suffixes)
81
+ base = stem or column.snake
82
+ forms = {target.snake, target.singular}
83
+ key = target.key_column
84
+ if (
85
+ base in forms
86
+ or any(base.endswith("_" + form) for form in forms)
87
+ or (key is not None and column.snake == key.snake)
88
+ ):
89
+ return NameHint.TARGET
90
+ return NameHint.SUFFIX if stem is not None else NameHint.NONE
91
+
92
+
93
+ def _factor(hint: NameHint, options: InferenceOptions) -> float:
94
+ if hint is NameHint.SUFFIX:
95
+ return options.weak_name_factor
96
+ if hint is NameHint.NONE:
97
+ return options.no_name_factor
98
+ return 1.0
99
+
100
+
101
+ def _owner(a: TableAnalysis, b: TableAnalysis) -> TableAnalysis:
102
+ """Of two tables whose keys hold the same values, the one owning them: not keyed by a
103
+ directional link column (``from_key``), then the widest, then by name."""
104
+
105
+ def rank(table: TableAnalysis) -> tuple[bool, int, str, str]:
106
+ key = table.key_column
107
+ directional = key is not None and key.snake.startswith(_FROM_PREFIXES + _TO_PREFIXES)
108
+ return (directional, -len(table.columns), table.snake, table.name)
109
+
110
+ return min((a, b), key=rank)
111
+
112
+
113
+ def dependent_tables(tables: Sequence[TableAnalysis], options: InferenceOptions) -> set[int]:
114
+ """Indexes of tables whose single key column is included in another table's key."""
115
+ dependent: set[int] = set()
116
+ for i, table in enumerate(tables):
117
+ key = table.key_column
118
+ if key is None or key.family is None:
119
+ continue
120
+ for j, other in enumerate(tables):
121
+ other_key = other.key_column
122
+ if i == j or other_key is None or other_key.family is not key.family:
123
+ continue
124
+ if inclusion(key, other_key) < options.min_inclusion_ratio:
125
+ continue
126
+ mutual = inclusion(other_key, key) >= options.min_inclusion_ratio
127
+ if not mutual or _owner(table, other) is other:
128
+ dependent.add(i)
129
+ break
130
+ return dependent
131
+
132
+
133
+ def _declared(
134
+ tables: Sequence[TableAnalysis], issues: list[InferenceIssue]
135
+ ) -> dict[tuple[int, int], Reference]:
136
+ by_name = {t.name: i for i, t in enumerate(tables)}
137
+ found: dict[tuple[int, int], Reference] = {}
138
+ for i, table in enumerate(tables):
139
+ for fk in table.foreign_keys:
140
+ target = by_name.get(fk.target_table)
141
+ column = table.column(fk.columns[0]) if len(fk.columns) == 1 else None
142
+ target_key = tables[target].key_column if target is not None else None
143
+ if (
144
+ target is None
145
+ or column is None
146
+ or target_key is None
147
+ or fk.target_columns != (target_key.name,)
148
+ ):
149
+ issues.append(
150
+ InferenceIssue(
151
+ code=InferenceIssueCode.UNSUPPORTED_REFERENCE,
152
+ message=f"foreign key ({', '.join(fk.columns)}) -> {fk.target_table}"
153
+ f"({', '.join(fk.target_columns)}) is not a single-column reference to "
154
+ "the key of a profiled table; no Relation proposed",
155
+ source=table.name,
156
+ )
157
+ )
158
+ continue
159
+ found[(i, column.index)] = Reference(
160
+ table=i,
161
+ column=column.index,
162
+ target=target,
163
+ inclusion=1.0,
164
+ hint=NameHint.DECLARED,
165
+ confidence=1.0,
166
+ reasons=(f"declared foreign key to {fk.target_table}({target_key.name})",),
167
+ )
168
+ return found
169
+
170
+
171
+ def find_references(
172
+ tables: Sequence[TableAnalysis], options: InferenceOptions, issues: list[InferenceIssue]
173
+ ) -> list[Reference]:
174
+ """Every reference between the tables, in table and column order."""
175
+ dependent = dependent_tables(tables, options)
176
+ targets = [j for j, t in enumerate(tables) if j not in dependent and t.key_column is not None]
177
+ declared = _declared(tables, issues)
178
+ references: list[Reference] = []
179
+ for i, table in enumerate(tables):
180
+ own_key = table.key_column
181
+ for column in table.columns:
182
+ if (i, column.index) in declared:
183
+ references.append(declared[(i, column.index)])
184
+ continue
185
+ if column.family is None or not column.distinct_keys:
186
+ continue
187
+ is_own_key = own_key is not None and own_key.index == column.index
188
+ if is_own_key and i not in dependent:
189
+ continue
190
+ candidates: list[tuple[float, NameHint, TableAnalysis, int]] = []
191
+ for j in targets:
192
+ target = tables[j]
193
+ key = target.key_column
194
+ assert key is not None
195
+ if (j == i and is_own_key) or key.family is not column.family:
196
+ continue
197
+ hint = name_hint(column, target, options)
198
+ if (
199
+ column.family.value == "integer"
200
+ and options.integer_reference_needs_name
201
+ and hint is NameHint.NONE
202
+ ):
203
+ continue
204
+ share = inclusion(column, key)
205
+ if share >= options.min_inclusion_ratio:
206
+ candidates.append((share, hint, target, j))
207
+ if not candidates:
208
+ continue
209
+ candidates.sort(key=lambda c: (-c[0], c[1], -len(c[2].columns), c[2].snake, c[2].name))
210
+ share, hint, target, j = candidates[0]
211
+ if len(candidates) > 1 and candidates[1][:2] == (share, hint):
212
+ issues.append(
213
+ InferenceIssue(
214
+ code=InferenceIssueCode.AMBIGUOUS_REFERENCE,
215
+ message=f"{table.name}.{column.name} matches the keys of "
216
+ + ", ".join(c[2].name for c in candidates if c[:2] == (share, hint))
217
+ + f"; {target.name} was chosen",
218
+ source=f"{table.name}.{column.name}",
219
+ )
220
+ )
221
+ sampling = options.sampled_uniqueness_factor if table.sampled or target.sampled else 1.0
222
+ confidence = share * _factor(hint, options) * support(column.non_null, options)
223
+ key = target.key_column
224
+ assert key is not None
225
+ reasons = [
226
+ f"{share:.0%} of {len(column.distinct_keys)} distinct values are keys of "
227
+ f"{target.name}({key.name})",
228
+ {
229
+ NameHint.TARGET: "the column name points at the target",
230
+ NameHint.SUFFIX: "the column name has a reference suffix",
231
+ NameHint.NONE: "no name hint",
232
+ NameHint.DECLARED: "declared",
233
+ }[hint],
234
+ ]
235
+ references.append(
236
+ Reference(
237
+ table=i,
238
+ column=column.index,
239
+ target=j,
240
+ inclusion=share,
241
+ hint=hint,
242
+ confidence=round(confidence * sampling, 4),
243
+ reasons=tuple(reasons),
244
+ )
245
+ )
246
+ return references
247
+
248
+
249
+ def _direction(
250
+ table: TableAnalysis, first: Reference, second: Reference
251
+ ) -> tuple[Reference, Reference, str]:
252
+ key = table.key.columns if table.key is not None else ()
253
+ if len(key) == 1:
254
+ if key[0] == second.column:
255
+ return second, first, "the key column is the from side"
256
+ return first, second, "the key column is the from side"
257
+ for ref, other in ((first, second), (second, first)):
258
+ name = table.columns[ref.column].snake
259
+ other_name = table.columns[other.column].snake
260
+ if name.startswith(_FROM_PREFIXES) or other_name.startswith(_TO_PREFIXES):
261
+ return ref, other, "column names give the direction"
262
+ return first, second, "column order gives the direction"
263
+
264
+
265
+ def find_junctions(
266
+ tables: Sequence[TableAnalysis],
267
+ references: Sequence[Reference],
268
+ options: InferenceOptions,
269
+ ) -> dict[int, Junction]:
270
+ """Junction tables by table index."""
271
+ if not options.detect_junction_tables:
272
+ return {}
273
+ targeted = {r.target for r in references}
274
+ by_table: dict[int, list[Reference]] = {}
275
+ for reference in references:
276
+ by_table.setdefault(reference.table, []).append(reference)
277
+ junctions: dict[int, Junction] = {}
278
+ for i, refs in by_table.items():
279
+ table = tables[i]
280
+ if len(refs) != 2 or i in targeted or table.key is None or table.key.minted:
281
+ continue
282
+ columns = {r.column for r in refs}
283
+ if not set(table.key.columns) <= columns:
284
+ continue
285
+ if not all(table.columns[c].required for c in columns):
286
+ continue
287
+ if len(table.columns) - 2 > options.junction_max_attributes:
288
+ continue
289
+ source, destination, why = _direction(table, refs[0], refs[1])
290
+ source_column = table.columns[source.column]
291
+ destination_column = table.columns[destination.column]
292
+ from_single = len(table.key.columns) == 1
293
+ to_single = destination_column.declared_unique or (
294
+ not options.one_to_one_needs_declared_unique and destination_column.unique
295
+ )
296
+ cardinality = {
297
+ (True, True): Cardinality.ONE_TO_ONE,
298
+ (True, False): Cardinality.MANY_TO_ONE,
299
+ (False, True): Cardinality.ONE_TO_MANY,
300
+ (False, False): Cardinality.MANY_TO_MANY,
301
+ }[(from_single, to_single)]
302
+ reasons = (
303
+ f"two references ({source_column.name} -> {tables[source.target].name}, "
304
+ f"{destination_column.name} -> {tables[destination.target].name}) form the key",
305
+ why,
306
+ f"{cardinality.value}: "
307
+ + (
308
+ f"{source_column.name!r} is unique"
309
+ if from_single
310
+ else "the pair of references is the key"
311
+ ),
312
+ )
313
+ junctions[i] = Junction(i, source, destination, cardinality, reasons)
314
+ return junctions