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.
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/.gitignore +1 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/PKG-INFO +5 -3
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/README.md +4 -2
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/pyproject.toml +1 -1
- quickdbd_cli-0.1.6/src/quickdbd_cli/__init__.py +1 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/cli.py +31 -4
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/dbt.py +133 -3
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_dbt.py +93 -0
- quickdbd_cli-0.1.5/src/quickdbd_cli/__init__.py +0 -1
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/LICENSE +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/NOTICE +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/quickdbd-sample.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/finance.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/hr.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/inventory.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/marketing.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/qdbd.layout.json +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/examples/synthetic/sales.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/gen_font_metrics.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/gen_schema.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/scripts/release.sh +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/__main__.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.ttf +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Bold.woff2 +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-LICENSE.txt +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.ttf +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/Inter-Regular.woff2 +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/editor.js +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk-LICENSE.md +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/elk.bundled.js +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/font-metrics.json +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/render.js +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/style.css +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/assets/viewer.js +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/diff.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/export.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/html.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/jsrt.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/layout.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/measure.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/model.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/parser.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/project.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/src/quickdbd_cli/serve.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/LICENSE +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/README.md +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/catalog.json +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/fixtures/jaffle/manifest.json +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_diff.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_parser.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_path.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_pipeline.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_select.py +0 -0
- {quickdbd_cli-0.1.5 → quickdbd_cli-0.1.6}/tests/test_serve.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quickdbd-cli
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|