Skip to content

Commit 627833d

Browse files
authored
Feat/117 new algo module semantic (#120)
* feat: add new algo module for looking at semantic models * chore: add sample artifacts of SL * feat: working semantic relationship detection * feat: add dataclasses * feat: implement find_related_nodes_by_id * test: add unittest * chore: add py3.12 * chore: add check for p3.12 * docs: update mkdocs * chore: update docs [skip ci]
1 parent 1855328 commit 627833d

File tree

22 files changed

+27084
-278
lines changed

22 files changed

+27084
-278
lines changed

.github/workflows/ci_pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
strategy:
1111
matrix:
1212
os: [ubuntu-latest, macos-latest, windows-latest]
13-
python-version: ["3.9", "3.10", "3.11"]
13+
python-version: ["3.9", "3.10", "3.11", "3.12"]
1414

1515
steps:
1616
- uses: actions/checkout@v3

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
Generate the ERD-as-a-code ([DBML](https://dbdiagram.io/d), [Mermaid](https://mermaid-js.github.io/mermaid-live-editor/), [PlantUML](https://plantuml.com/ie-diagram), [GraphViz](https://graphviz.org/), [D2](https://d2lang.com/)) from dbt artifact files (`dbt Core`) or from dbt metadata (`dbt Cloud`)
44

5+
Entity Relationships are configurably detected by ([docs](https://dbterd.datnguyen.de/latest/nav/guide/cli-references.html#dbterd-run-algo-a)):
6+
7+
- [Test Relationships](https://docs.getdbt.com/reference/resource-properties/data-tests#relationships) (default)
8+
- [Semantic Entities](https://docs.getdbt.com/docs/build/entities) (use `-a` option)
9+
510
[![PyPI version](https://badge.fury.io/py/dbterd.svg)](https://pypi.org/project/dbterd/)
611
![python-cli](https://img.shields.io/badge/CLI-Python-FFCE3E?labelColor=14354C&logo=python&logoColor=white)
712
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8-
[![python](https://img.shields.io/badge/Python-3.9|3.10|3.11-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
13+
[![python](https://img.shields.io/badge/Python-3.9|3.10|3.11|3.12-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org)
914
[![codecov](https://codecov.io/gh/datnguye/dbterd/branch/main/graph/badge.svg?token=N7DMQBLH4P)](https://codecov.io/gh/datnguye/dbterd)
1015

1116
```bash

dbterd/adapters/algos/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import click
55

6+
from dbterd.adapters.filter import is_selected_table
67
from dbterd.adapters.meta import Column, Ref, Table
78
from dbterd.constants import (
89
DEFAULT_ALGO_RULE,
@@ -96,6 +97,27 @@ def get_tables(manifest: Manifest, catalog: Catalog, **kwargs) -> List[Table]:
9697
return tables
9798

9899

100+
def filter_tables_based_on_selection(tables: List[Table], **kwargs) -> List[Table]:
101+
"""Filter list of tables based on the Selection Rules
102+
103+
Args:
104+
tables (List[Table]): Parsed tables
105+
106+
Returns:
107+
List[Table]: Filtered tables
108+
"""
109+
return [
110+
table
111+
for table in tables
112+
if is_selected_table(
113+
table=table,
114+
select_rules=kwargs.get("select") or [],
115+
resource_types=kwargs.get("resource_type", []),
116+
exclude_rules=kwargs.get("exclude") or [],
117+
)
118+
]
119+
120+
99121
def enrich_tables_from_relationships(
100122
tables: List[Table], relationships: List[Ref]
101123
) -> List[Table]:

dbterd/adapters/algos/semantic.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from typing import List, Tuple, Union
2+
3+
from dbterd.adapters.algos import base
4+
from dbterd.adapters.meta import Ref, SemanticEntity, Table
5+
from dbterd.constants import TEST_META_RELATIONSHIP_TYPE
6+
from dbterd.helpers.log import logger
7+
from dbterd.types import Catalog, Manifest
8+
9+
10+
def parse_metadata(data, **kwargs) -> Tuple[List[Table], List[Ref]]:
11+
raise NotImplementedError() # pragma: no cover
12+
13+
14+
def parse(
15+
manifest: Manifest, catalog: Union[str, Catalog], **kwargs
16+
) -> Tuple[List[Table], List[Ref]]:
17+
# Parse metadata
18+
if catalog == "metadata": # pragma: no cover
19+
return parse_metadata(data=manifest, **kwargs)
20+
21+
# Parse Table
22+
tables = base.get_tables(manifest=manifest, catalog=catalog, **kwargs)
23+
tables = base.filter_tables_based_on_selection(tables=tables, **kwargs)
24+
25+
# Parse Ref
26+
relationships = _get_relationships(manifest=manifest, **kwargs)
27+
relationships = base.make_up_relationships(
28+
relationships=relationships, tables=tables
29+
)
30+
31+
# Fulfill columns in Tables (due to `select *`)
32+
tables = base.enrich_tables_from_relationships(
33+
tables=tables, relationships=relationships
34+
)
35+
36+
logger.info(
37+
f"Collected {len(tables)} table(s) and {len(relationships)} relationship(s)"
38+
)
39+
return (
40+
sorted(tables, key=lambda tbl: tbl.node_name),
41+
sorted(relationships, key=lambda rel: rel.name),
42+
)
43+
44+
45+
def find_related_nodes_by_id(
46+
manifest: Union[Manifest, dict], node_unique_id: str, type: str = None, **kwargs
47+
) -> List[str]:
48+
"""Find FK/PK nodes which are linked to the given node
49+
50+
Args:
51+
manifest (Union[Manifest, dict]): Manifest data
52+
node_unique_id (str): Manifest model node unique id
53+
type (str, optional): Manifest type (local file or metadata). Defaults to None.
54+
55+
Returns:
56+
List[str]: Manifest nodes' unique ID
57+
"""
58+
found_nodes = [node_unique_id]
59+
if type == "metadata": # pragma: no cover
60+
return found_nodes # not supported yet, returned input only
61+
62+
entities = _get_linked_semantic_entities(manifest=manifest)
63+
for foreign, primary in entities:
64+
if primary.model == node_unique_id:
65+
found_nodes.append(foreign.model)
66+
if foreign.model == node_unique_id:
67+
found_nodes.append(primary.model)
68+
69+
return list(set(found_nodes))
70+
71+
72+
def _get_relationships(manifest: Manifest, **kwargs) -> List[Ref]:
73+
"""_summary_
74+
75+
Args:
76+
manifest (Manifest): Extract relationships from dbt artifacts based on Semantic Entities
77+
78+
Returns:
79+
List[Ref]: List of parsed relationship
80+
"""
81+
entities = _get_linked_semantic_entities(manifest=manifest)
82+
return base.get_unique_refs(
83+
refs=[
84+
Ref(
85+
name=primary_entity.semantic_model,
86+
table_map=(primary_entity.model, foreign_entity.model),
87+
column_map=(
88+
primary_entity.column_name,
89+
foreign_entity.column_name,
90+
),
91+
type=primary_entity.relationship_type,
92+
)
93+
for foreign_entity, primary_entity in entities
94+
]
95+
)
96+
97+
98+
def _get_linked_semantic_entities(
99+
manifest: Manifest,
100+
) -> List[Tuple[SemanticEntity, SemanticEntity]]:
101+
"""Get filtered list of Semantic Entities which are linked
102+
103+
Args:
104+
manifest (Manifest): Manifest data
105+
106+
Returns:
107+
List[Tuple[SemanticEntity, SemanticEntity]]: List of (FK, PK) objects
108+
"""
109+
foreigns, primaries = _get_semantic_entities(manifest=manifest)
110+
linked_entities = []
111+
for foreign_entity in foreigns:
112+
for primary_entity in primaries:
113+
if foreign_entity.entity_name == primary_entity.entity_name:
114+
linked_entities.append((foreign_entity, primary_entity))
115+
return linked_entities
116+
117+
118+
def _get_semantic_entities(
119+
manifest: Manifest,
120+
) -> Tuple[List[SemanticEntity], List[SemanticEntity]]:
121+
"""Get all Semantic Entities
122+
123+
Args:
124+
manifest (Manifest): Manifest data
125+
126+
Returns:
127+
Tuple[List[SemanticEntity], List[SemanticEntity]]: FK list and PK list
128+
"""
129+
FK = "foreign"
130+
PK = "primary"
131+
132+
semantic_entities = []
133+
for x in _get_semantic_nodes(manifest=manifest):
134+
semantic_node = manifest.semantic_models[x]
135+
for e in semantic_node.entities:
136+
if e.type.value in [PK, FK]:
137+
semantic_entities.append(
138+
SemanticEntity(
139+
semantic_model=x,
140+
model=semantic_node.depends_on.nodes[0],
141+
entity_name=e.name,
142+
entity_type=e.type.value,
143+
column_name=e.expr or e.name,
144+
relationship_type=semantic_node.config.meta.get(
145+
TEST_META_RELATIONSHIP_TYPE, ""
146+
),
147+
)
148+
)
149+
if semantic_node.primary_entity:
150+
semantic_entities.append(
151+
SemanticEntity(
152+
semantic_model=x,
153+
model=semantic_node.depends_on.nodes[0],
154+
entity_name=semantic_node.primary_entity,
155+
entity_type=PK,
156+
column_name=semantic_node.primary_entity,
157+
relationship_type=semantic_node.config.meta.get(
158+
TEST_META_RELATIONSHIP_TYPE, ""
159+
),
160+
)
161+
)
162+
163+
return (
164+
[x for x in semantic_entities if x.entity_type == FK],
165+
[x for x in semantic_entities if x.entity_type == PK],
166+
)
167+
168+
169+
def _get_semantic_nodes(manifest: Manifest) -> List:
170+
"""Extract the Semantic Models
171+
172+
Args:
173+
manifest (Manifest): Manifest data
174+
175+
Returns:
176+
List: List of Semantic Models
177+
"""
178+
if not hasattr(manifest, "semantic_models"):
179+
logger.warning(
180+
"No relationships will be captured"
181+
"since dbt version is NOT supported for the Semantic Models"
182+
)
183+
return []
184+
185+
return [
186+
x
187+
for x in manifest.semantic_models
188+
if len(manifest.semantic_models[x].depends_on.nodes)
189+
]

dbterd/adapters/algos/test_relationship.py

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import List, Tuple, Union
22

33
from dbterd.adapters.algos import base
4-
from dbterd.adapters.filter import is_selected_table
54
from dbterd.adapters.meta import Ref, Table
65
from dbterd.helpers.log import logger
76
from dbterd.types import Catalog, Manifest
@@ -22,18 +21,7 @@ def parse_metadata(data, **kwargs) -> Tuple[List[Table], List[Ref]]:
2221

2322
# Parse Table
2423
tables = base.get_tables_from_metadata(data=data, **kwargs)
25-
26-
# Apply selection
27-
tables = [
28-
table
29-
for table in tables
30-
if is_selected_table(
31-
table=table,
32-
select_rules=kwargs.get("select") or [],
33-
resource_types=kwargs.get("resource_type", []),
34-
exclude_rules=kwargs.get("exclude") or [],
35-
)
36-
]
24+
tables = base.filter_tables_based_on_selection(tables=tables, **kwargs)
3725

3826
# Parse Ref
3927
relationships = base.get_relationships_from_metadata(data=data, **kwargs)
@@ -68,18 +56,7 @@ def parse(
6856

6957
# Parse Table
7058
tables = base.get_tables(manifest=manifest, catalog=catalog, **kwargs)
71-
72-
# Apply selection
73-
tables = [
74-
table
75-
for table in tables
76-
if is_selected_table(
77-
table=table,
78-
select_rules=kwargs.get("select") or [],
79-
resource_types=kwargs.get("resource_type", []),
80-
exclude_rules=kwargs.get("exclude") or [],
81-
)
82-
]
59+
tables = base.filter_tables_based_on_selection(tables=tables, **kwargs)
8360

8461
# Parse Ref
8562
relationships = base.get_relationships(manifest=manifest, **kwargs)
@@ -113,9 +90,6 @@ def find_related_nodes_by_id(
11390
node_unique_id (str): Manifest node unique ID
11491
type (str, optional): Manifest type (local file or metadata). Defaults to None.
11592
116-
Raises:
117-
click.BadParameter: Not Supported manifest type
118-
11993
Returns:
12094
List[str]: Manifest nodes' unique ID
12195
"""

dbterd/adapters/meta.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ class Ref:
3737
type: str = "n1"
3838

3939

40+
@dataclass
41+
class SemanticEntity:
42+
"""Parsed Semantic Model's Entity object"""
43+
44+
semantic_model: str
45+
model: str
46+
entity_name: str
47+
entity_type: str
48+
column_name: str
49+
relationship_type: str
50+
51+
4052
class SelectionType(Enum):
4153
START_WITH_NAME = ""
4254
EXACT_NAME = "exact"

dbterd/adapters/targets/mermaid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def parse(manifest: Manifest, catalog: Catalog, **kwargs) -> str:
107107
key_to = f'"{rel.table_map[0]}"'
108108
reference_text = replace_column_name(rel.column_map[0])
109109
if rel.column_map[0] != rel.column_map[1]:
110-
reference_text += f"--{ replace_column_name(rel.column_map[1])}"
110+
reference_text += f"--{replace_column_name(rel.column_map[1])}"
111111
mermaid += f" {key_from.upper()} {get_rel_symbol(rel.type)} {key_to.upper()}: {reference_text}\n"
112112

113113
return mermaid

dbterd/cli/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import click
55

6+
from dbterd import default
67
from dbterd.adapters.base import Executor
78
from dbterd.cli import params
89
from dbterd.helpers import jsonify
@@ -51,7 +52,7 @@ def invoke(self, args: List[str]):
5152
@click.pass_context
5253
def dbterd(ctx, **kwargs):
5354
"""Tools for producing diagram-as-code"""
54-
logger.info(f"Run with dbterd=={__version__}")
55+
logger.info(f"Run with dbterd=={__version__} [{default.default_algo()}]")
5556

5657

5758
# dbterd run

dbterd/default.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1+
import os
12
from pathlib import Path
23
from typing import List
34

45

56
def default_artifact_path() -> str:
6-
return str(Path.cwd() / "target")
7+
return os.environ.get("DBTERD_ARTIFACT_PATH", str(Path.cwd() / "target"))
78

89

910
def default_output_path() -> str:
10-
return str(Path.cwd() / "target")
11+
return os.environ.get("DBTERD_OUTPUT_PATH", str(Path.cwd() / "target"))
1112

1213

1314
def default_target() -> str:
14-
return "dbml"
15+
return os.environ.get("DBTERD_TARGET", "dbml")
1516

1617

1718
def default_algo() -> str:
18-
return "test_relationship"
19+
return os.environ.get("DBTERD_ALGO", "test_relationship")
1920

2021

2122
def default_resource_types() -> List[str]:
22-
return ["model"]
23+
return os.environ.get("DBTERD_RESOURCE_TYPES", ["model"])
2324

2425

2526
def default_entity_name_format() -> str:
26-
return "resource.package.model"
27+
return os.environ.get("DBTERD_ENTITY_NAME_FORMAT", "resource.package.model")

0 commit comments

Comments
 (0)