dbdocs 1.6.3__tar.gz → 1.8.0__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.
- {dbdocs-1.6.3 → dbdocs-1.8.0}/PKG-INFO +15 -5
- {dbdocs-1.6.3 → dbdocs-1.8.0}/README.md +9 -1
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/cli/main.py +36 -5
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/artifacts.py +18 -3
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/config.py +16 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/column_lineage.py +7 -12
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/erd.py +25 -23
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/style.css +4 -2
- dbdocs-1.8.0/dbdocs/site/bundle/assets/graph/index.css +1 -0
- dbdocs-1.8.0/dbdocs/site/bundle/assets/graph/index.js +62 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/data/data.js +1 -1
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/ui.js +4 -1
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/deploy.py +8 -2
- dbdocs-1.8.0/dbdocs/sources/__init__.py +0 -0
- dbdocs-1.8.0/dbdocs/sources/factory.py +24 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/__init__.py +0 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/builder.py +65 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/reader.py +45 -0
- dbdocs-1.8.0/dbdocs/sources/info_schema/synth.py +604 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/pyproject.toml +6 -2
- dbdocs-1.6.3/dbdocs/site/bundle/assets/graph/index.css +0 -1
- dbdocs-1.6.3/dbdocs/site/bundle/assets/graph/index.js +0 -62
- {dbdocs-1.6.3 → dbdocs-1.8.0}/.gitignore +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/LICENSE +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/__main__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/cli/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/exceptions.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/log.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/graph.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/dimensions.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/extractor.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/base.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/documentation.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/modeling.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/structure.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/testing.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/registry.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/nodes.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/tests_index.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/main.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/__init__.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/api_schema.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/builder.py +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/service/service.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/dom.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/overlays.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/index.html +0 -0
- {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/inject.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: dbdocs
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Alternative dbt docs site: Catalog + ERD + column-level lineage
|
|
5
5
|
Project-URL: Homepage, https://github.com/datnguye/dbt-docs
|
|
6
6
|
Project-URL: Repository, https://github.com/datnguye/dbt-docs
|
|
@@ -14,12 +14,14 @@ Classifier: Topic :: Software Development :: Documentation
|
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
15
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
|
-
Requires-Dist: artifact-parser[dbt]>=1.
|
|
17
|
+
Requires-Dist: artifact-parser[dbt]>=1.1.0
|
|
18
18
|
Requires-Dist: click>=8.1
|
|
19
|
-
Requires-Dist: dbterd>=1.
|
|
19
|
+
Requires-Dist: dbterd>=1.35.1
|
|
20
20
|
Requires-Dist: pydantic>=2.13.4
|
|
21
21
|
Requires-Dist: pyyaml>=6.0
|
|
22
22
|
Requires-Dist: sqlglot>=25
|
|
23
|
+
Provides-Extra: info-schema
|
|
24
|
+
Requires-Dist: duckdb>=1.1; extra == 'info-schema'
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
24
26
|
|
|
25
27
|
<p align="center">
|
|
@@ -71,12 +73,20 @@ Requires Python 3.10+.
|
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
75
|
dbt docs generate # writes target/manifest.json + target/catalog.json
|
|
74
|
-
dbdocs generate # builds
|
|
76
|
+
dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
|
|
75
77
|
dbdocs serve # static http server on http://127.0.0.1:8000
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
|
|
79
81
|
|
|
82
|
+
### On dbt 2.0
|
|
83
|
+
|
|
84
|
+
dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
|
|
89
|
+
|
|
80
90
|
Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
|
|
81
91
|
|
|
82
92
|
## Contributing
|
|
@@ -47,12 +47,20 @@ Requires Python 3.10+.
|
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
49
|
dbt docs generate # writes target/manifest.json + target/catalog.json
|
|
50
|
-
dbdocs generate # builds
|
|
50
|
+
dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
|
|
51
51
|
dbdocs serve # static http server on http://127.0.0.1:8000
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
|
|
55
55
|
|
|
56
|
+
### On dbt 2.0
|
|
57
|
+
|
|
58
|
+
dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
|
|
59
|
+
|
|
60
|
+

|
|
61
|
+
|
|
62
|
+
The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
|
|
63
|
+
|
|
56
64
|
Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
|
|
57
65
|
|
|
58
66
|
## Contributing
|
|
@@ -9,11 +9,31 @@ from dbdocs.core.config import DbDocsConfig
|
|
|
9
9
|
from dbdocs.core.exceptions import DbDocsError
|
|
10
10
|
from dbdocs.core.log import logger
|
|
11
11
|
from dbdocs.site import deploy as deploy_module
|
|
12
|
-
from dbdocs.
|
|
12
|
+
from dbdocs.sources.factory import builder_for
|
|
13
13
|
|
|
14
14
|
__version__ = importlib.metadata.version("dbdocs")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def info_schema_options(command):
|
|
18
|
+
command = click.option(
|
|
19
|
+
"--info-schema-dir",
|
|
20
|
+
default=None,
|
|
21
|
+
help="Directory of the info-schema parquet files (default: <target_dir>/info_schema/v1).",
|
|
22
|
+
)(command)
|
|
23
|
+
return click.option(
|
|
24
|
+
"--info-schema/--no-info-schema",
|
|
25
|
+
default=None,
|
|
26
|
+
help="Read dbt 2.0's parquet information schema as the base artifact source.",
|
|
27
|
+
)(command)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply_info_schema(config: DbDocsConfig, info_schema, info_schema_dir) -> None:
|
|
31
|
+
if info_schema is not None:
|
|
32
|
+
config.info_schema = info_schema
|
|
33
|
+
if info_schema_dir is not None:
|
|
34
|
+
config.info_schema_dir = info_schema_dir
|
|
35
|
+
|
|
36
|
+
|
|
17
37
|
# dbdocs
|
|
18
38
|
@click.group(
|
|
19
39
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
@@ -37,6 +57,7 @@ def dbdocs(ctx, config_path):
|
|
|
37
57
|
|
|
38
58
|
|
|
39
59
|
@dbdocs.command(name="generate")
|
|
60
|
+
@info_schema_options
|
|
40
61
|
@click.option("-o", "--output-dir", default=None, help="Where to write the site (default: config).")
|
|
41
62
|
@click.option(
|
|
42
63
|
"--dialect", default=None, help="SQL dialect for column lineage (default: adapter_type)."
|
|
@@ -49,14 +70,15 @@ def dbdocs(ctx, config_path):
|
|
|
49
70
|
),
|
|
50
71
|
)
|
|
51
72
|
@click.pass_obj
|
|
52
|
-
def generate(config: DbDocsConfig, output_dir, dialect, run_results):
|
|
73
|
+
def generate(config: DbDocsConfig, output_dir, dialect, run_results, info_schema, info_schema_dir):
|
|
53
74
|
"""Build the site from dbt artifacts (served over HTTP; data loaded externally)."""
|
|
54
75
|
if dialect is not None:
|
|
55
76
|
config.dialect = dialect
|
|
56
77
|
if run_results is not None:
|
|
57
78
|
config.run_results = run_results
|
|
79
|
+
_apply_info_schema(config, info_schema, info_schema_dir)
|
|
58
80
|
try:
|
|
59
|
-
|
|
81
|
+
builder_for(config).generate(output_dir=output_dir)
|
|
60
82
|
except DbDocsError as exc:
|
|
61
83
|
raise click.ClickException(str(exc)) from exc
|
|
62
84
|
# The builder already logs "Generated site at <path> (N nodes, M edges)".
|
|
@@ -84,15 +106,24 @@ def serve(config: DbDocsConfig, port):
|
|
|
84
106
|
"--delete", "delete", is_flag=True, default=False, help="Delete this version instead."
|
|
85
107
|
)
|
|
86
108
|
@click.option("--push/--no-push", default=False, help="Publish to the gh-pages branch.")
|
|
109
|
+
@info_schema_options
|
|
87
110
|
@click.pass_obj
|
|
88
|
-
def deploy(config: DbDocsConfig, version, alias, title, delete, push):
|
|
111
|
+
def deploy(config: DbDocsConfig, version, alias, title, delete, push, info_schema, info_schema_dir):
|
|
89
112
|
"""Generate a versioned build and update the version index (or --delete one)."""
|
|
113
|
+
_apply_info_schema(config, info_schema, info_schema_dir)
|
|
90
114
|
try:
|
|
91
115
|
if delete:
|
|
92
116
|
deploy_module.delete(config, version=version, push=push)
|
|
93
117
|
click.echo(f"Deleted version {version}")
|
|
94
118
|
return
|
|
95
|
-
out = deploy_module.deploy(
|
|
119
|
+
out = deploy_module.deploy(
|
|
120
|
+
config,
|
|
121
|
+
version=version,
|
|
122
|
+
alias=alias,
|
|
123
|
+
push=push,
|
|
124
|
+
title=title,
|
|
125
|
+
builder=builder_for(config),
|
|
126
|
+
)
|
|
96
127
|
except DbDocsError as exc:
|
|
97
128
|
raise click.ClickException(str(exc)) from exc
|
|
98
129
|
click.echo(f"Deployed version {version} into {out}")
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Loading dbt artifacts (manifest/catalog) via the dbterd parser.
|
|
2
2
|
|
|
3
|
-
dbterd parses ``manifest.json`` / ``catalog.json`` into ``
|
|
3
|
+
dbterd parses ``manifest.json`` / ``catalog.json`` into ``artifact_parser``
|
|
4
4
|
Pydantic models. Two cross-cutting gotchas live here so the rest of dbdocs never
|
|
5
5
|
has to think about them:
|
|
6
6
|
|
|
7
|
-
* **Schema field aliasing.** ``
|
|
7
|
+
* **Schema field aliasing.** ``artifact_parser`` aliases the ``schema``
|
|
8
8
|
field to ``schema_`` to avoid clobbering Pydantic's ``BaseModel.schema()`` —
|
|
9
9
|
so ``node.schema`` is a *bound method*, not the value. Always read
|
|
10
10
|
``node.schema_``; :func:`db_schema` centralizes that.
|
|
@@ -19,6 +19,8 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
from dbterd.helpers import file
|
|
21
21
|
|
|
22
|
+
from dbdocs.core.log import logger
|
|
23
|
+
|
|
22
24
|
#: Bucket label used when a node/source has no database or schema set.
|
|
23
25
|
UNKNOWN = "_unknown"
|
|
24
26
|
|
|
@@ -54,10 +56,23 @@ def artifact_version(target_path: str, artifact: str) -> "int | None":
|
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def load_artifacts(target_path: str) -> "tuple[Any, Any]":
|
|
57
|
-
"""Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir.
|
|
59
|
+
"""Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir.
|
|
60
|
+
|
|
61
|
+
``catalog.json`` is optional (dbt 2.0's ``docs generate`` does not write it):
|
|
62
|
+
when absent, ``catalog`` is ``None`` and every consumer falls back to the
|
|
63
|
+
manifest — columns lose their warehouse type and column-level lineage can't
|
|
64
|
+
resolve ``SELECT *`` / unqualified columns.
|
|
65
|
+
"""
|
|
58
66
|
manifest = file.read_manifest(
|
|
59
67
|
path=target_path, version=artifact_version(target_path, "manifest")
|
|
60
68
|
)
|
|
69
|
+
if not (Path(target_path) / "catalog.json").is_file():
|
|
70
|
+
logger.warning(
|
|
71
|
+
"No catalog.json in %s — building from the manifest only "
|
|
72
|
+
"(no warehouse column types; reduced column-level lineage).",
|
|
73
|
+
target_path,
|
|
74
|
+
)
|
|
75
|
+
return manifest, None
|
|
61
76
|
catalog = file.read_catalog(path=target_path, version=artifact_version(target_path, "catalog"))
|
|
62
77
|
return manifest, catalog
|
|
63
78
|
|
|
@@ -81,6 +81,13 @@ class DbDocsConfig:
|
|
|
81
81
|
#: dotted import path whose ``register_rule`` calls add custom rules). Entry
|
|
82
82
|
#: points under ``dbdocs.health_rules`` are also discovered automatically.
|
|
83
83
|
health: dict = field(default_factory=dict)
|
|
84
|
+
#: Opt in to reading dbt 2.0's parquet information schema as the base artifact
|
|
85
|
+
#: source (``manifest.json`` / ``catalog.json`` / ``run_results.json`` in
|
|
86
|
+
#: ``target_dir`` then only enrich it). Needs ``dbdocs[info-schema]``.
|
|
87
|
+
info_schema: bool = False
|
|
88
|
+
#: Directory holding the information-schema parquet files; ``None`` ⇒
|
|
89
|
+
#: ``<target_dir>/info_schema/v1``.
|
|
90
|
+
info_schema_dir: "str | None" = None
|
|
84
91
|
#: The pinned About link shows by default; set false to hide it.
|
|
85
92
|
show_about: bool = True
|
|
86
93
|
#: Links rendered on the About page as external CTA buttons. Each entry is a
|
|
@@ -129,6 +136,8 @@ class DbDocsConfig:
|
|
|
129
136
|
"favicon",
|
|
130
137
|
"run_results",
|
|
131
138
|
"health",
|
|
139
|
+
"info_schema",
|
|
140
|
+
"info_schema_dir",
|
|
132
141
|
)
|
|
133
142
|
|
|
134
143
|
def render_context(self) -> dict:
|
|
@@ -164,3 +173,10 @@ class DbDocsConfig:
|
|
|
164
173
|
A relative path that would escape the cwd raises ``DbDocsConfigError``.
|
|
165
174
|
"""
|
|
166
175
|
return str(_resolve_within_cwd(self.output_dir, "output_dir"))
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def info_schema_path(self) -> str:
|
|
179
|
+
"""Directory of the information-schema parquet files (see ``info_schema_dir``)."""
|
|
180
|
+
if self.info_schema_dir:
|
|
181
|
+
return str(_resolve_within_cwd(self.info_schema_dir, "info_schema_dir"))
|
|
182
|
+
return str(Path(self.target_path) / "info_schema" / "v1")
|
|
@@ -61,10 +61,11 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
|
|
|
61
61
|
"""Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
|
|
62
62
|
|
|
63
63
|
A leaf is a node whose source is a real ``Table`` (not a CTE/subquery scope)
|
|
64
|
-
that maps back to a dbt node. The root itself is skipped.
|
|
64
|
+
that maps back to a dbt node. The root itself is skipped. Leaves are sorted
|
|
65
|
+
by ``(node, column)``: the tree's walk order follows sqlglot's internal set
|
|
66
|
+
iteration, which varies with ``PYTHONHASHSEED``.
|
|
65
67
|
"""
|
|
66
|
-
|
|
67
|
-
upstream = []
|
|
68
|
+
leaves = set()
|
|
68
69
|
for node in root.walk():
|
|
69
70
|
if node is root:
|
|
70
71
|
continue
|
|
@@ -72,15 +73,9 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
|
|
|
72
73
|
if not isinstance(source, exp.Table):
|
|
73
74
|
continue
|
|
74
75
|
mapped = _map_table(source, relation_to_node)
|
|
75
|
-
if mapped is None:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
key = (mapped, column)
|
|
79
|
-
if key in seen:
|
|
80
|
-
continue
|
|
81
|
-
seen.add(key)
|
|
82
|
-
upstream.append({"node": mapped, "column": column})
|
|
83
|
-
return upstream
|
|
76
|
+
if mapped is not None:
|
|
77
|
+
leaves.add((mapped, node_name(node.name)))
|
|
78
|
+
return [{"node": mapped, "column": column} for mapped, column in sorted(leaves)]
|
|
84
79
|
|
|
85
80
|
|
|
86
81
|
def _extract_model_columns(
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
"""Structured ERD data via dbterd's official ``json`` target.
|
|
2
2
|
|
|
3
|
-
dbterd
|
|
3
|
+
dbterd ships a built-in, schema-validated ``json`` target that emits
|
|
4
4
|
``{nodes, edges, metadata}``. Each node carries ``id`` (the dbt unique_id),
|
|
5
5
|
``name``, ``schema_name``, ``database``, ``resource_type``, and ``columns``
|
|
6
6
|
(with ``data_type``, ``is_primary_key``, ``is_foreign_key``). Each edge carries
|
|
7
7
|
``id``, ``from_id`` (FK/child side), ``to_id`` (referenced/parent side),
|
|
8
|
-
``from_columns``, ``to_columns``, ``label``, and
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
``from_columns``, ``to_columns``, ``label``, ``relationship_type``, and
|
|
9
|
+
``cardinality``.
|
|
10
|
+
|
|
11
|
+
``build_erd_data`` maps that shape into the SPA's ``{nodes, edges}`` using
|
|
12
|
+
dbterd's own field names throughout — no renaming. The React Flow bundle reads
|
|
13
|
+
``nodes`` (entities + column flags) and ``edges`` (FK relationships, keyed by
|
|
14
|
+
dbt unique_id in ``from_id``/``to_id``) and maps ``from_id``/``to_id`` to React
|
|
15
|
+
Flow's ``source``/``target`` on its own side, with the parent/referenced node
|
|
16
|
+
as ``to_id``.
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
19
|
import json
|
|
@@ -54,17 +58,14 @@ def build_erd_data(erd: DbtErd) -> dict:
|
|
|
54
58
|
When ``entity_name_format`` is configured, dbterd emits edge ``from_id`` /
|
|
55
59
|
``to_id`` as the formatted entity name (e.g. ``orders``) rather than the
|
|
56
60
|
full unique_id (e.g. ``model.jaffle_shop.orders``). A ``name_to_id`` map
|
|
57
|
-
resolves those short names back to the node id before building edges, so
|
|
58
|
-
|
|
61
|
+
resolves those short names back to the node id before building edges, so
|
|
62
|
+
the emitted ``from_id``/``to_id`` always reference a valid node ``id``.
|
|
59
63
|
|
|
60
64
|
Some dbterd algos (e.g. ``model_contract``) do not set ``is_foreign_key``
|
|
61
65
|
on node columns even when those columns appear in FK edges. After building
|
|
62
66
|
edges, any column named in an edge's ``from_columns`` (the FK/child side)
|
|
63
|
-
has its ``is_foreign_key`` flag back-filled to ``True`` on the
|
|
64
|
-
|
|
65
|
-
SPA edge direction: ``source`` is the referenced (parent) side, ``target``
|
|
66
|
-
is the FK (child) side — matching dbterd's ``to_id`` and ``from_id``
|
|
67
|
-
respectively.
|
|
67
|
+
has its ``is_foreign_key`` flag back-filled to ``True`` on the ``from_id``
|
|
68
|
+
node.
|
|
68
69
|
"""
|
|
69
70
|
payload = json.loads(erd.get_erd())
|
|
70
71
|
raw_nodes = payload.get("nodes", [])
|
|
@@ -95,13 +96,13 @@ def _backfill_fk_flags(nodes: "list[dict]", edges: "list[dict]") -> None:
|
|
|
95
96
|
"""
|
|
96
97
|
nodes_by_id = {n["id"]: n for n in nodes}
|
|
97
98
|
for edge in edges:
|
|
98
|
-
|
|
99
|
-
if
|
|
99
|
+
from_node = nodes_by_id.get(edge["from_id"])
|
|
100
|
+
if from_node is None:
|
|
100
101
|
continue
|
|
101
102
|
fk_cols = {c.lower() for c in edge.get("from_columns", [])}
|
|
102
103
|
if not fk_cols:
|
|
103
104
|
continue
|
|
104
|
-
for col in
|
|
105
|
+
for col in from_node["columns"]:
|
|
105
106
|
if col["name"].lower() in fk_cols:
|
|
106
107
|
col["is_foreign_key"] = True
|
|
107
108
|
|
|
@@ -111,12 +112,12 @@ def _build_node(node: dict) -> dict:
|
|
|
111
112
|
"id": node["id"],
|
|
112
113
|
"label": node.get("name") or "",
|
|
113
114
|
"database": node.get("database") or "",
|
|
114
|
-
"
|
|
115
|
+
"schema_name": node.get("schema_name") or "",
|
|
115
116
|
"resource_type": node.get("resource_type") or "model",
|
|
116
117
|
"columns": [
|
|
117
118
|
{
|
|
118
119
|
"name": c["name"],
|
|
119
|
-
"
|
|
120
|
+
"data_type": c.get("data_type") or "",
|
|
120
121
|
"description": c.get("description") or "",
|
|
121
122
|
"is_primary_key": bool(c.get("is_primary_key")),
|
|
122
123
|
"is_foreign_key": bool(c.get("is_foreign_key")),
|
|
@@ -135,16 +136,17 @@ def _resolve_edge_id(raw: str, node_ids: "set[str]", name_to_id: "dict[str, str]
|
|
|
135
136
|
|
|
136
137
|
|
|
137
138
|
def _build_edge(edge: dict, index: int, node_ids: "set[str]", name_to_id: "dict[str, str]") -> dict:
|
|
138
|
-
# from_id is the FK/child side; to_id is the referenced/parent side
|
|
139
|
-
#
|
|
139
|
+
# from_id is the FK/child side; to_id is the referenced/parent side —
|
|
140
|
+
# dbterd's own convention, carried through unchanged.
|
|
140
141
|
raw_from = edge.get("from_id") or ""
|
|
141
142
|
raw_to = edge.get("to_id") or ""
|
|
142
143
|
return {
|
|
143
144
|
"id": edge.get("id") or f"e{index}",
|
|
144
|
-
"
|
|
145
|
-
"
|
|
145
|
+
"from_id": _resolve_edge_id(raw_from, node_ids, name_to_id),
|
|
146
|
+
"to_id": _resolve_edge_id(raw_to, node_ids, name_to_id),
|
|
146
147
|
"from_columns": edge.get("from_columns") or [],
|
|
147
148
|
"to_columns": edge.get("to_columns") or [],
|
|
148
149
|
"label": edge.get("label") or "",
|
|
149
|
-
"
|
|
150
|
+
"relationship_type": edge.get("relationship_type") or "",
|
|
151
|
+
"cardinality": edge.get("cardinality") or "",
|
|
150
152
|
}
|
|
@@ -464,8 +464,10 @@ details.node-section:last-of-type { border-bottom: none; }
|
|
|
464
464
|
/* Graph host (React Flow bundle mounts here) */
|
|
465
465
|
.graph-host { width: 100%; }
|
|
466
466
|
/* Fullscreen: the host fills the screen; its inner .dbd-graph grows to match. */
|
|
467
|
-
.graph-host:fullscreen
|
|
468
|
-
.
|
|
467
|
+
.graph-host:fullscreen,
|
|
468
|
+
.erd-section-host:fullscreen { background: var(--bg); padding: 0; }
|
|
469
|
+
.graph-host:fullscreen .dbd-graph,
|
|
470
|
+
.erd-section-host:fullscreen .dbd-graph { height: 100vh; border: none; border-radius: 0; }
|
|
469
471
|
|
|
470
472
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
471
473
|
.page-head h2 { border-bottom: none; padding-bottom: 0; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.erd-table{background:var(--erd-node-bg, #1e1e1e);color:var(--erd-node-fg, #d4d4d4);border:1px solid var(--erd-border, #007acc);border-radius:6px;font-family:var(--erd-font-mono, monospace);font-size:12px;min-width:220px;box-shadow:0 1px 3px var(--erd-shadow, rgba(0, 0, 0, .25))}.erd-table[data-resource=source]{border-style:dashed}.erd-table[data-filter=match]{border-color:var(--erd-accent, #e5c07b);box-shadow:0 0 0 2px var(--erd-accent, #e5c07b),0 1px 4px var(--erd-shadow, rgba(0, 0, 0, .35))}.erd-table[data-filter=dim]{opacity:.32;filter:saturate(.6);transition:opacity .12s ease,filter .12s ease}.erd-table[data-filter=dim]:hover{opacity:.85;filter:none}.erd-table[data-active=true]{border-color:var(--erd-border, #007acc);box-shadow:0 0 0 2px var(--erd-border, #007acc),0 2px 8px var(--erd-shadow, rgba(0, 0, 0, .4))}.erd-table[data-active=true][data-filter=match]{box-shadow:0 0 0 2px var(--erd-border, #007acc),inset 0 0 0 1px var(--erd-accent, #e5c07b)}.erd-table-header{display:flex;align-items:center;gap:6px;box-sizing:border-box;height:32px;padding:6px 10px;background:var(--erd-header-bg, #252526);border-bottom:1px solid var(--erd-border, #007acc);cursor:pointer;-webkit-user-select:none;user-select:none}.erd-table-header:hover{background:var(--erd-hover-bg, #2a2d2e)}.erd-table-header:focus-visible{outline:2px solid var(--erd-border, #007acc);outline-offset:-2px}.erd-table-name{font-weight:600}.erd-table-schema{margin-left:auto;color:var(--erd-muted-fg, #808080);font-size:11px}.erd-table-columns{list-style:none;margin:0;padding:4px 0}.erd-column{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:6px;box-sizing:border-box;height:22px;padding:0 10px;position:relative}.erd-column[data-highlighted=true]{background:color-mix(in srgb,var(--erd-accent, #e5c07b) 16%,transparent);box-shadow:inset 2px 0 0 var(--erd-accent, #e5c07b)}.erd-column[data-highlighted=true] .erd-column-name{color:var(--erd-accent, #e5c07b);font-weight:600}.erd-column-badge{font-size:10px;font-weight:700;text-align:center}.badge-pk{color:var(--erd-accent, #e5c07b)}.badge-fk{color:var(--erd-fk, #61afef)}.badge-none{color:transparent}.erd-column-type{color:var(--erd-muted-fg, #808080)}.erd-table-icon{display:inline-flex;align-items:center;color:var(--erd-icon, #FF694A)}.erd-table-more{box-sizing:border-box;height:22px;padding:0 10px;border-top:1px solid var(--erd-divider, #3c3c3c);display:flex;align-items:center;color:var(--erd-muted-fg, #808080);font-size:11px;font-style:italic}.erd-table[data-dimmed=true]{opacity:.25;transition:opacity .12s ease}.erd-table-expand{display:block;width:100%;box-sizing:border-box;height:24px;padding:0 10px;background:transparent;color:var(--erd-link-fg, #3794ff);border:none;border-top:1px dashed var(--erd-divider, #3c3c3c);text-align:center;cursor:pointer;font-size:11px;font-family:inherit}.erd-table-expand:hover{background:var(--erd-hover-bg, #2a2d2e)}.erd-table .react-flow__handle.erd-hidden-handle{width:1px;height:1px;min-width:0;min-height:0;border:none;background:transparent;opacity:0;pointer-events:none}.erd-flow{width:100%;height:100%;min-height:0;position:relative}.erd-flow .react-flow{width:100%;height:100%}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}.dbd-graph{display:flex;flex-direction:column;height:72vh;border:1px solid var(--border, #e3e6ea);border-radius:8px;overflow:hidden;background:var(--bg-soft, #f5f6f8)}.dbd-canvas{flex:1;min-height:0}.dbd-graph-empty{display:flex;align-items:center;justify-content:center;height:100%;padding:24px}.dbd-graph-empty p{max-width:32rem;text-align:center;color:var(--text-soft, #5a6473)}.dbd-erd-bar{display:flex;justify-content:flex-end;padding:6px 8px;border-bottom:1px solid var(--border, #e3e6ea);background:var(--bg-elev, #fff)}.dbd-lock-btn{padding:4px 12px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text-soft, #5b636c);font-size:12px;cursor:pointer}.dbd-lock-btn:hover{border-color:var(--accent, #2f6feb)}.dbd-lock-btn.on{color:var(--accent, #2f6feb);border-color:var(--accent, #2f6feb)}.react-flow__edge{cursor:pointer}.react-flow__edge:hover .react-flow__edge-path{stroke-width:2.5}.react-flow__attribution{display:none}.react-flow__controls{box-shadow:0 1px 4px #00000040;border-radius:6px;overflow:hidden}.react-flow__controls-button{background:var(--bg-elev, #fff);border-bottom:1px solid var(--border, #e3e6ea);color:var(--text, #1c2024);fill:var(--text, #1c2024);width:26px;height:26px}.react-flow__controls-button:hover{background:var(--bg-soft, #f5f6f8)}.react-flow__controls-button svg{fill:currentColor;max-width:14px;max-height:14px}.react-flow__minimap{background:var(--bg-elev, #fff);border:1px solid var(--border, #e3e6ea);border-radius:6px}.react-flow__minimap-mask{fill:#7f7f7f2e}.react-flow__background{color:var(--border, #e3e6ea)}.dbd-toolbar{display:flex;flex-wrap:wrap;gap:10px;padding:8px 10px;border-bottom:1px solid var(--border, #e3e6ea);background:var(--bg-elev, #fff)}.dbd-toolbar input,.dbd-toolbar select{padding:5px 9px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text, #1c2024);font-size:13px}.dbd-layer-seg{display:flex;border:1px solid var(--border, #e3e6ea);border-radius:6px;overflow:hidden;background:var(--bg-soft, #f5f6f8)}.dbd-layer-seg button{padding:5px 10px;border:none;border-right:1px solid var(--border, #e3e6ea);background:transparent;color:var(--text-soft, #5b636c);font-size:13px;cursor:pointer;white-space:nowrap}.dbd-layer-seg button:last-child{border-right:none}.dbd-layer-seg button:hover{background:var(--bg-soft, #f0f1f3);color:var(--text, #1c2024)}.dbd-layer-seg button.active{background:var(--accent, #2f6feb);color:#fff}.dbd-multi{position:relative}.dbd-multi-trigger{display:flex;align-items:center;gap:4px;padding:5px 9px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text, #1c2024);font-size:13px;cursor:pointer;white-space:nowrap}.dbd-multi-trigger:hover,.dbd-multi-trigger[aria-expanded=true]{border-color:var(--accent, #2f6feb)}.dbd-multi-arrow{font-size:10px;color:var(--text-soft, #5b636c)}.dbd-multi-panel{position:absolute;top:calc(100% + 4px);left:0;z-index:100;min-width:180px;padding:4px 0;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-elev, #fff);box-shadow:0 4px 12px #0000001f}.dbd-multi-option{display:flex;align-items:center;gap:7px;padding:5px 12px;cursor:pointer;font-size:13px;color:var(--text, #1c2024);-webkit-user-select:none;user-select:none}.dbd-multi-option:hover{background:var(--bg-soft, #f5f6f8)}.dbd-multi-all{border-bottom:1px solid var(--border, #e3e6ea);margin-bottom:2px;font-weight:600}.dbd-multi-label{flex:1}.dbd-multi-count{color:var(--text-soft, #5b636c);font-size:11px}.dbd-multi-group{padding:4px 0 2px}.dbd-multi-group:not(:first-child){border-top:1px solid var(--border, #e3e6ea)}.dbd-multi-group-legend{padding:4px 12px 2px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-soft, #5b636c)}.dbd-node{display:flex;align-items:center;gap:7px;min-width:150px;padding:7px 12px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-elev, #fff);color:var(--text, #1c2024);font-size:12px}.dbd-node-bar{width:4px;height:18px;border-radius:2px}.dbd-node-label{font-weight:600;flex:1}.dbd-node-badge{padding:1px 5px;border-radius:3px;font-size:9px;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}.dbd-dim{opacity:.25}.dbd-graph .erd-table{min-width:210px;border-radius:8px;overflow:hidden;font-family:inherit;box-shadow:none}.dbd-graph .erd-table[data-resource=source]{border-style:solid}.dbd-graph .erd-table[data-focus=true]{box-shadow:0 0 0 2px var(--accent, #2f6feb)}.dbd-graph .erd-table-header{gap:8px;border-bottom:none;border-top:3px solid var(--border, #888)}.dbd-graph .erd-table[data-resource=model] .erd-table-header{border-top-color:var(--model, #2f6feb)}.dbd-graph .erd-table[data-resource=source] .erd-table-header{border-top-color:var(--source, #16a34a)}.dbd-graph .erd-table[data-resource=seed] .erd-table-header{border-top-color:var(--seed, #b45309)}.dbd-graph .erd-table[data-resource=snapshot] .erd-table-header{border-top-color:var(--snapshot, #7c3aed)}.dbd-graph .erd-table-icon{display:none}.dbd-graph .erd-table-name{font-weight:700}.dbd-graph .erd-column{grid-template-columns:auto 1fr auto;gap:8px;border-top:1px solid var(--border, #e3e6ea)}.dbd-graph .erd-column-name{font-family:monospace}.dbd-graph .erd-column-type{font-size:11px}.dbd-graph .erd-column-badge{display:inline-flex;justify-content:center;min-width:22px;padding:0 5px;border-radius:4px;color:#fff}.dbd-graph .erd-column-badge.badge-pk{background:var(--seed, #b45309);color:#fff}.dbd-graph .erd-column-badge.badge-fk{background:var(--accent, #2f6feb);color:#fff}.dbd-graph .erd-column-badge.badge-none{background:transparent}.dbd-graph .react-flow__edge:hover{--erd-edge-width: 2.5}
|