ripple-sql 0.1.2__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.
Files changed (75) hide show
  1. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/CHANGELOG.md +10 -1
  2. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/PKG-INFO +1 -1
  3. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/pyproject.toml +1 -1
  4. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/cte_tracing.py +2 -0
  5. ripple_sql-0.1.3/src/ripple/engine/generators.py +61 -0
  6. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/statement.py +2 -0
  7. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/render.py +45 -0
  8. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer.css +23 -0
  9. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer.html +13 -3
  10. ripple_sql-0.1.3/src/ripple/static/explore.js +190 -0
  11. ripple_sql-0.1.2/src/ripple/static/explore.js +0 -136
  12. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/.gitignore +0 -0
  13. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/LICENSE +0 -0
  14. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/README.md +0 -0
  15. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/__init__.py +0 -0
  16. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/answer.py +0 -0
  17. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/answer_page.py +0 -0
  18. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/cache.py +0 -0
  19. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/ci.py +0 -0
  20. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/ci_signature.py +0 -0
  21. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/cli.py +0 -0
  22. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/doctor.py +0 -0
  23. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/__init__.py +0 -0
  24. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/budget.py +0 -0
  25. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/column_lineage.py +0 -0
  26. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/column_ref.py +0 -0
  27. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dependencies.py +0 -0
  28. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dialect.py +0 -0
  29. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/dispatch.py +0 -0
  30. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/extraction.py +0 -0
  31. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/jinja.py +0 -0
  32. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/json_sources.py +0 -0
  33. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/macro_source.py +0 -0
  34. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/pipeline.py +0 -0
  35. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/preprocess.py +0 -0
  36. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/safe_gen.py +0 -0
  37. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/schema_qualification.py +0 -0
  38. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/scope.py +0 -0
  39. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/select_sources.py +0 -0
  40. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/sql_script.py +0 -0
  41. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tech_debt.py +0 -0
  42. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_catalog.py +0 -0
  43. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_scalar_vars.py +0 -0
  44. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_tvf.py +0 -0
  45. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/tsql_xml.py +0 -0
  46. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/types.py +0 -0
  47. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/unused_deps.py +0 -0
  48. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/engine/validation.py +0 -0
  49. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/graph.py +0 -0
  50. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/home.py +0 -0
  51. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/__init__.py +0 -0
  52. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/dbt.py +0 -0
  53. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/dbt_config.py +0 -0
  54. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/identity.py +0 -0
  55. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/sidecar.py +0 -0
  56. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/sqldir.py +0 -0
  57. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/loaders/types.py +0 -0
  58. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/lookml.py +0 -0
  59. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/mcp_server.py +0 -0
  60. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/names.py +0 -0
  61. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/project.py +0 -0
  62. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/py.typed +0 -0
  63. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/render_shims.py +0 -0
  64. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/schemas.py +0 -0
  65. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/semantic.py +0 -0
  66. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/server.py +0 -0
  67. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/sourcefiles.py +0 -0
  68. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/star_resolution.py +0 -0
  69. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/static/answer_twin.js +0 -0
  70. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/__init__.py +0 -0
  71. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/cli.py +0 -0
  72. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/collect.py +0 -0
  73. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/discover.py +0 -0
  74. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/ingest.py +0 -0
  75. {ripple_sql-0.1.2 → ripple_sql-0.1.3}/src/ripple/usage/report.py +0 -0
@@ -6,6 +6,14 @@ 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
+
9
17
  ## [0.1.2] - 2026-09-09
10
18
 
11
19
  ### Fixed
@@ -235,7 +243,8 @@ First public release.
235
243
  ### Removed
236
244
  - The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
237
245
 
238
- [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.2...HEAD
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
239
248
  [0.1.2]: https://github.com/bteh/ripple/releases/tag/v0.1.2
240
249
  [0.1.1]: https://github.com/bteh/ripple/releases/tag/v0.1.1
241
250
  [0.1.0]: https://github.com/bteh/ripple/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ripple-sql
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Offline column-level SQL lineage. See what breaks before you merge.
5
5
  Project-URL: Homepage, https://github.com/bteh/ripple
6
6
  Project-URL: Repository, https://github.com/bteh/ripple
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ripple-sql"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Offline column-level SQL lineage. See what breaks before you merge."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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)):
@@ -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
  {
@@ -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
- mapApi = renderMap(mapEl, L.map, {esc: esc, onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); }});
242
- if (mapApi.many) document.getElementById('maphint').textContent = 'Hover a model to see its links and chain. Click it for its columns, a column to ask.';
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