quickdbd-cli 0.1.5__tar.gz → 0.1.6__tar.gz

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 (54) hide show
  1. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/.gitignore +1 -0
  2. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/PKG-INFO +5 -3
  3. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/README.md +4 -2
  4. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/pyproject.toml +1 -1
  5. quickdbd_cli-0.1.6/src/quickdbd_cli/__init__.py +1 -0
  6. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/cli.py +31 -4
  7. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/dbt.py +133 -3
  8. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_dbt.py +93 -0
  9. quickdbd_cli-0.1.5/src/quickdbd_cli/__init__.py +0 -1
  10. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/LICENSE +0 -0
  11. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/NOTICE +0 -0
  12. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/quickdbd-sample.txt +0 -0
  13. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/finance.txt +0 -0
  14. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/hr.txt +0 -0
  15. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/inventory.txt +0 -0
  16. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/marketing.txt +0 -0
  17. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/qdbd.layout.json +0 -0
  18. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/sales.txt +0 -0
  19. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/gen_font_metrics.py +0 -0
  20. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/gen_schema.py +0 -0
  21. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/release.sh +0 -0
  22. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/__main__.py +0 -0
  23. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
  24. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
  25. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
  26. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
  27. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
  28. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/editor.js +0 -0
  29. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
  30. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
  31. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/font-metrics.json +0 -0
  32. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/render.js +0 -0
  33. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/style.css +0 -0
  34. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/viewer.js +0 -0
  35. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/diff.py +0 -0
  36. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/export.py +0 -0
  37. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/html.py +0 -0
  38. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/jsrt.py +0 -0
  39. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/layout.py +0 -0
  40. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/measure.py +0 -0
  41. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/model.py +0 -0
  42. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/parser.py +0 -0
  43. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/project.py +0 -0
  44. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/serve.py +0 -0
  45. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/LICENSE +0 -0
  46. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/README.md +0 -0
  47. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/catalog.json +0 -0
  48. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/manifest.json +0 -0
  49. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_diff.py +0 -0
  50. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_parser.py +0 -0
  51. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_path.py +0 -0
  52. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_pipeline.py +0 -0
  53. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_select.py +0 -0
  54. {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_serve.py +0 -0
@@ -221,3 +221,4 @@ __marimo__/
221
221
  .venv/
222
222
  .superpowers/
223
223
  site/
224
+ jaffle-erd/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quickdbd-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit.
5
5
  Project-URL: Homepage, https://github.com/MarkPhamm/quickdbd-cli
6
6
  Author: Minh (Mark) Pham
@@ -83,6 +83,8 @@ qdbd build erd/ -o erd.html
83
83
  - **Relationships:** `relationships` tests (including dbt_utils `relationships_where`) and `foreign_key` constraints.
84
84
  - **Primary keys:** `primary_key` constraints, or else a column with both `unique` and `not_null` tests.
85
85
  - **Docs:** model and column descriptions become doc comments, shown as tooltips.
86
+ - **`--infer-fks`:** also links `customer_id`-style columns to the table they name, when no relationship is declared. Ambiguous matches are skipped, never guessed.
87
+ - **`--check`:** in CI, fails when the committed files are out of date.
86
88
  - **Options:**
87
89
  - `--select 'marts.*'` narrows by name, fqn or path (repeatable)
88
90
  - `--package all` includes installed packages
@@ -183,7 +185,7 @@ jobs:
183
185
  permissions: { contents: read, pull-requests: write }
184
186
  steps:
185
187
  - uses: actions/checkout@v4
186
- - uses: MarkPhamm/quickdbd-cli@v0.1.5
188
+ - uses: MarkPhamm/quickdbd-cli@v0.1.6
187
189
  with:
188
190
  path: erd/ # schema file(s) or folder(s)
189
191
  output: site/index.html
@@ -207,7 +209,7 @@ Outputs are `html`, `changed` and `diff-file`.
207
209
  ```yaml
208
210
  repos:
209
211
  - repo: https://github.com/MarkPhamm/quickdbd-cli
210
- rev: v0.1.5
212
+ rev: v0.1.6
211
213
  hooks:
212
214
  - id: qdbd-check
213
215
  files: ^erd/.*\.txt$ # point at your schema files
@@ -55,6 +55,8 @@ qdbd build erd/ -o erd.html
55
55
  - **Relationships:** `relationships` tests (including dbt_utils `relationships_where`) and `foreign_key` constraints.
56
56
  - **Primary keys:** `primary_key` constraints, or else a column with both `unique` and `not_null` tests.
57
57
  - **Docs:** model and column descriptions become doc comments, shown as tooltips.
58
+ - **`--infer-fks`:** also links `customer_id`-style columns to the table they name, when no relationship is declared. Ambiguous matches are skipped, never guessed.
59
+ - **`--check`:** in CI, fails when the committed files are out of date.
58
60
  - **Options:**
59
61
  - `--select 'marts.*'` narrows by name, fqn or path (repeatable)
60
62
  - `--package all` includes installed packages
@@ -155,7 +157,7 @@ jobs:
155
157
  permissions: { contents: read, pull-requests: write }
156
158
  steps:
157
159
  - uses: actions/checkout@v4
158
- - uses: MarkPhamm/quickdbd-cli@v0.1.5
160
+ - uses: MarkPhamm/quickdbd-cli@v0.1.6
159
161
  with:
160
162
  path: erd/ # schema file(s) or folder(s)
161
163
  output: site/index.html
@@ -179,7 +181,7 @@ Outputs are `html`, `changed` and `diff-file`.
179
181
  ```yaml
180
182
  repos:
181
183
  - repo: https://github.com/MarkPhamm/quickdbd-cli
182
- rev: v0.1.5
184
+ rev: v0.1.6
183
185
  hooks:
184
186
  - id: qdbd-check
185
187
  files: ^erd/.*\.txt$ # point at your schema files
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quickdbd-cli"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "Compile QuickDBD-syntax schema files into interactive HTML ER diagrams, with no table limit."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.6"
@@ -145,7 +145,7 @@ def cmd_layout(args) -> int:
145
145
 
146
146
 
147
147
  def cmd_import_dbt(args) -> int:
148
- from .dbt import DbtImportError, import_dbt, write_result
148
+ from .dbt import DbtImportError, import_dbt, stale_files, write_result
149
149
  from .parser import compile_sources
150
150
 
151
151
  out = Path(args.output)
@@ -158,6 +158,7 @@ def cmd_import_dbt(args) -> int:
158
158
  select=args.select,
159
159
  sources=not args.no_sources,
160
160
  split=split,
161
+ infer_fks=args.infer_fks,
161
162
  )
