ripple-sql 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The pure half of the answer page: the text twin of ripple.answer and the
|
|
2
|
+
// graph twin of graph.breaks and graph.trace. No DOM. answer_page.py inlines
|
|
3
|
+
// this file into answer.html at render, and the tests run it under node.
|
|
4
|
+
// TEXT TWIN START
|
|
5
|
+
// A port of ripple.answer.breaks_lines and trace_lines. Pure functions, no DOM.
|
|
6
|
+
// tests/test_answer_page.py runs this block under node and compares it to Python.
|
|
7
|
+
var KIND_WORDS = {semantic: 'semantic model', metric: 'metric', exposure: 'exposure'};
|
|
8
|
+
function plural(n, w){ return n + ' ' + w + (n === 1 ? '' : 's'); }
|
|
9
|
+
function isDash(model){ return /^(metric|semantic|exposure):/.test(model); }
|
|
10
|
+
function headline(a){
|
|
11
|
+
var s = a.summary, parts = [];
|
|
12
|
+
if (s.models) parts.push(plural(s.models, a.noun));
|
|
13
|
+
if (s.dashboard_numbers) parts.push(plural(s.dashboard_numbers, 'dashboard number'));
|
|
14
|
+
var where = parts.length ? parts.join(' and ') : '0 ' + a.noun + 's';
|
|
15
|
+
return a.kind === 'trace'
|
|
16
|
+
? a.target + ' comes from ' + s.columns + ' columns in ' + where
|
|
17
|
+
: s.columns + ' columns in ' + where + ' affected by ' + a.target;
|
|
18
|
+
}
|
|
19
|
+
function hasHits(a){ var s = a.summary; return !!(s.columns || s.dashboard_numbers || (a.row_level && a.row_level.length)); }
|
|
20
|
+
function breaksText(a){
|
|
21
|
+
var s = a.summary, out = [];
|
|
22
|
+
if (!hasHits(a)) return 'Nothing downstream reads ' + a.target + '.';
|
|
23
|
+
out.push(headline(a));
|
|
24
|
+
if (s.review) out.push(s.review + ' of them need review (lineage uncertain)');
|
|
25
|
+
var groups = {}, order = [];
|
|
26
|
+
a.nodes.forEach(function(n){ if (n.depth === 0 || n.kind === 'unknown') return; if (!groups[n.model]){ groups[n.model] = []; order.push(n.model); } groups[n.model].push(n); });
|
|
27
|
+
var models = order.filter(function(m){ return !isDash(m); }), dashes = order.filter(isDash);
|
|
28
|
+
if (models.length > 1){
|
|
29
|
+
var worst = models.map(function(m){ return [m, groups[m].length]; }).sort(function(x, y){ return y[1] - x[1]; }).slice(0, 3);
|
|
30
|
+
out.push('Hit hardest: ' + worst.map(function(x){ return x[0] + ' (' + x[1] + ')'; }).join(', '));
|
|
31
|
+
}
|
|
32
|
+
out.push('');
|
|
33
|
+
var via = {}; a.edges.forEach(function(e){ if (!(e.dst in via)) via[e.dst] = e.src; });
|
|
34
|
+
models.forEach(function(m){
|
|
35
|
+
var ns = groups[m], d = Math.min.apply(null, ns.map(function(n){ return n.depth; }));
|
|
36
|
+
out.push(' ' + m + ' · ' + plural(d, 'step'));
|
|
37
|
+
var g = {}, go = [];
|
|
38
|
+
ns.forEach(function(n){
|
|
39
|
+
var src = via[n.id] || '?', i = src.lastIndexOf('.'), sm = src.slice(0, i), sc = src.slice(i + 1);
|
|
40
|
+
var label = (sm && sc === n.column) ? sm : src, review = n.trust === 'review_required';
|
|
41
|
+
var key = label + '|' + review;
|
|
42
|
+
if (!g[key]){ g[key] = {label: label, review: review, cols: []}; go.push(key); }
|
|
43
|
+
g[key].cols.push(n.column);
|
|
44
|
+
});
|
|
45
|
+
go.forEach(function(k){ var x = g[k]; out.push(' ' + x.cols.join(', ') + ' ← ' + x.label + (x.review ? ' needs review' : '')); });
|
|
46
|
+
});
|
|
47
|
+
if (dashes.length){
|
|
48
|
+
out.push(''); out.push('Dashboard numbers');
|
|
49
|
+
var ent = {}, eo = [];
|
|
50
|
+
dashes.forEach(function(m){
|
|
51
|
+
var kind = m.split(':')[0], rest = m.slice(kind.length + 1), entity, cols;
|
|
52
|
+
if (kind === 'semantic'){ var i = rest.lastIndexOf('.'); entity = i > 0 ? rest.slice(0, i) : rest; cols = groups[m].map(function(n){ return n.column; }); }
|
|
53
|
+
else { entity = kind; cols = [rest]; }
|
|
54
|
+
var key = entity + '|' + kind;
|
|
55
|
+
if (!ent[key]){ ent[key] = {entity: entity, kind: kind, cols: [], review: false}; eo.push(key); }
|
|
56
|
+
ent[key].cols = ent[key].cols.concat(cols);
|
|
57
|
+
if (groups[m].some(function(n){ return n.trust === 'review_required'; })) ent[key].review = true;
|
|
58
|
+
});
|
|
59
|
+
eo.forEach(function(k){
|
|
60
|
+
var x = ent[k], line = x.entity === x.kind ? ' ' + x.kind + 's: ' + x.cols.join(', ') : ' ' + x.entity + ' (' + KIND_WORDS[x.kind] + '): ' + x.cols.join(', ');
|
|
61
|
+
out.push(line + (x.review ? ' needs review' : ''));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
a.row_level.forEach(function(r){
|
|
65
|
+
var uses = r.uses || [], first = uses.length ? uses[0] : '', m = first.match(/\((\w+)\)$/);
|
|
66
|
+
var kinds = {}; uses.forEach(function(u){ var mm = u.match(/\((\w+)\)$/); if (mm) kinds[mm[1]] = 1; });
|
|
67
|
+
var note = (Object.keys(kinds).length === 1 && kinds.window) ? 'partitions or orders a window on it' : 'filters rows on it (' + (m ? m[1] : 'filter') + ')';
|
|
68
|
+
out.push(' ' + r.model + ' · ' + note + (r.trust === 'review_required' ? ' needs review' : ''));
|
|
69
|
+
});
|
|
70
|
+
if (!s.complete){ var cap = a.truncated_at_depth; out.push(' stopped ' + (cap ? 'at depth ' + cap : 'at the depth cap') + '; the full radius may be larger'); }
|
|
71
|
+
out.push('');
|
|
72
|
+
return out.join('\n');
|
|
73
|
+
}
|
|
74
|
+
function traceText(a){
|
|
75
|
+
var byId = {}; a.nodes.forEach(function(n){ byId[n.id] = n; });
|
|
76
|
+
var hops = a.edges.filter(function(e){ return !(byId[e.src] && byId[e.src].kind === 'unknown'); });
|
|
77
|
+
if (!hops.length) return 'No upstream lineage for ' + a.target + '.';
|
|
78
|
+
var out = [a.target + ' comes from:'];
|
|
79
|
+
hops.forEach(function(e){
|
|
80
|
+
var n = byId[e.src], pad = ''; for (var i = 0; i < n.depth; i++) pad += ' ';
|
|
81
|
+
out.push(pad + e.src + ((e.path_trust || n.trust) === 'review_required' ? ' review' : '') + ' → ' + e.dst);
|
|
82
|
+
});
|
|
83
|
+
if (a.truncated_at_depth) out.push(' stopped at depth ' + a.truncated_at_depth + '; the trail may go further back (--depth to follow it)');
|
|
84
|
+
return out.join('\n');
|
|
85
|
+
}
|
|
86
|
+
// TEXT TWIN END
|
|
87
|
+
|
|
88
|
+
// GRAPH TWIN START
|
|
89
|
+
// A port of LineageGraph.breaks/trace and ripple.answer.breaks_answer/trace_answer
|
|
90
|
+
// over the compact export the page carries (answer_page.compact_edges). Pure functions, no DOM.
|
|
91
|
+
// tests/test_answer_page.py runs this block under node and compares it to Python.
|
|
92
|
+
var TRUST = {v: 'verified', h: 'high_confidence', m: 'moderate', r: 'review_required'};
|
|
93
|
+
var RANK = {verified: 0, high_confidence: 1, moderate: 2, review_required: 3};
|
|
94
|
+
var MAX_DEPTH = 25;
|
|
95
|
+
function worse(){ var w = arguments[0]; for (var i = 1; i < arguments.length; i++) if (RANK[arguments[i]] > RANK[w]) w = arguments[i]; return w; }
|
|
96
|
+
function cmp(a, b){ return a < b ? -1 : a > b ? 1 : 0; }
|
|
97
|
+
function splitId(id){ var i = id.lastIndexOf('.'); return i < 0 ? ['', id] : [id.slice(0, i), id.slice(i + 1)]; }
|
|
98
|
+
function buildIndex(G){
|
|
99
|
+
var index = {down: Object.create(null), downByModel: Object.create(null), up: Object.create(null), ids: Object.create(null), names: Object.create(null)};
|
|
100
|
+
var reasons = G.reasons || [];
|
|
101
|
+
// an export written before names travelled, and the change page, carry none
|
|
102
|
+
var given = G.names || {};
|
|
103
|
+
Object.keys(given).forEach(function(spelling){ index.names[spelling] = given[spelling]; });
|
|
104
|
+
G.edges.forEach(function(row){
|
|
105
|
+
var s = splitId(row[0]), d = splitId(row[1]);
|
|
106
|
+
var e = {src: row[0], dst: row[1], srcModel: s[0], srcColumn: s[1], dstModel: d[0], dstColumn: d[1],
|
|
107
|
+
trust: TRUST[row[2]] || 'review_required', kind: row[3] || 'value',
|
|
108
|
+
reason: row.length > 4 ? (reasons[row[4]] || '') : ''};
|
|
109
|
+
index.ids[e.src] = 1; index.ids[e.dst] = 1;
|
|
110
|
+
(index.down[e.src] = index.down[e.src] || []).push(e);
|
|
111
|
+
(index.downByModel[e.srcModel] = index.downByModel[e.srcModel] || []).push(e);
|
|
112
|
+
(index.up[e.dst] = index.up[e.dst] || []).push(e);
|
|
113
|
+
});
|
|
114
|
+
(G.columns || []).forEach(function(id){ index.ids[id] = 1; });
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
117
|
+
// graph._askable_name over the export: the shortest dotted spelling that means
|
|
118
|
+
// only this model, so an ambiguity names models the reader can actually type
|
|
119
|
+
function askableSpelling(names, canonical){
|
|
120
|
+
var best = null;
|
|
121
|
+
for (var spelling in names){
|
|
122
|
+
var means = names[spelling];
|
|
123
|
+
if (means.length !== 1 || means[0] !== canonical || spelling.indexOf('.') < 0) continue;
|
|
124
|
+
if (best === null || spelling.length < best.length) best = spelling;
|
|
125
|
+
}
|
|
126
|
+
return best === null ? canonical : best;
|
|
127
|
+
}
|
|
128
|
+
// the one place a typed name becomes an id, a port of graph._require_target.
|
|
129
|
+
// Returns {id: ...} or {error: ..., candidates: [...]}; the page must take
|
|
130
|
+
// every spelling the command takes, and refuse the ones it refuses
|
|
131
|
+
function resolveTarget(index, typed){
|
|
132
|
+
var asked = String(typed === null || typed === undefined ? '' : typed).trim();
|
|
133
|
+
if (!asked) return {error: 'Ask for a column as model.column'};
|
|
134
|
+
var parts = splitId(asked), model = parts[0], column = parts[1];
|
|
135
|
+
var names = index.names || {}, canonical = model ? names[model.toLowerCase()] : null, also = [];
|
|
136
|
+
if (canonical && canonical.length > 1){
|
|
137
|
+
// a spelling that IS one model's own name is that model, even when
|
|
138
|
+
// another model carries it as an alias
|
|
139
|
+
var exact = canonical.filter(function(c){ return c.toLowerCase() === model.toLowerCase(); });
|
|
140
|
+
if (exact.length === 1){
|
|
141
|
+
also = canonical.filter(function(c){ return c !== exact[0]; })
|
|
142
|
+
.map(function(c){ return askableSpelling(names, c); }).sort(cmp);
|
|
143
|
+
canonical = exact;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (canonical && canonical.length > 1){
|
|
147
|
+
return {
|
|
148
|
+
error: "'" + model + "' names " + canonical.length + ' different models; ask by full name',
|
|
149
|
+
candidates: canonical.map(function(c){ return askableSpelling(names, c); }).sort(cmp)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
var tries = canonical ? [canonical[0] + '.' + column, canonical[0] + '.' + column.toLowerCase()] : [];
|
|
153
|
+
// the command lowercases a spelling before looking it up, so the page must
|
|
154
|
+
tries.push(asked, model + '.' + column.toLowerCase(), model.toLowerCase() + '.' + column.toLowerCase());
|
|
155
|
+
for (var i = 0; i < tries.length; i++){
|
|
156
|
+
if (index.ids[tries[i]]) return also.length ? {id: tries[i], alsoMatches: also} : {id: tries[i]};
|
|
157
|
+
}
|
|
158
|
+
return {error: 'No column named ' + asked + ' in this graph'};
|
|
159
|
+
}
|
|
160
|
+
function mkNode(model, column, depth, trust){
|
|
161
|
+
return {id: model + '.' + column, model: model, column: column, kind: isDash(model) ? 'dashboard' : 'model', depth: depth, trust: trust};
|
|
162
|
+
}
|
|
163
|
+
function deepest(nodes){ return Math.max.apply(null, nodes.map(function(n){ return n.depth; })); }
|
|
164
|
+
function mergeNodes(nodes){
|
|
165
|
+
var byId = Object.create(null), out = [];
|
|
166
|
+
nodes.forEach(function(n){
|
|
167
|
+
var seen = byId[n.id];
|
|
168
|
+
if (!seen){ seen = byId[n.id] = {id: n.id, model: n.model, column: n.column, kind: n.kind, depth: n.depth, trust: n.trust}; out.push(seen); return; }
|
|
169
|
+
seen.depth = Math.min(seen.depth, n.depth); seen.trust = worse(seen.trust, n.trust);
|
|
170
|
+
});
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
// a link may name a column the walk never listed (a wildcard, or a column reached
|
|
174
|
+
// through one); it gets a stand-in so nothing dangles. Links point away from the
|
|
175
|
+
// asked column on breaks and toward it on trace, so which end sits one step nearer depends on the kind
|
|
176
|
+
function closeEdges(nodes, edges, kind){
|
|
177
|
+
var byId = Object.create(null); nodes.forEach(function(n){ byId[n.id] = n; });
|
|
178
|
+
edges.forEach(function(e){
|
|
179
|
+
[['src', 'dst'], ['dst', 'src']].forEach(function(pair){
|
|
180
|
+
var id = e[pair[0]]; if (byId[id]) return;
|
|
181
|
+
var neighbour = byId[e[pair[1]]], parts = splitId(id), nearer = (pair[0] === 'src') === (kind === 'breaks');
|
|
182
|
+
var n = mkNode(parts[0], parts[1], Math.max(0, (neighbour ? neighbour.depth : 1) + (nearer ? -1 : 1)), 'review_required');
|
|
183
|
+
n.kind = 'unknown'; nodes.push(n); byId[id] = n;
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
return nodes;
|
|
187
|
+
}
|
|
188
|
+
// a node is expanded again when a worse path reaches it, so a downgrade
|
|
189
|
+
// propagates past a convergence point (each node expands at most RANK-many times)
|
|
190
|
+
function walkDown(index, target){
|
|
191
|
+
var expandedAt = Object.create(null), hits = Object.create(null), order = [], rows = Object.create(null), truncated = false;
|
|
192
|
+
var frontier = [[target, 0, 'verified']];
|
|
193
|
+
for (var head = 0; head < frontier.length; head++){
|
|
194
|
+
var node = frontier[head][0], depth = frontier[head][1], pathTrust = frontier[head][2], previous = expandedAt[node];
|
|
195
|
+
if (previous !== undefined && RANK[pathTrust] <= RANK[previous]) continue;
|
|
196
|
+
if (depth >= MAX_DEPTH){ truncated = true; continue; }
|
|
197
|
+
expandedAt[node] = pathTrust;
|
|
198
|
+
var parts = splitId(node), nodeModel = parts[0], nodeColumn = parts[1];
|
|
199
|
+
var outgoing = nodeColumn === '*'
|
|
200
|
+
? (index.downByModel[nodeModel] || [])
|
|
201
|
+
: (index.down[node] || []).concat(index.down[nodeModel + '.*'] || []);
|
|
202
|
+
outgoing.forEach(function(e){
|
|
203
|
+
var star = e.srcColumn === '*' || e.dstColumn === '*' || nodeColumn === '*';
|
|
204
|
+
var trust = star ? worse(pathTrust, e.trust, 'review_required') : worse(pathTrust, e.trust);
|
|
205
|
+
if (e.kind === 'filter' || e.kind === 'join' || e.kind === 'window'){
|
|
206
|
+
var row = rows[e.dstModel] || (rows[e.dstModel] = {model: e.dstModel, uses: [], trust: trust});
|
|
207
|
+
row.uses.push(e.src + ' (' + e.kind + ')'); row.trust = worse(row.trust, trust);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
var hit = hits[e.dst];
|
|
211
|
+
if (!hit){ hits[e.dst] = {model: e.dstModel, column: e.dstColumn, via: e.src, trust: trust, edgeTrust: e.trust, reason: e.reason, depth: depth + 1}; order.push(e.dst); }
|
|
212
|
+
else hit.trust = worse(hit.trust, trust);
|
|
213
|
+
if (e.dstColumn.charAt(0) !== '(') frontier.push([e.dst, depth + 1, trust]);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// graph.breaks' key: depth, edge trust (verified first), model; the sort is stable so
|
|
217
|
+
// same-model hits keep discovery order, which is export order on both sides
|
|
218
|
+
var list = order.map(function(id){ return hits[id]; });
|
|
219
|
+
list.sort(function(a, b){ return a.depth - b.depth || RANK[a.edgeTrust] - RANK[b.edgeTrust] || cmp(a.model, b.model); });
|
|
220
|
+
var byModel = Object.create(null), modelOrder = [];
|
|
221
|
+
list.forEach(function(h){ if (!byModel[h.model]){ byModel[h.model] = []; modelOrder.push(h.model); } byModel[h.model].push(h); });
|
|
222
|
+
return {byModel: byModel, modelOrder: modelOrder, rows: rows, truncated: truncated};
|
|
223
|
+
}
|
|
224
|
+
function computeBreaks(index, target, noun){
|
|
225
|
+
var found = resolveTarget(index, target);
|
|
226
|
+
if (!found.id) return null;
|
|
227
|
+
var w = walkDown(index, found.id), t = splitId(found.id);
|
|
228
|
+
var asked = mkNode(t[0], t[1], 0, 'verified'); asked.kind = 'asked';
|
|
229
|
+
var nodes = [asked], edges = [], columns = 0, models = 0, dashboards = 0, review = 0;
|
|
230
|
+
w.modelOrder.forEach(function(m){
|
|
231
|
+
var hs = w.byModel[m];
|
|
232
|
+
if (isDash(m)) dashboards += 1; else { models += 1; columns += hs.length; }
|
|
233
|
+
hs.forEach(function(h){
|
|
234
|
+
var n = mkNode(h.model, h.column, h.depth, h.trust); nodes.push(n);
|
|
235
|
+
var edge = {src: h.via, dst: n.id, trust: h.edgeTrust, path_trust: h.trust};
|
|
236
|
+
if (h.reason) edge.reason = h.reason;
|
|
237
|
+
edges.push(edge);
|
|
238
|
+
if (h.trust === 'review_required') review += 1;
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
nodes = closeEdges(mergeNodes(nodes), edges, 'breaks');
|
|
242
|
+
var rows = Object.keys(w.rows).sort(cmp).map(function(m){ var r = w.rows[m]; return {model: r.model, uses: r.uses.slice(), trust: r.trust}; });
|
|
243
|
+
var a = {kind: 'breaks', target: asked.id, noun: noun,
|
|
244
|
+
summary: {columns: columns, models: models, dashboard_numbers: dashboards, review: review,
|
|
245
|
+
row_level_models: rows.length, deepest_hops: deepest(nodes), complete: !w.truncated},
|
|
246
|
+
nodes: nodes, edges: edges, row_level: rows};
|
|
247
|
+
if (w.truncated) a.truncated_at_depth = MAX_DEPTH;
|
|
248
|
+
if (found.alsoMatches) a.also_matches = found.alsoMatches;
|
|
249
|
+
return a;
|
|
250
|
+
}
|
|
251
|
+
function walkUp(index, target){
|
|
252
|
+
var expandedAt = Object.create(null), hops = Object.create(null), order = [], truncated = false;
|
|
253
|
+
var frontier = [[target, 0, 'verified']];
|
|
254
|
+
for (var head = 0; head < frontier.length; head++){
|
|
255
|
+
var node = frontier[head][0], depth = frontier[head][1], pathTrust = frontier[head][2], previous = expandedAt[node];
|
|
256
|
+
if (previous !== undefined && RANK[pathTrust] <= RANK[previous]) continue;
|
|
257
|
+
if (depth >= MAX_DEPTH){ truncated = true; continue; }
|
|
258
|
+
expandedAt[node] = pathTrust;
|
|
259
|
+
var parts = splitId(node), incoming = index.up[node] || [];
|
|
260
|
+
if (parts[1] !== '*') incoming = incoming.concat(index.up[parts[0] + '.*'] || []);
|
|
261
|
+
incoming.forEach(function(e){
|
|
262
|
+
if (e.kind !== 'value') return;
|
|
263
|
+
var trust = worse(pathTrust, e.trust), key = e.src + ' > ' + e.dst, hop = hops[key];
|
|
264
|
+
if (!hop){ hops[key] = {model: e.srcModel, column: e.srcColumn, feeds: e.dst, trust: trust, edgeTrust: e.trust, reason: e.reason, depth: depth + 1}; order.push(key); }
|
|
265
|
+
else hop.trust = worse(hop.trust, trust);
|
|
266
|
+
frontier.push([e.src, depth + 1, trust]);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
var list = order.map(function(k){ return hops[k]; });
|
|
270
|
+
list.sort(function(a, b){ return a.depth - b.depth || cmp(a.model, b.model) || cmp(a.column, b.column); });
|
|
271
|
+
return {hops: list, truncated: truncated};
|
|
272
|
+
}
|
|
273
|
+
function computeTrace(index, target, noun){
|
|
274
|
+
var found = resolveTarget(index, target);
|
|
275
|
+
if (!found.id) return null;
|
|
276
|
+
var w = walkUp(index, found.id), t = splitId(found.id);
|
|
277
|
+
var asked = mkNode(t[0], t[1], 0, 'verified'); asked.kind = 'asked';
|
|
278
|
+
var nodes = [asked], edges = [];
|
|
279
|
+
w.hops.forEach(function(h){
|
|
280
|
+
var n = mkNode(h.model, h.column, h.depth, h.trust); nodes.push(n);
|
|
281
|
+
var edge = {src: n.id, dst: h.feeds, trust: h.edgeTrust, path_trust: h.trust};
|
|
282
|
+
if (h.reason) edge.reason = h.reason;
|
|
283
|
+
edges.push(edge);
|
|
284
|
+
});
|
|
285
|
+
nodes = closeEdges(mergeNodes(nodes), edges, 'trace');
|
|
286
|
+
var upstream = nodes.filter(function(n){ return n.depth > 0; }), models = Object.create(null);
|
|
287
|
+
upstream.forEach(function(n){ models[n.model] = 1; });
|
|
288
|
+
var names = Object.keys(models);
|
|
289
|
+
var a = {kind: 'trace', target: asked.id, noun: noun,
|
|
290
|
+
summary: {columns: upstream.length,
|
|
291
|
+
models: names.filter(function(m){ return !isDash(m); }).length,
|
|
292
|
+
dashboard_numbers: names.filter(function(m){ return isDash(m); }).length,
|
|
293
|
+
review: upstream.filter(function(n){ return n.trust === 'review_required'; }).length,
|
|
294
|
+
row_level_models: 0, deepest_hops: deepest(nodes), complete: !w.truncated},
|
|
295
|
+
nodes: nodes, edges: edges, row_level: []};
|
|
296
|
+
if (w.truncated) a.truncated_at_depth = MAX_DEPTH;
|
|
297
|
+
return a;
|
|
298
|
+
}
|
|
299
|
+
// GRAPH TWIN END
|
ripple/static/explore.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
b.innerHTML = label(m) + (m.columns.length ? ' <small>' + m.columns.length + '</small>' : '');
|
|
51
|
+
var reads = (ups[m.id] || []).length, feeds = (downs[m.id] || []).length;
|
|
52
|
+
b.title = (m.path || m.id) + ' · reads ' + reads + ', feeds ' + feeds + (broken ? ' · ' + m.status.replace('_', ' ') : '');
|
|
53
|
+
b.addEventListener('click', function(){ toggle(m, g, b); });
|
|
54
|
+
b.addEventListener('mouseenter', function(){ focus(m.id); });
|
|
55
|
+
b.addEventListener('focus', function(){ focus(m.id); });
|
|
56
|
+
b.addEventListener('mouseleave', unfocus);
|
|
57
|
+
b.addEventListener('blur', unfocus);
|
|
58
|
+
g.appendChild(b); chips[m.id] = b; lvl.appendChild(g);
|
|
59
|
+
});
|
|
60
|
+
if (models.length > MAP_PER_LAYER){
|
|
61
|
+
var more = el('button', 'more-chips'); more.type = 'button';
|
|
62
|
+
more.textContent = '+ ' + (models.length - MAP_PER_LAYER) + ' more';
|
|
63
|
+
more.addEventListener('click', function(){
|
|
64
|
+
Array.prototype.slice.call(lvl.querySelectorAll('.grp')).forEach(function(x){ x.hidden = false; });
|
|
65
|
+
more.remove(); draw();
|
|
66
|
+
});
|
|
67
|
+
lvl.appendChild(more);
|
|
68
|
+
}
|
|
69
|
+
inner.appendChild(lvl);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function toggle(m, g, b){
|
|
73
|
+
var open = g.querySelector('.cols');
|
|
74
|
+
if (open){ open.remove(); b.setAttribute('aria-expanded', 'false'); draw(); return; }
|
|
75
|
+
var cols = el('div', 'cols');
|
|
76
|
+
m.columns.slice(0, MAP_COLUMNS).forEach(function(c){
|
|
77
|
+
var s = el('button', 'node in'); s.type = 'button'; s.textContent = c;
|
|
78
|
+
s.title = 'ask about ' + m.id + '.' + c;
|
|
79
|
+
s.addEventListener('click', function(){ opts.onAsk(m.id + '.' + c); });
|
|
80
|
+
cols.appendChild(s);
|
|
81
|
+
});
|
|
82
|
+
var note = null;
|
|
83
|
+
if (m.columns.length > MAP_COLUMNS) note = '+ ' + (m.columns.length - MAP_COLUMNS) + ' more, ask by name';
|
|
84
|
+
else if (!m.columns.length) note = m.kind === 'source' ? 'columns unknown, see Coverage' : 'no columns known';
|
|
85
|
+
if (note){ var n = el('span', 'gname'); n.textContent = note; cols.appendChild(n); }
|
|
86
|
+
g.appendChild(cols); b.setAttribute('aria-expanded', 'true'); draw();
|
|
87
|
+
}
|
|
88
|
+
function focus(id){
|
|
89
|
+
lit = chain(id); root.classList.add('focus');
|
|
90
|
+
Object.keys(chips).forEach(function(k){ chips[k].classList.toggle('on', !!lit[k]); });
|
|
91
|
+
if (many) draw();
|
|
92
|
+
else paths().forEach(function(p){ p.classList.toggle('on', !!(lit[p.dataset.from] && lit[p.dataset.to])); });
|
|
93
|
+
}
|
|
94
|
+
function unfocus(){
|
|
95
|
+
lit = null; root.classList.remove('focus');
|
|
96
|
+
Object.keys(chips).forEach(function(k){ chips[k].classList.remove('on'); });
|
|
97
|
+
if (many) draw();
|
|
98
|
+
else paths().forEach(function(p){ p.classList.remove('on'); });
|
|
99
|
+
}
|
|
100
|
+
function draw(){
|
|
101
|
+
svg.innerHTML = '';
|
|
102
|
+
if (root.hidden || window.innerWidth <= 680 || (many && !lit)) return;
|
|
103
|
+
var base = inner.getBoundingClientRect();
|
|
104
|
+
svg.setAttribute('width', inner.scrollWidth); svg.setAttribute('height', inner.scrollHeight);
|
|
105
|
+
map.links.forEach(function(l){
|
|
106
|
+
if (many && !(lit[l.src] && lit[l.dst])) return;
|
|
107
|
+
var from = chips[l.src], to = chips[l.dst];
|
|
108
|
+
if (!from || !to || from.parentNode.hidden || to.parentNode.hidden) return;
|
|
109
|
+
var ra = from.getBoundingClientRect(), rb = to.getBoundingClientRect();
|
|
110
|
+
var x1 = ra.right - base.left, y1 = ra.top + ra.height / 2 - base.top;
|
|
111
|
+
var x2 = rb.left - base.left, y2 = rb.top + rb.height / 2 - base.top;
|
|
112
|
+
if (x2 < x1){ x1 = ra.left - base.left; x2 = rb.right - base.left; }
|
|
113
|
+
var dx = Math.max(18, Math.abs(x2 - x1) / 2);
|
|
114
|
+
var p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
115
|
+
p.setAttribute('d', 'M' + x1 + ' ' + y1 + ' C' + (x1 + dx) + ' ' + y1 + ' ' + (x2 - dx) + ' ' + y2 + ' ' + x2 + ' ' + y2);
|
|
116
|
+
p.dataset.from = l.src; p.dataset.to = l.dst;
|
|
117
|
+
var cls = (l.review ? 'r ' : '') + (lit && lit[l.src] && lit[l.dst] ? 'on' : '');
|
|
118
|
+
if (cls.trim()) p.setAttribute('class', cls.trim());
|
|
119
|
+
svg.appendChild(p);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function filter(text){
|
|
123
|
+
text = (text || '').toLowerCase();
|
|
124
|
+
root.classList.toggle('filter', !!text);
|
|
125
|
+
map.models.forEach(function(m){
|
|
126
|
+
var hit = !!text && (m.id.toLowerCase().indexOf(text) >= 0 ||
|
|
127
|
+
m.columns.some(function(c){ return (m.id + '.' + c).toLowerCase().indexOf(text) >= 0; }));
|
|
128
|
+
chips[m.id].classList.toggle('hit', hit);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return {draw: draw, filter: filter, many: many};
|
|
132
|
+
}
|
|
133
|
+
// EXPLORE END
|
ripple/usage/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Query-history usage: what actually ran, held to the same no-guessing bar.
|
|
2
|
+
|
|
3
|
+
Files say what is supposed to happen; the warehouse's query log says what
|
|
4
|
+
did. This package ingests a hand-carried export of that log and aggregates
|
|
5
|
+
it against the project's models. Two rules are load-bearing:
|
|
6
|
+
|
|
7
|
+
- The word "unused" is banned from every surface this package feeds. A short
|
|
8
|
+
export cannot tell a quarterly job from a dead one, so the only honest
|
|
9
|
+
claim is "not seen in this window", window printed.
|
|
10
|
+
- Raw query text is never persisted and never returned over MCP. The store
|
|
11
|
+
holds per-table counts only, so nothing sensitive can leak into a commit
|
|
12
|
+
or an AI conversation later.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ripple.usage.ingest import ingest_file, store_path
|
|
16
|
+
from ripple.usage.report import render, render_empty
|
|
17
|
+
|
|
18
|
+
__all__ = ["ingest_file", "render", "render_empty", "store_path"]
|
ripple/usage/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI surface for collect: render the capability table, run one collect.
|
|
2
|
+
|
|
3
|
+
Thin over collect.py so the MCP tool and this command share every rule.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_capabilities(caps: dict) -> str:
|
|
14
|
+
lines = ["Warehouse CLIs on this machine:", ""]
|
|
15
|
+
sf = caps["snowflake"]
|
|
16
|
+
status = sf["cli"] if sf["cli_found"] else f"{sf['cli']} not installed"
|
|
17
|
+
lines.append(f" snowflake {status}")
|
|
18
|
+
if sf["connections"]:
|
|
19
|
+
marked = [
|
|
20
|
+
name + (" (default)" if name == sf["default"] else "") for name in sf["connections"]
|
|
21
|
+
]
|
|
22
|
+
lines.append(f" connections: {', '.join(marked)}")
|
|
23
|
+
if sf["browser_sso"]:
|
|
24
|
+
lines.append(
|
|
25
|
+
f" browser sign-in: {', '.join(sf['browser_sso'])} "
|
|
26
|
+
"(a browser window will open)"
|
|
27
|
+
)
|
|
28
|
+
bq = caps["bigquery"]
|
|
29
|
+
status = bq["cli"] if bq["cli_found"] else f"{bq['cli']} not installed"
|
|
30
|
+
lines.append(f" bigquery {status}")
|
|
31
|
+
for config in bq["configurations"]:
|
|
32
|
+
active = " (active)" if config["name"] == bq["active"] else ""
|
|
33
|
+
project = config["project"] or "no project set"
|
|
34
|
+
lines.append(f" {config['name']}: {project}{active}")
|
|
35
|
+
db = caps["databricks"]
|
|
36
|
+
status = db["cli"] if db["cli_found"] else f"{db['cli']} not installed"
|
|
37
|
+
lines.append(f" databricks {status}")
|
|
38
|
+
if db["profiles"]:
|
|
39
|
+
named = [p["name"] + (f" ({p['host']})" if p.get("host") else "") for p in db["profiles"]]
|
|
40
|
+
lines.append(f" profiles: {', '.join(named)}")
|
|
41
|
+
lines.append("")
|
|
42
|
+
lines.append("try: ripple collect-usage snowflake (your own queries, last 7 days)")
|
|
43
|
+
lines.append("The CLI runs with the login you already have; Ripple never sees a credential.")
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_collect(args, load_graph) -> None:
|
|
48
|
+
from ripple.usage import collect as collect_mod
|
|
49
|
+
from ripple.usage.discover import capabilities
|
|
50
|
+
|
|
51
|
+
if not args.platform:
|
|
52
|
+
caps = capabilities()
|
|
53
|
+
print(json.dumps(caps, indent=2) if args.json else render_capabilities(caps))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
from ripple.project import find_project_root
|
|
57
|
+
from ripple.usage.ingest import write_store
|
|
58
|
+
from ripple.usage.report import render
|
|
59
|
+
|
|
60
|
+
project, _ = load_graph(args)
|
|
61
|
+
try:
|
|
62
|
+
store = collect_mod.collect(
|
|
63
|
+
args.platform,
|
|
64
|
+
[m.name for m in project.models],
|
|
65
|
+
connection=args.connection,
|
|
66
|
+
days=args.days,
|
|
67
|
+
scope=args.scope,
|
|
68
|
+
region=args.region,
|
|
69
|
+
timeout=args.timeout,
|
|
70
|
+
model_aliases={m.name: set(m.aliases) for m in project.models},
|
|
71
|
+
)
|
|
72
|
+
except collect_mod.CollectError as e:
|
|
73
|
+
sys.exit(str(e))
|
|
74
|
+
write_store(find_project_root(Path(args.path)), store)
|
|
75
|
+
if args.json:
|
|
76
|
+
print(json.dumps(store, indent=2))
|
|
77
|
+
return
|
|
78
|
+
print(render(store))
|