ripple-sql 0.1.1__tar.gz → 0.1.3__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.
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/CHANGELOG.md +16 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/PKG-INFO +1 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/pyproject.toml +1 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/cte_tracing.py +2 -0
- ripple_sql-0.1.3/src/ripple/engine/generators.py +61 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/statement.py +2 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/dbt.py +5 -2
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/dbt_config.py +3 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/sqldir.py +2 -2
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/types.py +7 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/lookml.py +2 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/render.py +45 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/semantic.py +2 -1
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/static/answer.css +23 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/static/answer.html +13 -3
- ripple_sql-0.1.3/src/ripple/static/explore.js +190 -0
- ripple_sql-0.1.1/src/ripple/static/explore.js +0 -136
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/.gitignore +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/LICENSE +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/README.md +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/__init__.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/answer.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/answer_page.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/cache.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/ci.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/ci_signature.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/cli.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/doctor.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/__init__.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/budget.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/column_lineage.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/column_ref.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/dependencies.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/dialect.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/dispatch.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/extraction.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/jinja.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/json_sources.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/macro_source.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/pipeline.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/preprocess.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/safe_gen.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/schema_qualification.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/scope.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/select_sources.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/sql_script.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/tech_debt.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/tsql_catalog.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/tsql_scalar_vars.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/tsql_tvf.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/tsql_xml.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/types.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/unused_deps.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/engine/validation.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/graph.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/home.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/__init__.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/identity.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/loaders/sidecar.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/mcp_server.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/names.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/project.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/py.typed +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/render_shims.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/schemas.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/server.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/sourcefiles.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/star_resolution.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/static/answer_twin.js +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/__init__.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/cli.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/collect.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/discover.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/ingest.py +0 -0
- {ripple_sql-0.1.1 → ripple_sql-0.1.3}/src/ripple/usage/report.py +0 -0
|
@@ -6,6 +6,19 @@ Notable changes to Ripple. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-09-09
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- The map of a large project is compact. Past forty models the columns-and-wires layout gave a page-tall column per layer, every link drawn at rest, and long names clipped at the edge (Wikimedia's dbt-jobs: 48 sources, 71 models in one layer). Now the layers stack as wrapped grids, nothing is wired at rest, each layer leads with the models most read and folds the rest behind "more", hovering a model lights its chain (what feeds it in teal, what reads it in ink) and clicking keeps it lit while its columns unfold, and typing hides every model that does not match, with the count of what it hid. Every chip carries the number of models that read it, and the key says so. A small project keeps the columns and wires.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Two Spark shapes from Wikimedia's dbt-jobs, the first outside repo the published build was tried on. A bare table-generating function in a select list names its own output columns: `INLINE(ARRAY(STRUCT('a' AS platform)))` yields `platform`, `EXPLODE(x)` yields `col`, `POSEXPLODE(x)` yields `pos` and `col`; a lookup table built that way reported no columns at all. dbt's `as_bool`, `as_number`, `as_native` and `as_text` filters are registered on the Jinja environment, so a config that uses one renders instead of failing the whole model.
|
|
16
|
+
|
|
17
|
+
## [0.1.2] - 2026-09-09
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- A symlink whose target is gone matched the SQL, YAML, LookML, macro, and seed globs and crashed the load with a traceback on the first real folder the published build ran in. One helper, `files_under`, now serves every project glob and keeps regular files only.
|
|
21
|
+
|
|
9
22
|
## [0.1.1] - 2026-09-09
|
|
10
23
|
|
|
11
24
|
### Changed
|
|
@@ -230,6 +243,8 @@ First public release.
|
|
|
230
243
|
### Removed
|
|
231
244
|
- The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
|
|
232
245
|
|
|
233
|
-
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.
|
|
246
|
+
[Unreleased]: https://github.com/bteh/ripple/compare/v0.1.3...HEAD
|
|
247
|
+
[0.1.3]: https://github.com/bteh/ripple/releases/tag/v0.1.3
|
|
248
|
+
[0.1.2]: https://github.com/bteh/ripple/releases/tag/v0.1.2
|
|
234
249
|
[0.1.1]: https://github.com/bteh/ripple/releases/tag/v0.1.1
|
|
235
250
|
[0.1.0]: https://github.com/bteh/ripple/releases/tag/v0.1.0
|
|
@@ -39,6 +39,7 @@ from ripple.engine.column_ref import (
|
|
|
39
39
|
under_subquery_where,
|
|
40
40
|
unwrap_select,
|
|
41
41
|
)
|
|
42
|
+
from ripple.engine.generators import name_generator_outputs
|
|
42
43
|
from ripple.engine.preprocess import restore_jinja_dots
|
|
43
44
|
from ripple.engine.safe_gen import safe_sql
|
|
44
45
|
from ripple.engine.scope import register_table
|
|
@@ -120,6 +121,7 @@ def extract_cte_column_lineage(
|
|
|
120
121
|
|
|
121
122
|
cleaned_sql, _ = prepare_sql_for_parse(raw_sql, dialect, clean_jinja_func)
|
|
122
123
|
parsed = sqlglot.parse_one(cleaned_sql, dialect=dialect)
|
|
124
|
+
parsed = name_generator_outputs(parsed)
|
|
123
125
|
if (dialect or "").lower() == "tsql":
|
|
124
126
|
from ripple.engine.tsql_xml import rewrite_xml_method_calls
|
|
125
127
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Bare table-generating functions name their own output columns.
|
|
2
|
+
|
|
3
|
+
Spark's `SELECT INLINE(ARRAY(STRUCT('a' AS platform)))` has no alias and
|
|
4
|
+
no column reference; its output columns are the struct's fields. A bare
|
|
5
|
+
`EXPLODE(x)` is a column called `col`, `POSEXPLODE(x)` is `pos` and `col`.
|
|
6
|
+
sqlglot leaves such items nameless, and the projection walks named them by
|
|
7
|
+
their SQL text or dropped them, so a lookup table built this way reported
|
|
8
|
+
no columns at all (Wikimedia dbt-jobs, platforms_ephemeral).
|
|
9
|
+
|
|
10
|
+
The rewrite gives each such item its default names as aliases before any
|
|
11
|
+
projection walk runs; a field over a column keeps the column as its source.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from sqlglot import exp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def output_names(item: exp.Expression) -> list[str] | None:
|
|
20
|
+
"""The default output columns of a bare generator, None for anything else."""
|
|
21
|
+
if isinstance(item, exp.Inline):
|
|
22
|
+
array = item.this
|
|
23
|
+
if not (isinstance(array, exp.Array) and array.expressions):
|
|
24
|
+
return None
|
|
25
|
+
first = array.expressions[0]
|
|
26
|
+
if not isinstance(first, exp.Struct):
|
|
27
|
+
return None
|
|
28
|
+
names: list[str] = []
|
|
29
|
+
for field in first.expressions:
|
|
30
|
+
if isinstance(field, exp.PropertyEQ):
|
|
31
|
+
names.append(field.this.name)
|
|
32
|
+
elif isinstance(field, exp.Alias):
|
|
33
|
+
names.append(field.alias)
|
|
34
|
+
elif isinstance(field, exp.Column):
|
|
35
|
+
names.append(field.name)
|
|
36
|
+
else:
|
|
37
|
+
return None
|
|
38
|
+
return names or None
|
|
39
|
+
if isinstance(item, exp.Posexplode):
|
|
40
|
+
return ["pos", "col"]
|
|
41
|
+
if isinstance(item, exp.Explode):
|
|
42
|
+
return ["col"]
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def name_generator_outputs(tree: exp.Expression) -> exp.Expression:
|
|
47
|
+
"""Alias every bare generator in every select list with its default
|
|
48
|
+
output names, in place, and return the tree."""
|
|
49
|
+
for select in list(tree.find_all(exp.Select)):
|
|
50
|
+
items = select.expressions
|
|
51
|
+
if not any(output_names(item) for item in items):
|
|
52
|
+
continue
|
|
53
|
+
renamed: list[exp.Expression] = []
|
|
54
|
+
for item in items:
|
|
55
|
+
names = output_names(item)
|
|
56
|
+
if not names:
|
|
57
|
+
renamed.append(item)
|
|
58
|
+
continue
|
|
59
|
+
renamed.extend(exp.alias_(item.copy(), name) for name in names)
|
|
60
|
+
select.set("expressions", renamed)
|
|
61
|
+
return tree
|
|
@@ -26,6 +26,7 @@ from ripple.engine.cte_tracing import (
|
|
|
26
26
|
star_except_spellings,
|
|
27
27
|
trace_through_ctes,
|
|
28
28
|
)
|
|
29
|
+
from ripple.engine.generators import name_generator_outputs
|
|
29
30
|
from ripple.engine.preprocess import (
|
|
30
31
|
has_unresolved_identifier,
|
|
31
32
|
prepare_sql_for_parse,
|
|
@@ -233,6 +234,7 @@ def extract_column_lineage_fast(
|
|
|
233
234
|
logger.debug("Parsing succeeded with minimal cleaning after full cleaning failed")
|
|
234
235
|
except Exception:
|
|
235
236
|
raise full_clean_error from None
|
|
237
|
+
parsed = name_generator_outputs(parsed)
|
|
236
238
|
|
|
237
239
|
# Validate the parsed result is a valid SELECT-type statement
|
|
238
240
|
if not isinstance(parsed, (exp.Select, exp.Union, exp.Except, exp.Intersect)):
|
|
@@ -24,6 +24,7 @@ from ripple.loaders.types import (
|
|
|
24
24
|
DEFAULT_DIALECT,
|
|
25
25
|
Model,
|
|
26
26
|
Project,
|
|
27
|
+
files_under,
|
|
27
28
|
relative_posix,
|
|
28
29
|
)
|
|
29
30
|
|
|
@@ -124,7 +125,7 @@ def _seed_tables(root: Path) -> list[Model]:
|
|
|
124
125
|
|
|
125
126
|
seeds: list[Model] = []
|
|
126
127
|
for seed_dir in _seed_dirs(root):
|
|
127
|
-
for path in
|
|
128
|
+
for path in files_under(seed_dir, "*.csv"):
|
|
128
129
|
try:
|
|
129
130
|
with open(path, newline="", encoding="utf-8", errors="replace") as f:
|
|
130
131
|
header = next(csv.reader(f), [])
|
|
@@ -257,7 +258,9 @@ def _load_dbt_raw(root: Path, dialect: str | None) -> Project:
|
|
|
257
258
|
"run 'dbt compile' for exact lineage."
|
|
258
259
|
]
|
|
259
260
|
model_dirs = _dbt_model_dirs(root)
|
|
260
|
-
pairs = sorted(
|
|
261
|
+
pairs = sorted(
|
|
262
|
+
((d, p) for d in model_dirs for p in files_under(d, "*.sql")), key=lambda t: t[1]
|
|
263
|
+
)
|
|
261
264
|
# a slice of every model, the same sample the sql-dir loader sniffs
|
|
262
265
|
sample = "\n".join(p.read_text(errors="replace", encoding="utf-8")[:4000] for _, p in pairs)[
|
|
263
266
|
:200000
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from ripple.loaders.types import files_under
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
def _project_vars(root: Path) -> dict:
|
|
10
12
|
"""Declared vars from dbt_project.yml. A sub-dict scoped under the
|
|
@@ -236,7 +238,7 @@ def _macro_sources(root: Path) -> list[tuple[str | None, str]]:
|
|
|
236
238
|
macros_dir = base / dir_name
|
|
237
239
|
if not macros_dir.is_dir():
|
|
238
240
|
continue
|
|
239
|
-
for macro_file in
|
|
241
|
+
for macro_file in files_under(macros_dir, "*.sql"):
|
|
240
242
|
resolved = macro_file.resolve()
|
|
241
243
|
if resolved in seen_files:
|
|
242
244
|
continue
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from ripple.engine.budget import TIMED_OUT, budget_seconds, max_sql_bytes, run_within_budget
|
|
8
8
|
from ripple.loaders.identity import directory_identity
|
|
9
|
-
from ripple.loaders.types import IGNORE_DIRS, Model, Project, relative_posix
|
|
9
|
+
from ripple.loaders.types import IGNORE_DIRS, Model, Project, files_under, relative_posix
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _iter_sql_files(root: Path, exclude: list[Path] | None = None) -> tuple[list[Path], int]:
|
|
@@ -19,7 +19,7 @@ def _iter_sql_files(root: Path, exclude: list[Path] | None = None) -> tuple[list
|
|
|
19
19
|
kept: list[Path] = []
|
|
20
20
|
skipped = 0
|
|
21
21
|
excluded = [d.resolve() for d in (exclude or [])]
|
|
22
|
-
for path in
|
|
22
|
+
for path in files_under(root, "*.sql"):
|
|
23
23
|
dirs = path.relative_to(root).parts[:-1]
|
|
24
24
|
if any(part in IGNORE_DIRS or part.startswith(".") for part in dirs):
|
|
25
25
|
skipped += 1
|
|
@@ -218,3 +218,10 @@ def whole_machine(path: Path) -> bool:
|
|
|
218
218
|
would read every file the user owns."""
|
|
219
219
|
here = Path(path).resolve()
|
|
220
220
|
return here == Path.home().resolve() or here == Path(here.anchor)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def files_under(root: Path, pattern: str) -> list[Path]:
|
|
224
|
+
"""The regular files matching a glob under root, sorted. A symlink whose
|
|
225
|
+
target is gone matches the glob and is not a file; reading it raised a
|
|
226
|
+
traceback on the first real folder this ran in."""
|
|
227
|
+
return sorted(p for p in root.rglob(pattern) if p.is_file())
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
import yaml
|
|
18
18
|
|
|
19
|
+
from ripple.loaders.types import files_under
|
|
19
20
|
from ripple.project import IGNORE_DIRS
|
|
20
21
|
from ripple.semantic import DownstreamEdge
|
|
21
22
|
|
|
@@ -133,7 +134,7 @@ def load_lookml_downstream(root: Path) -> list[DownstreamEdge]:
|
|
|
133
134
|
return []
|
|
134
135
|
|
|
135
136
|
edges: list[DownstreamEdge] = []
|
|
136
|
-
for path in
|
|
137
|
+
for path in files_under(root, "*.lkml"):
|
|
137
138
|
parts = path.relative_to(root).parts[:-1]
|
|
138
139
|
if any(p in IGNORE_DIRS or p.startswith(".") for p in parts):
|
|
139
140
|
continue
|
|
@@ -275,6 +275,50 @@ class _Modules:
|
|
|
275
275
|
itertools = _itertools
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
def _as_bool(value):
|
|
279
|
+
"""dbt's as_bool filter: the strings a YAML reader calls true or false."""
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return value
|
|
282
|
+
text = str(value).strip().lower()
|
|
283
|
+
if text in ("true", "yes", "on", "1"):
|
|
284
|
+
return True
|
|
285
|
+
if text in ("false", "no", "off", "0", "", "none", "null"):
|
|
286
|
+
return False
|
|
287
|
+
return bool(value)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _as_number(value):
|
|
291
|
+
try:
|
|
292
|
+
return int(value)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
return float(value)
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
return value
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _as_native(value):
|
|
302
|
+
"""dbt's as_native: a string read back as YAML, anything else untouched."""
|
|
303
|
+
if not isinstance(value, str):
|
|
304
|
+
return value
|
|
305
|
+
try:
|
|
306
|
+
import yaml
|
|
307
|
+
|
|
308
|
+
return yaml.safe_load(value)
|
|
309
|
+
except Exception:
|
|
310
|
+
return value
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# the filters dbt registers on every project's environment
|
|
314
|
+
DBT_FILTERS = {
|
|
315
|
+
"as_bool": _as_bool,
|
|
316
|
+
"as_number": _as_number,
|
|
317
|
+
"as_native": _as_native,
|
|
318
|
+
"as_text": lambda value: "" if value is None else str(value),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
278
322
|
_SET_BLOCK_RE = re.compile(r"{%-?\s*(call|materialization|test)\b.*?{%-?\s*end\1\s*-?%}", re.S)
|
|
279
323
|
_MACRO_DEF_RE = re.compile(r"{%-?\s*macro\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
280
324
|
_MACRO_DEF_SUB_RE = re.compile(r"({%-?\s*macro\s+)([A-Za-z_][A-Za-z0-9_]*)(\s*\()")
|
|
@@ -299,6 +343,7 @@ def build_environment(
|
|
|
299
343
|
undefined=_Anything,
|
|
300
344
|
extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"],
|
|
301
345
|
)
|
|
346
|
+
env.filters.update(DBT_FILTERS)
|
|
302
347
|
dialect = registry.dialect if registry else "snowflake"
|
|
303
348
|
env.globals.update(
|
|
304
349
|
{
|
|
@@ -19,6 +19,7 @@ from pathlib import Path
|
|
|
19
19
|
|
|
20
20
|
import yaml
|
|
21
21
|
|
|
22
|
+
from ripple.loaders.types import files_under
|
|
22
23
|
from ripple.project import IGNORE_DIRS
|
|
23
24
|
|
|
24
25
|
_REF = re.compile(r"ref\(\s*['\"]([^'\"]+)['\"]\s*(?:,\s*['\"]([^'\"]+)['\"])?\s*\)")
|
|
@@ -47,7 +48,7 @@ def _ref_model(value) -> str | None:
|
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
def _iter_schema_docs(root: Path):
|
|
50
|
-
for path in
|
|
51
|
+
for path in files_under(root, "*.yml") + files_under(root, "*.yaml"):
|
|
51
52
|
parts = path.relative_to(root).parts[:-1]
|
|
52
53
|
if any(p in IGNORE_DIRS or p.startswith(".") for p in parts):
|
|
53
54
|
continue
|
|
@@ -76,6 +76,7 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
|
|
|
76
76
|
.key i.r{border-top-style:dashed; border-color:var(--review)}
|
|
77
77
|
.key s{display:inline-block; width:11px; height:11px; border-radius:2px; background:var(--ink); border:1px solid var(--ink); text-decoration:none}
|
|
78
78
|
.key s.src{background:var(--well); border:1px dashed var(--line-strong)}
|
|
79
|
+
.key small.n{font:400 11.5px/1 var(--sans); color:var(--faint); border:1px solid var(--line-strong); border-radius:var(--r); padding:2px 5px; background:var(--surface)}
|
|
79
80
|
.hint{margin-left:auto; font-size:12.5px; color:var(--faint)}
|
|
80
81
|
.rows{padding:0 22px 14px; font-size:13.5px; color:var(--muted)}
|
|
81
82
|
.rows b{color:var(--ink); font-weight:600}
|
|
@@ -111,6 +112,28 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
|
|
|
111
112
|
.cols .node:hover{border-color:var(--accent); color:var(--accent-strong)}
|
|
112
113
|
.more-chips{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:4px 0; cursor:pointer; text-align:left}
|
|
113
114
|
.more-chips:hover{text-decoration:underline}
|
|
115
|
+
/* a lit chain: the model you are on, what feeds it, what it feeds */
|
|
116
|
+
.mdl.sel,.mdl.sel:hover{background:var(--accent); color:#fff; border-color:var(--accent); border-style:solid}
|
|
117
|
+
.mdl.sel small{color:rgba(255,255,255,.7)}
|
|
118
|
+
.mdl.up{border-color:var(--accent); background:#E4F1F4; border-style:solid}
|
|
119
|
+
.mdl.down{border-color:var(--ink); background:var(--well); border-style:solid}
|
|
120
|
+
.key s.up{background:#E4F1F4; border:1px solid var(--accent)}
|
|
121
|
+
.key s.sel{background:var(--accent); border-color:var(--accent)}
|
|
122
|
+
.key s.down{background:var(--well); border:1px solid var(--ink)}
|
|
123
|
+
/* compact: the map of a large project, layers as wrapped grids, no wires */
|
|
124
|
+
.map.compact .board{overflow-x:visible; padding:18px 22px 8px}
|
|
125
|
+
.map.compact .board-inner{flex-direction:column; gap:22px; min-width:0}
|
|
126
|
+
.map.compact .wires{display:none}
|
|
127
|
+
.map.compact .lvl{min-width:0; gap:10px}
|
|
128
|
+
.map.compact .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:flex-start}
|
|
129
|
+
.map.compact .grp{display:contents}
|
|
130
|
+
.map.compact .grp.open{display:flex; flex-basis:100%; flex-direction:column; gap:6px}
|
|
131
|
+
.map.compact .grp.miss{display:none}
|
|
132
|
+
.map.compact .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
|
|
133
|
+
.map.compact .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:0 0 8px 14px; padding:8px 0 8px 10px}
|
|
134
|
+
.map.compact .cols .gname{flex-basis:100%; max-width:60ch}
|
|
135
|
+
.map.compact.focus .mdl:not(.on){opacity:.28}
|
|
136
|
+
.map .hidden-note{color:var(--muted)}
|
|
114
137
|
|
|
115
138
|
.ladder{margin:26px 0 0; display:grid; grid-template-columns:max-content 1fr; gap:12px 20px; align-items:baseline}
|
|
116
139
|
.ladder p{display:contents; margin:0}
|
|
@@ -29,10 +29,13 @@
|
|
|
29
29
|
<section class="map" id="map" hidden aria-label="every model in this project">
|
|
30
30
|
<div class="board"><div class="board-inner" id="mapboard"><svg class="wires" id="mapwires" aria-hidden="true"></svg></div></div>
|
|
31
31
|
<div class="board-foot">
|
|
32
|
-
<span class="key"><i></i> feeds</span>
|
|
32
|
+
<span class="key" id="key-feeds"><i></i> feeds</span>
|
|
33
33
|
<span class="key"><s class="src"></s> a source table</span>
|
|
34
34
|
<span class="key"><s></s> a dashboard number</span>
|
|
35
35
|
<span class="key"><i class="r"></i> needs review</span>
|
|
36
|
+
<span class="key" id="key-chain" hidden><s class="up"></s> feeds it <s class="sel"></s> chosen <s class="down"></s> reads it</span>
|
|
37
|
+
<span class="key"><small class="n">3</small> models that read it</span>
|
|
38
|
+
<span class="hidden-note" id="maphidden" hidden></span>
|
|
36
39
|
<span class="hint" id="maphint">Hover lights the chain. Click a model for its columns, a column to ask.</span>
|
|
37
40
|
</div>
|
|
38
41
|
</section>
|
|
@@ -238,8 +241,15 @@
|
|
|
238
241
|
});
|
|
239
242
|
}
|
|
240
243
|
if (L.map && L.map.models.length){
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
var hiddenNote = document.getElementById('maphidden');
|
|
245
|
+
mapApi = renderMap(mapEl, L.map, {esc: esc,
|
|
246
|
+
onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); },
|
|
247
|
+
onFilter: function(n){ hiddenNote.hidden = !n; hiddenNote.textContent = n ? plural(n, 'model') + ' hidden by the filter' : ''; }});
|
|
248
|
+
if (mapApi.compact){
|
|
249
|
+
document.getElementById('key-feeds').hidden = true;
|
|
250
|
+
document.getElementById('key-chain').hidden = false;
|
|
251
|
+
document.getElementById('maphint').textContent = 'Hover lights the chain, click keeps it. Click a column to ask; type to narrow the map.';
|
|
252
|
+
}
|
|
243
253
|
document.getElementById('q').addEventListener('input', function(){ if (!mapEl.hidden) mapApi.filter(this.value.trim()); });
|
|
244
254
|
document.getElementById('tomap').addEventListener('click', function(){ home(); history.replaceState(null, '', location.pathname); });
|
|
245
255
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// EXPLORE START
|
|
2
|
+
// The map: every model in its layer, the links between them, and a model's
|
|
3
|
+
// columns when it is clicked. Pure DOM over the map the server embeds;
|
|
4
|
+
// nothing here fetches.
|
|
5
|
+
//
|
|
6
|
+
// Two layouts. A small project (up to COMPACT_AT models) reads left to right
|
|
7
|
+
// as columns with wires, like the answer board. Past that the wires would be
|
|
8
|
+
// a hairball and a layer a page tall, so the map goes compact: layers stack
|
|
9
|
+
// as wrapped grids, nothing is wired at rest, hovering a model lights its
|
|
10
|
+
// chain (upstream in teal, downstream in ink), clicking pins it, and typing
|
|
11
|
+
// hides what does not match.
|
|
12
|
+
var COMPACT_AT = 40, MAP_PER_LAYER = 40, COMPACT_PER_LAYER = 12, MAP_COLUMNS = 60;
|
|
13
|
+
|
|
14
|
+
function renderMap(root, map, opts){
|
|
15
|
+
var inner = root.querySelector('.board-inner'), svg = root.querySelector('.wires');
|
|
16
|
+
var esc = opts.esc, compact = map.models.length > COMPACT_AT;
|
|
17
|
+
var ups = {}, downs = {}, chips = {}, groups = {}, folds = [], lit = null, pinned = null;
|
|
18
|
+
map.links.forEach(function(l){
|
|
19
|
+
(ups[l.dst] = ups[l.dst] || []).push(l.src);
|
|
20
|
+
(downs[l.src] = downs[l.src] || []).push(l.dst);
|
|
21
|
+
});
|
|
22
|
+
root.classList.toggle('compact', compact);
|
|
23
|
+
function el(tag, cls){ var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
24
|
+
function label(m){
|
|
25
|
+
if (m.kind !== 'dashboard') return esc(m.id);
|
|
26
|
+
var kind = m.id.split(':')[0];
|
|
27
|
+
return esc(m.id.slice(kind.length + 1)) + ' <small>' + esc(kind) + '</small>';
|
|
28
|
+
}
|
|
29
|
+
function walk(dir, id){
|
|
30
|
+
var seen = {}, queue = [id];
|
|
31
|
+
while (queue.length){
|
|
32
|
+
var cur = queue.shift();
|
|
33
|
+
(dir[cur] || []).forEach(function(n){ if (!seen[n] && n !== id){ seen[n] = true; queue.push(n); } });
|
|
34
|
+
}
|
|
35
|
+
return seen;
|
|
36
|
+
}
|
|
37
|
+
function paths(){ return Array.prototype.slice.call(svg.querySelectorAll('path')); }
|
|
38
|
+
|
|
39
|
+
var layers = [];
|
|
40
|
+
map.models.forEach(function(m){ (layers[m.layer] = layers[m.layer] || []).push(m); });
|
|
41
|
+
layers.forEach(function(models, d){
|
|
42
|
+
if (!models) return;
|
|
43
|
+
if (compact){
|
|
44
|
+
// hubs first: the model that feeds the most is the one worth a look
|
|
45
|
+
models = models.slice().sort(function(a, b){
|
|
46
|
+
return ((downs[b.id] || []).length - (downs[a.id] || []).length) || (a.id < b.id ? -1 : 1);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
var lvl = el('div', 'lvl'), head = el('div', 'lhead');
|
|
50
|
+
var dash = models.every(function(m){ return m.kind === 'dashboard'; });
|
|
51
|
+
head.textContent = (d === 0 ? 'Sources' : dash ? 'Dashboards' : 'Layer ' + d) + ' · ' + models.length;
|
|
52
|
+
lvl.appendChild(head);
|
|
53
|
+
var strip = compact ? el('div', 'chips') : lvl;
|
|
54
|
+
// compact: a layer shows its top dozen and folds the rest, so the whole
|
|
55
|
+
// map fits a screen; the filter lifts the fold while it is active
|
|
56
|
+
var cap = compact ? COMPACT_PER_LAYER : MAP_PER_LAYER;
|
|
57
|
+
var fold = {cap: cap, on: models.length > cap};
|
|
58
|
+
folds.push(fold);
|
|
59
|
+
models.forEach(function(m, i){
|
|
60
|
+
var g = el('div', 'grp');
|
|
61
|
+
g.dataset.pos = i; g.dataset.fold = folds.length - 1;
|
|
62
|
+
if (i >= cap) g.hidden = true;
|
|
63
|
+
var broken = m.status && m.status !== 'ok' && m.status !== 'star_only';
|
|
64
|
+
var b = el('button', 'mdl' + (m.kind === 'source' ? ' src' : '') + (m.kind === 'dashboard' ? ' bi' : '') + (broken ? ' r' : ''));
|
|
65
|
+
b.type = 'button'; b.dataset.id = m.id; b.setAttribute('aria-expanded', 'false');
|
|
66
|
+
var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
|
|
67
|
+
// the number a change cares about: how many models read this one
|
|
68
|
+
b.innerHTML = label(m) + (feeds ? ' <small>' + feeds + '</small>' : '');
|
|
69
|
+
b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
|
|
70
|
+
b.addEventListener('click', function(){ toggle(m, g, b); });
|
|
71
|
+
b.addEventListener('mouseenter', function(){ if (!pinned) light(m.id); });
|
|
72
|
+
b.addEventListener('focus', function(){ if (!pinned) light(m.id); });
|
|
73
|
+
b.addEventListener('mouseleave', function(){ if (!pinned) unlight(); });
|
|
74
|
+
b.addEventListener('blur', function(){ if (!pinned) unlight(); });
|
|
75
|
+
g.appendChild(b); chips[m.id] = b; groups[m.id] = g; strip.appendChild(g);
|
|
76
|
+
});
|
|
77
|
+
if (compact) lvl.appendChild(strip);
|
|
78
|
+
if (fold.on){
|
|
79
|
+
var more = el('button', 'more-chips'); more.type = 'button';
|
|
80
|
+
more.textContent = '+ ' + (models.length - cap) + ' more';
|
|
81
|
+
more.addEventListener('click', function(){
|
|
82
|
+
fold.on = false;
|
|
83
|
+
Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
|
|
84
|
+
more.remove(); draw();
|
|
85
|
+
});
|
|
86
|
+
lvl.appendChild(more);
|
|
87
|
+
}
|
|
88
|
+
inner.appendChild(lvl);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function toggle(m, g, b){
|
|
92
|
+
var open = g.querySelector('.cols');
|
|
93
|
+
if (open){
|
|
94
|
+
open.remove(); b.setAttribute('aria-expanded', 'false'); g.classList.remove('open');
|
|
95
|
+
if (pinned === m.id){ pinned = null; unlight(); }
|
|
96
|
+
draw(); return;
|
|
97
|
+
}
|
|
98
|
+
var cols = el('div', 'cols');
|
|
99
|
+
m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
|
|
100
|
+
var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
|
|
101
|
+
s.title = 'ask about ' + m.id + '.' + c;
|
|
102
|
+
s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
|
|
103
|
+
cols.appendChild(s);
|
|
104
|
+
});
|
|
105
|
+
var note = null;
|
|
106
|
+
if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
|
|
107
|
+
else if (!m.declared && m.kind === 'source') note = (m.columns.length ? 'only the columns this project names; the full list is' : 'no column list; it is') +
|
|
108
|
+
' not declared in the project (a select * reads it unnamed). Coverage below adds it.';
|
|
109
|
+
else if (!m.columns.length) note = 'no columns known';
|
|
110
|
+
if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
|
|
111
|
+
g.appendChild(cols); g.classList.add('open'); b.setAttribute('aria-expanded', 'true');
|
|
112
|
+
// a click keeps the chain lit so the mouse can travel to the columns
|
|
113
|
+
if (pinned && pinned !== m.id){ var was = groups[pinned].querySelector('.cols'); if (was){ was.remove(); groups[pinned].classList.remove('open'); chips[pinned].setAttribute('aria-expanded', 'false'); } }
|
|
114
|
+
pinned = m.id; light(m.id); draw();
|
|
115
|
+
}
|
|
116
|
+
function light(id){
|
|
117
|
+
var up = walk(ups, id), down = walk(downs, id);
|
|
118
|
+
lit = {}; lit[id] = true;
|
|
119
|
+
Object.keys(up).forEach(function(k){ lit[k] = true; });
|
|
120
|
+
Object.keys(down).forEach(function(k){ lit[k] = true; });
|
|
121
|
+
root.classList.add('focus');
|
|
122
|
+
Object.keys(chips).forEach(function(k){
|
|
123
|
+
var c = chips[k].classList;
|
|
124
|
+
c.toggle('on', !!lit[k]); c.toggle('sel', k === id); c.toggle('up', !!up[k]); c.toggle('down', !!down[k]);
|
|
125
|
+
// a chain member behind a layer's fold comes out while the chain is lit
|
|
126
|
+
if (compact && lit[k]) groups[k].hidden = false;
|
|
127
|
+
});
|
|
128
|
+
if (compact) return;
|
|
129
|
+
paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
|
|
130
|
+
}
|
|
131
|
+
function unlight(){
|
|
132
|
+
lit = null; root.classList.remove('focus');
|
|
133
|
+
Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on', 'sel', 'up', 'down'); });
|
|
134
|
+
if (compact) refold(); else paths().forEach(function(p){ p.classList.remove('on'); });
|
|
135
|
+
}
|
|
136
|
+
var filterText = '';
|
|
137
|
+
function refold(){
|
|
138
|
+
if (filterText) return;
|
|
139
|
+
Object.keys(groups).forEach(function(k){
|
|
140
|
+
var g = groups[k], fold = folds[g.dataset.fold];
|
|
141
|
+
g.hidden = fold.on && Number(g.dataset.pos) >= fold.cap;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
root.addEventListener('keydown', function(ev){ if (ev.key === 'Escape' && pinned){ var g = groups[pinned]; toggle(map.models.filter(function(m){ return m.id === pinned; })[0], g, chips[pinned]); } });
|
|
145
|
+
|
|
146
|
+
function draw(){
|
|
147
|
+
svg.innerHTML = '';
|
|
148
|
+
if (compact || root.hidden || window.innerWidth <= 680) return;
|
|
149
|
+
var base = inner.getBoundingClientRect();
|
|
150
|
+
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
151
|
+
map.links.forEach(function(l){
|
|
152
|
+
var from = chips[l.src], to = chips[l.dst];
|
|
153
|
+
if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
|
|
154
|
+
var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
|
|
155
|
+
var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
|
|
156
|
+
var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
|
|
157
|
+
if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
|
|
158
|
+
var dx = Math.max(18, Math.abs(x2 - x1) / 2);
|
|
159
|
+
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
160
|
+
p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
|
|
161
|
+
p.dataset.from = l.src; p.dataset.to = l.dst;
|
|
162
|
+
var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
|
|
163
|
+
if (cls.trim()) p.setAttribute('class', cls.trim());
|
|
164
|
+
svg.appendChild(p);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function filter(text){
|
|
168
|
+
text = (text || '').toLowerCase();
|
|
169
|
+
filterText = text;
|
|
170
|
+
root.classList.toggle('filter', !!text);
|
|
171
|
+
var hidden = 0;
|
|
172
|
+
map.models.forEach(function(m){
|
|
173
|
+
var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
|
|
174
|
+
m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
|
|
175
|
+
chips[m.id].classList.toggle('hit', hit);
|
|
176
|
+
// compact: what does not match leaves the page instead of fading on it,
|
|
177
|
+
// and a match past a layer's fold comes out from under it
|
|
178
|
+
if (compact){
|
|
179
|
+
var g = groups[m.id], off = !!text && !hit;
|
|
180
|
+
g.classList.toggle('miss', off); if (off) hidden += 1;
|
|
181
|
+
var fold = folds[g.dataset.fold];
|
|
182
|
+
g.hidden = !text && fold.on && Number(g.dataset.pos) >= fold.cap && !(lit && lit[m.id]);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (opts.onFilter) opts.onFilter(text ? hidden : 0);
|
|
186
|
+
draw();
|
|
187
|
+
}
|
|
188
|
+
return {draw: draw, filter: filter, compact: compact};
|
|
189
|
+
}
|
|
190
|
+
// EXPLORE END
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
// EXPLORE START
|
|
2
|
-
// The map: every model in its layer, the model-level links between them,
|
|
3
|
-
// and a model's columns when it is clicked. Pure DOM over the map the
|
|
4
|
-
// server embeds; nothing here fetches. Hover or focus a model to light its
|
|
5
|
-
// whole chain, upstream and down. Past a cap, links draw only for the lit
|
|
6
|
-
// chain so a large repo stays readable.
|
|
7
|
-
var MAP_PER_LAYER = 40, MAP_WIRE_CAP = 400, MAP_COLUMNS = 60;
|
|
8
|
-
|
|
9
|
-
function renderMap(root, map, opts){
|
|
10
|
-
var inner = root.querySelector('.board-inner'), svg = root.querySelector('.wires');
|
|
11
|
-
var esc = opts.esc, many = map.links.length > MAP_WIRE_CAP;
|
|
12
|
-
var ups = {}, downs = {}, chips = {}, lit = null;
|
|
13
|
-
map.links.forEach(function(l){
|
|
14
|
-
(ups[l.dst] = ups[l.dst] || []).push(l.src);
|
|
15
|
-
(downs[l.src] = downs[l.src] || []).push(l.dst);
|
|
16
|
-
});
|
|
17
|
-
function el(tag, cls){ var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
18
|
-
function label(m){
|
|
19
|
-
if (m.kind !== 'dashboard') return esc(m.id);
|
|
20
|
-
var kind = m.id.split(':')[0];
|
|
21
|
-
return esc(m.id.slice(kind.length + 1)) + ' <small>' + esc(kind) + '</small>';
|
|
22
|
-
}
|
|
23
|
-
function chain(id){
|
|
24
|
-
var seen = {}; seen[id] = true;
|
|
25
|
-
[ups, downs].forEach(function(dir){
|
|
26
|
-
var queue = [id];
|
|
27
|
-
while (queue.length){
|
|
28
|
-
var cur = queue.shift();
|
|
29
|
-
(dir[cur] || []).forEach(function(n){ if (!seen[n]){ seen[n] = true; queue.push(n); } });
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
return seen;
|
|
33
|
-
}
|
|
34
|
-
function paths(){ return Array.prototype.slice.call(svg.querySelectorAll('path')); }
|
|
35
|
-
|
|
36
|
-
var layers = [];
|
|
37
|
-
map.models.forEach(function(m){ (layers[m.layer] = layers[m.layer] || []).push(m); });
|
|
38
|
-
layers.forEach(function(models, d){
|
|
39
|
-
if (!models) return;
|
|
40
|
-
var lvl = el('div', 'lvl'), head = el('div', 'lhead');
|
|
41
|
-
var dash = models.every(function(m){ return m.kind === 'dashboard'; });
|
|
42
|
-
head.textContent = (d === 0 ? 'Sources' : dash ? 'Dashboards' : 'Layer ' + d) + ' · ' + models.length;
|
|
43
|
-
lvl.appendChild(head);
|
|
44
|
-
models.forEach(function(m, i){
|
|
45
|
-
var g = el('div', 'grp');
|
|
46
|
-
if (i >= MAP_PER_LAYER) g.hidden = true;
|
|
47
|
-
var broken = m.status && m.status !== 'ok' && m.status !== 'star_only';
|
|
48
|
-
var b = el('button', 'mdl' + (m.kind === 'source' ? ' src' : '') + (m.kind === 'dashboard' ? ' bi' : '') + (broken ? ' r' : ''));
|
|
49
|
-
b.type = 'button'; b.dataset.id = m.id; b.setAttribute('aria-expanded', 'false');
|
|
50
|
-
// a source's count would be the columns some model names, not the table's width
|
|
51
|
-
b.innerHTML = label(m) + (m.declared && m.kind !== 'source' && m.columns.length ? ' <small>' + m.columns.length + '</small>' : '');
|
|
52
|
-
var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
|
|
53
|
-
b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
|
|
54
|
-
b.addEventListener('click', function(){ toggle(m, g, b); });
|
|
55
|
-
b.addEventListener('mouseenter', function(){ focus(m.id); });
|
|
56
|
-
b.addEventListener('focus', function(){ focus(m.id); });
|
|
57
|
-
b.addEventListener('mouseleave', unfocus);
|
|
58
|
-
b.addEventListener('blur', unfocus);
|
|
59
|
-
g.appendChild(b); chips[m.id] = b; lvl.appendChild(g);
|
|
60
|
-
});
|
|
61
|
-
if (models.length > MAP_PER_LAYER){
|
|
62
|
-
var more = el('button', 'more-chips'); more.type = 'button';
|
|
63
|
-
more.textContent = '+ ' + (models.length - MAP_PER_LAYER) + ' more';
|
|
64
|
-
more.addEventListener('click', function(){
|
|
65
|
-
Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
|
|
66
|
-
more.remove(); draw();
|
|
67
|
-
});
|
|
68
|
-
lvl.appendChild(more);
|
|
69
|
-
}
|
|
70
|
-
inner.appendChild(lvl);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
function toggle(m, g, b){
|
|
74
|
-
var open = g.querySelector('.cols');
|
|
75
|
-
if (open){ open.remove(); b.setAttribute('aria-expanded', 'false'); draw(); return; }
|
|
76
|
-
var cols = el('div', 'cols');
|
|
77
|
-
m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
|
|
78
|
-
var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
|
|
79
|
-
s.title = 'ask about ' + m.id + '.' + c;
|
|
80
|
-
s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
|
|
81
|
-
cols.appendChild(s);
|
|
82
|
-
});
|
|
83
|
-
var note = null;
|
|
84
|
-
if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
|
|
85
|
-
else if (!m.declared && m.kind === 'source') note = (m.columns.length ? 'only the columns this project names; the full list is' : 'no column list; it is') +
|
|
86
|
-
' not declared in the project (a select * reads it unnamed). Coverage below adds it.';
|
|
87
|
-
else if (!m.columns.length) note = 'no columns known';
|
|
88
|
-
if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
|
|
89
|
-
g.appendChild(cols); b.setAttribute('aria-expanded', 'true'); draw();
|
|
90
|
-
}
|
|
91
|
-
function focus(id){
|
|
92
|
-
lit = chain(id); root.classList.add('focus');
|
|
93
|
-
Object.keys(chips).forEach(function(k){ chips[k].classList.toggle('on', !!lit[k]); });
|
|
94
|
-
if (many) draw();
|
|
95
|
-
else paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
|
|
96
|
-
}
|
|
97
|
-
function unfocus(){
|
|
98
|
-
lit = null; root.classList.remove('focus');
|
|
99
|
-
Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on'); });
|
|
100
|
-
if (many) draw();
|
|
101
|
-
else paths().forEach(function(p){ p.classList.remove('on'); });
|
|
102
|
-
}
|
|
103
|
-
function draw(){
|
|
104
|
-
svg.innerHTML = '';
|
|
105
|
-
if (root.hidden || window.innerWidth <= 680 || (many && !lit)) return;
|
|
106
|
-
var base = inner.getBoundingClientRect();
|
|
107
|
-
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
108
|
-
map.links.forEach(function(l){
|
|
109
|
-
if (many && !(lit[l.src] && lit[l.dst])) return;
|
|
110
|
-
var from = chips[l.src], to = chips[l.dst];
|
|
111
|
-
if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
|
|
112
|
-
var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
|
|
113
|
-
var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
|
|
114
|
-
var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
|
|
115
|
-
if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
|
|
116
|
-
var dx = Math.max(18, Math.abs(x2 - x1) / 2);
|
|
117
|
-
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
118
|
-
p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
|
|
119
|
-
p.dataset.from = l.src; p.dataset.to = l.dst;
|
|
120
|
-
var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
|
|
121
|
-
if (cls.trim()) p.setAttribute('class', cls.trim());
|
|
122
|
-
svg.appendChild(p);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
function filter(text){
|
|
126
|
-
text = (text || '').toLowerCase();
|
|
127
|
-
root.classList.toggle('filter', !!text);
|
|
128
|
-
map.models.forEach(function(m){
|
|
129
|
-
var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
|
|
130
|
-
m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
|
|
131
|
-
chips[m.id].classList.toggle('hit', hit);
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
return {draw: draw, filter: filter, many: many};
|
|
135
|
-
}
|
|
136
|
-
// EXPLORE END
|
|
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
|
|
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
|