@linklabjs/core 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/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- package/src/types/index.ts +506 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphCompiler — v2.0.0
|
|
3
|
+
*
|
|
4
|
+
* Changements vs v1 :
|
|
5
|
+
* - Routes sémantiques (semantic_view) compilées et incluses
|
|
6
|
+
* - compiled-graph contient physical + semantic routes
|
|
7
|
+
* - version bump : '2.0.0'
|
|
8
|
+
*
|
|
9
|
+
* v2.1 :
|
|
10
|
+
* - Support expose config (ADR-0010)
|
|
11
|
+
* - node.exposed compilé depuis CompilerConfig.expose
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Graph, CompiledGraph, CompilerConfig, RouteInfo, MetricsMap, GraphNode, ExposeConfig } from '../types/index.js'
|
|
15
|
+
import { PathFinder } from '../core/PathFinder.js'
|
|
16
|
+
|
|
17
|
+
export interface EdgeMetadata {
|
|
18
|
+
fromCol: string
|
|
19
|
+
toCol: string
|
|
20
|
+
condition?: Record<string, unknown>
|
|
21
|
+
label?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class GraphCompiler {
|
|
25
|
+
private config: Required<Omit<CompilerConfig, 'expose'>> & { expose: ExposeConfig }
|
|
26
|
+
|
|
27
|
+
constructor(config: Partial<CompilerConfig> = {}) {
|
|
28
|
+
this.config = {
|
|
29
|
+
weightThreshold: config.weightThreshold ?? 1000,
|
|
30
|
+
minUsage: config.minUsage ?? 0,
|
|
31
|
+
keepFallbacks: config.keepFallbacks ?? true,
|
|
32
|
+
maxFallbacks: config.maxFallbacks ?? 2,
|
|
33
|
+
expose: config.expose ?? 'none'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
compile(graph: Graph, metrics: MetricsMap): CompiledGraph {
|
|
38
|
+
console.log('🔧 Compiling optimized graph (v2 — physical + semantic)...\n')
|
|
39
|
+
|
|
40
|
+
const compiled: CompiledGraph = {
|
|
41
|
+
version: '2.0.0',
|
|
42
|
+
compiledAt: new Date().toISOString(),
|
|
43
|
+
config: this.config,
|
|
44
|
+
nodes: this.compileNodes(graph.nodes, this.config.expose),
|
|
45
|
+
routes: [],
|
|
46
|
+
stats: { totalPairs: 0, routesCompiled: 0, routesFiltered: 0, compressionRatio: '0%' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Nœuds réels (depuis les edges) ───────────────────────────────────────
|
|
50
|
+
const realNodes = new Set<string>()
|
|
51
|
+
graph.edges.forEach(e => {
|
|
52
|
+
realNodes.add(e.from)
|
|
53
|
+
realNodes.add(e.to)
|
|
54
|
+
})
|
|
55
|
+
const nodeIds = Array.from(realNodes)
|
|
56
|
+
|
|
57
|
+
// ── 1. Routes physiques ───────────────────────────────────────────────────
|
|
58
|
+
const fkEdges = graph.edges.filter(
|
|
59
|
+
(e: any) =>
|
|
60
|
+
e.metadata?.type !== 'semantic_view' &&
|
|
61
|
+
e.metadata?.type !== 'virtual' &&
|
|
62
|
+
e.metadata?.type !== 'SEMANTIC'
|
|
63
|
+
)
|
|
64
|
+
const existingPairs = new Set(fkEdges.map((e: any) => `${e.from}→${e.to}`))
|
|
65
|
+
const inverseEdges = fkEdges
|
|
66
|
+
.filter((e: any) => !existingPairs.has(`${e.to}→${e.from}`))
|
|
67
|
+
.map((e: any) => ({
|
|
68
|
+
...e,
|
|
69
|
+
from: e.to,
|
|
70
|
+
to: e.from,
|
|
71
|
+
name: `${e.name}_inv`,
|
|
72
|
+
metadata: { ...e.metadata, type: 'physical_reverse' }
|
|
73
|
+
}))
|
|
74
|
+
const physicalGraph = { ...graph, edges: [...fkEdges, ...inverseEdges] }
|
|
75
|
+
|
|
76
|
+
let kept = 0,
|
|
77
|
+
filtered = 0
|
|
78
|
+
const pairs = this.getAllPairs(nodeIds)
|
|
79
|
+
|
|
80
|
+
for (const { from, to } of pairs) {
|
|
81
|
+
const route = this.compilePath(from, to, physicalGraph, metrics)
|
|
82
|
+
if (route) {
|
|
83
|
+
compiled.routes.push(route as any)
|
|
84
|
+
kept++
|
|
85
|
+
} else {
|
|
86
|
+
filtered++
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 2. Routes sémantiques ─────────────────────────────────────────────────
|
|
91
|
+
const semanticEdges = graph.edges.filter(
|
|
92
|
+
(e: any) => e.metadata?.type === 'semantic_view' && e.metadata?.condition != null
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
let semanticKept = 0
|
|
96
|
+
for (const edge of semanticEdges as any[]) {
|
|
97
|
+
const route = this.compileSemanticRoute(edge, graph)
|
|
98
|
+
if (route) {
|
|
99
|
+
compiled.routes.push(route as any)
|
|
100
|
+
semanticKept++
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 2b. Routes virtuelles ─────────────────────────────────────────────────
|
|
105
|
+
const virtualEdges = graph.edges.filter((e: any) => e.metadata?.type === 'virtual')
|
|
106
|
+
|
|
107
|
+
let virtualKept = 0
|
|
108
|
+
for (const edge of virtualEdges as any[]) {
|
|
109
|
+
const { from, to, via, name, weight } = edge as any
|
|
110
|
+
if (!nodeIds.includes(from) || !nodeIds.includes(to)) continue
|
|
111
|
+
|
|
112
|
+
const viaTable = via && nodeIds.includes(via) ? via : null
|
|
113
|
+
const path = viaTable ? [from, viaTable, to] : [from, to]
|
|
114
|
+
const edges = viaTable
|
|
115
|
+
? [
|
|
116
|
+
{ fromCol: 'id', toCol: 'id' },
|
|
117
|
+
{ fromCol: 'id', toCol: 'id' }
|
|
118
|
+
]
|
|
119
|
+
: [{ fromCol: 'id', toCol: 'id' }]
|
|
120
|
+
|
|
121
|
+
compiled.routes.push({
|
|
122
|
+
from,
|
|
123
|
+
to,
|
|
124
|
+
semantic: false,
|
|
125
|
+
composed: false,
|
|
126
|
+
label: name ?? `virtual_${from}_${to}`,
|
|
127
|
+
virtual: true,
|
|
128
|
+
primary: {
|
|
129
|
+
path,
|
|
130
|
+
edges,
|
|
131
|
+
weight: weight ?? 1,
|
|
132
|
+
joins: path.length - 1,
|
|
133
|
+
avgTime: weight ?? 1
|
|
134
|
+
},
|
|
135
|
+
fallbacks: [],
|
|
136
|
+
alternativesDiscarded: 0
|
|
137
|
+
} as any)
|
|
138
|
+
virtualKept++
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── 3. Routes composées ───────────────────────────────────────────────────
|
|
142
|
+
const compiledSemRoutes = compiled.routes.filter((r: any) => r.semantic) as any[]
|
|
143
|
+
let composedKept = 0
|
|
144
|
+
|
|
145
|
+
const semByFrom = new Map<string, any[]>()
|
|
146
|
+
for (const r of compiledSemRoutes) {
|
|
147
|
+
if (!semByFrom.has(r.from)) semByFrom.set(r.from, [])
|
|
148
|
+
semByFrom.get(r.from)!.push(r)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const [entityId, outRoutes] of semByFrom) {
|
|
152
|
+
const inRoutes = compiledSemRoutes.filter((r: any) => r.to === entityId)
|
|
153
|
+
if (!inRoutes.length) continue
|
|
154
|
+
|
|
155
|
+
for (const rOut of outRoutes) {
|
|
156
|
+
const pivot = rOut.to
|
|
157
|
+
const matchingIn = inRoutes.filter((r: any) => r.from === pivot)
|
|
158
|
+
|
|
159
|
+
for (const rIn of matchingIn) {
|
|
160
|
+
if (rOut.label === rIn.label) continue
|
|
161
|
+
|
|
162
|
+
const composedLabel = `${rOut.label}→${rIn.label}`
|
|
163
|
+
const composedWeight = rOut.primary.weight + rIn.primary.weight
|
|
164
|
+
const composedPath = [...rOut.primary.path, ...rIn.primary.path.slice(1)]
|
|
165
|
+
const composedEdges = [...rOut.primary.edges, ...rIn.primary.edges]
|
|
166
|
+
|
|
167
|
+
const metricKey = `composed:${entityId}→${entityId}:${composedLabel}`
|
|
168
|
+
const metric = metrics.get(metricKey)
|
|
169
|
+
if (metrics.size > 0) {
|
|
170
|
+
if (!metric) continue
|
|
171
|
+
const w = metric.avgTime ?? composedWeight
|
|
172
|
+
if (!metric.used || w > this.config.weightThreshold) continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
compiled.routes.push({
|
|
176
|
+
from: entityId,
|
|
177
|
+
to: entityId,
|
|
178
|
+
semantic: true,
|
|
179
|
+
composed: true,
|
|
180
|
+
label: composedLabel,
|
|
181
|
+
primary: {
|
|
182
|
+
path: composedPath,
|
|
183
|
+
edges: composedEdges,
|
|
184
|
+
weight: composedWeight,
|
|
185
|
+
joins: composedPath.length - 1,
|
|
186
|
+
avgTime: composedWeight
|
|
187
|
+
},
|
|
188
|
+
fallbacks: [],
|
|
189
|
+
alternativesDiscarded: 0
|
|
190
|
+
} as any)
|
|
191
|
+
composedKept++
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
compiled.stats = {
|
|
197
|
+
totalPairs: pairs.length,
|
|
198
|
+
routesCompiled: kept + semanticKept + virtualKept + composedKept,
|
|
199
|
+
routesFiltered: filtered,
|
|
200
|
+
compressionRatio: '—'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log('\n✅ Compilation complete:')
|
|
204
|
+
console.log(` Physical routes: ${kept}`)
|
|
205
|
+
console.log(` Semantic routes: ${semanticKept}`)
|
|
206
|
+
console.log(` Composed routes: ${composedKept}`)
|
|
207
|
+
console.log(` Filtered: ${filtered}`)
|
|
208
|
+
|
|
209
|
+
return compiled
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Compile exposed flag sur chaque node ────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
private compileNodes(nodes: GraphNode[], expose: ExposeConfig): GraphNode[] {
|
|
215
|
+
return nodes.map(node => {
|
|
216
|
+
let exposed: boolean
|
|
217
|
+
|
|
218
|
+
if (expose === 'all') {
|
|
219
|
+
exposed = true
|
|
220
|
+
} else if (expose === 'none') {
|
|
221
|
+
exposed = false
|
|
222
|
+
} else if ('include' in expose) {
|
|
223
|
+
exposed = expose.include.includes(node.id)
|
|
224
|
+
} else {
|
|
225
|
+
exposed = !expose.exclude.includes(node.id)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { ...node, exposed }
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Route sémantique ─────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
private compileSemanticRoute(edge: any, graph: Graph): any | null {
|
|
235
|
+
const { from, to, via, metadata } = edge
|
|
236
|
+
const condition: Record<string, unknown> = metadata.condition ?? {}
|
|
237
|
+
const label: string = edge.name ?? metadata.label ?? 'view'
|
|
238
|
+
|
|
239
|
+
const e1Raw = graph.edges.find(
|
|
240
|
+
(e: any) =>
|
|
241
|
+
((e.from === from && e.to === via) || (e.from === via && e.to === from)) &&
|
|
242
|
+
(e.metadata?.type === 'physical' || e.metadata?.type === 'physical_reverse')
|
|
243
|
+
)
|
|
244
|
+
const e2Raw = graph.edges.find(
|
|
245
|
+
(e: any) =>
|
|
246
|
+
((e.from === via && e.to === to) || (e.from === to && e.to === via)) &&
|
|
247
|
+
(e.metadata?.type === 'physical' || e.metadata?.type === 'physical_reverse')
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if (!e1Raw || !e2Raw) return null
|
|
251
|
+
|
|
252
|
+
const e1IsReversed = e1Raw.from === via
|
|
253
|
+
const e1: EdgeMetadata = {
|
|
254
|
+
fromCol: e1IsReversed ? 'id' : (e1Raw.via ?? 'id'),
|
|
255
|
+
toCol: e1IsReversed ? (e1Raw.via ?? 'id') : 'id',
|
|
256
|
+
condition,
|
|
257
|
+
label
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const e2IsReversed = e2Raw.from === to
|
|
261
|
+
const e2: EdgeMetadata = {
|
|
262
|
+
fromCol: e2IsReversed ? 'id' : (e2Raw.via ?? 'id'),
|
|
263
|
+
toCol: e2IsReversed ? (e2Raw.via ?? 'id') : 'id'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
from,
|
|
268
|
+
to,
|
|
269
|
+
semantic: true,
|
|
270
|
+
label,
|
|
271
|
+
primary: {
|
|
272
|
+
path: [from, via, to],
|
|
273
|
+
edges: [e1, e2],
|
|
274
|
+
weight: edge.weight ?? 0.8,
|
|
275
|
+
joins: 2,
|
|
276
|
+
avgTime: edge.weight ?? 0.8
|
|
277
|
+
},
|
|
278
|
+
fallbacks: [],
|
|
279
|
+
alternativesDiscarded: 0
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Routes physiques ──────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
private getAllPairs(nodeIds: string[]): Array<{ from: string; to: string }> {
|
|
286
|
+
const pairs: Array<{ from: string; to: string }> = []
|
|
287
|
+
for (const from of nodeIds) for (const to of nodeIds) if (from !== to) pairs.push({ from, to })
|
|
288
|
+
return pairs
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private compilePath(from: string, to: string, graph: Graph, metrics: MetricsMap): any | null {
|
|
292
|
+
const finder = new PathFinder(graph)
|
|
293
|
+
const allPaths = finder.findAllPaths(from, to, 5)
|
|
294
|
+
if (!allPaths.length) return null
|
|
295
|
+
|
|
296
|
+
const pathsWithMetrics = allPaths.map(path => {
|
|
297
|
+
const key = path.join('→')
|
|
298
|
+
const metric = metrics.get(key)
|
|
299
|
+
let w = 0
|
|
300
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
301
|
+
const ee = graph.edges.filter(
|
|
302
|
+
e =>
|
|
303
|
+
(e.from === path[i] && e.to === path[i + 1]) ||
|
|
304
|
+
(e.from === path[i + 1] && e.to === path[i])
|
|
305
|
+
)
|
|
306
|
+
const ws = ee.map(e => Number(e.weight)).filter(x => !isNaN(x))
|
|
307
|
+
w += ws.length ? Math.min(...ws) : 1
|
|
308
|
+
}
|
|
309
|
+
const finalWeight = metric && !isNaN(metric.avgTime) ? metric.avgTime : w
|
|
310
|
+
return {
|
|
311
|
+
path,
|
|
312
|
+
key,
|
|
313
|
+
weight: finalWeight,
|
|
314
|
+
failed: metric?.failed === true,
|
|
315
|
+
used: metric ? metric.used : true
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const valid = pathsWithMetrics
|
|
320
|
+
.filter(p => !p.failed && p.used !== false)
|
|
321
|
+
.filter(p => !isNaN(p.weight) && p.weight <= this.config.weightThreshold)
|
|
322
|
+
.sort((a, b) => a.weight - b.weight)
|
|
323
|
+
|
|
324
|
+
if (!valid.length) return null
|
|
325
|
+
|
|
326
|
+
const unique: typeof valid = []
|
|
327
|
+
const seen = new Set<string>()
|
|
328
|
+
for (const p of valid) {
|
|
329
|
+
if (!seen.has(p.key)) {
|
|
330
|
+
unique.push(p)
|
|
331
|
+
seen.add(p.key)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const best = unique[0]
|
|
336
|
+
const fallbacks = this.config.keepFallbacks ? unique.slice(1, this.config.maxFallbacks + 1) : []
|
|
337
|
+
|
|
338
|
+
const primaryEdges = this.resolveEdges(best.path, graph)
|
|
339
|
+
if (!primaryEdges) return null
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
from,
|
|
343
|
+
to,
|
|
344
|
+
primary: {
|
|
345
|
+
path: best.path,
|
|
346
|
+
edges: primaryEdges,
|
|
347
|
+
weight: best.weight,
|
|
348
|
+
joins: best.path.length - 1,
|
|
349
|
+
avgTime: best.weight
|
|
350
|
+
},
|
|
351
|
+
fallbacks: fallbacks
|
|
352
|
+
.map(fb => {
|
|
353
|
+
const ee = this.resolveEdges(fb.path, graph)
|
|
354
|
+
if (!ee) return null
|
|
355
|
+
return {
|
|
356
|
+
path: fb.path,
|
|
357
|
+
edges: ee,
|
|
358
|
+
weight: fb.weight,
|
|
359
|
+
joins: fb.path.length - 1,
|
|
360
|
+
avgTime: fb.weight
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
.filter((fb): fb is NonNullable<typeof fb> => fb !== null),
|
|
364
|
+
alternativesDiscarded: unique.length - 1 - fallbacks.length
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private resolveEdges(path: string[], graph: any): EdgeMetadata[] | null {
|
|
369
|
+
const result: EdgeMetadata[] = []
|
|
370
|
+
const nodeNames = new Set(graph.nodes.map((n: any) => n.id))
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
373
|
+
const from = path[i],
|
|
374
|
+
to = path[i + 1]
|
|
375
|
+
const edgeDirect = graph.edges.find((e: any) => e.from === from && e.to === to)
|
|
376
|
+
const edgeReverse = graph.edges.find((e: any) => e.from === to && e.to === from)
|
|
377
|
+
const edge = edgeDirect ?? edgeReverse
|
|
378
|
+
const isReversed = !edgeDirect && !!edgeReverse
|
|
379
|
+
|
|
380
|
+
if (!edge) {
|
|
381
|
+
result.push({ fromCol: 'id', toCol: `${from.toLowerCase()}Id` })
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (edge.metadata?.type === 'semantic_view' && nodeNames.has(edge.via)) return null
|
|
386
|
+
|
|
387
|
+
const flipCols = edge.metadata?.type === 'physical_reverse' || isReversed
|
|
388
|
+
result.push({
|
|
389
|
+
fromCol: flipCols ? 'id' : edge.via || 'id',
|
|
390
|
+
toCol: flipCols ? edge.via || 'id' : 'id'
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
return result
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
static getStats(compiled: CompiledGraph) {
|
|
397
|
+
const routes = compiled.routes as any[]
|
|
398
|
+
const semantic = routes.filter(r => r.semantic && !r.composed).length
|
|
399
|
+
const composed = routes.filter(r => r.composed).length
|
|
400
|
+
const physical = routes.length - semantic - composed
|
|
401
|
+
if (!routes.length)
|
|
402
|
+
return { totalRoutes: 0, fallbackRatio: '0%', semantic: 0, physical: 0, composed: 0 }
|
|
403
|
+
const withFallbacks = routes.filter(r => r.fallbacks.length > 0).length
|
|
404
|
+
return {
|
|
405
|
+
totalRoutes: routes.length,
|
|
406
|
+
physical,
|
|
407
|
+
semantic,
|
|
408
|
+
composed,
|
|
409
|
+
fallbackRatio: ((withFallbacks / routes.length) * 100).toFixed(1) + '%'
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import type {
|
|
3
|
+
Graph,
|
|
4
|
+
GraphNode,
|
|
5
|
+
GraphEdge,
|
|
6
|
+
Provider,
|
|
7
|
+
Column,
|
|
8
|
+
ActionRegistry
|
|
9
|
+
} from '../types/index.js'
|
|
10
|
+
|
|
11
|
+
interface TableInfo {
|
|
12
|
+
name: string
|
|
13
|
+
columns: Column[]
|
|
14
|
+
rowCount: number
|
|
15
|
+
description?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ForeignKeyInfo {
|
|
19
|
+
fromTable: string
|
|
20
|
+
toTable: string
|
|
21
|
+
column: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class GraphExtractor {
|
|
25
|
+
private provider: Provider
|
|
26
|
+
private actionRegistry?: ActionRegistry
|
|
27
|
+
|
|
28
|
+
constructor(provider: Provider, actionRegistry?: ActionRegistry) {
|
|
29
|
+
this.provider = provider
|
|
30
|
+
this.actionRegistry = actionRegistry
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extrait le graphe complet : Tables + Actions + Relations
|
|
35
|
+
*/
|
|
36
|
+
async extract(): Promise<Graph> {
|
|
37
|
+
console.log('📊 LinkLab : Extraction du graphe sémantique...')
|
|
38
|
+
|
|
39
|
+
// 1. Extraction des tables et leurs métadonnées
|
|
40
|
+
const tables = await this.getTables()
|
|
41
|
+
console.log(` Found ${tables.length} tables`)
|
|
42
|
+
|
|
43
|
+
// 2. Extraction des clés étrangères (Relations natives)
|
|
44
|
+
const foreignKeys = await this.getForeignKeys()
|
|
45
|
+
console.log(` Found ${foreignKeys.length} foreign keys`)
|
|
46
|
+
|
|
47
|
+
// 3. Construction des Nœuds (Tables)
|
|
48
|
+
const nodes: GraphNode[] = tables.map(t => ({
|
|
49
|
+
id: t.name,
|
|
50
|
+
type: 'table' as const,
|
|
51
|
+
columns: t.columns,
|
|
52
|
+
rowCount: t.rowCount,
|
|
53
|
+
description: t.description || ''
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
// 4. Injection des Nœuds (Actions) - Si présentes dans le registre
|
|
57
|
+
if (this.actionRegistry) {
|
|
58
|
+
const actions = this.actionRegistry.getAll()
|
|
59
|
+
actions.forEach(action => {
|
|
60
|
+
nodes.push({
|
|
61
|
+
id: action.id,
|
|
62
|
+
type: 'action' as const,
|
|
63
|
+
description: action.description || 'Action système',
|
|
64
|
+
params: action.requiredParams
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 5. Construction des Edges (Liaisons)
|
|
70
|
+
const edges: GraphEdge[] = foreignKeys.map(fk => ({
|
|
71
|
+
name: `rel_${fk.fromTable}_${fk.toTable}`,
|
|
72
|
+
from: fk.fromTable,
|
|
73
|
+
to: fk.toTable,
|
|
74
|
+
via: fk.column,
|
|
75
|
+
type: 'foreign_key' as const,
|
|
76
|
+
weight: this.calculateInitialWeight(fk, tables)
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const graph: Graph = { nodes, edges }
|
|
80
|
+
|
|
81
|
+
console.log('✅ Graphe LinkLab extrait avec succès')
|
|
82
|
+
fs.writeFileSync('./graph.json', JSON.stringify(graph, null, 2))
|
|
83
|
+
|
|
84
|
+
return graph
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async getTables(): Promise<TableInfo[]> {
|
|
88
|
+
// On récupère aussi la description de la table (COMMENT ON TABLE)
|
|
89
|
+
const query = `
|
|
90
|
+
SELECT
|
|
91
|
+
t.table_name as name,
|
|
92
|
+
obj_description(pgc.oid, 'pg_class') as description
|
|
93
|
+
FROM information_schema.tables t
|
|
94
|
+
JOIN pg_class pgc ON t.table_name = pgc.relname
|
|
95
|
+
JOIN pg_namespace pgn ON pgc.relnamespace = pgn.oid
|
|
96
|
+
WHERE t.table_schema = 'public'
|
|
97
|
+
AND t.table_type = 'BASE TABLE'
|
|
98
|
+
AND pgn.nspname = 'public'
|
|
99
|
+
`
|
|
100
|
+
|
|
101
|
+
const result = await this.provider.query<{ name: string; description: string }>(query)
|
|
102
|
+
const tables: TableInfo[] = []
|
|
103
|
+
|
|
104
|
+
for (const table of result) {
|
|
105
|
+
const columns = await this.getColumns(table.name)
|
|
106
|
+
const rowCount = await this.getRowCount(table.name)
|
|
107
|
+
|
|
108
|
+
tables.push({
|
|
109
|
+
name: table.name,
|
|
110
|
+
columns,
|
|
111
|
+
rowCount,
|
|
112
|
+
description: table.description
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return tables
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async getColumns(tableName: string): Promise<Column[]> {
|
|
120
|
+
// Récupère les colonnes ET leurs descriptions (COMMENT ON COLUMN)
|
|
121
|
+
const query = `
|
|
122
|
+
SELECT
|
|
123
|
+
cols.column_name,
|
|
124
|
+
cols.data_type,
|
|
125
|
+
(SELECT pg_catalog.col_description(c.oid, cols.ordinal_position::int)
|
|
126
|
+
FROM pg_catalog.pg_class c
|
|
127
|
+
WHERE c.relname = cols.table_name) as description
|
|
128
|
+
FROM information_schema.columns cols
|
|
129
|
+
WHERE table_name = $1
|
|
130
|
+
`
|
|
131
|
+
|
|
132
|
+
const result = await this.provider.query<{
|
|
133
|
+
column_name: string
|
|
134
|
+
data_type: string
|
|
135
|
+
description: string
|
|
136
|
+
}>(query, [tableName])
|
|
137
|
+
|
|
138
|
+
return result.map(c => ({
|
|
139
|
+
name: c.column_name,
|
|
140
|
+
type: c.data_type,
|
|
141
|
+
description: c.description || ''
|
|
142
|
+
}))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async getRowCount(tableName: string): Promise<number> {
|
|
146
|
+
try {
|
|
147
|
+
const query = `SELECT reltuples::bigint as count FROM pg_class WHERE relname = $1`
|
|
148
|
+
const result = await this.provider.query<{ count: string }>(query, [tableName])
|
|
149
|
+
return parseInt(result[0]?.count || '0', 10)
|
|
150
|
+
} catch {
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async getForeignKeys(): Promise<any[]> {
|
|
156
|
+
const query = `
|
|
157
|
+
SELECT
|
|
158
|
+
tc.table_name as from_table,
|
|
159
|
+
kcu.column_name as column,
|
|
160
|
+
ccu.table_name as to_table,
|
|
161
|
+
-- Vérifie si la colonne est unique ou PK pour la cardinalité
|
|
162
|
+
(SELECT COUNT(*)
|
|
163
|
+
FROM information_schema.table_constraints i_tc
|
|
164
|
+
JOIN information_schema.key_column_usage i_kcu
|
|
165
|
+
ON i_tc.constraint_name = i_kcu.constraint_name
|
|
166
|
+
WHERE i_tc.table_name = tc.table_name
|
|
167
|
+
AND i_kcu.column_name = kcu.column_name
|
|
168
|
+
AND (i_tc.constraint_type = 'PRIMARY KEY' OR i_tc.constraint_type = 'UNIQUE')
|
|
169
|
+
) > 0 as is_unique
|
|
170
|
+
FROM information_schema.table_constraints tc
|
|
171
|
+
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
|
|
172
|
+
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
|
173
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
174
|
+
`
|
|
175
|
+
|
|
176
|
+
return await this.provider.query(query)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Calcul du poids initial (Physique de la donnée)
|
|
181
|
+
* On utilise le logarithme de la taille pour ne pas pénaliser trop lourdement
|
|
182
|
+
* les grosses tables, mais garder une notion de "frais de déplacement".
|
|
183
|
+
*/
|
|
184
|
+
private calculateInitialWeight(fk: ForeignKeyInfo, tables: TableInfo[]): number {
|
|
185
|
+
const targetTable = tables.find(t => t.name === fk.toTable)
|
|
186
|
+
if (!targetTable || targetTable.rowCount <= 0) return 1
|
|
187
|
+
|
|
188
|
+
// Formule : 1 + log10(n) -> 100 lignes = poids 3, 1M lignes = poids 7.
|
|
189
|
+
return 1 + Math.log10(targetTable.rowCount)
|
|
190
|
+
}
|
|
191
|
+
}
|