162
163
  except DbtImportError as e:
163
164
  print(f"error: {e}", file=sys.stderr)
@@ -169,14 +170,32 @@ def cmd_import_dbt(args) -> int:
169
170
  print(d.format(), file=sys.stderr)
170
171
  print("error: generated schema did not compile (please report this as a bug)", file=sys.stderr)
171
172
  return 1
172
- written = write_result(result, out)
173
173
  for s in result.skipped:
174
174
  print(f"skipped: {s}", file=sys.stderr)
175
+ for s in result.inferred:
176
+ print(f"inferred: {s}", file=sys.stderr)
177
+ inferred = f" ({len(result.inferred)} inferred)" if args.infer_fks else ""
178
+ if args.check:
179
+ stale = stale_files(result, out)
180
+ if stale:
181
+ for s in stale:
182
+ print(f"stale: {s}", file=sys.stderr)
183
+ print(
184
+ f"error: schema files are out of date; run `qdbd import dbt {args.manifest} -o {args.output}` "
185
+ "and commit the result",
186
+ file=sys.stderr,
187
+ )
188
+ return 1
189
+ print(f"Up to date: {out}: {result.tables} tables, {result.relationships} relationships{inferred}")
190
+ return 0
191
+ written = write_result(result, out)
175
192
  where = str(out) if not split else f"{out}/ ({len(written)} files)"
176
- print(f"Wrote {where}: {result.tables} tables, {result.relationships} relationships")
193
+ print(f"Wrote {where}: {result.tables} tables, {result.relationships} relationships{inferred}")
177
194
  if result.tables and not result.relationships:
