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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""``infer_schema``: table profiles in, a reviewed-to-be ``SchemaProposal`` out.
|
|
2
|
+
|
|
3
|
+
Steps: analyze columns and keys of every table (``tables``), find references and junction
|
|
4
|
+
tables (``references``), then build Kinds, Attributes and Relations with deterministic ids and
|
|
5
|
+
api names, record the evidence of each element and validate the result statically.
|
|
6
|
+
|
|
7
|
+
Tables are processed in normalized name order and columns in source order, and ids are derived
|
|
8
|
+
from a hash of the seed, the Domain and the source names: the same input always gives the same
|
|
9
|
+
proposal, whatever the order the tables come in.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
|
|
18
|
+
from pydantic import JsonValue
|
|
19
|
+
|
|
20
|
+
from lorebase_core.infer.columns import TEXT_FAMILY, ColumnAnalysis
|
|
21
|
+
from lorebase_core.infer.names import (
|
|
22
|
+
humanize,
|
|
23
|
+
normalize,
|
|
24
|
+
pascal,
|
|
25
|
+
plural,
|
|
26
|
+
rank,
|
|
27
|
+
strip_suffix,
|
|
28
|
+
unique_name,
|
|
29
|
+
unique_pascal,
|
|
30
|
+
)
|
|
31
|
+
from lorebase_core.infer.options import InferenceOptions
|
|
32
|
+
from lorebase_core.infer.profile import TableProfile
|
|
33
|
+
from lorebase_core.infer.proposal import (
|
|
34
|
+
Evidence,
|
|
35
|
+
InferenceIssue,
|
|
36
|
+
InferenceIssueCode,
|
|
37
|
+
SchemaProposal,
|
|
38
|
+
TableOutcome,
|
|
39
|
+
TableRole,
|
|
40
|
+
)
|
|
41
|
+
from lorebase_core.infer.references import Junction, Reference, find_junctions, find_references
|
|
42
|
+
from lorebase_core.infer.tables import TableAnalysis, analyze_table
|
|
43
|
+
from lorebase_core.model.attribute import Attribute, SearchWeight
|
|
44
|
+
from lorebase_core.model.base import snake_case
|
|
45
|
+
from lorebase_core.model.domain import Domain
|
|
46
|
+
from lorebase_core.model.ids import IdPrefix, new_id
|
|
47
|
+
from lorebase_core.model.kind import Kind
|
|
48
|
+
from lorebase_core.model.relation import Cardinality, Relation, RelationBacking
|
|
49
|
+
from lorebase_core.model.schema import Schema
|
|
50
|
+
from lorebase_core.model.yaml_schema import locate_issues
|
|
51
|
+
from lorebase_core.types import AttributeType, Constraints, TypeSpec
|
|
52
|
+
from lorebase_core.validate.issues import Severity
|
|
53
|
+
from lorebase_core.validate.schema_rules import SchemaRules, validate_schema
|
|
54
|
+
|
|
55
|
+
#: Confidence of a Kind without any key candidate.
|
|
56
|
+
KEYLESS_KIND_CONFIDENCE = 0.5
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def infer_schema(
|
|
60
|
+
tables: Iterable[TableProfile],
|
|
61
|
+
domain: Domain | str,
|
|
62
|
+
options: InferenceOptions | None = None,
|
|
63
|
+
rules: SchemaRules | None = None,
|
|
64
|
+
) -> SchemaProposal:
|
|
65
|
+
"""Propose a schema for ``tables``. Pure: no I/O, no model, nothing committed.
|
|
66
|
+
|
|
67
|
+
The proposal is validated with ``rules`` (the defaults when unset); their reserved
|
|
68
|
+
Attribute names are replaced by those of ``options``.
|
|
69
|
+
"""
|
|
70
|
+
options = options or InferenceOptions()
|
|
71
|
+
domain = Domain(id=domain) if isinstance(domain, str) else domain
|
|
72
|
+
profiles = sorted(tables, key=lambda t: (normalize(t.name), t.name))
|
|
73
|
+
analyses = [analyze_table(profile, options) for profile in profiles]
|
|
74
|
+
issues: list[InferenceIssue] = []
|
|
75
|
+
references = find_references(analyses, options, issues)
|
|
76
|
+
junctions = find_junctions(analyses, references, options)
|
|
77
|
+
return _Builder(domain, options, analyses, references, junctions, issues, rules).build()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _KindSlot:
|
|
82
|
+
table: TableAnalysis
|
|
83
|
+
kind_id: str
|
|
84
|
+
api_name: str
|
|
85
|
+
snake: str
|
|
86
|
+
attributes: dict[int, Attribute] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Builder:
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
domain: Domain,
|
|
93
|
+
options: InferenceOptions,
|
|
94
|
+
tables: list[TableAnalysis],
|
|
95
|
+
references: list[Reference],
|
|
96
|
+
junctions: dict[int, Junction],
|
|
97
|
+
issues: list[InferenceIssue],
|
|
98
|
+
rules: SchemaRules | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.rules = rules or SchemaRules()
|
|
101
|
+
self.domain = domain
|
|
102
|
+
self.options = options
|
|
103
|
+
self.tables = tables
|
|
104
|
+
self.references = references
|
|
105
|
+
self.junctions = junctions
|
|
106
|
+
self.issues = issues
|
|
107
|
+
self.evidence: list[Evidence] = []
|
|
108
|
+
self.outcomes: list[TableOutcome] = []
|
|
109
|
+
self.slots: dict[int, _KindSlot] = {}
|
|
110
|
+
self.kinds: dict[str, Kind] = {}
|
|
111
|
+
self.relations: dict[str, Relation] = {}
|
|
112
|
+
self.relation_names: set[str] = set()
|
|
113
|
+
self.refs_by_column = {
|
|
114
|
+
(r.table, r.column): r for r in references if r.table not in junctions
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Ids -------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def mint(self, prefix: IdPrefix, *parts: str) -> str:
|
|
120
|
+
seed = "\x1f".join((self.options.id_seed, self.domain.id, prefix.value, *parts))
|
|
121
|
+
randomness = hashlib.sha256(seed.encode("utf-8")).digest()[:10]
|
|
122
|
+
return new_id(prefix, timestamp_ms=self.options.id_timestamp_ms, randomness=randomness)
|
|
123
|
+
|
|
124
|
+
def note(
|
|
125
|
+
self,
|
|
126
|
+
code: InferenceIssueCode,
|
|
127
|
+
message: str,
|
|
128
|
+
*,
|
|
129
|
+
element: str | None = None,
|
|
130
|
+
source: str | None = None,
|
|
131
|
+
severity: Severity = Severity.WARNING,
|
|
132
|
+
**details: JsonValue,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.issues.append(
|
|
135
|
+
InferenceIssue(
|
|
136
|
+
code=code,
|
|
137
|
+
message=message,
|
|
138
|
+
severity=severity,
|
|
139
|
+
element=element,
|
|
140
|
+
source=source,
|
|
141
|
+
details=details,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Build -----------------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def build(self) -> SchemaProposal:
|
|
148
|
+
taken_kinds: set[str] = set()
|
|
149
|
+
for index, table in enumerate(self.tables):
|
|
150
|
+
if index in self.junctions:
|
|
151
|
+
continue
|
|
152
|
+
base = pascal(table.singular)
|
|
153
|
+
api_name = unique_pascal(base, taken_kinds)
|
|
154
|
+
if api_name != base:
|
|
155
|
+
self.note(
|
|
156
|
+
InferenceIssueCode.NAME_ADJUSTED,
|
|
157
|
+
f"table {table.name!r} would be Kind {base}, already taken: named {api_name}",
|
|
158
|
+
element=f"kinds.{api_name}",
|
|
159
|
+
source=table.name,
|
|
160
|
+
)
|
|
161
|
+
self.slots[index] = _KindSlot(
|
|
162
|
+
table=table,
|
|
163
|
+
kind_id=self.mint(IdPrefix.KIND, table.name),
|
|
164
|
+
api_name=api_name,
|
|
165
|
+
snake=snake_case(api_name),
|
|
166
|
+
)
|
|
167
|
+
for index, slot in self.slots.items():
|
|
168
|
+
self.kinds[slot.kind_id] = self.build_kind(index, slot)
|
|
169
|
+
for index, table in enumerate(self.tables):
|
|
170
|
+
if index in self.junctions:
|
|
171
|
+
self.build_junction(self.junctions[index])
|
|
172
|
+
elif index in self.slots:
|
|
173
|
+
for column in table.columns:
|
|
174
|
+
reference = self.refs_by_column.get((index, column.index))
|
|
175
|
+
if reference is not None:
|
|
176
|
+
self.build_foreign_key(self.slots[index], column, reference)
|
|
177
|
+
|
|
178
|
+
schema = Schema(domain=self.domain, kinds=self.kinds, relations=self.relations)
|
|
179
|
+
rules = replace(
|
|
180
|
+
self.rules,
|
|
181
|
+
reserved_attribute_names=frozenset(self.options.reserved_attribute_names),
|
|
182
|
+
)
|
|
183
|
+
for issue in locate_issues(schema, validate_schema(schema, rules)):
|
|
184
|
+
self.note(
|
|
185
|
+
InferenceIssueCode.SCHEMA_INVALID,
|
|
186
|
+
issue.message,
|
|
187
|
+
element=".".join(str(p) for p in issue.path),
|
|
188
|
+
severity=issue.severity,
|
|
189
|
+
validation_code=issue.code.value,
|
|
190
|
+
)
|
|
191
|
+
return SchemaProposal(
|
|
192
|
+
schema=schema,
|
|
193
|
+
evidence=tuple(self.evidence),
|
|
194
|
+
issues=tuple(self.issues),
|
|
195
|
+
tables=tuple(sorted(self.outcomes, key=lambda o: o.table)),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Kinds -----------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def attribute_name(self, slot: _KindSlot, column: ColumnAnalysis, taken: set[str]) -> str:
|
|
201
|
+
name = column.snake
|
|
202
|
+
if name in self.options.reserved_attribute_names:
|
|
203
|
+
name = f"{slot.snake}_{name}"
|
|
204
|
+
result = unique_name(name, taken)
|
|
205
|
+
if result != column.snake:
|
|
206
|
+
self.note(
|
|
207
|
+
InferenceIssueCode.NAME_ADJUSTED,
|
|
208
|
+
f"column {column.name!r} is named {result!r} (reserved or duplicate name)",
|
|
209
|
+
element=f"kinds.{slot.api_name}.attributes.{result}",
|
|
210
|
+
source=f"{slot.table.name}.{column.name}",
|
|
211
|
+
)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
def build_kind(self, index: int, slot: _KindSlot) -> Kind:
|
|
215
|
+
table = slot.table
|
|
216
|
+
options = self.options
|
|
217
|
+
key = table.key
|
|
218
|
+
element = f"kinds.{slot.api_name}"
|
|
219
|
+
minted_column = key.single if key is not None and key.minted else None
|
|
220
|
+
taken: set[str] = set()
|
|
221
|
+
key_columns = set(key.columns) if key is not None else set()
|
|
222
|
+
for column in table.columns:
|
|
223
|
+
if column.index == minted_column:
|
|
224
|
+
continue
|
|
225
|
+
reference = self.refs_by_column.get((index, column.index))
|
|
226
|
+
name = self.attribute_name(slot, column, taken)
|
|
227
|
+
attribute_id = self.mint(IdPrefix.ATTRIBUTE, table.name, column.name)
|
|
228
|
+
spec = (
|
|
229
|
+
TypeSpec.ref(self.slots[reference.target].kind_id)
|
|
230
|
+
if reference is not None
|
|
231
|
+
else column.type
|
|
232
|
+
)
|
|
233
|
+
in_key = column.index in key_columns
|
|
234
|
+
unique = (
|
|
235
|
+
not in_key
|
|
236
|
+
and reference is None
|
|
237
|
+
and column.unique
|
|
238
|
+
and column.type.name in (AttributeType.STRING, AttributeType.INTEGER)
|
|
239
|
+
and (
|
|
240
|
+
column.declared_unique
|
|
241
|
+
or rank(column.snake, options.unique_name_patterns) is not None
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
slot.attributes[column.index] = Attribute(
|
|
245
|
+
id=attribute_id,
|
|
246
|
+
api_name=name,
|
|
247
|
+
display_name=humanize(name),
|
|
248
|
+
description=column.comment or "",
|
|
249
|
+
type=spec,
|
|
250
|
+
required=column.required or in_key,
|
|
251
|
+
unique=unique,
|
|
252
|
+
constraints=Constraints() if reference is not None else column.constraints,
|
|
253
|
+
)
|
|
254
|
+
self.column_evidence(
|
|
255
|
+
f"{element}.attributes.{name}", attribute_id, table, column, reference, unique
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
title = self.title(slot, key_columns)
|
|
259
|
+
attributes = {}
|
|
260
|
+
for column_index, attribute in slot.attributes.items():
|
|
261
|
+
if options.propose_search and attribute.type.name in TEXT_FAMILY - {AttributeType.ENUM}:
|
|
262
|
+
if attribute.id == title:
|
|
263
|
+
attribute = attribute.model_copy(
|
|
264
|
+
update={"searchable": True, "search_weight": SearchWeight.A}
|
|
265
|
+
)
|
|
266
|
+
elif attribute.type.name is AttributeType.TEXT:
|
|
267
|
+
attribute = attribute.model_copy(
|
|
268
|
+
update={"searchable": True, "search_weight": SearchWeight.B}
|
|
269
|
+
)
|
|
270
|
+
slot.attributes[column_index] = attribute
|
|
271
|
+
attributes[attribute.id] = attribute
|
|
272
|
+
|
|
273
|
+
primary_key = (
|
|
274
|
+
() if key is None or key.minted else tuple(slot.attributes[c].id for c in key.columns)
|
|
275
|
+
)
|
|
276
|
+
kind = Kind(
|
|
277
|
+
id=slot.kind_id,
|
|
278
|
+
api_name=slot.api_name,
|
|
279
|
+
display_name=humanize(slot.snake),
|
|
280
|
+
description=table.comment or "",
|
|
281
|
+
primary_key=primary_key,
|
|
282
|
+
title_attribute=title,
|
|
283
|
+
attributes=attributes,
|
|
284
|
+
status=options.element_status,
|
|
285
|
+
)
|
|
286
|
+
self.kind_evidence(element, slot, title)
|
|
287
|
+
self.outcomes.append(
|
|
288
|
+
TableOutcome(table.name, TableRole.KIND, element, table.rows, table.values_read)
|
|
289
|
+
)
|
|
290
|
+
return kind
|
|
291
|
+
|
|
292
|
+
def title(self, slot: _KindSlot, key_columns: set[int]) -> str | None:
|
|
293
|
+
columns = slot.table.columns
|
|
294
|
+
textual = [
|
|
295
|
+
(index, attribute)
|
|
296
|
+
for index, attribute in slot.attributes.items()
|
|
297
|
+
if attribute.type.name in (AttributeType.STRING, AttributeType.TEXT)
|
|
298
|
+
and not columns[index].generated_ids
|
|
299
|
+
]
|
|
300
|
+
ranked = sorted(
|
|
301
|
+
(rank(a.api_name, self.options.title_names), i, a)
|
|
302
|
+
for i, a in textual
|
|
303
|
+
if rank(a.api_name, self.options.title_names) is not None
|
|
304
|
+
)
|
|
305
|
+
if ranked:
|
|
306
|
+
return ranked[0][2].id
|
|
307
|
+
if len(key_columns) == 1:
|
|
308
|
+
(only,) = key_columns
|
|
309
|
+
attribute = slot.attributes.get(only)
|
|
310
|
+
if attribute is not None and any(i == only for i, _ in textual):
|
|
311
|
+
return attribute.id
|
|
312
|
+
return textual[0][1].id if textual else None
|
|
313
|
+
|
|
314
|
+
def kind_evidence(self, element: str, slot: _KindSlot, title: str | None) -> None:
|
|
315
|
+
table = slot.table
|
|
316
|
+
key = table.key
|
|
317
|
+
reasons = [f"table {table.name!r}: {table.rows} rows, {table.values_read} read"]
|
|
318
|
+
samples: tuple[str, ...] = ()
|
|
319
|
+
if key is None:
|
|
320
|
+
confidence = KEYLESS_KIND_CONFIDENCE * (1.0 if table.values_read else 0.5)
|
|
321
|
+
reasons.append("no key candidate: Records get minted keys")
|
|
322
|
+
self.note(
|
|
323
|
+
InferenceIssueCode.NO_PRIMARY_KEY,
|
|
324
|
+
f"no unique, never-null column (or key-named pair) in {table.name!r}: "
|
|
325
|
+
"Records get minted keys",
|
|
326
|
+
element=element,
|
|
327
|
+
source=table.name,
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
confidence = key.confidence
|
|
331
|
+
reasons.extend(key.reasons)
|
|
332
|
+
samples = table.columns[key.columns[0]].samples
|
|
333
|
+
if len(key.candidates) > 1:
|
|
334
|
+
names = [table.columns[c].name for c in key.candidates]
|
|
335
|
+
self.note(
|
|
336
|
+
InferenceIssueCode.AMBIGUOUS_PRIMARY_KEY,
|
|
337
|
+
f"{len(names)} key candidates in {table.name!r}: {', '.join(names)}; "
|
|
338
|
+
f"{names[0]!r} was chosen",
|
|
339
|
+
element=element,
|
|
340
|
+
source=table.name,
|
|
341
|
+
candidates=list[JsonValue](names),
|
|
342
|
+
)
|
|
343
|
+
if title is not None:
|
|
344
|
+
name = next(a.api_name for a in slot.attributes.values() if a.id == title)
|
|
345
|
+
reasons.append(f"title attribute: {name}")
|
|
346
|
+
if table.values_read == 0:
|
|
347
|
+
self.note(
|
|
348
|
+
InferenceIssueCode.EMPTY_TABLE,
|
|
349
|
+
f"table {table.name!r} has no rows: every column defaults to optional string",
|
|
350
|
+
element=element,
|
|
351
|
+
source=table.name,
|
|
352
|
+
)
|
|
353
|
+
if table.sampled:
|
|
354
|
+
self.note(
|
|
355
|
+
InferenceIssueCode.SAMPLED,
|
|
356
|
+
f"{table.values_read} of {table.rows} rows of {table.name!r} were read: "
|
|
357
|
+
"uniqueness and references hold for the sample only",
|
|
358
|
+
element=element,
|
|
359
|
+
source=table.name,
|
|
360
|
+
)
|
|
361
|
+
self.evidence.append(
|
|
362
|
+
Evidence(
|
|
363
|
+
element, slot.kind_id, table.name, round(confidence, 4), tuple(reasons), samples
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def column_evidence(
|
|
368
|
+
self,
|
|
369
|
+
element: str,
|
|
370
|
+
element_id: str,
|
|
371
|
+
table: TableAnalysis,
|
|
372
|
+
column: ColumnAnalysis,
|
|
373
|
+
reference: Reference | None,
|
|
374
|
+
unique: bool,
|
|
375
|
+
) -> None:
|
|
376
|
+
source = f"{table.name}.{column.name}"
|
|
377
|
+
reasons = list(column.reasons)
|
|
378
|
+
confidence = column.confidence
|
|
379
|
+
if reference is not None:
|
|
380
|
+
target = self.slots[reference.target]
|
|
381
|
+
reasons.append(f"references {target.api_name}: type ref({target.api_name})")
|
|
382
|
+
reasons.extend(reference.reasons)
|
|
383
|
+
confidence = min(confidence, reference.confidence)
|
|
384
|
+
if unique:
|
|
385
|
+
reasons.append("named like an identifier: unique")
|
|
386
|
+
if column.mixed is not None:
|
|
387
|
+
mixed = column.mixed
|
|
388
|
+
self.note(
|
|
389
|
+
InferenceIssueCode.MIXED_TYPES,
|
|
390
|
+
f"{mixed.share:.0%} of {source} values are {mixed.dominant}; "
|
|
391
|
+
+ (
|
|
392
|
+
"accepted within type_tolerance"
|
|
393
|
+
if mixed.accepted
|
|
394
|
+
else f"kept as {column.type.describe()}"
|
|
395
|
+
),
|
|
396
|
+
element=element,
|
|
397
|
+
source=source,
|
|
398
|
+
dominant=mixed.dominant.value,
|
|
399
|
+
outliers=list[JsonValue](mixed.outliers),
|
|
400
|
+
)
|
|
401
|
+
if column.count and not column.non_null:
|
|
402
|
+
self.note(
|
|
403
|
+
InferenceIssueCode.EMPTY_COLUMN,
|
|
404
|
+
f"{source} has only nulls: optional string by default",
|
|
405
|
+
element=element,
|
|
406
|
+
source=source,
|
|
407
|
+
)
|
|
408
|
+
self.evidence.append(
|
|
409
|
+
Evidence(
|
|
410
|
+
element, element_id, source, round(confidence, 4), tuple(reasons), column.samples
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Relations -------------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def inverse_name(self, from_snake: str, name: str, self_reference: bool) -> str:
|
|
417
|
+
if self_reference:
|
|
418
|
+
return unique_name(f"{name}_of", self.relation_names)
|
|
419
|
+
many = plural(from_snake)
|
|
420
|
+
return unique_name(many, self.relation_names, (f"{many}_via_{name}",))
|
|
421
|
+
|
|
422
|
+
def relation_name(self, base: str, from_snake: str, source: str) -> str:
|
|
423
|
+
name = unique_name(base, self.relation_names, (f"{from_snake}_{base}",))
|
|
424
|
+
if name != base:
|
|
425
|
+
self.note(
|
|
426
|
+
InferenceIssueCode.NAME_ADJUSTED,
|
|
427
|
+
f"Relation name {base!r} is taken: named {name!r}",
|
|
428
|
+
element=f"relations.{name}",
|
|
429
|
+
source=source,
|
|
430
|
+
)
|
|
431
|
+
return name
|
|
432
|
+
|
|
433
|
+
def build_foreign_key(
|
|
434
|
+
self, slot: _KindSlot, column: ColumnAnalysis, reference: Reference
|
|
435
|
+
) -> None:
|
|
436
|
+
table = slot.table
|
|
437
|
+
attribute = slot.attributes[column.index]
|
|
438
|
+
target = self.slots[reference.target]
|
|
439
|
+
source = f"{table.name}.{column.name}"
|
|
440
|
+
base = strip_suffix(column.snake, self.options.foreign_key_suffixes) or column.snake
|
|
441
|
+
name = self.relation_name(base, slot.snake, source)
|
|
442
|
+
self_reference = target.kind_id == slot.kind_id
|
|
443
|
+
inverse = self.inverse_name(slot.snake, name, self_reference)
|
|
444
|
+
own_key = table.key is not None and table.key.single == column.index
|
|
445
|
+
one = (
|
|
446
|
+
own_key
|
|
447
|
+
or column.declared_unique
|
|
448
|
+
or (not self.options.one_to_one_needs_declared_unique and column.unique)
|
|
449
|
+
)
|
|
450
|
+
cardinality = Cardinality.ONE_TO_ONE if one else Cardinality.MANY_TO_ONE
|
|
451
|
+
relation_id = self.mint(IdPrefix.RELATION, table.name, column.name)
|
|
452
|
+
self.relations[relation_id] = Relation(
|
|
453
|
+
id=relation_id,
|
|
454
|
+
api_name=name,
|
|
455
|
+
inverse_api_name=inverse,
|
|
456
|
+
display_name=humanize(name),
|
|
457
|
+
description=column.comment or "",
|
|
458
|
+
from_kind=slot.kind_id,
|
|
459
|
+
to_kind=target.kind_id,
|
|
460
|
+
cardinality=cardinality,
|
|
461
|
+
required_from=attribute.required,
|
|
462
|
+
backing=RelationBacking.FOREIGN_KEY,
|
|
463
|
+
foreign_key_attribute=attribute.id,
|
|
464
|
+
status=self.options.element_status,
|
|
465
|
+
)
|
|
466
|
+
reasons = (
|
|
467
|
+
f"{slot.api_name}.{attribute.api_name} references {target.api_name}",
|
|
468
|
+
*reference.reasons,
|
|
469
|
+
f"{cardinality.value}: "
|
|
470
|
+
+ ("the reference is the key of its table" if one else "one target per row"),
|
|
471
|
+
*(("self-reference",) if self_reference else ()),
|
|
472
|
+
)
|
|
473
|
+
self.evidence.append(
|
|
474
|
+
Evidence(
|
|
475
|
+
f"relations.{name}",
|
|
476
|
+
relation_id,
|
|
477
|
+
source,
|
|
478
|
+
reference.confidence,
|
|
479
|
+
reasons,
|
|
480
|
+
column.samples,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def build_junction(self, junction: Junction) -> None:
|
|
485
|
+
table = self.tables[junction.table]
|
|
486
|
+
source_slot = self.slots[junction.source.target]
|
|
487
|
+
target_slot = self.slots[junction.destination.target]
|
|
488
|
+
name = self.relation_name(table.snake, source_slot.snake, table.name)
|
|
489
|
+
self_reference = source_slot.kind_id == target_slot.kind_id
|
|
490
|
+
inverse = self.inverse_name(source_slot.snake, name, self_reference)
|
|
491
|
+
relation_id = self.mint(IdPrefix.RELATION, table.name)
|
|
492
|
+
element = f"relations.{name}"
|
|
493
|
+
link_columns = {junction.source.column, junction.destination.column}
|
|
494
|
+
taken: set[str] = set()
|
|
495
|
+
attributes: dict[str, Attribute] = {}
|
|
496
|
+
for column in table.columns:
|
|
497
|
+
if column.index in link_columns:
|
|
498
|
+
continue
|
|
499
|
+
attribute_name = unique_name(column.snake, taken)
|
|
500
|
+
attribute_id = self.mint(IdPrefix.ATTRIBUTE, table.name, column.name)
|
|
501
|
+
attributes[attribute_id] = Attribute(
|
|
502
|
+
id=attribute_id,
|
|
503
|
+
api_name=attribute_name,
|
|
504
|
+
display_name=humanize(attribute_name),
|
|
505
|
+
description=column.comment or "",
|
|
506
|
+
type=column.type,
|
|
507
|
+
required=column.required,
|
|
508
|
+
constraints=column.constraints,
|
|
509
|
+
)
|
|
510
|
+
self.column_evidence(
|
|
511
|
+
f"{element}.attributes.{attribute_name}", attribute_id, table, column, None, False
|
|
512
|
+
)
|
|
513
|
+
self.relations[relation_id] = Relation(
|
|
514
|
+
id=relation_id,
|
|
515
|
+
api_name=name,
|
|
516
|
+
inverse_api_name=inverse,
|
|
517
|
+
display_name=humanize(name),
|
|
518
|
+
description=table.comment or "",
|
|
519
|
+
from_kind=source_slot.kind_id,
|
|
520
|
+
to_kind=target_slot.kind_id,
|
|
521
|
+
cardinality=junction.cardinality,
|
|
522
|
+
attributes=attributes,
|
|
523
|
+
status=self.options.element_status,
|
|
524
|
+
)
|
|
525
|
+
confidence = min(junction.source.confidence, junction.destination.confidence)
|
|
526
|
+
if table.key is not None:
|
|
527
|
+
confidence = min(confidence, table.key.confidence)
|
|
528
|
+
reasons = (
|
|
529
|
+
f"junction table {table.name!r}: {source_slot.api_name} {name} {target_slot.api_name}",
|
|
530
|
+
*junction.reasons,
|
|
531
|
+
*junction.source.reasons,
|
|
532
|
+
*junction.destination.reasons,
|
|
533
|
+
)
|
|
534
|
+
self.evidence.append(
|
|
535
|
+
Evidence(
|
|
536
|
+
element,
|
|
537
|
+
relation_id,
|
|
538
|
+
table.name,
|
|
539
|
+
round(confidence, 4),
|
|
540
|
+
reasons,
|
|
541
|
+
table.columns[junction.source.column].samples,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
self.outcomes.append(
|
|
545
|
+
TableOutcome(table.name, TableRole.RELATION, element, table.rows, table.values_read)
|
|
546
|
+
)
|