ripple-sql 0.1.5__tar.gz → 0.1.7__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.

Potentially problematic release.


This version of ripple-sql might be problematic. Click here for more details.

Files changed (77) hide show
  1. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/CHANGELOG.md +13 -1
  2. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/PKG-INFO +1 -1
  3. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/pyproject.toml +1 -1
  4. ripple_sql-0.1.7/src/ripple/engine/generators.py +149 -0
  5. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/schema_qualification.py +12 -2
  6. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/statement.py +1 -1
  7. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/home.py +86 -34
  8. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/identity.py +4 -3
  9. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/render.py +21 -10
  10. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/answer.css +2 -2
  11. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/answer.html +33 -13
  12. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/explore.js +59 -42
  13. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/find.js +3 -3
  14. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/focus.js +46 -19
  15. ripple_sql-0.1.5/src/ripple/engine/generators.py +0 -61
  16. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/.gitignore +0 -0
  17. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/LICENSE +0 -0
  18. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/README.md +0 -0
  19. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/__init__.py +0 -0
  20. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/answer.py +0 -0
  21. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/answer_page.py +0 -0
  22. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/cache.py +0 -0
  23. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/ci.py +0 -0
  24. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/ci_signature.py +0 -0
  25. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/cli.py +0 -0
  26. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/doctor.py +0 -0
  27. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/__init__.py +0 -0
  28. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/budget.py +0 -0
  29. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/column_lineage.py +0 -0
  30. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/column_ref.py +0 -0
  31. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/cte_tracing.py +0 -0
  32. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/dependencies.py +0 -0
  33. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/dialect.py +0 -0
  34. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/dispatch.py +0 -0
  35. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/extraction.py +0 -0
  36. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/jinja.py +0 -0
  37. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/json_sources.py +0 -0
  38. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/macro_source.py +0 -0
  39. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/pipeline.py +0 -0
  40. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/preprocess.py +0 -0
  41. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/safe_gen.py +0 -0
  42. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/scope.py +0 -0
  43. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/select_sources.py +0 -0
  44. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/sql_script.py +0 -0
  45. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/tech_debt.py +0 -0
  46. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/tsql_catalog.py +0 -0
  47. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/tsql_scalar_vars.py +0 -0
  48. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/tsql_tvf.py +0 -0
  49. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/tsql_xml.py +0 -0
  50. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/types.py +0 -0
  51. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/unused_deps.py +0 -0
  52. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/engine/validation.py +0 -0
  53. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/graph.py +0 -0
  54. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/__init__.py +0 -0
  55. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/dbt.py +0 -0
  56. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/dbt_config.py +0 -0
  57. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/sidecar.py +0 -0
  58. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/sqldir.py +0 -0
  59. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/loaders/types.py +0 -0
  60. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/lookml.py +0 -0
  61. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/mcp_server.py +0 -0
  62. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/names.py +0 -0
  63. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/project.py +0 -0
  64. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/py.typed +0 -0
  65. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/render_shims.py +0 -0
  66. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/schemas.py +0 -0
  67. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/semantic.py +0 -0
  68. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/server.py +0 -0
  69. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/sourcefiles.py +0 -0
  70. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/star_resolution.py +0 -0
  71. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/static/answer_twin.js +0 -0
  72. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/__init__.py +0 -0
  73. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/cli.py +0 -0
  74. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/collect.py +0 -0
  75. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/discover.py +0 -0
  76. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/ingest.py +0 -0
  77. {ripple_sql-0.1.5 → ripple_sql-0.1.7}/src/ripple/usage/report.py +0 -0
@@ -6,6 +6,16 @@ Notable changes to Ripple. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.7] - 2026-09-10
10
+
11
+ ### Fixed
12
+ - A plain-SQL repo's `udf.sql` is the function its folder names. Mozilla's bigquery-etl keeps 155 of them at `udf_js/<function>/udf.sql`; named by file every one was "udf", the collision handler mangled them into path names, and the home page listed 118 one-file folders. The folder is the function now, `udf_js.<function>` is its alias, and those files sit in their dataset's row.
13
+
14
+ ## [0.1.6] - 2026-09-10
15
+
16
+ ### Fixed
17
+ - Thirty-six findings from three rounds of a two-model review of the 0.1.3 to 0.1.5 work: the engine and map fixes each pinned by a test, the page fixes checked with headless browser probes (the page has no automated tests). Rounds two and three also settled INLINE over NULL and non-struct elements, name-matched fields, `AS (xx, yy)` alias lists, maps behind parentheses and casts, a cap before `as_native` parses, kind-prefixed group identities, the loader's own say on which folders are tables, and a cycle twin that stays visible while its other copy is folded. Engine: the generator names (`INLINE`, `EXPLODE`, `POSEXPLODE`) survive schema qualification, which reparsed the original SQL and threw them away; each `INLINE` output now takes lineage from its own struct field, not every field; `EXPLODE` over a map yields `key` and `value`; `as_number` keeps floats and `as_native` reads Python literals as dbt documents. Page: hovering a group heading threw instead of tinting (the group adjacency was walked as arrays); a group or model named `constructor` or `__proto__` crashed the page; a model's badge and a group's reader count included dashboard numbers while the key said models; a column suggestion showed its model's reader count instead of its own; two folders ending in the same name, or a schema and a folder of one name, merged into one row; a monorepo model named `models__marts__a` lost its folder; the filter went stale across focus and home and fought the fold; a late answer could overwrite newer navigation; Escape did not leave focus from the question box; a cycle drew a model on both sides; the typeahead left a stale `aria-activedescendant`.
18
+
9
19
  ## [0.1.5] - 2026-09-09
