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