@yanqirenshi/d3.sql 0.1.0
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.
- package/.babelrc +14 -0
- package/README.md +64 -0
- package/dist/components/D3Sql.js +538 -0
- package/dist/index.js +4 -0
- package/dist/js/SqlModel.js +717 -0
- package/jest.config.cjs +21 -0
- package/package.json +47 -0
- package/src/components/D3Sql.js +356 -0
- package/src/index.js +5 -0
- package/src/js/SqlModel.js +601 -0
- package/tests/SqlModel.test.js +135 -0
- package/tests/d3-dag-stub.js +4 -0
- package/tests/d3-stub.js +19 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
// SqlModel — SQL リネージグラフのデータクラス。
|
|
2
|
+
// 入力({entities, lineageEdges, joinEdges})の正規化・自動レイアウト・
|
|
3
|
+
// 線の幾何計算までを持ち、DOM/React には依存しない(描画は D3Sql が担当)。
|
|
4
|
+
//
|
|
5
|
+
// データモデル(元プロジェクト sql-sitter との会話で決めたルール):
|
|
6
|
+
// - entities: 箱1つ分。kind='table'は物理テーブル、kind='result'はそのSELECT
|
|
7
|
+
// スコープの出力(サブクエリなら内側から Result 1, Result 2, ... と数える。
|
|
8
|
+
// 最終スコープだけ番号を外して太字の「Result」にする)。
|
|
9
|
+
// alias は FROM句の `AS エイリアス` を箱の上に表示するためのキャプション。
|
|
10
|
+
// `whereText`があれば、その箱の直下にWHERE/HAVINGなどの句をそのまま
|
|
11
|
+
// テキストで表示する。`orderByColumns`(列名の配列、ORDER BY内の
|
|
12
|
+
// 並び順)があれば、対応する行の右端に順序番号のboxを付ける。
|
|
13
|
+
// `distinct`があれば右辺に縦帯を付ける(DISTINCTに限らず、UNIONの
|
|
14
|
+
// 合流点など他の意味でも使う)。帯のラベルは既定で「distinct」、
|
|
15
|
+
// `bandLabel`があればそちらを表示する。`columns`を指定しなければ、
|
|
16
|
+
// lineageEdges/joinEdgesで実際に参照されている列から自動導出する。
|
|
17
|
+
// - lineageEdges: 列の値がどこから来たかを示す矢印(常にResult側へ向く)。
|
|
18
|
+
// label は GROUP BYキーなら `G01`/`G02`...(GROUP BY内の並び順)、集約/
|
|
19
|
+
// ウィンドウ関数・式経由ならその内容(例: `MAX`, `LTRIM`, `/1000`)、
|
|
20
|
+
// 単純な値渡しならラベル無し。`from`は単一の{entity,column}のほか、
|
|
21
|
+
// 式が複数列にわたる場合は配列になる(1つのラベルboxから複数本の
|
|
22
|
+
// 斜め線を引く)。リテラル値や引数に列を取らない関数呼び出し(例:
|
|
23
|
+
// `current_date()`)は由来が無いので矢印無し(`from: null`)。
|
|
24
|
+
// - joinEdges: 列同士の一致関係(データの変換ではなく条件)。joinType の
|
|
25
|
+
// 矢印方向は LEFT='b側にだけ矢印', RIGHT='a側にだけ矢印', INNER='矢印
|
|
26
|
+
// 無しの線'。`kind:'in'`は`WHERE col IN (サブクエリ)`(破線の間隔を
|
|
27
|
+
// JOINより細かくして見分ける、label既定は'IN')。
|
|
28
|
+
|
|
29
|
+
import * as d3 from 'd3';
|
|
30
|
+
import { graphStratify, sugiyama, tweakDirection } from 'd3-dag';
|
|
31
|
+
|
|
32
|
+
export const ROW_H = 28;
|
|
33
|
+
export const HEADER_H = 34;
|
|
34
|
+
export const BOX_W = 240;
|
|
35
|
+
export const BRANCH_GAP = 11;
|
|
36
|
+
export const LINEAGE_LABEL_GAP = 40;
|
|
37
|
+
// DISTINCTを表す縦長の帯の幅。SELECT DISTINCTのResultの右辺に付け、
|
|
38
|
+
// そのResultへ入る全てのリネージ線をこの帯の右端まで延長する。
|
|
39
|
+
export const DISTINCT_BAND_WIDTH = 24;
|
|
40
|
+
|
|
41
|
+
const LAYER_SPACING = 400;
|
|
42
|
+
const SIBLING_GAP = 40;
|
|
43
|
+
|
|
44
|
+
export function shuffled (items) {
|
|
45
|
+
const arr = [...items];
|
|
46
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
47
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
48
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
49
|
+
}
|
|
50
|
+
return arr;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 実テーブル(kind='table')のヘッダ色。同じ物理テーブル名(title)を使う
|
|
54
|
+
// エンティティは全て同じ色にする。色の割り当てには d3 の scaleOrdinal +
|
|
55
|
+
// schemeTableau10 を使い、どのtitleがどの色になるかはドメインの順序を
|
|
56
|
+
// シャッフルすることでランダムにする。
|
|
57
|
+
export function buildTableColors (entities) {
|
|
58
|
+
const titles = Array.from(new Set(entities.filter((e) => e.kind === 'table').map((e) => e.title)));
|
|
59
|
+
const colorScale = d3.scaleOrdinal(d3.schemeTableau10).domain(shuffled(titles));
|
|
60
|
+
const colors = {};
|
|
61
|
+
titles.forEach((title) => {
|
|
62
|
+
colors[title] = colorScale(title);
|
|
63
|
+
});
|
|
64
|
+
return colors;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// sql-sitterのextract_graph()(実データ)がentityに列一覧を持たない場合は、
|
|
68
|
+
// lineageEdges/joinEdgesで実際に参照されている列を初出順に集めて補う
|
|
69
|
+
// (明示された`columns`があればそれを優先する)。
|
|
70
|
+
export function withDerivedColumns (entities, lineageEdges, joinEdges) {
|
|
71
|
+
const columnsById = {};
|
|
72
|
+
const seenById = {};
|
|
73
|
+
entities.forEach((e) => { columnsById[e.id] = []; seenById[e.id] = new Set(); });
|
|
74
|
+
|
|
75
|
+
const addRef = (ref) => {
|
|
76
|
+
if (!ref) return;
|
|
77
|
+
(Array.isArray(ref) ? ref : [ref]).forEach(({ entity, column }) => {
|
|
78
|
+
if (!seenById[entity] || seenById[entity].has(column)) return;
|
|
79
|
+
seenById[entity].add(column);
|
|
80
|
+
columnsById[entity].push(column);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
lineageEdges.forEach((edge) => { addRef(edge.from); addRef(edge.to); });
|
|
85
|
+
joinEdges.forEach((edge) => { addRef(edge.a); addRef(edge.b); });
|
|
86
|
+
|
|
87
|
+
return entities.map((e) => (e.columns ? e : { ...e, columns: columnsById[e.id] }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// sql-sitterはResultスコープを内側から順にResult 1, Result 2, ...と
|
|
91
|
+
// 番号を振り、最終スコープ(一番外側)が常に一番大きい番号になる
|
|
92
|
+
// (番号付きは中間サブクエリの出力、最終スコープだけ番号を外して
|
|
93
|
+
// 太字+黒背景白文字の「Result」にする)。この規則で自動判定し、
|
|
94
|
+
// データ側でいちいち title/headerColor を指定しなくて済むようにする。
|
|
95
|
+
const RESULT_NUMBER_RE = /^Result (\d+)$/;
|
|
96
|
+
|
|
97
|
+
export function markFinalResult (entities) {
|
|
98
|
+
let final = null;
|
|
99
|
+
entities.forEach((e) => {
|
|
100
|
+
if (e.kind !== 'result') return;
|
|
101
|
+
const m = RESULT_NUMBER_RE.exec(e.title);
|
|
102
|
+
if (!m) return;
|
|
103
|
+
const num = parseInt(m[1], 10);
|
|
104
|
+
if (!final || num > final.num) final = { entity: e, num };
|
|
105
|
+
});
|
|
106
|
+
if (!final) return entities;
|
|
107
|
+
return entities.map((e) => (e === final.entity
|
|
108
|
+
? {
|
|
109
|
+
...e,
|
|
110
|
+
title: 'Result',
|
|
111
|
+
headerColor: e.headerColor || '#000000',
|
|
112
|
+
headerTextColor: e.headerTextColor || '#ffffff',
|
|
113
|
+
}
|
|
114
|
+
: e));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function entityHeight (entity) {
|
|
118
|
+
return HEADER_H + entity.columns.length * ROW_H;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// JOINで結び付くエンティティ同士をUnion-Findでグループ化する。同じ
|
|
122
|
+
// グループのエンティティは自動レイアウトで同じ列(レイヤー)に配置する
|
|
123
|
+
// (JOINで繋がる相手とは同じ列に並べる)。
|
|
124
|
+
export function groupByJoin (entities, joinEdges) {
|
|
125
|
+
const parent = {};
|
|
126
|
+
entities.forEach((e) => { parent[e.id] = e.id; });
|
|
127
|
+
const find = (x) => {
|
|
128
|
+
while (parent[x] !== x) x = parent[x];
|
|
129
|
+
return x;
|
|
130
|
+
};
|
|
131
|
+
joinEdges.forEach((edge) => {
|
|
132
|
+
const ra = find(edge.a.entity);
|
|
133
|
+
const rb = find(edge.b.entity);
|
|
134
|
+
if (ra !== rb) parent[ra] = rb;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const groups = {};
|
|
138
|
+
entities.forEach((entity) => {
|
|
139
|
+
const g = find(entity.id);
|
|
140
|
+
(groups[g] = groups[g] || []).push(entity);
|
|
141
|
+
});
|
|
142
|
+
return { find, groups };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// entities/lineageEdges/joinEdgesからエンティティ毎の(x,y)を自動計算する。
|
|
146
|
+
// JOINで繋がるエンティティをグループ化して1つのノードとしてd3-dagに渡し、
|
|
147
|
+
// リネージの依存関係(lineageEdges)をグループ間のparentIdsとして
|
|
148
|
+
// d3-dagのsugiyama(層状レイアウト)にレイヤリングさせる。tweakDirection
|
|
149
|
+
// ('RL')で「深い(=物理テーブルに近い)ほど右、浅い(=最終結果)ほど左」に
|
|
150
|
+
// 揃える。グループ内の複数エンティティ(JOINの相手同士)は同じ列内で
|
|
151
|
+
// 縦に並べる。
|
|
152
|
+
export function buildAutoPositions (entities, lineageEdges, joinEdges) {
|
|
153
|
+
if (entities.length === 0) return {};
|
|
154
|
+
|
|
155
|
+
const { find, groups } = groupByJoin(entities, joinEdges);
|
|
156
|
+
|
|
157
|
+
const groupParentIds = {};
|
|
158
|
+
Object.keys(groups).forEach((g) => { groupParentIds[g] = new Set(); });
|
|
159
|
+
lineageEdges.forEach((edge) => {
|
|
160
|
+
// `from`が無い(リテラル値・列を取らない関数呼び出し)エッジは
|
|
161
|
+
// 由来となる実体が無いのでレイアウトの依存関係には使わない。
|
|
162
|
+
if (!edge.from) return;
|
|
163
|
+
// `from`は単一の{entity,column}か、複数列を1つの式(例: ||連結)で
|
|
164
|
+
// 結合した場合の配列。
|
|
165
|
+
const froms = Array.isArray(edge.from) ? edge.from : [edge.from];
|
|
166
|
+
const gTo = find(edge.to.entity);
|
|
167
|
+
froms.forEach((f) => {
|
|
168
|
+
const gFrom = find(f.entity);
|
|
169
|
+
if (gFrom !== gTo) groupParentIds[gTo].add(gFrom);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const dagData = Object.keys(groups).map((g) => ({
|
|
174
|
+
id: g,
|
|
175
|
+
parentIds: Array.from(groupParentIds[g]),
|
|
176
|
+
}));
|
|
177
|
+
const groupHeight = (g) => groups[g].reduce((sum, e) => sum + entityHeight(e) + SIBLING_GAP, -SIBLING_GAP);
|
|
178
|
+
|
|
179
|
+
const dag = graphStratify()(dagData);
|
|
180
|
+
const layout = sugiyama()
|
|
181
|
+
.nodeSize((node) => [groupHeight(node.data.id), LAYER_SPACING])
|
|
182
|
+
// sugiyamaの既定gapは[1,1]で、同じ列(レイヤー)に並ぶ別グループ同士が
|
|
183
|
+
// ほぼ隙間無く接してしまうため、グループ内で使っているSIBLING_GAPと
|
|
184
|
+
// 同じ幅を明示的に確保する。
|
|
185
|
+
.gap([SIBLING_GAP, 0])
|
|
186
|
+
.tweaks([tweakDirection('RL')]);
|
|
187
|
+
layout(dag);
|
|
188
|
+
|
|
189
|
+
// X(列・レイヤー)はsugiyamaの結果をそのまま使う。Yはsugiyamaに任せず、
|
|
190
|
+
// 「1つのSQL(サブクエリも含む)ではスコープのメンバー全員を同じ高さに
|
|
191
|
+
// 揃える」というルールで実テーブル側から再帰的に組み立て直す。
|
|
192
|
+
const xById = {};
|
|
193
|
+
for (const node of dag.nodes()) {
|
|
194
|
+
groups[node.data.id].forEach((entity) => { xById[entity.id] = node.x; });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ラベルbox(見積もり幅)がJOIN線や次の列のエンティティと被らないよう、
|
|
198
|
+
// 列の間隔をラベル幅に応じて広げる(ラベルboxの右端とJOIN線/
|
|
199
|
+
// エンティティの間は11pxの余白を確保する)。
|
|
200
|
+
const widenedXById = widenColumnsForLabels(xById, lineageEdges, entities);
|
|
201
|
+
|
|
202
|
+
return buildTopAlignedPositions(entities, lineageEdges, joinEdges, widenedXById, find, groups);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ラベル文字列からラベルboxのおおよその幅を見積もる(全角文字は半角の
|
|
206
|
+
// 約2倍幅として計算)。実際の描画幅はSVGの<text>で実測するが、レイアウト
|
|
207
|
+
// (列の間隔)を決める段階ではDOM測定前なので、この見積もりを使う。
|
|
208
|
+
export function estimateLabelWidth (label) {
|
|
209
|
+
if (!label) return 0;
|
|
210
|
+
let width = 8;
|
|
211
|
+
for (const ch of label) {
|
|
212
|
+
const code = ch.codePointAt(0);
|
|
213
|
+
const isWide =
|
|
214
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
215
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
216
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
217
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
218
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
219
|
+
(code >= 0xffe0 && code <= 0xffe6);
|
|
220
|
+
width += isWide ? 13 : 7;
|
|
221
|
+
}
|
|
222
|
+
return width;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 各列(x)の右側の間隔を、そこを`to`とするラベル付きリネージ線のラベル幅
|
|
226
|
+
// (見積もり)+ 余白ぶん確保できるよう広げる。列の並び順(ランク)は
|
|
227
|
+
// 変えず、間隔だけを必要に応じて広げる。ラベルの左端は`to`のエンティティ
|
|
228
|
+
// 自身の幅(BOX_W)・DISTINCT帯の分だけ`to.x`より右にずれるので、その分も
|
|
229
|
+
// 加味する(これを忘れるとJOIN線/次のエンティティと被る)。
|
|
230
|
+
export function widenColumnsForLabels (xById, lineageEdges, entities) {
|
|
231
|
+
const ranks = [...new Set(Object.values(xById))].sort((a, b) => a - b);
|
|
232
|
+
if (ranks.length === 0) return xById;
|
|
233
|
+
|
|
234
|
+
const entityById = {};
|
|
235
|
+
entities.forEach((e) => { entityById[e.id] = e; });
|
|
236
|
+
|
|
237
|
+
const requiredGapAfter = {};
|
|
238
|
+
lineageEdges.forEach((edge) => {
|
|
239
|
+
if (!edge.label || !edge.to) return;
|
|
240
|
+
const toX = xById[edge.to.entity];
|
|
241
|
+
const toEntity = entityById[edge.to.entity];
|
|
242
|
+
if (toX === undefined || !toEntity) return;
|
|
243
|
+
const toEdgeOffset = BOX_W + (toEntity.distinct ? BRANCH_GAP + DISTINCT_BAND_WIDTH : 0);
|
|
244
|
+
const needed = toEdgeOffset + LINEAGE_LABEL_GAP + estimateLabelWidth(edge.label) + BRANCH_GAP;
|
|
245
|
+
requiredGapAfter[toX] = Math.max(requiredGapAfter[toX] || 0, needed);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const remapped = { [ranks[0]]: ranks[0] };
|
|
249
|
+
for (let i = 1; i < ranks.length; i++) {
|
|
250
|
+
const prevRank = ranks[i - 1];
|
|
251
|
+
const gap = Math.max(ranks[i] - prevRank, requiredGapAfter[prevRank] || 0);
|
|
252
|
+
remapped[ranks[i]] = remapped[prevRank] + gap;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const adjusted = {};
|
|
256
|
+
Object.keys(xById).forEach((id) => {
|
|
257
|
+
adjusted[id] = remapped[xById[id]];
|
|
258
|
+
});
|
|
259
|
+
return adjusted;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// entity.id -> lineageEdgesから求めた「直接の子」(そのエンティティ自身の
|
|
263
|
+
// SQLスコープのFROM句に出てくる参照先)の一覧(重複無し、初出順)。実テーブル
|
|
264
|
+
// は他から値を受け取らないので常に空になる。
|
|
265
|
+
export function buildLineageChildren (entities, lineageEdges) {
|
|
266
|
+
const children = {};
|
|
267
|
+
entities.forEach((e) => { children[e.id] = []; });
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
lineageEdges.forEach((edge) => {
|
|
270
|
+
// `from`が無い(リテラル値・列を取らない関数呼び出し)エッジは
|
|
271
|
+
// 由来となる実体が無いのでスコープの子には含めない。
|
|
272
|
+
if (!edge.from) return;
|
|
273
|
+
const froms = Array.isArray(edge.from) ? edge.from : [edge.from];
|
|
274
|
+
froms.forEach((f) => {
|
|
275
|
+
if (f.entity === edge.to.entity) return;
|
|
276
|
+
const key = `${edge.to.entity}|${f.entity}`;
|
|
277
|
+
if (seen.has(key)) return;
|
|
278
|
+
seen.add(key);
|
|
279
|
+
children[edge.to.entity].push(f.entity);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
return children;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// JOINの a側を上、b側を下にする表示順。members内で直接JOINしている関係
|
|
286
|
+
// だけを使う簡易的な位相ソート(サイクルが無い前提。無ければ元の順のまま)。
|
|
287
|
+
export function orderByJoinAB (members, joinEdges) {
|
|
288
|
+
const before = {};
|
|
289
|
+
members.forEach((m) => { before[m] = new Set(); });
|
|
290
|
+
joinEdges.forEach((edge) => {
|
|
291
|
+
const a = edge.a.entity;
|
|
292
|
+
const b = edge.b.entity;
|
|
293
|
+
if (before[a] !== undefined && before[b] !== undefined) {
|
|
294
|
+
before[b].add(a);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
const ordered = [];
|
|
298
|
+
const remaining = new Set(members);
|
|
299
|
+
while (remaining.size > 0) {
|
|
300
|
+
const next = [...remaining].find((m) => [...before[m]].every((dep) => !remaining.has(dep)));
|
|
301
|
+
const pick = next !== undefined ? next : [...remaining][0];
|
|
302
|
+
ordered.push(pick);
|
|
303
|
+
remaining.delete(pick);
|
|
304
|
+
}
|
|
305
|
+
return ordered;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 「1つのSQL(サブクエリも含む)では上端を揃え、JOINがあれば縦に積む」
|
|
309
|
+
// というルールでYを実テーブル側から再帰的に組み立てる。あるエンティティの
|
|
310
|
+
// 「スコープメンバー」=直接の子をJOIN相手も含めて拡張した集合。スコープ
|
|
311
|
+
// メンバーが1つだけなら、その子と同じYに揃える。複数(JOIN)で、そのうち
|
|
312
|
+
// 自分の子を持つ(=さらに深いリネージがある)メンバーが1つだけなら、その
|
|
313
|
+
// メンバーの自然なYはそのままにして、他の実テーブル側はJOINのa/b順で
|
|
314
|
+
// 隙間無く隣接させる(上にも負のYまで拡張して構わない)。複数のメンバーが
|
|
315
|
+
// それぞれ自分の子を持つ場合は、無理に隙間0で隣接させず、reachが大きい方を
|
|
316
|
+
// 優先して自然なYを尊重しつつ重ならないように詰める。
|
|
317
|
+
export function buildTopAlignedPositions (entities, lineageEdges, joinEdges, xById, find, groups) {
|
|
318
|
+
const entityById = {};
|
|
319
|
+
entities.forEach((e) => { entityById[e.id] = e; });
|
|
320
|
+
const lineageChildren = buildLineageChildren(entities, lineageEdges);
|
|
321
|
+
|
|
322
|
+
const scopeMembersOf = (entityId) => {
|
|
323
|
+
const memberIds = new Set();
|
|
324
|
+
lineageChildren[entityId].forEach((childId) => {
|
|
325
|
+
groups[find(childId)].forEach((m) => memberIds.add(m.id));
|
|
326
|
+
});
|
|
327
|
+
return entities.filter((e) => memberIds.has(e.id)).map((e) => e.id);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const reachCache = {};
|
|
331
|
+
const reachOf = (entityId) => {
|
|
332
|
+
if (entityId in reachCache) return reachCache[entityId];
|
|
333
|
+
reachCache[entityId] = xById[entityId];
|
|
334
|
+
let reach = xById[entityId];
|
|
335
|
+
scopeMembersOf(entityId).forEach((childId) => {
|
|
336
|
+
reach = Math.max(reach, reachOf(childId));
|
|
337
|
+
});
|
|
338
|
+
reachCache[entityId] = reach;
|
|
339
|
+
return reach;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const positions = {};
|
|
343
|
+
const nextY = {};
|
|
344
|
+
const visited = new Set();
|
|
345
|
+
|
|
346
|
+
function place (entityId, desiredY) {
|
|
347
|
+
const x = xById[entityId];
|
|
348
|
+
// 列が未使用ならdesiredYをそのまま使う(負の値も許す。JOIN相手の
|
|
349
|
+
// 実テーブル側をa/b順で隣接させるために上へ伸びることがある)。
|
|
350
|
+
const minY = x in nextY ? nextY[x] : -Infinity;
|
|
351
|
+
const y = Math.max(desiredY, minY);
|
|
352
|
+
positions[entityId] = { x, y };
|
|
353
|
+
nextY[x] = y + entityHeight(entityById[entityId]) + SIBLING_GAP;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// メンバー全体(メンバー自身の既に確定済みの子孫も含む)をdelta分
|
|
357
|
+
// だけずらす。子孫との相対位置(揃え)を保ったまま動かすため。
|
|
358
|
+
function shiftSubtree (id, delta) {
|
|
359
|
+
if (delta === 0) return;
|
|
360
|
+
positions[id] = { ...positions[id], y: positions[id].y + delta };
|
|
361
|
+
scopeMembersOf(id).forEach((childId) => shiftSubtree(childId, delta));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function assign (entityId) {
|
|
365
|
+
if (visited.has(entityId)) return;
|
|
366
|
+
visited.add(entityId);
|
|
367
|
+
|
|
368
|
+
const members = scopeMembersOf(entityId);
|
|
369
|
+
if (members.length === 0) {
|
|
370
|
+
place(entityId, 0);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 制約の強い(その先の列が深い)メンバーから先に確定させる。
|
|
375
|
+
const byReach = [...members].sort((a, b) => reachOf(b) - reachOf(a));
|
|
376
|
+
byReach.forEach(assign);
|
|
377
|
+
|
|
378
|
+
// 一番reachが大きい(外部への繋がりが深い)メンバーを基準
|
|
379
|
+
// (アンカー)にして動かさない。他のメンバーはJOINのa/b順で隙間
|
|
380
|
+
// 無く隣接させ、既に確定済みの子孫がいればまとめてずらす(負のY
|
|
381
|
+
// まで拡張して構わない)。このエンティティ自身は、メンバー全員の
|
|
382
|
+
// 中で一番上(Yが最小)になったものに揃える。
|
|
383
|
+
const anchor = byReach[0];
|
|
384
|
+
const ab = orderByJoinAB(members, joinEdges);
|
|
385
|
+
const anchorIndex = ab.indexOf(anchor);
|
|
386
|
+
for (let i = anchorIndex - 1; i >= 0; i--) {
|
|
387
|
+
const below = ab[i + 1];
|
|
388
|
+
const targetY = positions[below].y - entityHeight(entityById[ab[i]]) - SIBLING_GAP;
|
|
389
|
+
shiftSubtree(ab[i], targetY - positions[ab[i]].y);
|
|
390
|
+
}
|
|
391
|
+
for (let i = anchorIndex + 1; i < ab.length; i++) {
|
|
392
|
+
const above = ab[i - 1];
|
|
393
|
+
const targetY = positions[above].y + entityHeight(entityById[above]) + SIBLING_GAP;
|
|
394
|
+
shiftSubtree(ab[i], targetY - positions[ab[i]].y);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const topY = Math.min(...members.map((id) => positions[id].y));
|
|
398
|
+
place(entityId, topY);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
[...entities].sort((a, b) => reachOf(b.id) - reachOf(a.id)).forEach((e) => assign(e.id));
|
|
402
|
+
|
|
403
|
+
// 保険: 再帰的な組み立てで多少の誤差が出ても、列内の重なりだけは必ず
|
|
404
|
+
// 無くす(Y順に並べ、隙間が無ければ後ろのものを必要な分だけ下へずらす)。
|
|
405
|
+
const byColumn = {};
|
|
406
|
+
Object.entries(positions).forEach(([id, pos]) => {
|
|
407
|
+
(byColumn[pos.x] = byColumn[pos.x] || []).push(id);
|
|
408
|
+
});
|
|
409
|
+
Object.values(byColumn).forEach((ids) => {
|
|
410
|
+
ids.sort((a, b) => positions[a].y - positions[b].y);
|
|
411
|
+
let minY = -Infinity;
|
|
412
|
+
ids.forEach((id) => {
|
|
413
|
+
if (positions[id].y < minY) positions[id].y = minY;
|
|
414
|
+
minY = positions[id].y + entityHeight(entityById[id]) + SIBLING_GAP;
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return positions;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function buildLayout (entities, positions, tableColors) {
|
|
422
|
+
const boxes = {};
|
|
423
|
+
entities.forEach((entity) => {
|
|
424
|
+
const h = entityHeight(entity);
|
|
425
|
+
const pos = positions[entity.id];
|
|
426
|
+
boxes[entity.id] = {
|
|
427
|
+
id: entity.id,
|
|
428
|
+
x: pos.x,
|
|
429
|
+
y: pos.y,
|
|
430
|
+
w: BOX_W,
|
|
431
|
+
h,
|
|
432
|
+
title: entity.title,
|
|
433
|
+
alias: entity.alias,
|
|
434
|
+
headerColor: entity.headerColor || (entity.kind === 'table' ? tableColors[entity.title] : null),
|
|
435
|
+
headerTextColor: entity.headerTextColor || null,
|
|
436
|
+
distinct: !!entity.distinct,
|
|
437
|
+
bandLabel: entity.bandLabel || 'distinct',
|
|
438
|
+
orderByColumns: entity.orderByColumns || null,
|
|
439
|
+
whereText: entity.whereText || null,
|
|
440
|
+
rows: entity.columns.map((col, i) => ({
|
|
441
|
+
name: col,
|
|
442
|
+
y: pos.y + HEADER_H + i * ROW_H + ROW_H / 2,
|
|
443
|
+
})),
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
return boxes;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function findRowY (box, columnName) {
|
|
450
|
+
const row = box && box.rows.find((r) => r.name === columnName);
|
|
451
|
+
return row ? row.y : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// JOIN線の幾何。aの左辺から一度左に出てから下へ、bの左辺へ横に入る
|
|
455
|
+
// エルボー型の折れ線。同じ起点(a)から出るJOIN線が複数あるときに、縦の
|
|
456
|
+
// ガイド線が重ならないよう起点ごとに少しずつオフセットする。
|
|
457
|
+
// あわせて「どの列がJOIN線の分岐点(branchX)を持つか」の索引も返す。
|
|
458
|
+
// SELECT側の線はこの分岐点がある列なら、箱の縁ではなく分岐点から始める
|
|
459
|
+
// (「分岐点から線を引く」ルール)。
|
|
460
|
+
export function buildJoinLines (boxes, joinEdges) {
|
|
461
|
+
const branchOffsets = {};
|
|
462
|
+
const branchPointByPort = {};
|
|
463
|
+
const lines = joinEdges.map((edge) => {
|
|
464
|
+
const a = boxes[edge.a.entity];
|
|
465
|
+
const b = boxes[edge.b.entity];
|
|
466
|
+
const y1 = findRowY(a, edge.a.column);
|
|
467
|
+
const y2 = findRowY(b, edge.b.column);
|
|
468
|
+
if (!a || !b || y1 === null || y2 === null) return null;
|
|
469
|
+
|
|
470
|
+
const offsetIndex = branchOffsets[edge.a.entity] || 0;
|
|
471
|
+
branchOffsets[edge.a.entity] = offsetIndex + 1;
|
|
472
|
+
const branchX = a.x - 20 - offsetIndex * 10;
|
|
473
|
+
branchPointByPort[`${edge.a.entity}.${edge.a.column}`] = branchX;
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
key: `join:${edge.a.entity}.${edge.a.column}<->${edge.b.entity}.${edge.b.column}`,
|
|
477
|
+
x1: a.x,
|
|
478
|
+
y1,
|
|
479
|
+
x2: b.x,
|
|
480
|
+
y2,
|
|
481
|
+
branchX,
|
|
482
|
+
joinType: edge.joinType,
|
|
483
|
+
label: edge.label,
|
|
484
|
+
kind: edge.kind,
|
|
485
|
+
};
|
|
486
|
+
}).filter(Boolean);
|
|
487
|
+
|
|
488
|
+
return { lines, branchPointByPort };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// リネージ線の幾何。常にsource(右)からResult側(左)へ向く。
|
|
492
|
+
// DISTINCTの帯がある場合は、そこまで線を延長する(distinctは全ての
|
|
493
|
+
// リネージ線が通る帯として表現する。帯とResult本体の間は11pxの余白)。
|
|
494
|
+
// ラベルの左端はbranchXのブレに関わらず、宛先(to)の縁からの固定距離に
|
|
495
|
+
// する(左端位置を揃える)。
|
|
496
|
+
export function buildLineageLines (boxes, lineageEdges, branchPointByPort) {
|
|
497
|
+
return lineageEdges.map((edge) => {
|
|
498
|
+
const to = boxes[edge.to.entity];
|
|
499
|
+
const y2 = findRowY(to, edge.to.column);
|
|
500
|
+
if (!to || y2 === null) return null;
|
|
501
|
+
|
|
502
|
+
const toEdgeX = to.x + to.w + (to.distinct ? BRANCH_GAP + DISTINCT_BAND_WIDTH : 0);
|
|
503
|
+
const labelX = toEdgeX + LINEAGE_LABEL_GAP;
|
|
504
|
+
|
|
505
|
+
if (!edge.from) {
|
|
506
|
+
// リテラル値・列を取らない関数呼び出し(由来となる実テーブルが
|
|
507
|
+
// 無い列)は、ラベルboxからResultへの短い矢印だけを描く。
|
|
508
|
+
return {
|
|
509
|
+
key: `literal:${edge.to.entity}.${edge.to.column}`,
|
|
510
|
+
x1: labelX,
|
|
511
|
+
y1: y2,
|
|
512
|
+
x2: toEdgeX,
|
|
513
|
+
y2,
|
|
514
|
+
labelX,
|
|
515
|
+
label: edge.label,
|
|
516
|
+
showPort: false,
|
|
517
|
+
labelInfo: {
|
|
518
|
+
label: edge.label,
|
|
519
|
+
fromTitle: null,
|
|
520
|
+
fromColumn: null,
|
|
521
|
+
toTitle: to.alias || to.title,
|
|
522
|
+
toColumn: edge.to.column,
|
|
523
|
+
sql: edge.sql,
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// `from`は単一の{entity,column}か、複数列を1つの式(例: ||連結)で
|
|
529
|
+
// 結合した場合の配列(1つのラベルboxから複数本の斜め線を引く)。
|
|
530
|
+
const froms = Array.isArray(edge.from) ? edge.from : [edge.from];
|
|
531
|
+
const sources = [];
|
|
532
|
+
for (const f of froms) {
|
|
533
|
+
const from = boxes[f.entity];
|
|
534
|
+
const y1 = findRowY(from, f.column);
|
|
535
|
+
if (!from || y1 === null) return null;
|
|
536
|
+
const branchX = branchPointByPort[`${f.entity}.${f.column}`];
|
|
537
|
+
// JOIN線の角(branchX)、またはJOIN線が無い場合はエンティティの縁
|
|
538
|
+
// との間を、SELECT側の線の開始点が重なって見えないよう少しだけ
|
|
539
|
+
// 離す。
|
|
540
|
+
const startX = (branchX !== undefined ? branchX : from.x) - BRANCH_GAP;
|
|
541
|
+
sources.push({ x: startX, y: y1, title: from.alias || from.title, column: f.column });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
key: `lineage:${froms.map((f) => `${f.entity}.${f.column}`).join('+')}->${edge.to.entity}.${edge.to.column}`,
|
|
546
|
+
x1: sources[0].x,
|
|
547
|
+
y1: sources[0].y,
|
|
548
|
+
x2: toEdgeX,
|
|
549
|
+
y2,
|
|
550
|
+
labelX,
|
|
551
|
+
label: edge.label,
|
|
552
|
+
sources,
|
|
553
|
+
labelInfo: {
|
|
554
|
+
label: edge.label,
|
|
555
|
+
fromDisplay: sources.map((s) => `${s.title}.${s.column}`).join(' , '),
|
|
556
|
+
toTitle: to.alias || to.title,
|
|
557
|
+
toColumn: edge.to.column,
|
|
558
|
+
sql: edge.sql,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}).filter(Boolean);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// SQL リネージグラフのデータクラス本体。入力の正規化(列の自動導出・
|
|
565
|
+
// 最終Resultの判定)からレイアウト(自動配置・箱の寸法)・線の幾何までを
|
|
566
|
+
// 構築時に済ませ、描画側(D3Sql)は結果を SVG に写すだけにする。
|
|
567
|
+
export default class SqlModel {
|
|
568
|
+
constructor (data) {
|
|
569
|
+
const raw = data || {};
|
|
570
|
+
|
|
571
|
+
this._core = raw;
|
|
572
|
+
|
|
573
|
+
this.entities = markFinalResult(
|
|
574
|
+
withDerivedColumns(raw.entities || [], raw.lineageEdges || [], raw.joinEdges || []));
|
|
575
|
+
this.lineageEdges = raw.lineageEdges || [];
|
|
576
|
+
this.joinEdges = raw.joinEdges || [];
|
|
577
|
+
|
|
578
|
+
this.tableColors = buildTableColors(this.entities);
|
|
579
|
+
this.positions = buildAutoPositions(this.entities, this.lineageEdges, this.joinEdges);
|
|
580
|
+
this.boxes = buildLayout(this.entities, this.positions, this.tableColors);
|
|
581
|
+
|
|
582
|
+
const join = buildJoinLines(this.boxes, this.joinEdges);
|
|
583
|
+
this.joinLines = join.lines;
|
|
584
|
+
this.lineageLines = buildLineageLines(this.boxes, this.lineageEdges, join.branchPointByPort);
|
|
585
|
+
}
|
|
586
|
+
boxList () {
|
|
587
|
+
return Object.values(this.boxes);
|
|
588
|
+
}
|
|
589
|
+
// レイアウトは負のYまで伸びることがあるので、初期表示はコンテンツの
|
|
590
|
+
// 左上(負の座標も含む)に余白を付けて揃える(そうしないと、最終Result
|
|
591
|
+
// などの箱が初期ビューポート外になり、パン操作をしないと見えない)。
|
|
592
|
+
initialTransform (padding = 40) {
|
|
593
|
+
const boxes = this.boxList();
|
|
594
|
+
if (boxes.length === 0) return { x: 0, y: 0, k: 1 };
|
|
595
|
+
return {
|
|
596
|
+
x: padding - Math.min(...boxes.map((b) => b.x)),
|
|
597
|
+
y: padding - Math.min(...boxes.map((b) => b.y)),
|
|
598
|
+
k: 1,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|