10
20
 
11
21
  ### Added
@@ -254,7 +264,9 @@ First public release.
254
264
  ### Removed
255
265
  - The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
256
266
 
257
- [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.5...HEAD
267
+ [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.7...HEAD
268
+ [0.1.7]: https://github.com/bteh/ripple/releases/tag/v0.1.7
269
+ [0.1.6]: https://github.com/bteh/ripple/releases/tag/v0.1.6
258
270
  [0.1.5]: https://github.com/bteh/ripple/releases/tag/v0.1.5
259
271
  [0.1.4]: https://github.com/bteh/ripple/releases/tag/v0.1.4
260
272
  [0.1.3]: https://github.com/bteh/ripple/releases/tag/v0.1.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ripple-sql
3
- Version: 0.1.5
3
+ Version: 0.1.7
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.5"
3
+ version = "0.1.7"
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"
@@ -0,0 +1,149 @@
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
+ and over a map they are `key` and `value`. sqlglot leaves such items
7
+ nameless, and the projection walks named them by their SQL text or dropped
8
+ them, so a lookup table built this way reported no columns at all
9
+ (Wikimedia dbt-jobs, platforms_ephemeral).
10
+
11
+ The rewrite gives each output its default name as an alias over the
12
+ expression that feeds it, before any projection walk runs: a struct field
13
+ feeds its own output only, so a change to one field does not ripple to
14
+ every column of the table.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from sqlglot import exp
20
+
21
+ Output = tuple[str, exp.Expression]
22
+
23
+
24
+ def generator_outputs(item: exp.Expression) -> list[Output] | None:
25
+ """(name, expression) per output column of a bare generator, None for
26
+ anything else. `INLINE(...) AS (xx, yy)` is the same generator with its
27
+ outputs named by position."""
28
+ if isinstance(item, exp.Aliases):
29
+ inner = generator_outputs(item.this)
30
+ names = [alias.name for alias in item.expressions]
31
+ if inner is None or len(names) != len(inner):
32
+ return None
33
+ return [(name, expr) for name, (_, expr) in zip(names, inner, strict=True)]
34
+ if isinstance(item, exp.Inline):
35
+ return _inline_outputs(item)
36
+ if isinstance(item, (exp.Explode, exp.Posexplode)):
37
+ return _explode_outputs(item)
38
+ return None
39
+
40
+
41
+ def _struct_fields(element: exp.Expression) -> list[Output] | None:
42
+ if not isinstance(element, exp.Struct):
43
+ return None
44
+ fields: list[Output] = []
45
+ for field in element.expressions:
46
+ if isinstance(field, exp.PropertyEQ):
47
+ fields.append((field.this.name, field.expression))
48
+ elif isinstance(field, exp.Alias):
49
+ fields.append((field.alias, field.this))
50
+ elif isinstance(field, exp.Column):
51
+ fields.append((field.name, field))
52
+ else:
53
+ return None
54
+ return fields or None
55
+
56
+
57
+ def _inline_outputs(item: exp.Inline) -> list[Output] | None:
58
+ """INLINE over a literal array: the first struct names the outputs and
59
+ field i of every struct feeds output i. That is Spark's own rule: the
60
+ array has one element type, and a later struct must carry the same
61
+ field names at the same positions, case-insensitively, or the query
62
+ fails analysis (TypeCoercion.findTypeForComplex). A struct whose names
63
+ are a permutation of the first's would not run in Spark; it is read by
64
+ name, as written. A NULL element adds nothing, and an element that is
65
+ not a struct literal (a struct column, a CASE) feeds every output, since
66
+ its fields cannot be told apart."""
67
+ array = item.this
68
+ if not (isinstance(array, exp.Array) and array.expressions):
69
+ return None
70
+ elements = [e for e in array.expressions if not isinstance(e, exp.Null)]
71
+ structs = [(e, _struct_fields(e)) for e in elements]
72
+ first = next((fields for _, fields in structs if fields), None)
73
+ if first is None:
74
+ return None
75
+ names = [name for name, _ in first]
76
+ feeds: list[list[exp.Expression]] = [[] for _ in names]
77
+ for element, fields in structs:
78
+ if fields is None:
79
+ for fed_by in feeds:
80
+ fed_by.append(element.copy())
81
+ continue
82
+ matched = _match_fields(first, fields)
83
+ if matched is None:
84
+ return None
85
+ for fed_by, expr in zip(feeds, matched, strict=True):
86
+ fed_by.append(expr.copy())
87
+ return [
88
+ (name, fed_by[0] if len(fed_by) == 1 else exp.Array(expressions=fed_by))
89
+ for name, fed_by in zip(names, feeds, strict=True)
90
+ ]
91
+
92
+
93
+ def _match_fields(first: list[Output], fields: list[Output]) -> list[exp.Expression] | None:
94
+ """The expression of `fields` feeding each output of `first`, in order:
95
+ by position when the names line up (the only shape Spark accepts, and
96
+ the only reading when two unnamed fields share a name, `a.id, b.id`);
97
+ by name when they are a permutation; None when the shapes disagree."""
98
+ if len(fields) != len(first):
99
+ return None
100
+ want = [name.lower() for name, _ in first]
101
+ have = [name.lower() for name, _ in fields]
102
+ if want == have or len(set(have)) != len(have) or set(want) != set(have):
103
+ return [expr for _, expr in fields]
104
+ by_name = {name.lower(): expr for name, expr in fields}
105
+ return [by_name[name] for name in want]
106
+
107
+
108
+ def _is_map(source: exp.Expression) -> bool:
109
+ if isinstance(source, (exp.Map, exp.VarMap)):
110
+ return True
111
+ to = source.args.get("to") if isinstance(source, exp.Cast) else None
112
+ return isinstance(to, exp.DataType) and to.this == exp.DataType.Type.MAP
113
+
114
+
115
+ def _explode_outputs(item: exp.Expression) -> list[Output]:
116
+ """EXPLODE and POSEXPLODE: `col` fed by the whole call over an array;
117
+ `key` and `value` fed by the map's keys and values when the input is
118
+ visibly a map, or both by the whole map when only its type is visible
119
+ (a CAST to MAP). `pos` comes from the whole call either way."""
120
+ source = item.this
121
+ while isinstance(source, exp.Paren):
122
+ source = source.this
123
+ if isinstance(source, (exp.Map, exp.VarMap)):
124
+ outputs: list[Output] = [("key", source.args["keys"]), ("value", source.args["values"])]
125
+ elif _is_map(source):
126
+ outputs = [("key", source), ("value", source)]
127
+ else:
128
+ outputs = [("col", item)]
129
+ if isinstance(item, exp.Posexplode):
130
+ outputs = [("pos", item)] + outputs
131
+ return [(name, expr.copy()) for name, expr in outputs]
132
+
133
+
134
+ def name_generator_outputs(tree: exp.Expression) -> exp.Expression:
135
+ """Alias every bare generator in every select list with its default
136
+ output names, in place, and return the tree."""
137
+ for select in list(tree.find_all(exp.Select)):
138
+ items = select.expressions
139
+ if not any(generator_outputs(item) for item in items):
140
+ continue
141
+ renamed: list[exp.Expression] = []
142
+ for item in items:
143
+ outputs = generator_outputs(item)
144
+ if not outputs:
145
+ renamed.append(item)
146
+ continue
147
+ renamed.extend(exp.alias_(expr, name) for name, expr in outputs)
148
+ select.set("expressions", renamed)
149
+ return tree
@@ -92,9 +92,14 @@ def qualify_sql_with_schema(
92
92
  sql: str,
93
93
  dialect: str,
94
94
  warehouse_columns: WarehouseColumns,
95
+ expression: "exp.Expression | None" = None,
95
96
  ) -> tuple["exp.Expression | None", bool]:
96
97
  """Qualify unqualified columns in SQL using warehouse schema.
97
98
 
99
+ Pass `expression` when the caller has already normalized the tree (bare
100
+ generators named, for one): re-parsing the text here would throw that
101
+ work away, and the caller's names would come back as _col_0.
102
+
98
103
  Uses SQLGlot's optimizer.qualify to add table qualifiers to columns.
99
104
  This resolves ambiguous column references like SELECT col FROM a, b
100
105
  into SELECT a.col FROM a, b when 'col' only exists in table 'a'.
@@ -120,8 +125,13 @@ def qualify_sql_with_schema(
120
125
  return None, False
121
126
 
122
127
  schema_dict = build_sqlglot_schema(warehouse_columns)
128
+
129
+ def fresh() -> "exp.Expression":
130
+ # qualify() mutates, so each attempt starts from its own copy
131
+ return expression.copy() if expression is not None else sqlglot.parse_one(sql, read=dialect)
132
+
123
133
  try:
124
- parsed = sqlglot.parse_one(sql, read=dialect)
134
+ parsed = fresh()
125
135
  qualified = qualify(parsed, schema=schema_dict, dialect=dialect)
126
136
  return qualified, True
127
137
  except Exception as e:
@@ -130,7 +140,7 @@ def qualify_sql_with_schema(
130
140
  # what the schema resolves, leave the rest as written instead of throwing
131
141
  # away every resolved column with it.
132
142
  try:
133
- parsed = sqlglot.parse_one(sql, read=dialect)
143
+ parsed = fresh()
134
144
  qualified = qualify(
135
145
  parsed,
136
146
  schema=schema_dict,
@@ -247,7 +247,7 @@ def extract_column_lineage_fast(
247
247
  # return the AST directly to avoid double-parsing
248
248
  if warehouse_columns:
249
249
  qualified_ast, was_qualified = qualify_sql_with_schema(
250
- cleaned_sql, dialect, warehouse_columns
250
+ cleaned_sql, dialect, warehouse_columns, expression=parsed
251
251
  )
252
252
  if was_qualified and qualified_ast is not None:
253
253
  parsed = qualified_ast
@@ -115,6 +115,9 @@ def model_map(nodes: list[dict], edges) -> dict:
115
115
  it, a dashboard number past a dashboard prefix, and a model otherwise.
116
116
  Star and placeholder columns are links, never columns."""
117
117
  members: dict[str, dict] = {}
118
+ # the loader's identity says whether a folder is the table; kept aside so
119
+ # the page payload does not carry it
120
+ uids = {n["name"]: n.get("id") or "" for n in nodes}
118
121
  for n in nodes:
119
122
  source = n["type"] == "source"
120
123
  columns = [c for c in n.get("columns") or [] if _is_column(c)]
@@ -171,19 +174,22 @@ def model_map(nodes: list[dict], edges) -> dict:
171
174
  links = _links(edges)
172
175
  layers = _layers(members, links)
173
176
  models = [{**m, "layer": layers[m["id"]]} for m in members.values()]
174
- _name_groups(models)
177
+ labels = _name_groups(models, uids)
175
178
  ordered = _ordered(models, links)
176
- return {"models": ordered, "links": links, "groups": _groups(ordered, links)}
179
+ return {"models": ordered, "links": links, "groups": _groups(ordered, links, labels)}
177
180
 
178
181
 
179
- def group_of(member: dict) -> str:
180
- """Where a model lives, as a person would say it: the folder of a model,
181
- the schema of a source table, "dashboards" for a dashboard number.
182
+ def group_of(member: dict, uid: str = "", table_folders: frozenset[str] = frozenset()) -> str:
183
+ """Where a model lives, unshortened: the folder of a model, the schema of
184
+ a source table, "dashboards" for a dashboard number.
182
185
 
183
- A file named after its model sits in its folder (`models/marts/orders.sql`
184
- is in `models/marts`). A file with a generic name names its folder as the
185
- table (`telemetry_derived/clients_v1/query.sql`), so the group is the
186
- folder above. A model with no path is in "models"."""
186
+ A model file sits in its folder (`models/marts/orders.sql` is in
187
+ `models/marts`), whatever the model is called: a monorepo may name it
188
+ `models__marts__orders`, and a dbt model may be called query. When the
189
+ loader itself took a folder as the table (`telemetry_derived/clients_v1/
190
+ query.sql`, a schema.yaml sidecar) the group is the folder above, for
191
+ that model and for every other file in the table's folder (a script.sql
192
+ beside the query). A model with no path is in "models"."""
187
193
  if member["kind"] == "dashboard":
188
194
  return "dashboards"
189
195
  if member["kind"] == "source":
@@ -192,55 +198,95 @@ def group_of(member: dict) -> str:
192
198
  path = member.get("path")
193
199
  if not path:
194
200
  return "models"
195
- parts = path.replace("\\", "/").split("/")
196
- stem = parts[-1].rsplit(".", 1)[0]
197
- folders = parts[:-1]
198
- if folders and stem != member["id"]:
201
+ folders = path.replace("\\", "/").split("/")[:-1]
202
+ if table_folder(member, uid) or "/".join(folders).lower() in table_folders:
199
203
  folders = folders[:-1]
200
204
  return "/".join(folders) or "models"
201
205
 
202
206
 
207
+ def table_folder(member: dict, uid: str = "") -> str | None:
208
+ """The folder a model's loader took as the table, lowercased, or None.
209
+
210
+ That happened when the model's name, or the relation in its uid
211
+ (path::relation, possibly dataset.table), is the last folder and the
212
+ file is called something else (query.sql, schema.yaml). A file that is
213
+ the folder's namesake, or any file in a dbt project, keeps its folder."""
214
+ path = member.get("path")
215
+ if not path or member["kind"] != "model":
216
+ return None
217
+ parts = path.replace("\\", "/").split("/")
218
+ folders, stem = parts[:-1], parts[-1].rsplit(".", 1)[0]
219
+ if not folders:
220
+ return None
221
+ folder = folders[-1].lower()
222
+ relation = uid.rpartition("::")[-1].rpartition(".")[-1].lower()
223
+ name = member["id"].lower()
224
+ named_after_folder = folder in (name, relation) or name.endswith("__" + folder)
225
+ if named_after_folder and stem.lower() != folder:
226
+ return "/".join(folders).lower()
227
+ return None
228
+
229
+
203
230
  def _shorten(names: set[str], sep: str) -> dict[str, str]:
204
- """Drop the leading segments every name shares, so `models/staging` and
205
- `models/marts` read as `staging` and `marts`. A lone name keeps itself."""
231
+ """Labels: drop the leading segments every deep name shares, so
232
+ `models/staging` and `models/marts` read as `staging` and `marts`. A lone
233
+ name keeps itself, and `models/core` next to `models/staging/core` keeps
234
+ enough to tell them apart."""
206
235
  parts = {n: n.split(sep) for n in names}
207
236
  while True:
208
237
  deep = [p for p in parts.values() if len(p) > 1]
209
238
  if len(deep) < 2 or len({p[0] for p in deep}) != 1:
210
239
  break
211
- parts = {n: p[1:] if len(p) > 1 else p for n, p in parts.items()}
240
+ nxt = {n: p[1:] if len(p) > 1 else p for n, p in parts.items()}
241
+ # a strip that makes two names read the same stops here
242
+ if len({sep.join(p) for p in nxt.values()}) < len(nxt):
243
+ break
244
+ parts = nxt
212
245
  return {n: sep.join(p) for n, p in parts.items()}
213
246
 
214
247
 
215
248
  SOURCE_SCHEMAS = 6
216
249
 
217
250
 
218
- def _name_groups(models: list[dict]) -> None:
219
- """Set each model's group, shortened per kind, in place. A project that
220
- reads from more than a handful of schemas gets one "sources" group: a
221
- row per schema would be a page of one-table rows, and the chip already
222
- carries the schema in its name."""
223
- raw = {m["id"]: group_of(m) for m in models}
224
- short: dict[str, str] = {}
225
- for kind, sep in (("model", "/"), ("source", ".")):
251
+ def _name_groups(models: list[dict], uids: dict[str, str]) -> dict[str, str]:
252
+ """Set each model's group in place and return the label of every group.
253
+
254
+ The group is an identity, never shown: the kind and the unshortened
255
+ folder or schema (`model:models/marts`, `source:raw`), so a schema and a
256
+ folder of one name, or a folder called dashboards, stay separate rows.
257
+ The label is what the page prints. A project that reads
258
+ from more than a handful of schemas gets one "sources" group: a row per
259
+ schema would be a page of one-table rows, and the chip already carries
260
+ the schema in its name."""
261
+ tables = frozenset(t for m in models if (t := table_folder(m, uids.get(m["id"], ""))))
262
+ raw = {m["id"]: group_of(m, uids.get(m["id"], ""), tables) for m in models}
263
+ labels: dict[str, str] = {}
264
+ for kind, sep in (("model", "/"), ("source", "."), ("dashboard", "/")):
265
+ prefix = kind + ":"
226
266
  names = {raw[m["id"]] for m in models if m["kind"] == kind}
227
267
  if kind == "source" and len(names) > SOURCE_SCHEMAS:
228
- short.update({n: "sources" for n in names})
229
- continue
230
- short.update(_shorten(names, sep))
268
+ for m in models:
269
+ if m["kind"] == kind:
270
+ raw[m["id"]] = "sources"
271
+ names = {"sources"}
272
+ for name, label in _shorten(names, sep).items():
273
+ labels[prefix + name] = label
231
274
  for m in models:
232
- m["group"] = short.get(raw[m["id"]], raw[m["id"]])
275
+ m["group"] = m["kind"] + ":" + raw[m["id"]]
276
+ return labels
233
277
 
234
278
 
235
279
  GROUP_RANK = {"source": 0, "model": 1, "dashboard": 2}
236
280
 
237
281
 
238
- def _groups(models: list[dict], links: list[dict]) -> list[dict]:
282
+ def _groups(models: list[dict], links: list[dict], labels: dict[str, str]) -> list[dict]:
239
283
  """The groups in the order the data flows: source schemas, then model
240
284
  folders by the mean layer of their models, then dashboards. Each carries
241
- how many models it holds and how many models outside it read one of its."""
285
+ its label, how many models it holds and how many models outside it read
286
+ one of its; a dashboard number is not a reader."""
242
287
  by: dict[str, dict] = {}
243
288
  of: dict[str, str] = {}
289
+ kind_of: dict[str, str] = {}
244
290
  for m in models:
245
291
  g = by.setdefault(
246
292
  m["group"],
@@ -248,19 +294,25 @@ def _groups(models: list[dict], links: list[dict]) -> list[dict]:
248
294
  )
249
295
  g["n"] += 1
250
296
  g["layers"].append(m["layer"])
251
- if GROUP_RANK[m["kind"]] > GROUP_RANK[g["kind"]]:
252
- g["kind"] = m["kind"]
253
297
  of[m["id"]] = m["group"]
298
+ kind_of[m["id"]] = m["kind"]
254
299
  for link in links:
255
300
  src, dst = of.get(link["src"]), of.get(link["dst"])
256
- if src and dst and src != dst:
301
+ if src and dst and src != dst and kind_of[link["dst"]] != "dashboard":
257
302
  by[src]["readers"].add(link["dst"])
258
303
  out = sorted(
259
304
  by.values(),
260
305
  key=lambda g: (GROUP_RANK[g["kind"]], sum(g["layers"]) / len(g["layers"]), g["id"]),
261
306
  )
262
307
  return [
263
- {"id": g["id"], "kind": g["kind"], "n": g["n"], "readers": len(g["readers"])} for g in out
308
+ {
309
+ "id": g["id"],
310
+ "label": labels.get(g["id"], g["id"]),
311
+ "kind": g["kind"],
312
+ "n": g["n"],
313
+ "readers": len(g["readers"]),
314
+ }
315
+ for g in out
264
316
  ]
265
317
 
266
318
 
@@ -301,7 +301,7 @@ def _merge_self_read_derivations(project: Project, group: list[Model]) -> list[M
301
301
  return kept
302
302
 
303
303
 
304
- ROLE_STEMS = frozenset({"query", "view", "model", "table", "select", "main", "definition"})
304
+ ROLE_STEMS = frozenset({"query", "view", "model", "table", "select", "main", "definition", "udf"})
305
305
 
306
306
 
307
307
  def directory_identity(rel: str) -> tuple[str, set[str]] | None:
@@ -313,8 +313,9 @@ def directory_identity(rel: str) -> tuple[str, set[str]] | None:
313
313
  collision handler mangled them into path names no reference could match,
314
314
  and a 2,096-model repo came out with 0 verified edges. The directory is
315
315
  the relation; its two nearest ancestors are the qualified spellings.
316
- Returns None when the stem carries identity as usual. checks.sql and
317
- script.sql are deliberately not roles: a check reads the relation, it
316
+ udf.sql is a role too: Mozilla keeps 155 of them at udf_js/<function>/,
317
+ and the directory is the function. Returns None when the stem carries
318
+ identity as usual. checks.sql and script.sql are deliberately not roles: a check reads the relation, it
318
319
  does not define it, and claiming the directory would collide with the
319
320
  real definition beside it.
320
321
  """
@@ -18,6 +18,7 @@ matters; unknown macros degrade to NULL (parseable, no fake lineage).
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import ast as _ast
21
22
  import datetime as _datetime
22
23
  import itertools as _itertools
23
24
  import re
@@ -288,25 +289,35 @@ def _as_bool(value):
288
289
 
289
290
 
290
291
  def _as_number(value):
292
+ """dbt's as_number: a number stays what it is (1.5 is not 1), a string
293
+ becomes an int or a float, anything else comes back untouched."""
294
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
295
+ return value
296
+ text = str(value).strip()
291
297
  try:
292
- return int(value)
293
- except (TypeError, ValueError):
298
+ return int(text)
299
+ except ValueError:
294
300
  pass
295
301
  try:
296
- return float(value)
297
- except (TypeError, ValueError):
302
+ return float(text)
303
+ except ValueError:
298
304
  return value
299
305
 
300
306
 
307
+ # a literal past this length never reaches the parser: deep enough nesting
308
+ # can overflow the C parser outright, past any except clause, and no dbt
309
+ # var is a tenth of it
310
+ _LITERAL_MAX = 10_000
311
+
312
+
301
313
  def _as_native(value):
302
- """dbt's as_native: a string read back as YAML, anything else untouched."""
303
- if not isinstance(value, str):
314
+ """dbt's as_native: a string read as a Python literal (a tuple, a dict
315
+ with None in it), the string itself when it is not one."""
316
+ if not isinstance(value, str) or len(value) > _LITERAL_MAX:
304
317
  return value
305
318
  try:
306
- import yaml
307
-
308
- return yaml.safe_load(value)
309
- except Exception:
319
+ return _ast.literal_eval(value.strip())
320
+ except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
310
321
  return value
311
322
 
312
323
 
@@ -144,8 +144,8 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
144
144
  .gcell:hover{background:var(--well)}
145
145
  .grow.sel .gcell{background:var(--accent); color:#fff}
146
146
  .grow.sel .gcell small{color:rgba(255,255,255,.72)}
147
- .grow.up .gcell{background:#E4F1F4; box-shadow:inset 3px 0 0 var(--accent)}
148
- .grow.down .gcell{background:var(--well); box-shadow:inset 3px 0 0 var(--ink)}
147
+ .grow.up .gcell{background:#E4F1F4; color:var(--accent-strong)}
148
+ .grow.down .gcell{background:var(--well)}
149
149
  .index .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:center}
150
150
  .index .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
151
151
  .index .gname{flex-basis:100%}
@@ -112,9 +112,11 @@
112
112
  function renderBoard(a){
113
113
  Array.prototype.slice.call(board.querySelectorAll('.lvl')).forEach(function(el){ el.remove(); });
114
114
  wires.innerHTML = '';
115
- var via = {};
115
+ // model and column names are user data: a model called constructor
116
+ // must not meet an inherited property, so every lookup is a bare dict
117
+ var via = dict();
116
118
  a.edges.forEach(function(e){ if (!(e.dst in via)) via[e.dst] = e; });
117
- var byDepth = {}, maxD = 0;
119
+ var byDepth = dict(), maxD = 0;
118
120
  a.nodes.forEach(function(n){ (byDepth[n.depth] = byDepth[n.depth] || []).push(n); if (n.depth > maxD) maxD = n.depth; });
119
121
  var depths = []; for (var d = 0; d <= maxD; d++) if (byDepth[d]) depths.push(d);
120
122
  if (a.kind === 'trace') depths.reverse();
@@ -124,7 +126,7 @@
124
126
  head.textContent = d === 0 ? (a.kind === 'trace' ? 'You asked about' : 'Your change')
125
127
  : plural(d, 'step') + (a.kind === 'trace' ? ' back' : '');
126
128
  lvl.appendChild(head);
127
- var groups = {}, order = [];
129
+ var groups = dict(), order = [];
128
130
  byDepth[d].forEach(function(n){ if (!groups[n.model]){ groups[n.model] = []; order.push(n.model); } groups[n.model].push(n); });
129
131
  order.forEach(function(m){
130
132
  var g = document.createElement('div'); g.className = 'grp';
@@ -157,9 +159,9 @@
157
159
  if (window.innerWidth <= 680) return;
158
160
  var base = board.getBoundingClientRect();
159
161
  wires.setAttribute('width', board.scrollWidth); wires.setAttribute('height', board.scrollHeight);
160
- var chips = {};
162
+ var chips = dict();
161
163
  Array.prototype.slice.call(board.querySelectorAll('.node')).forEach(function(el){ chips[el.dataset.id] = el; });
162
- var byId = {}; a.nodes.forEach(function(n){ byId[n.id] = n; });
164
+ var byId = dict(); a.nodes.forEach(function(n){ byId[n.id] = n; });
163
165
  a.edges.forEach(function(e){
164
166
  var from = chips[e.src], to = chips[e.dst];
165
167
  if (!from || !to) return;
@@ -253,6 +255,7 @@
253
255
  onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); },
254
256
  onFocus: function(id){ go(id, true); },
255
257
  onHome: function(){ goHome(true); },
258
+ onPin: function(){ invalidate(); },
256
259
  onFilter: function(n){ hiddenNote.hidden = !n; hiddenNote.textContent = n ? plural(n, 'model') + ' hidden by the filter' : ''; }});
257
260
  if (mapApi.compact){
258
261
  document.getElementById('key-feeds').hidden = true;
@@ -268,17 +271,23 @@
268
271
  if (found) return found;
269
272
  found = [];
270
273
  if (!L.map) return found;
271
- var feeds = {};
272
- L.map.links.forEach(function(l){ feeds[l.src] = (feeds[l.src] || 0) + 1; });
274
+ // rank: how many models read the model; readers: the number shown,
275
+ // a column's own count (a dashboard number is not a reader)
276
+ var kind = Object.create(null), feeds = Object.create(null);
277
+ L.map.models.forEach(function(m){ kind[m.id] = m.kind; });
278
+ L.map.links.forEach(function(l){ if (kind[l.dst] !== 'dashboard') feeds[l.src] = (feeds[l.src] || 0) + 1; });
273
279
  L.map.models.forEach(function(m){
274
- found.push({id: m.id, kind: m.kind, feeds: feeds[m.id] || 0});
275
- if (m.kind !== 'dashboard') m.columns.forEach(function(c){ found.push({id: m.id + '.' + c, kind: 'column', feeds: feeds[m.id] || 0}); });
280
+ var n = feeds[m.id] || 0;
281
+ found.push({id: m.id, kind: m.kind, rank: n, readers: n});
282
+ if (m.kind !== 'dashboard') m.columns.forEach(function(c, i){
283
+ found.push({id: m.id + '.' + c, kind: 'column', rank: n, readers: m.reads ? m.reads[i] : 0});
284
+ });
276
285
  });
277
286
  return found;
278
287
  },
279
288
  onPick: function(c){
280
289
  if (c.kind === 'column' || !mapApi){ ask(c.id, document.getElementById('dir').value); return; }
281
- document.getElementById('q').value = '';
290
+ document.getElementById('q').value = ''; mapApi.filter('');
282
291
  go(c.id, true);
283
292
  }});
284
293
  var ladder = document.getElementById('ladder'); ladder.hidden = false;
@@ -298,19 +307,28 @@
298
307
  ev.preventDefault();
299
308
  var target = document.getElementById('q').value.trim();
300
309
  if (!target) return;
301
- if (mapApi && !/\./.test(target) && mapApi.focus(target)){ document.getElementById('q').value = ''; go(target, true); return; }
310
+ if (mapApi && !/\./.test(target) && mapApi.focus(target)){ document.getElementById('q').value = ''; mapApi.filter(''); go(target, true); return; }
302
311
  ask(target, document.getElementById('dir').value);
303
312
  });
304
313
  }