178
195
  print(
179
- "hint: no relationships found; add `relationships` tests or foreign_key constraints in dbt", file=sys.stderr
196
+ "hint: no relationships found; add `relationships` tests or foreign_key constraints in dbt, "
197
+ "or try --infer-fks",
198
+ file=sys.stderr,
180
199
  )
181
200
  return 0
182
201
 
@@ -357,6 +376,14 @@ def build_parser() -> argparse.ArgumentParser:
357
376
  "--select", action="append", help="glob on model name, fqn (a.b.c) or file path, e.g. 'marts.*' (repeatable)"
358
377
  )
359
378
  dp.add_argument("--no-sources", action="store_true", help="leave out dbt sources")
379
+ dp.add_argument(
380
+ "--infer-fks",
381
+ action="store_true",
382
+ help="also link <entity>_id columns to the table named after the entity when no relationship is declared",
383
+ )
384
+ dp.add_argument(
385
+ "--check", action="store_true", help="don't write; exit 1 if the files at -o are out of date (for CI)"
386
+ )
360
387
  dp.set_defaults(func=cmd_import_dbt)
361
388
 
362
389
  sp = sub.add_parser("serve", help="live editor: reload on save, drag tables to arrange")
@@ -35,6 +35,47 @@ _TYPE_ALIASES = {
35
35
  }
36
36
 
37
37
 
38
+ _ENTITY = re.compile(r"^(.+?)(_id|_key|_sk|Id|ID|Key)$")
39
+ _LAYERS = ("staging_", "stg_", "int_", "base_", "raw_", "src_", "dim_", "fct_", "fact_", "mart_", "d_", "f_")
40
+
41
+
42
+ def _layer(name: str) -> tuple[str, str]:
43
+ """Split a dbt-style layer prefix off a table name: 'stg_orders' -> ('stg_', 'orders')."""
44
+ low = name.lower()
45
+ for p in _LAYERS:
46
+ if low.startswith(p) and len(low) > len(p):
47
+ return p, low[len(p) :]
48
+ return "", low
49
+
50
+
51
+ def _prefix_rank(child: str, parent: str) -> int:
52
+ """Lower is better: facts point at dimensions; otherwise stay within the same layer (stg_ -> stg_)."""
53
+ if child in ("fct_", "fact_", "f_") and parent in ("dim_", "d_"):
54
+ return 0
55
+ return 1 if parent == child else 2
56
+
57
+
58
+ def _same_entity(table_base: str, entity: str) -> bool:
59
+ forms = {entity, entity + "s", entity + "es"}
60
+ if entity.endswith("y"):
61
+ forms.add(entity[:-1] + "ies")
62
+ return table_base in forms
63
+
64
+
65
+ def _key_column(t: Tbl, name: str) -> Col | None:
66
+ """The parent column an `<entity>_id` points at: same name, else `id`; primary keys first."""
67
+
68
+ def norm(n: str) -> str: # customer_id == customerId == CUSTOMER_ID
69
+ return n.lower().replace("_", "")
70
+
71
+ cols = {norm(c.name): c for c in t.cols.values()}
72
+ same, plain = cols.get(norm(name)), cols.get("id")
73
+ for c in (same, plain):
74
+ if c is not None and c.pk:
75
+ return c
76
+ return same or plain
77
+
78
+
38
79
  class DbtImportError(Exception):
39
80
  pass
40
81
 
@@ -74,6 +115,7 @@ class ImportResult:
74
115
  tables: int
75
116
  relationships: int
76
117
  skipped: list[str]
118
+ inferred: list[str] = field(default_factory=list)
77
119
 
78
120
 
79
121
  def _strip_quotes(s: str) -> str:
@@ -117,9 +159,17 @@ def _comment_lines(text: str) -> list[str]:
117
159
 
118
160
  class _Importer:
119
161
  def __init__(
120
- self, manifest: dict, catalog: dict | None, packages: list[str] | None, select: list[str], sources: bool
162
+ self,
163
+ manifest: dict,
164
+ catalog: dict | None,
165
+ packages: list[str] | None,
166
+ select: list[str],
167
+ sources: bool,
168
+ infer: bool = False,
121
169
  ):
122
170
  self.m = manifest
171
+ self.infer = infer
172
+ self.inferred: list[str] = []
123
173
  self.catalog = catalog or {}
