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,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
|