305
314
  // one model's neighbourhood, the index of all models, or an answer: the
306
315
  // address bar says which, so Back and a shared link land on the same view
316
+ var asking = 0, ASKING = 'asking the local server…';
317
+ // any move on the page outdates the answer still on its way: the reply is
318
+ // dropped when it lands, and the line under the box stops saying "asking"
319
+ function invalidate(){
320
+ asking += 1;
321
+ if (fine.textContent === ASKING) fine.textContent = LIVE_FINE;
322
+ }
307
323
  function go(id, push){
324
+ invalidate();
308
325
  if (current || mapEl.hidden) home();
309
326
  if (!mapApi.focus(id)){ fine.textContent = 'No model called ' + id + ' in this project.'; return; }
310
327
  hint(mapApi.compact ? HINT_FOCUS : HINT_SMALL);
311
328
  if (push) history.pushState({m: id}, '', '?m=' + encodeURIComponent(id));
312
329
  }
313
330
  function goHome(push){
331
+ invalidate();
314
332
  if (current || mapEl.hidden) home();
315
333
  if (mapApi){ mapApi.home(); hint(mapApi.compact ? HINT_INDEX : HINT_SMALL); requestAnimationFrame(mapApi.draw); }
316
334
  if (push) history.pushState({}, '', location.pathname);
@@ -325,10 +343,12 @@
325
343
  else goHome(false);
326
344
  }
327
345
  function ask(target, dir, silent){
328
- fine.textContent = 'asking the local server…';
346
+ var mine = ++asking;
347
+ fine.textContent = ASKING;
329
348
  fetch(L.api + '/' + dir + '?target=' + encodeURIComponent(target), {cache: 'no-store'}).then(function(r){
330
349
  return r.json().then(function(j){ return [r.ok, j]; });
331
350
  }).then(function(pair){
351
+ if (mine !== asking) return;
332
352
  var ok = pair[0], j = pair[1];
333
353
  if (!ok){
334
354
  var msg = j.error || 'no answer';
@@ -339,7 +359,7 @@
339
359
  fine.textContent = LIVE_FINE;
340
360
  show(j.answer, j.text);
341
361
  if (!silent) history.pushState({q: target}, '', '?q=' + encodeURIComponent(target) + '&dir=' + dir);
342
- }).catch(function(){ fine.textContent = 'The local server did not answer. Is ripple serve still running?'; });
362
+ }).catch(function(){ if (mine === asking) fine.textContent = 'The local server did not answer. Is ripple serve still running?'; });
343
363
  }
344
364
  // SERVE END
345
365