124
174
  self.select = select
125
175
  self.sources = sources
@@ -310,6 +360,62 @@ class _Importer:
310
360
  count += 1
311
361
  return count
312
362
 
363
+ # -- inference from column names (--infer-fks) ---------------------------
364
+ def infer_fks(self) -> int:
365
+ """Link `<entity>_id` columns to the one table named after the entity, when no relationship is declared.
366
+
367
+ Candidates are tables whose name, without a dbt layer prefix (stg_, dim_, fct_, ...) and in singular
368
+ or plural, equals the entity, and that have a matching key column. Among several, prefer a dim_ parent
369
+ for a fct_ child, else the same prefix as the child, then the same folder, then a declared primary key;
370
+ a tie is skipped rather than guessed.
371
+ """
372
+ # columns already on the parent side of a declared relationship
373
+ referenced = {(c.fk[1], c.fk[2].lower()) for t in self.tables.values() for c in t.cols.values() if c.fk}
374
+ count = 0
375
+ for t in sorted(self.tables.values(), key=lambda t: t.uid):
376
+ pks = [c for c in t.cols.values() if c.pk]
377
+ for c in sorted(t.cols.values(), key=lambda c: (c.order, c.name.lower())):
378
+ if c.fk is not None or (c.pk and len(pks) == 1) or (t.uid, c.name.lower()) in referenced:
379
+ continue
380
+ m = _ENTITY.match(c.name)
381
+ if not m:
382
+ continue
383
+ entity = m.group(1).rstrip("_").lower()
384
+ found = self._infer_target(t, c, entity)
385
+ if found is None:
386
+ continue
387
+ parent, pcol = found
388
+ c.fk = (">-", parent.uid, pcol.name)
389
+ note = "Relationship inferred from the column name (qdbd import dbt --infer-fks)."
390
+ c.description = (c.description + "\n" if c.description else "") + note
391
+ self.inferred.append(f"{t.name}.{c.name} -> {parent.name}.{pcol.name}")
392
+ count += 1
393
+ return count
394
+
395
+ def _infer_target(self, child: Tbl, col: Col, entity: str) -> tuple[Tbl, Col] | None:
396
+ child_prefix = _layer(child.name.split(".")[-1])[0]
397
+ ranked = []
398
+ for t in self.tables.values():
399
+ if t.uid == child.uid:
400
+ continue
401
+ prefix, base = _layer(t.name.split(".")[-1])
402
+ if not _same_entity(base, entity):
403
+ continue
404
+ key = _key_column(t, col.name)
405
+ if key is None:
406
+ continue
407
+ score = (_prefix_rank(child_prefix, prefix), t.group != child.group, not key.pk)
408
+ ranked.append((score, t, key))
409
+ if not ranked:
410
+ return None
411
+ ranked.sort(key=lambda r: r[0])
412
+ best = [r for r in ranked if r[0] == ranked[0][0]]
413
+ if len(best) > 1:
414
+ names = ", ".join(sorted(r[1].name for r in best))
415
+ self.skipped.append(f"{child.name}.{col.name}: ambiguous ({names}), not inferred")
416
+ return None
417
+ return best[0][1], best[0][2]
418
+
313
419
  # -- output -------------------------------------------------------------
314
420
  def render_table(self, t: Tbl) -> list[str]:
315
421
  lines = _comment_lines(t.description)
@@ -336,6 +442,8 @@ class _Importer:
336
442
  self.collect()
337
443
  self.tests()
338
444
  rels = self.link()
445
+ if self.infer:
446
+ rels += self.infer_fks()
339
447
  groups: dict[str, list[Tbl]] = {}
340
448
  for t in sorted(self.tables.values(), key=lambda t: (t.group, t.name.lower())):
341
449
  groups.setdefault(t.group, []).append(t)
@@ -358,6 +466,7 @@ def import_dbt(
358
466
  select: list[str] | None = None,
359
467
  sources: bool = True,
360
468
  split: bool = False,
469
+ infer_fks: bool = False,
361
470
  ) -> ImportResult:
362
471
  if manifest_path.is_dir():
363
472
  manifest_path = manifest_path / "manifest.json"
