clearmetric-core 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clearmetric/cli/__init__.py +157 -0
- clearmetric/cli/__main__.py +8 -0
- clearmetric/core/__init__.py +78 -0
- clearmetric/core/_version.py +3 -0
- clearmetric/core/aliases.py +91 -0
- clearmetric/core/errors.py +19 -0
- clearmetric/core/ids.py +172 -0
- clearmetric/core/interop.py +126 -0
- clearmetric/core/merge.py +147 -0
- clearmetric/core/models.py +68 -0
- clearmetric/core/serialize.py +10 -0
- clearmetric/lineage/__init__.py +36 -0
- clearmetric/lineage/_version.py +5 -0
- clearmetric/lineage/api.py +86 -0
- clearmetric/lineage/build.py +961 -0
- clearmetric/lineage/coverage.py +198 -0
- clearmetric/lineage/errors.py +15 -0
- clearmetric/lineage/graph.py +143 -0
- clearmetric/lineage/loaders.py +249 -0
- clearmetric/lineage/models.py +34 -0
- clearmetric/lineage/render/json.py +10 -0
- clearmetric/lineage/render/mermaid.py +40 -0
- clearmetric/lineage/render/text.py +108 -0
- clearmetric/lineage/sql_analyzer.py +409 -0
- clearmetric/powerbi/__init__.py +28 -0
- clearmetric/powerbi/_version.py +5 -0
- clearmetric/powerbi/api.py +55 -0
- clearmetric/powerbi/build.py +369 -0
- clearmetric/powerbi/discovery.py +109 -0
- clearmetric/powerbi/errors.py +20 -0
- clearmetric/powerbi/m_parser.py +242 -0
- clearmetric/powerbi/models.py +63 -0
- clearmetric/powerbi/native_sql.py +35 -0
- clearmetric/powerbi/render/json.py +15 -0
- clearmetric/powerbi/render/text.py +31 -0
- clearmetric/powerbi/report_parser.py +245 -0
- clearmetric/powerbi/tmdl.py +59 -0
- clearmetric/query/__init__.py +39 -0
- clearmetric/query/_version.py +5 -0
- clearmetric/query/api.py +39 -0
- clearmetric/query/ast_utils.py +81 -0
- clearmetric/query/build.py +263 -0
- clearmetric/query/ctes.py +127 -0
- clearmetric/query/errors.py +15 -0
- clearmetric/query/models.py +93 -0
- clearmetric/query/parser.py +67 -0
- clearmetric/query/relations.py +229 -0
- clearmetric/query/render/__init__.py +1 -0
- clearmetric/query/render/json.py +10 -0
- clearmetric/query/render/text.py +42 -0
- clearmetric_core-0.2.0.dist-info/METADATA +91 -0
- clearmetric_core-0.2.0.dist-info/RECORD +55 -0
- clearmetric_core-0.2.0.dist-info/WHEEL +5 -0
- clearmetric_core-0.2.0.dist-info/entry_points.txt +2 -0
- clearmetric_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""ClearMetric Core CLI — ``cm`` command router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from clearmetric.core import __version__, render_json
|
|
10
|
+
from clearmetric.lineage import (
|
|
11
|
+
build_catalog_artifact,
|
|
12
|
+
build_lineage_map,
|
|
13
|
+
trace_downstream,
|
|
14
|
+
trace_upstream,
|
|
15
|
+
)
|
|
16
|
+
from clearmetric.lineage.errors import LineageError
|
|
17
|
+
from clearmetric.lineage.render.mermaid import render_traversal_mermaid
|
|
18
|
+
from clearmetric.lineage.render.text import render_text, render_traversal_tree
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_root_parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="cm",
|
|
24
|
+
description="ClearMetric Core — local compiler, graph engine, and CLI.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--version",
|
|
28
|
+
action="version",
|
|
29
|
+
version=f"cm {__version__} (ClearMetric Core)",
|
|
30
|
+
)
|
|
31
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
32
|
+
|
|
33
|
+
compile_parser = subparsers.add_parser(
|
|
34
|
+
"compile",
|
|
35
|
+
help="Compile project input into a catalog graph artifact (JSON).",
|
|
36
|
+
)
|
|
37
|
+
compile_parser.add_argument(
|
|
38
|
+
"project_input",
|
|
39
|
+
help="Path to a dbt manifest.json file or a folder of UTF-8 .sql files.",
|
|
40
|
+
)
|
|
41
|
+
compile_parser.add_argument(
|
|
42
|
+
"--dialect",
|
|
43
|
+
required=True,
|
|
44
|
+
help="sqlglot dialect name, for example postgres, snowflake, tsql, or bigquery.",
|
|
45
|
+
)
|
|
46
|
+
compile_parser.add_argument(
|
|
47
|
+
"--format",
|
|
48
|
+
choices=("json", "text"),
|
|
49
|
+
default="json",
|
|
50
|
+
help="Output format (default: json).",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
impact_parser = subparsers.add_parser(
|
|
54
|
+
"impact",
|
|
55
|
+
help="Trace upstream or downstream column lineage for one selection.",
|
|
56
|
+
)
|
|
57
|
+
impact_parser.add_argument(
|
|
58
|
+
"selection",
|
|
59
|
+
help="Dataset column selection, for example orders.amount.",
|
|
60
|
+
)
|
|
61
|
+
impact_parser.add_argument(
|
|
62
|
+
"project_input",
|
|
63
|
+
help="Path to a dbt manifest.json file or a folder of UTF-8 .sql files.",
|
|
64
|
+
)
|
|
65
|
+
impact_parser.add_argument(
|
|
66
|
+
"--dialect",
|
|
67
|
+
required=True,
|
|
68
|
+
help="sqlglot dialect name, for example postgres, snowflake, tsql, or bigquery.",
|
|
69
|
+
)
|
|
70
|
+
traversal = impact_parser.add_mutually_exclusive_group(required=True)
|
|
71
|
+
traversal.add_argument(
|
|
72
|
+
"--upstream",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Trace upstream lineage for the selection.",
|
|
75
|
+
)
|
|
76
|
+
traversal.add_argument(
|
|
77
|
+
"--downstream",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Trace downstream impact for the selection.",
|
|
80
|
+
)
|
|
81
|
+
impact_parser.add_argument(
|
|
82
|
+
"--format",
|
|
83
|
+
choices=("text", "json", "mermaid"),
|
|
84
|
+
default="text",
|
|
85
|
+
help="Output format (default: text).",
|
|
86
|
+
)
|
|
87
|
+
return parser
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run_compile(args: argparse.Namespace) -> int:
|
|
91
|
+
if args.format == "json":
|
|
92
|
+
artifact = build_catalog_artifact(args.project_input, dialect=args.dialect)
|
|
93
|
+
print(json.dumps(render_json(artifact), indent=2, sort_keys=False))
|
|
94
|
+
else:
|
|
95
|
+
lineage_map = build_lineage_map(args.project_input, dialect=args.dialect)
|
|
96
|
+
print(render_text(lineage_map))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_impact(args: argparse.Namespace) -> int:
|
|
101
|
+
direction = "upstream" if args.upstream else "downstream"
|
|
102
|
+
if direction == "upstream":
|
|
103
|
+
result = trace_upstream(
|
|
104
|
+
args.project_input,
|
|
105
|
+
dialect=args.dialect,
|
|
106
|
+
selection=args.selection,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
result = trace_downstream(
|
|
110
|
+
args.project_input,
|
|
111
|
+
dialect=args.dialect,
|
|
112
|
+
selection=args.selection,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if args.format == "json":
|
|
116
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=False))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
artifact = build_catalog_artifact(args.project_input, dialect=args.dialect)
|
|
120
|
+
if args.format == "mermaid":
|
|
121
|
+
print(
|
|
122
|
+
render_traversal_mermaid(
|
|
123
|
+
result.selection_id,
|
|
124
|
+
artifact,
|
|
125
|
+
direction=direction,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
print(
|
|
131
|
+
render_traversal_tree(
|
|
132
|
+
result,
|
|
133
|
+
artifact,
|
|
134
|
+
direction=direction,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
parser = _build_root_parser()
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
if args.command == "compile":
|
|
146
|
+
return _run_compile(args)
|
|
147
|
+
if args.command == "impact":
|
|
148
|
+
return _run_impact(args)
|
|
149
|
+
except LineageError as exc:
|
|
150
|
+
print(f"cm error: {exc}", file=sys.stderr)
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
print(f"cm: unknown command {args.command!r}", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Public package surface for clearmetric-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._version import __version__
|
|
6
|
+
from .aliases import load_table_alias_map
|
|
7
|
+
from .errors import (
|
|
8
|
+
AliasMapError,
|
|
9
|
+
CanonicalIdError,
|
|
10
|
+
ClearMetricError,
|
|
11
|
+
MergeConflictError,
|
|
12
|
+
)
|
|
13
|
+
from .ids import (
|
|
14
|
+
asset_id,
|
|
15
|
+
column_id,
|
|
16
|
+
cte_id,
|
|
17
|
+
leaf_name,
|
|
18
|
+
measure_id,
|
|
19
|
+
model_id,
|
|
20
|
+
normalize_identifier,
|
|
21
|
+
normalize_identifier_part,
|
|
22
|
+
normalize_identifier_parts,
|
|
23
|
+
page_id,
|
|
24
|
+
report_id,
|
|
25
|
+
schema_name,
|
|
26
|
+
split_qualified_identifier,
|
|
27
|
+
table_id,
|
|
28
|
+
visual_id,
|
|
29
|
+
)
|
|
30
|
+
from .interop import (
|
|
31
|
+
AliasMap,
|
|
32
|
+
apply_alias_map,
|
|
33
|
+
normalize_fqn_for_matching,
|
|
34
|
+
resolve_table_match,
|
|
35
|
+
warehouse_table_fqn_candidates,
|
|
36
|
+
warehouse_table_fqn_candidates_from_name,
|
|
37
|
+
)
|
|
38
|
+
from .merge import merge
|
|
39
|
+
from .models import CatalogArtifact, Edge, Evidence, MatchStatus, Node, Warning
|
|
40
|
+
from .serialize import render_json
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__",
|
|
44
|
+
"AliasMap",
|
|
45
|
+
"AliasMapError",
|
|
46
|
+
"MatchStatus",
|
|
47
|
+
"apply_alias_map",
|
|
48
|
+
"asset_id",
|
|
49
|
+
"CatalogArtifact",
|
|
50
|
+
"ClearMetricError",
|
|
51
|
+
"CanonicalIdError",
|
|
52
|
+
"column_id",
|
|
53
|
+
"cte_id",
|
|
54
|
+
"Edge",
|
|
55
|
+
"Evidence",
|
|
56
|
+
"leaf_name",
|
|
57
|
+
"load_table_alias_map",
|
|
58
|
+
"measure_id",
|
|
59
|
+
"merge",
|
|
60
|
+
"normalize_fqn_for_matching",
|
|
61
|
+
"MergeConflictError",
|
|
62
|
+
"model_id",
|
|
63
|
+
"Node",
|
|
64
|
+
"page_id",
|
|
65
|
+
"normalize_identifier",
|
|
66
|
+
"normalize_identifier_part",
|
|
67
|
+
"normalize_identifier_parts",
|
|
68
|
+
"render_json",
|
|
69
|
+
"report_id",
|
|
70
|
+
"resolve_table_match",
|
|
71
|
+
"schema_name",
|
|
72
|
+
"split_qualified_identifier",
|
|
73
|
+
"table_id",
|
|
74
|
+
"visual_id",
|
|
75
|
+
"warehouse_table_fqn_candidates",
|
|
76
|
+
"warehouse_table_fqn_candidates_from_name",
|
|
77
|
+
"Warning",
|
|
78
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Load versioned table alias files for cross-graph matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import AliasMapError
|
|
8
|
+
from .interop import AliasMap, normalize_fqn_for_matching
|
|
9
|
+
|
|
10
|
+
_SUPPORTED_VERSION = "1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_table_alias_map(path: str | Path) -> AliasMap:
|
|
14
|
+
"""
|
|
15
|
+
Load a version-1 alias file into an ``AliasMap``.
|
|
16
|
+
|
|
17
|
+
Expected format::
|
|
18
|
+
|
|
19
|
+
version: 1
|
|
20
|
+
table_aliases:
|
|
21
|
+
salesmart.dbo.orders: orders
|
|
22
|
+
"""
|
|
23
|
+
file_path = Path(path).expanduser().resolve()
|
|
24
|
+
if not file_path.is_file():
|
|
25
|
+
raise AliasMapError(f"Alias file does not exist: {file_path}")
|
|
26
|
+
|
|
27
|
+
version: str | None = None
|
|
28
|
+
aliases: AliasMap = {}
|
|
29
|
+
in_table_aliases = False
|
|
30
|
+
|
|
31
|
+
for line_number, raw_line in enumerate(
|
|
32
|
+
file_path.read_text(encoding="utf-8").splitlines(), start=1
|
|
33
|
+
):
|
|
34
|
+
line = raw_line.strip()
|
|
35
|
+
if not line or line.startswith("#"):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
if line.startswith("version:"):
|
|
39
|
+
version = line.split(":", 1)[1].strip()
|
|
40
|
+
if version != _SUPPORTED_VERSION:
|
|
41
|
+
raise AliasMapError(
|
|
42
|
+
f"Unsupported alias file version {version!r} at {file_path}:{line_number}; "
|
|
43
|
+
f"expected {_SUPPORTED_VERSION!r}."
|
|
44
|
+
)
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
if line == "table_aliases:":
|
|
48
|
+
in_table_aliases = True
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if not in_table_aliases:
|
|
52
|
+
raise AliasMapError(
|
|
53
|
+
f"Unexpected content at {file_path}:{line_number}; "
|
|
54
|
+
"expected 'version:' and 'table_aliases:' sections."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if ":" not in line:
|
|
58
|
+
raise AliasMapError(
|
|
59
|
+
f"Invalid alias entry at {file_path}:{line_number}; "
|
|
60
|
+
"expected 'source: target' form."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
source, target = line.split(":", 1)
|
|
64
|
+
source = source.strip()
|
|
65
|
+
target = target.strip()
|
|
66
|
+
if not source or not target:
|
|
67
|
+
raise AliasMapError(
|
|
68
|
+
f"Invalid alias entry at {file_path}:{line_number}; "
|
|
69
|
+
"source and target must be non-empty."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
normalized_source = normalize_fqn_for_matching(source)
|
|
73
|
+
normalized_target = normalize_fqn_for_matching(target)
|
|
74
|
+
if (
|
|
75
|
+
normalized_source in aliases
|
|
76
|
+
and aliases[normalized_source] != normalized_target
|
|
77
|
+
):
|
|
78
|
+
raise AliasMapError(
|
|
79
|
+
f"Duplicate alias key {normalized_source!r} with conflicting targets "
|
|
80
|
+
f"at {file_path}:{line_number}."
|
|
81
|
+
)
|
|
82
|
+
aliases[normalized_source] = normalized_target
|
|
83
|
+
|
|
84
|
+
if version is None:
|
|
85
|
+
raise AliasMapError(f"Missing 'version:' in alias file: {file_path}")
|
|
86
|
+
if not in_table_aliases:
|
|
87
|
+
raise AliasMapError(
|
|
88
|
+
f"Missing 'table_aliases:' section in alias file: {file_path}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return aliases
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared errors for clearmetric-core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClearMetricError(Exception):
|
|
7
|
+
"""Base class for clearmetric-core failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CanonicalIdError(ClearMetricError):
|
|
11
|
+
"""Raised when an identifier cannot be normalized into a canonical ID."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MergeConflictError(ClearMetricError):
|
|
15
|
+
"""Raised when artifacts cannot be merged without losing information."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AliasMapError(ClearMetricError):
|
|
19
|
+
"""Raised when a table alias file is invalid or unsupported."""
|
clearmetric/core/ids.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Canonical identifier normalization and ID builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from .errors import CanonicalIdError
|
|
8
|
+
|
|
9
|
+
_QUOTE_PAIRS = {
|
|
10
|
+
'"': '"',
|
|
11
|
+
"`": "`",
|
|
12
|
+
"[": "]",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_matching_quotes(value: str) -> str:
|
|
17
|
+
if len(value) < 2:
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
first = value[0]
|
|
21
|
+
last = value[-1]
|
|
22
|
+
expected_last = _QUOTE_PAIRS.get(first)
|
|
23
|
+
if expected_last == last:
|
|
24
|
+
return value[1:-1]
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_identifier(value: str) -> str:
|
|
29
|
+
"""Normalize a possibly qualified identifier into canonical dotted form."""
|
|
30
|
+
parts = split_qualified_identifier(value)
|
|
31
|
+
return normalize_identifier_parts(parts)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_identifier_parts(parts: Iterable[str]) -> str:
|
|
35
|
+
"""Normalize already separated identifier parts into canonical dotted form."""
|
|
36
|
+
normalized_parts = [
|
|
37
|
+
normalize_identifier_part(part) for part in parts if str(part).strip()
|
|
38
|
+
]
|
|
39
|
+
if not normalized_parts:
|
|
40
|
+
raise CanonicalIdError("Identifier must contain at least one non-empty part.")
|
|
41
|
+
return ".".join(normalized_parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def normalize_identifier_part(part: str) -> str:
|
|
45
|
+
"""Normalize one identifier segment."""
|
|
46
|
+
value = str(part).strip()
|
|
47
|
+
if not value:
|
|
48
|
+
raise CanonicalIdError("Identifier part cannot be empty.")
|
|
49
|
+
if value == "*":
|
|
50
|
+
raise CanonicalIdError("Wildcard identifiers cannot be canonicalized.")
|
|
51
|
+
unquoted = _strip_matching_quotes(value).strip()
|
|
52
|
+
if not unquoted:
|
|
53
|
+
raise CanonicalIdError("Identifier part cannot be empty after unquoting.")
|
|
54
|
+
return unquoted.lower()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def split_qualified_identifier(value: str) -> list[str]:
|
|
58
|
+
"""Split a qualified identifier on dots while respecting quoted segments."""
|
|
59
|
+
text = str(value).strip()
|
|
60
|
+
if not text:
|
|
61
|
+
raise CanonicalIdError("Identifier cannot be empty.")
|
|
62
|
+
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
current: list[str] = []
|
|
65
|
+
quote_stack: list[str] = []
|
|
66
|
+
|
|
67
|
+
for char in text:
|
|
68
|
+
if quote_stack:
|
|
69
|
+
current.append(char)
|
|
70
|
+
if char == quote_stack[-1]:
|
|
71
|
+
quote_stack.pop()
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if char in _QUOTE_PAIRS:
|
|
75
|
+
quote_stack.append(_QUOTE_PAIRS[char])
|
|
76
|
+
current.append(char)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if char == ".":
|
|
80
|
+
part = "".join(current).strip()
|
|
81
|
+
if not part:
|
|
82
|
+
raise CanonicalIdError(f"Invalid qualified identifier {value!r}.")
|
|
83
|
+
parts.append(part)
|
|
84
|
+
current = []
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
current.append(char)
|
|
88
|
+
|
|
89
|
+
if quote_stack:
|
|
90
|
+
raise CanonicalIdError(f"Unclosed quote in identifier {value!r}.")
|
|
91
|
+
|
|
92
|
+
final_part = "".join(current).strip()
|
|
93
|
+
if not final_part:
|
|
94
|
+
raise CanonicalIdError(f"Invalid qualified identifier {value!r}.")
|
|
95
|
+
parts.append(final_part)
|
|
96
|
+
return parts
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def table_id(qualified_name: str) -> str:
|
|
100
|
+
return f"table:{normalize_identifier(qualified_name)}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cte_id(name: str) -> str:
|
|
104
|
+
return f"cte:{normalize_identifier_part(name)}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def column_id(parent_qualified_name: str, column_name: str) -> str:
|
|
108
|
+
parent = normalize_identifier(parent_qualified_name)
|
|
109
|
+
column = normalize_identifier_part(column_name)
|
|
110
|
+
return f"column:{parent}.{column}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def model_id(qualified_name: str) -> str:
|
|
114
|
+
return f"model:{normalize_identifier(qualified_name)}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def report_id(qualified_name: str) -> str:
|
|
118
|
+
return f"report:{normalize_identifier(qualified_name)}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def asset_id(qualified_name: str) -> str:
|
|
122
|
+
return f"asset:{normalize_identifier(qualified_name)}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def visual_id(report_qualified_name: str, page_id: str, visual_id_value: str) -> str:
|
|
126
|
+
parent = normalize_identifier_parts(
|
|
127
|
+
[report_qualified_name, page_id, visual_id_value]
|
|
128
|
+
)
|
|
129
|
+
return f"visual:{parent}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def page_id(report_qualified_name: str, page_id_value: str) -> str:
|
|
133
|
+
parent = normalize_identifier_parts([report_qualified_name, page_id_value])
|
|
134
|
+
return f"page:{parent}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def measure_id(table_qualified_name: str, measure_name: str) -> str:
|
|
138
|
+
parent = normalize_identifier(table_qualified_name)
|
|
139
|
+
measure = normalize_identifier_part(measure_name)
|
|
140
|
+
return f"measure:{parent}.{measure}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def schema_name(qualified_name: str) -> str | None:
|
|
144
|
+
normalized = normalize_identifier(qualified_name)
|
|
145
|
+
parts = normalized.split(".")
|
|
146
|
+
if len(parts) <= 1:
|
|
147
|
+
return None
|
|
148
|
+
return ".".join(parts[:-1])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def leaf_name(qualified_name: str) -> str:
|
|
152
|
+
normalized = normalize_identifier(qualified_name)
|
|
153
|
+
return normalized.split(".")[-1]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = [
|
|
157
|
+
"asset_id",
|
|
158
|
+
"column_id",
|
|
159
|
+
"cte_id",
|
|
160
|
+
"leaf_name",
|
|
161
|
+
"measure_id",
|
|
162
|
+
"model_id",
|
|
163
|
+
"normalize_identifier",
|
|
164
|
+
"normalize_identifier_part",
|
|
165
|
+
"normalize_identifier_parts",
|
|
166
|
+
"page_id",
|
|
167
|
+
"report_id",
|
|
168
|
+
"schema_name",
|
|
169
|
+
"split_qualified_identifier",
|
|
170
|
+
"table_id",
|
|
171
|
+
"visual_id",
|
|
172
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Cross-graph interop: FQN matching, alias maps, and match status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import CanonicalIdError
|
|
6
|
+
from .ids import normalize_identifier
|
|
7
|
+
from .models import MatchStatus
|
|
8
|
+
|
|
9
|
+
AliasMap = dict[str, str]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_fqn_for_matching(value: str) -> str:
|
|
13
|
+
"""Normalize a fully-qualified name for case-insensitive cross-graph comparison."""
|
|
14
|
+
return normalize_identifier(value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def warehouse_table_fqn_candidates(
|
|
18
|
+
*,
|
|
19
|
+
database: str | None = None,
|
|
20
|
+
schema: str | None = None,
|
|
21
|
+
table: str,
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
"""Build ordered FQN candidates for matching a warehouse table reference."""
|
|
24
|
+
if not str(table).strip():
|
|
25
|
+
raise CanonicalIdError(
|
|
26
|
+
"Table name is required to build warehouse FQN candidates."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
parts: list[str] = []
|
|
30
|
+
if database and str(database).strip():
|
|
31
|
+
parts.append(str(database).strip())
|
|
32
|
+
if schema and str(schema).strip():
|
|
33
|
+
parts.append(str(schema).strip())
|
|
34
|
+
parts.append(str(table).strip())
|
|
35
|
+
|
|
36
|
+
candidates: list[str] = []
|
|
37
|
+
if len(parts) == 3:
|
|
38
|
+
candidates.append(normalize_fqn_for_matching(".".join(parts)))
|
|
39
|
+
if len(parts) >= 2:
|
|
40
|
+
candidates.append(normalize_fqn_for_matching(".".join(parts[-2:])))
|
|
41
|
+
candidates.append(normalize_fqn_for_matching(parts[-1]))
|
|
42
|
+
|
|
43
|
+
seen: set[str] = set()
|
|
44
|
+
ordered: list[str] = []
|
|
45
|
+
for candidate in candidates:
|
|
46
|
+
if candidate in seen:
|
|
47
|
+
continue
|
|
48
|
+
seen.add(candidate)
|
|
49
|
+
ordered.append(candidate)
|
|
50
|
+
return ordered
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def warehouse_table_fqn_candidates_from_name(normalized_fqn: str) -> list[str]:
|
|
54
|
+
"""Rebuild ordered warehouse FQN candidates from one normalized dotted name."""
|
|
55
|
+
parts = normalized_fqn.split(".")
|
|
56
|
+
if len(parts) == 3:
|
|
57
|
+
return warehouse_table_fqn_candidates(
|
|
58
|
+
database=parts[0],
|
|
59
|
+
schema=parts[1],
|
|
60
|
+
table=parts[2],
|
|
61
|
+
)
|
|
62
|
+
if len(parts) == 2:
|
|
63
|
+
return warehouse_table_fqn_candidates(schema=parts[0], table=parts[1])
|
|
64
|
+
if len(parts) == 1:
|
|
65
|
+
return warehouse_table_fqn_candidates(table=parts[0])
|
|
66
|
+
raise CanonicalIdError(
|
|
67
|
+
f"Cannot derive warehouse FQN candidates from name: {normalized_fqn!r}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply_alias_map(name: str, alias_map: AliasMap | None) -> str:
|
|
72
|
+
"""Resolve a name through the alias map, returning normalized form."""
|
|
73
|
+
normalized = normalize_fqn_for_matching(name)
|
|
74
|
+
if not alias_map:
|
|
75
|
+
return normalized
|
|
76
|
+
mapped = alias_map.get(normalized)
|
|
77
|
+
if mapped is None:
|
|
78
|
+
return normalized
|
|
79
|
+
return normalize_fqn_for_matching(mapped)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_table_match(
|
|
83
|
+
source_candidates: list[str],
|
|
84
|
+
target_table_ids: set[str],
|
|
85
|
+
*,
|
|
86
|
+
alias_map: AliasMap | None = None,
|
|
87
|
+
) -> tuple[str | None, MatchStatus]:
|
|
88
|
+
"""
|
|
89
|
+
Match source FQN candidates against canonical ``table:`` node IDs.
|
|
90
|
+
|
|
91
|
+
Returns the matched ``table:...`` ID and match status.
|
|
92
|
+
"""
|
|
93
|
+
if not source_candidates:
|
|
94
|
+
return None, "unresolved"
|
|
95
|
+
|
|
96
|
+
target_by_normalized = {
|
|
97
|
+
normalize_fqn_for_matching(tid.removeprefix("table:")): tid
|
|
98
|
+
for tid in target_table_ids
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
matches: list[str] = []
|
|
102
|
+
for raw_candidate in source_candidates:
|
|
103
|
+
candidate = apply_alias_map(raw_candidate, alias_map)
|
|
104
|
+
for normalized_target, table_id in target_by_normalized.items():
|
|
105
|
+
if candidate == normalized_target or normalized_target.endswith(
|
|
106
|
+
f".{candidate}"
|
|
107
|
+
):
|
|
108
|
+
matches.append(table_id)
|
|
109
|
+
|
|
110
|
+
unique_matches = sorted(set(matches))
|
|
111
|
+
if len(unique_matches) == 1:
|
|
112
|
+
return unique_matches[0], "resolved"
|
|
113
|
+
if len(unique_matches) > 1:
|
|
114
|
+
return unique_matches[0], "ambiguous"
|
|
115
|
+
return None, "unresolved"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"AliasMap",
|
|
120
|
+
"MatchStatus",
|
|
121
|
+
"apply_alias_map",
|
|
122
|
+
"normalize_fqn_for_matching",
|
|
123
|
+
"resolve_table_match",
|
|
124
|
+
"warehouse_table_fqn_candidates",
|
|
125
|
+
"warehouse_table_fqn_candidates_from_name",
|
|
126
|
+
]
|