@@ -367,7 +476,7 @@ def import_dbt(
367
476
  if catalog_path is None and (manifest_path.parent / "catalog.json").exists():
368
477
  catalog_path = manifest_path.parent / "catalog.json"
369
478
  catalog = load_json(catalog_path) if catalog_path else None
370
- imp = _Importer(manifest, catalog, packages, select or [], sources)
479
+ imp = _Importer(manifest, catalog, packages, select or [], sources, infer=infer_fks)
371
480
  groups, rels = imp.run()
372
481
  project = (manifest.get("metadata") or {}).get("project_name") or "dbt"
373
482
  head = [
@@ -388,7 +497,7 @@ def import_dbt(
388
497
  for t in tables:
389
498
  body += imp.render_table(t) + [""]
390
499
  files["schema.txt"] = "\n".join(body).rstrip("\n") + "\n"
391
- return ImportResult(files, len(imp.tables), rels, imp.skipped)
500
+ return ImportResult(files, len(imp.tables), rels, imp.skipped, imp.inferred)
392
501
 
393
502
 
394
503
  def write_result(result: ImportResult, out: Path) -> list[Path]:
@@ -410,3 +519,24 @@ def write_result(result: ImportResult, out: Path) -> list[Path]:
410
519
  if old.name not in keep and old.read_text(encoding="utf-8").startswith(HEADER):
411
520
  old.unlink()
412
521
  return written
522
+
523
+
524
+ def stale_files(result: ImportResult, out: Path) -> list[str]:
525
+ """What `write_result` would change: files that differ, are missing, or would be removed. Empty = up to date."""
526
+ if out.suffix == ".txt":
527
+ text = next(iter(result.files.values()))
528
+ if not out.exists():
529
+ return [f"{out} (missing)"]
530
+ return [] if out.read_text(encoding="utf-8") == text else [f"{out} (out of date)"]
531
+ problems = []
532
+ for name, text in sorted(result.files.items()):
533
+ p = out / name
534
+ if not p.exists():
535
+ problems.append(f"{p} (missing)")
536
+ elif p.read_text(encoding="utf-8") != text:
537
+ problems.append(f"{p} (out of date)")
538
+ if out.is_dir():
539
+ for old in sorted(out.glob("*.txt")):
540
+ if old.name not in result.files and old.read_text(encoding="utf-8").startswith(HEADER):
541
+ problems.append(f"{old} (no longer generated)")
542
+ return problems
@@ -229,3 +229,96 @@ def test_helpers():
229
229
  assert norm_type("NUMBER(38, 0)") == "NUMBER(38,0)"
230
230
  assert norm_type("struct<a int>") == "struct<a_int>"
231
231
  assert norm_type(None) is None
232
+
233
+
234
+ # ------------------------------------------------------------------ --infer-fks / --check
235
+
236
+
237
+ def test_infer_fks_on_jaffle():
238
+ res = import_dbt(JAFFLE / "manifest.json", infer_fks=True)
239
+ s = compiled(res)
240
+ assert res.relationships == 4
241
+ assert sorted(res.inferred) == [
242
+ "raw_payments.order_id -> raw_orders.id", # same raw_ prefix; parent key is plain `id`
243
+ "stg_orders.customer_id -> stg_customers.customer_id", # same stg_ prefix wins over marts customers
244
+ "stg_payments.order_id -> stg_orders.order_id",
245
+ ]
246
+ assert "raw_orders.user_id" not in " ".join(res.inferred) # no `users` table: nothing to link
247
+ col = s.tables["stg_orders"].columns["customer_id"]
248
+ assert col.fk and "inferred from the column name" in col.comments[-1]
249
+ # declared relationships are untouched and not duplicated
250
+ assert ("orders", "customer_id", "many", "customers", "customer_id", "one") in rels(s)
251
+
252
+
253
+ def infer_manifest():
254
+ def m(uid, name, cols, fqn=None, pk=None):
255
+ n = node(uid, name, fqn=fqn, cols=[(c, "int", [{"type": "primary_key"}] if c == pk else None) for c in cols])
256
+ return n
257
+
258
+ nodes = {
259
+ "model.shop.dim_category": m("model.shop.dim_category", "dim_category", ["category_id"], pk="category_id"),
260
+ "model.shop.categories": m(
261
+ "model.shop.categories",
262
+ "categories",
263
+ ["category_id"],
264
+ pk="category_id",
265
+ fqn=["shop", "other", "categories"],
266
+ ),
267
+ "model.shop.fct_sales": m(
268
+ "model.shop.fct_sales",
269
+ "fct_sales",
270
+ ["sale_id", "categoryId", "store_key", "owner_id", "region_id"],
271
+ pk="sale_id",
272
+ ),
273
+ "model.shop.dim_stores": m("model.shop.dim_stores", "dim_stores", ["store_key"], pk="store_key"),
274
+ "model.shop.people": m("model.shop.people", "people", ["id"]),
275
+ "model.shop.owners_a": m("model.shop.owners_a", "a_owner", ["owner_id"], pk="owner_id"),
276
+ "model.shop.dim_owner": m(
277
+ "model.shop.dim_owner", "dim_owner", ["owner_id"], pk="owner_id", fqn=["shop", "x", "dim_owner"]
278
+ ),
279
+ "model.shop.dim_region": m("model.shop.dim_region", "dim_region", ["region_id"], pk="region_id"),
280
+ "model.shop.dim_regions": m("model.shop.dim_regions", "dim_regions", ["region_id"], pk="region_id"),
281
+ "model.shop.fct_owner": m(
282
+ "model.shop.fct_owner", "fct_owner", ["owner_id"], pk="owner_id", fqn=["shop", "x", "fct_owner"]
283
+ ),
284
+ }
285
+ return {"metadata": {"project_name": "shop"}, "nodes": nodes, "sources": {}}
286
+
287
+
288
+ def test_infer_rules(tmp_path):
289
+ p = tmp_path / "manifest.json"
290
+ p.write_text(json.dumps(infer_manifest()))
291
+ res = import_dbt(p, infer_fks=True)
292
+ compiled(res)
293
+ inferred = sorted(res.inferred)
294
+ # camelCase `categoryId` -> category; both dim_category and categories match, the fct_ child's folder decides
295
+ assert "fct_sales.categoryId -> dim_category.category_id" in inferred
296
+ assert "fct_sales.store_key -> dim_stores.store_key" in inferred # `_key` suffix, plural table
297
+ # facts point at dimensions: dim_owner beats the same-layer fct_owner
298
+ assert "fct_sales.owner_id -> dim_owner.owner_id" in inferred
299
+ # region_id: dim_region and dim_regions tie on every rule -> skipped as ambiguous, never guessed
300
+ assert not any(x.startswith("fct_sales.region_id") for x in inferred)
301
+ assert any("fct_sales.region_id: ambiguous (dim_region, dim_regions)" in x for x in res.skipped)
302
+ # single primary keys are never turned into FKs
303
+ assert not any(x.startswith(("dim_category.", "dim_stores.")) for x in inferred)
304
+
305
+
306
+ def test_infer_off_by_default():
307
+ res = import_dbt(JAFFLE / "manifest.json")
308
+ assert res.inferred == [] and res.relationships == 1
309
+
310
+
311
+ def test_check_mode(tmp_path, capsys):
312
+ out = tmp_path / "erd"
313
+ assert main(["import", "dbt", str(JAFFLE), "-o", str(out), "--check"]) == 1 # nothing written yet
314
+ assert "(missing)" in capsys.readouterr().err
315
+ assert not out.exists()
316
+ assert main(["import", "dbt", str(JAFFLE), "-o", str(out)]) == 0
317
+ assert main(["import", "dbt", str(JAFFLE), "-o", str(out), "--check"]) == 0
318
+ assert "Up to date" in capsys.readouterr().out
319
+ assert main(["import", "dbt", str(JAFFLE), "-o", str(out), "--check", "--infer-fks"]) == 1
320
+ err = capsys.readouterr().err
321
+ assert "(out of date)" in err and "commit the result" in err
322
+ (out / "old.txt").write_text(HEADER + " from x\n")
323
+ assert main(["import", "dbt", str(JAFFLE), "-o", str(out), "--check"]) == 1
324
+ assert "no longer generated" in capsys.readouterr().err
@@ -1 +0,0 @@
1
- __version__ = "0.1.5"
File without changes
File without changes