@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.
Files changed (72) hide show
  1. package/README.md +411 -0
  2. package/package.json +48 -0
  3. package/src/api/DomainNode.ts +1433 -0
  4. package/src/api/Graph.ts +271 -0
  5. package/src/api/PathBuilder.ts +247 -0
  6. package/src/api/index.ts +15 -0
  7. package/src/api/loadGraph.ts +207 -0
  8. package/src/api/test-api.ts +153 -0
  9. package/src/api/test-domain.ts +119 -0
  10. package/src/api/types.ts +88 -0
  11. package/src/config/synonyms.json +28 -0
  12. package/src/core/EventBus.ts +187 -0
  13. package/src/core/GraphEvents.ts +153 -0
  14. package/src/core/PathFinder.ts +283 -0
  15. package/src/formatters/BaseFormatter.ts +17 -0
  16. package/src/graph/GraphAssembler.ts +50 -0
  17. package/src/graph/GraphCompiler.ts +412 -0
  18. package/src/graph/GraphExtractor.ts +191 -0
  19. package/src/graph/GraphOptimizer.ts +404 -0
  20. package/src/graph/GraphTrainer.ts +247 -0
  21. package/src/http/LinkBuilder.ts +244 -0
  22. package/src/http/TrailRequest.ts +48 -0
  23. package/src/http/example-netflix.ts +59 -0
  24. package/src/http/hateoas/README.md +87 -0
  25. package/src/http/index.ts +33 -0
  26. package/src/http/plugin.ts +360 -0
  27. package/src/index.ts +121 -0
  28. package/src/instrumentation/TelemetryShim.ts +172 -0
  29. package/src/navigation/NavigationEngine.ts +441 -0
  30. package/src/navigation/Resolver.ts +134 -0
  31. package/src/navigation/Scheduler.ts +136 -0
  32. package/src/navigation/Trail.ts +252 -0
  33. package/src/navigation/TrailParser.ts +207 -0
  34. package/src/navigation/index.ts +11 -0
  35. package/src/providers/MockProvider.ts +68 -0
  36. package/src/providers/PostgresProvider.ts +187 -0
  37. package/src/runtime/CompiledGraphEngine.ts +274 -0
  38. package/src/runtime/DataLoader.ts +236 -0
  39. package/src/runtime/Engine.ts +163 -0
  40. package/src/runtime/QueryEngine.ts +222 -0
  41. package/src/scenarios/test-metro-paris/config.json +6 -0
  42. package/src/scenarios/test-metro-paris/graph.json +16325 -0
  43. package/src/scenarios/test-metro-paris/queries.ts +152 -0
  44. package/src/scenarios/test-metro-paris/stack.json +1 -0
  45. package/src/scenarios/test-musicians/config.json +10 -0
  46. package/src/scenarios/test-musicians/graph.json +20 -0
  47. package/src/scenarios/test-musicians/stack.json +1 -0
  48. package/src/scenarios/test-netflix/MIGRATION.md +23 -0
  49. package/src/scenarios/test-netflix/README.md +138 -0
  50. package/src/scenarios/test-netflix/actions.ts +92 -0
  51. package/src/scenarios/test-netflix/config.json +6 -0
  52. package/src/scenarios/test-netflix/data/categories.json +1 -0
  53. package/src/scenarios/test-netflix/data/companies.json +1 -0
  54. package/src/scenarios/test-netflix/data/credits.json +19797 -0
  55. package/src/scenarios/test-netflix/data/departments.json +18 -0
  56. package/src/scenarios/test-netflix/data/jobs.json +142 -0
  57. package/src/scenarios/test-netflix/data/movies.json +3497 -0
  58. package/src/scenarios/test-netflix/data/people.json +1 -0
  59. package/src/scenarios/test-netflix/data/synonyms.json +8 -0
  60. package/src/scenarios/test-netflix/data/users.json +70 -0
  61. package/src/scenarios/test-netflix/graph.json +1017 -0
  62. package/src/scenarios/test-netflix/queries.ts +159 -0
  63. package/src/scenarios/test-netflix/stack.json +14 -0
  64. package/src/schema/GraphBuilder.ts +106 -0
  65. package/src/schema/JsonSchemaExtractor.ts +107 -0
  66. package/src/schema/SchemaAnalyzer.ts +175 -0
  67. package/src/schema/SchemaExtractor.ts +102 -0
  68. package/src/schema/SynonymResolver.ts +143 -0
  69. package/src/scripts/dictionary.json +796 -0
  70. package/src/scripts/graph.json +664 -0
  71. package/src/scripts/regenerate.ts +248 -0
  72. package/src/types/index.ts +506 -0
@@ -0,0 +1,1433 @@
1
+ /**
2
+ * api/DomainNode.ts — Niveau 1 : navigation sémantique
3
+ *
4
+ * Un DomainNode représente une frame dans le trail de navigation.
5
+ * Il est Proxy sur lui-même pour intercepter les accès de propriétés
6
+ * et les traduire en étapes de navigation.
7
+ *
8
+ * Usage :
9
+ * cinema.movies → DomainNode(entity='movies')
10
+ * cinema.people(278) → DomainNode(entity='people', filters={id:278})
11
+ * cinema.people(278).movies → DomainNode(entity='movies', parent=people(278))
12
+ * await cinema.people(278).movies → LinkLabResult (tableau enrichi)
13
+ *
14
+ * LinkLabResult = any[] + { path, timing, from, to }
15
+ * const films = await cinema.film()
16
+ * films.forEach(f => console.log(f.title)) // itération native
17
+ * films.length // nombre de résultats
18
+ * films.path // ['film']
19
+ * films.timing // 12ms
20
+ *
21
+ * cinema.directors('Nolan').movies → QueryResult (route sémantique director_in)
22
+ * cinema.movies(278).actors → QueryResult (route sémantique actor)
23
+ *
24
+ * Résolution des propriétés navigables :
25
+ * 1. node.id === prop → accès direct (netflix: 'movies', 'people')
26
+ * 2. node.type === prop → type singulier (musicians: 'artist')
27
+ * 3. singular(prop) est un type connu → collection (musicians: 'artists' → type 'artist')
28
+ * 4. label sémantique dans compiled.routes → vue filtrée (netflix: 'actor', 'director')
29
+ *
30
+ * Pattern thenable :
31
+ * Le DomainNode implémente .then() — JavaScript le traite comme une Promise.
32
+ * L'exécution réelle (fetch) n'est déclenchée qu'au `await`.
33
+ */
34
+
35
+ import type { Graph as GraphData, CompiledGraph, Provider } from '../types/index.js'
36
+ import { QueryEngine } from '../runtime/QueryEngine.js'
37
+ import type { QueryResult } from './types.js'
38
+
39
+ // ── LinkLabResult — tableau enrichi retourné par await ───────────────────────
40
+ //
41
+ // Se comporte comme un Array natif (forEach, map, filter, length...)
42
+ // tout en exposant les métadonnées de la traversée.
43
+ //
44
+ // const films = await cinema.film()
45
+ // films.forEach(f => console.log(f.title)) // itération native
46
+ // films.length // nombre de résultats
47
+ // films.path // ['film']
48
+ // films.timing // 12ms
49
+
50
+ export type LinkLabResult<T = any> = T[] & {
51
+ /** Chemin de traversée dans le graphe */
52
+ path: string[]
53
+ /** Durée d'exécution en ms */
54
+ timing: number
55
+ /** Entité source */
56
+ from: string
57
+ /** Entité cible */
58
+ to: string
59
+ /** Label sémantique composé du Trail (ex: "director_in→actor") */
60
+ semanticLabel?: string
61
+ /** SQL généré (disponible en mode CTE postgres) */
62
+ sql?: string
63
+ }
64
+
65
+ function makeResult<T = any>(queryResult: QueryResult): LinkLabResult<T> {
66
+ const arr = [...(queryResult.data ?? [])] as LinkLabResult<T>
67
+ arr.path = queryResult.path ?? []
68
+ arr.timing = queryResult.timing ?? 0
69
+ arr.from = queryResult.from ?? ''
70
+ arr.to = queryResult.to ?? ''
71
+ arr.semanticLabel = (queryResult as any).semanticLabel
72
+ arr.sql = (queryResult as any).sql
73
+ return arr
74
+ }
75
+
76
+ // ── Types internes ────────────────────────────────────────────────────────────
77
+
78
+ interface DomainContext {
79
+ graphData: GraphData
80
+ compiled: CompiledGraph | null
81
+ dataset: Record<string, any[]> | null
82
+ provider?: Provider | null
83
+ navMode?: boolean // true = comportement original stateless
84
+ dictionary?: Record<string, any> | null // routes enrichies avec labels humains
85
+ }
86
+
87
+ /**
88
+ * Résultat de resolveEntity — entity résolue + label sémantique optionnel.
89
+ * Le label sémantique est propagé au QueryEngine lors de _fetchViaRoute.
90
+ */
91
+ interface ResolvedEntity {
92
+ entity: string // ID du nœud cible (ex: 'people')
93
+ semantic: string | null // label de route sémantique (ex: 'director_in') ou null
94
+ }
95
+
96
+ // ── Résolution des noms de propriétés → IDs de nodes ─────────────────────────
97
+
98
+ /**
99
+ * resolveEntity — résout un nom de propriété en entité navigable.
100
+ *
101
+ * Ordre de priorité :
102
+ * 1. ID direct dans graphData.nodes → 'movies', 'people'
103
+ * 2. Type singulier dans graphData.nodes → 'artist'
104
+ * 3. Pluriel → singulier → 'artists' → 'artist'
105
+ * 4. Label sémantique dans compiled.routes → 'actor', 'director', 'writer'
106
+ * Nécessite compiled — silencieux si absent.
107
+ */
108
+ function resolveEntity(
109
+ prop: string,
110
+ graphData: GraphData,
111
+ compiled: CompiledGraph | null = null,
112
+ currentEntity: string | null = null
113
+ ): ResolvedEntity | null {
114
+ // 1. ID direct : 'movies' → node {id: 'movies'}
115
+ if (graphData.nodes.some(n => n.id === prop)) {
116
+ return { entity: prop, semantic: null }
117
+ }
118
+
119
+ // 2. Type singulier : 'artist' → premier node de type 'artist'
120
+ const byType = graphData.nodes.find(n => (n as any).type === prop)
121
+ if (byType) {
122
+ return { entity: byType.id, semantic: null }
123
+ }
124
+
125
+ // 3. Pluriel → singulier : 'artists' → type 'artist'
126
+ const singular = toSingular(prop)
127
+ const byPlural = graphData.nodes.find(n => (n as any).type === singular)
128
+ if (byPlural) {
129
+ return { entity: singular, semantic: null }
130
+ }
131
+
132
+ // 4. Label sémantique dans compiled.routes : 'director', 'actor', 'writer'
133
+ // Stratégie de recherche :
134
+ // a. Match exact sur le label : 'actor' → label='actor'
135
+ // b. Singulier du prop : 'actors' → label='actor'
136
+ // c. Singulier + suffixe '_in' (sens inverse) : 'directors' → label='director_in'
137
+ // Si currentEntity est fourni, on priorise la route dont from === currentEntity.
138
+ if (compiled) {
139
+ const singular = toSingular(prop)
140
+ const candidates = [prop, singular, `${prop}_in`, `${singular}_in`]
141
+ const semanticRoutes = compiled.routes.filter(
142
+ r => (r as any).semantic === true && candidates.includes((r as any).label)
143
+ )
144
+ if (semanticRoutes.length > 0) {
145
+ // Deux contextes distincts :
146
+ //
147
+ // A) Depuis createDomain (currentEntity=null) :
148
+ // cinema.directors('Nolan') — on navigue DEPUIS l'entité de la route
149
+ // director_in : people→movies → entity='people' (point de départ)
150
+ //
151
+ // B) Depuis un DomainNode parent (currentEntity fourni) :
152
+ // movies(278).actors — on navigue VERS l'entité de la route
153
+ // actor : movies→people → entity='people' (destination)
154
+ // On priorise la route dont from === currentEntity
155
+ if (currentEntity) {
156
+ // Cas B — depuis un DomainNode parent
157
+ //
158
+ // Deux sous-cas :
159
+ //
160
+ // B1 — Navigation vers une autre entité : movies(278).directors
161
+ // currentEntity='movies', prop='directors'
162
+ // → label='director' (movies→people) → entity='people' (destination)
163
+ // La route part DE currentEntity → naviguer VERS to
164
+ //
165
+ // B2 — Qualification/filtre sur même entité : people('Nolan').director
166
+ // currentEntity='people', prop='director'
167
+ // → label='director_in' (people→movies) mais on reste sur 'people'
168
+ // La route part DE currentEntity mais c'est un filtre, pas une nav vers movies
169
+ //
170
+ // Distinction : si prop (singulier) correspond à un label _in depuis currentEntity
171
+ // → c'est un filtre (B2) : entity = currentEntity, semantic = label_in
172
+ // Sinon : c'est une navigation (B1) : entity = to, semantic = label
173
+
174
+ const propSingular = toSingular(prop)
175
+ const inLabel = `${propSingular}_in`
176
+
177
+ // Chercher une route _in depuis currentEntity (filtre sur même entité)
178
+ const filterRoute = semanticRoutes.find(
179
+ r => (r as any).label === inLabel && r.from === currentEntity
180
+ )
181
+ if (filterRoute) {
182
+ // B2 — filtre : on reste sur currentEntity avec le semantic _in
183
+ return { entity: currentEntity, semantic: (filterRoute as any).label }
184
+ }
185
+
186
+ // Chercher une route depuis currentEntity (navigation vers autre entité)
187
+ const navRoute = semanticRoutes.find(r => r.from === currentEntity)
188
+ if (navRoute) {
189
+ // B1 — navigation : on va vers to
190
+ return { entity: navRoute.to, semantic: (navRoute as any).label }
191
+ }
192
+
193
+ // Fallback : première route disponible → navigation vers to
194
+ const best = semanticRoutes[0]
195
+ return { entity: best.to, semantic: (best as any).label }
196
+ } else {
197
+ // Cas A — depuis createDomain : cinema.directors('Nolan')
198
+ // On navigue DEPUIS l'entité source de la vue sémantique
199
+ // Prioriser la route dont le label se termine par '_in' (sens inverse = point de départ)
200
+ // Ex: 'directors' → label='director_in' (people→movies) → entity='people'
201
+ // Ex: 'director' → label='director_in' en priorité, sinon 'director' (movies→people) → from='movies'
202
+ const propSingular = toSingular(prop)
203
+ const inLabel = `${propSingular}_in`
204
+ const bestIn = semanticRoutes.find(r => (r as any).label === inLabel)
205
+ const best = bestIn ?? semanticRoutes[0]
206
+ // Retourner l'entité SOURCE (from) — c'est le point d'entrée pour cinema.directors(...)
207
+ return { entity: best.from, semantic: (best as any).label }
208
+ }
209
+ }
210
+ }
211
+
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Résout les nodes correspondant à une entité (peut être un type avec N nodes).
217
+ * Pour Netflix (type='table') : un seul node par entité.
218
+ * Pour Musicians (type='artist') : N nodes du même type.
219
+ */
220
+ function resolveNodes(entity: string, graphData: GraphData): typeof graphData.nodes {
221
+ // Cherche d'abord par ID exact
222
+ const byId = graphData.nodes.filter(n => n.id === entity)
223
+ if (byId.length > 0) return byId
224
+
225
+ // Sinon par type
226
+ return graphData.nodes.filter(n => (n as any).type === entity)
227
+ }
228
+
229
+ function toSingular(s: string): string {
230
+ if (s.endsWith('ies')) return s.slice(0, -3) + 'y'
231
+ if (s.endsWith('s') && !s.endsWith('ss')) return s.slice(0, -1)
232
+ return s
233
+ }
234
+
235
+ // ── DomainNode ────────────────────────────────────────────────────────────────
236
+
237
+ export class DomainNode {
238
+ readonly entity: string // ID ou type du node courant
239
+ readonly filters: Record<string, any> // {id: 278} ou {name: 'Nolan'}
240
+ readonly parent: DomainNode | null // frame précédente dans le trail
241
+ readonly semantic: string | null // label sémantique si résolu via compiled.routes
242
+
243
+ private readonly _ctx: DomainContext
244
+
245
+ constructor(
246
+ entity: string,
247
+ filters: Record<string, any>,
248
+ parent: DomainNode | null,
249
+ ctx: DomainContext,
250
+ semantic: string | null = null
251
+ ) {
252
+ this.entity = entity
253
+ this.filters = filters
254
+ this.parent = parent
255
+ this._ctx = ctx
256
+ this.semantic = semantic
257
+
258
+ // Retourner un Proxy pour intercepter les accès de propriétés
259
+ return new Proxy(this, {
260
+ get(target, prop: string) {
261
+ // Propriétés natives de DomainNode — accès direct
262
+ if (prop in target) return (target as any)[prop]
263
+
264
+ // Propriétés Symbol (iteration, etc.) — passe-plat
265
+ if (typeof prop === 'symbol') return undefined
266
+
267
+ // Méthodes Array — déclenchent l'exécution et appliquent la méthode sur le résultat
268
+ // Permet : await cinema.movies.map(m => m.title)
269
+ // await cinema.film.filter(f => f.rating === 'PG')
270
+ // await cinema.film.find(f => f.id === 278)
271
+ const ARRAY_METHODS = [
272
+ 'map',
273
+ 'filter',
274
+ 'find',
275
+ 'findIndex',
276
+ 'forEach',
277
+ 'some',
278
+ 'every',
279
+ 'reduce',
280
+ 'reduceRight',
281
+ 'slice',
282
+ 'flat',
283
+ 'flatMap',
284
+ 'includes'
285
+ ]
286
+ if (ARRAY_METHODS.includes(prop)) {
287
+ return (...args: any[]) =>
288
+ target._execute().then(result => (result as any)[prop](...args))
289
+ }
290
+
291
+ // then/catch/finally — pattern thenable (Promise-like)
292
+ // Déclenche l'exécution au `await`
293
+ if (prop === 'then') {
294
+ return (resolve: (v: any) => any, reject: (e: any) => any) =>
295
+ target._execute().then(resolve, reject)
296
+ }
297
+ if (prop === 'catch') {
298
+ return (reject: (e: any) => any) => target._execute().catch(reject)
299
+ }
300
+ if (prop === 'finally') {
301
+ return (fn: () => any) => target._execute().finally(fn)
302
+ }
303
+
304
+ // Propriété navigable ? → nouvelle frame
305
+ // On passe compiled pour permettre la résolution des labels sémantiques (cas 4)
306
+ const resolved = resolveEntity(
307
+ prop,
308
+ target._ctx.graphData,
309
+ target._ctx.compiled,
310
+ target.entity
311
+ )
312
+ if (resolved !== null) {
313
+ return makeCallableDomainNode(resolved.entity, {}, target, target._ctx, resolved.semantic)
314
+ }
315
+
316
+ // Propriété inconnue
317
+ return undefined
318
+ }
319
+ })
320
+ }
321
+
322
+ // ── Exécution (thenable) ──────────────────────────────────────────────────
323
+
324
+ /**
325
+ * _execute() — déclenché par `await domainNode`.
326
+ *
327
+ * Mode query (défaut) : cumulatif — chaque étape passe ses IDs à la suivante.
328
+ * Mode nav (préfixe) : stateless — comportement original, anchor→current direct.
329
+ */
330
+ private async _execute(): Promise<LinkLabResult> {
331
+ const start = Date.now()
332
+
333
+ // Reconstruire le trail (du plus ancien au plus récent)
334
+ const trail: DomainNode[] = []
335
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
336
+ let cursor: DomainNode | null = this
337
+ while (cursor) {
338
+ trail.unshift(cursor)
339
+ cursor = cursor.parent
340
+ }
341
+
342
+ // Cas 1 : un seul node dans le trail → fetch direct (identique query/nav)
343
+ if (trail.length === 1) {
344
+ return makeResult(await this._fetchDirect(trail[0], start))
345
+ }
346
+
347
+ // Cas 2 : mode nav — comportement original (anchor→current direct)
348
+ if (this._ctx.navMode) {
349
+ const anchor = trail[0]
350
+ const current = trail[trail.length - 1]
351
+ return makeResult(await this._fetchViaRoute(anchor, current, trail, start))
352
+ }
353
+
354
+ // Cas 3 : mode query — cumulatif étape par étape
355
+ if (process.env.LINKLAB_DEBUG) {
356
+ console.log(
357
+ `[_execute query] trail=${trail.map(n => `${n.entity}(sem=${n.semantic},fil=${JSON.stringify(n.filters)})`).join('→')}`
358
+ )
359
+ }
360
+ return this._executeQuery(trail, start)
361
+ }
362
+
363
+ /**
364
+ * linksFrom() — routes disponibles depuis l'entité courante.
365
+ *
366
+ * Retourne les routes avec labels humains depuis le dictionnaire résolu.
367
+ * Si le dictionnaire n'est pas chargé, retourne les labels bruts du compilé.
368
+ *
369
+ * cinema.movies.linksFrom()
370
+ * → [
371
+ * { to: 'people', label: 'Acteurs de', semantic: 'actor', composed: false },
372
+ * { to: 'people', label: 'Réalisé par', semantic: 'director', composed: false },
373
+ * { to: 'movies', label: 'Films avec', semantic: 'actor_in→director', composed: true },
374
+ * ]
375
+ */
376
+ linksFrom(options: { composed?: boolean; semantic?: boolean } = {}): Array<{
377
+ to: string
378
+ label: string
379
+ semantic: string | null
380
+ composed: boolean
381
+ weight?: number
382
+ }> {
383
+ const { compiled, dictionary } = this._ctx
384
+ if (!compiled) return []
385
+
386
+ let routes = compiled.routes.filter((r: any) => r.from === this.entity)
387
+
388
+ // Filtres optionnels
389
+ if (options.composed !== undefined)
390
+ routes = routes.filter((r: any) => !!r.composed === options.composed)
391
+ if (options.semantic !== undefined)
392
+ routes = routes.filter((r: any) => !!r.semantic === options.semantic)
393
+
394
+ const dictRoutes = (dictionary as any)?.routes ?? {}
395
+
396
+ return routes.map((r: any) => {
397
+ const key = r.label && r.semantic ? `${r.from}→${r.to}[${r.label}]` : `${r.from}→${r.to}`
398
+ const dictEntry = dictRoutes[key]
399
+
400
+ return {
401
+ to: r.to,
402
+ label: dictEntry?.label ?? r.label ?? `${r.from}→${r.to}`,
403
+ semantic: r.label ?? null,
404
+ composed: !!r.composed,
405
+ weight: r.primary?.weight
406
+ }
407
+ })
408
+ }
409
+
410
+ /**
411
+ * _executeQuery() — mode query cumulatif.
412
+ *
413
+ * Exécute chaque étape du Trail séquentiellement.
414
+ * Les IDs trouvés à l'étape N deviennent une contrainte IN à l'étape N+1.
415
+ * Le label sémantique est préservé d'une étape à l'autre.
416
+ *
417
+ * cinema.movies('Inception').director.movies :
418
+ * Étape 1 : movies WHERE title='Inception' → [{ id: 27205 }]
419
+ * Étape 2 : people WHERE movieId IN [27205] → [{ id: 525 }] (semantic: director_in, jobId=2)
420
+ * Étape 3 : movies WHERE personId IN [525] → 6 films (jobId=2 préservé)
421
+ */
422
+ private async _executeQuery(trail: DomainNode[], start: number): Promise<LinkLabResult> {
423
+ const { compiled, dataset, provider } = this._ctx
424
+
425
+ if (!compiled || (!dataset && !provider)) {
426
+ throw new Error(`Mode query nécessite un compiledGraph et un dataset ou provider.`)
427
+ }
428
+
429
+ // Mode SQL → générer une requête CTE globale (évite les IN géants)
430
+ if (provider) {
431
+ return this._executeQueryCTE(trail, start, provider, compiled)
432
+ }
433
+
434
+ const engine = new QueryEngine(compiled)
435
+ let currentIds: any[] | null = null
436
+ let lastSemantic: string | null = null
437
+ let lastResult: QueryResult | null = null
438
+ const resolvedPath: string[] = [] // chemin réel parcouru (pour breadcrumb)
439
+ const trailSemantics: string[] = [] // labels sémantiques du Trail (pour breadcrumb)
440
+
441
+ for (let i = 0; i < trail.length; i++) {
442
+ const node = trail[i]
443
+
444
+ if (i === 0) {
445
+ lastResult = await this._fetchDirect(node, start)
446
+ currentIds = lastResult.data.map((row: any) => row.id ?? row[Object.keys(row)[0]])
447
+ lastSemantic = node.semantic
448
+ if (process.env.LINKLAB_DEBUG) {
449
+ console.log(
450
+ `[_fetchDirect] entity=${node.entity} filters=${JSON.stringify(node.filters)} → ${lastResult.data.length} rows, currentIds=${JSON.stringify(currentIds?.slice(0, 3))}`
451
+ )
452
+ }
453
+ if (lastResult.path?.length) resolvedPath.push(...lastResult.path)
454
+ else resolvedPath.push(node.entity)
455
+ continue
456
+ }
457
+
458
+ // Étapes suivantes : traversée avec contrainte IN sur les IDs précédents
459
+ const prev = trail[i - 1]
460
+ const semantic = node.semantic ?? lastSemantic
461
+
462
+ // Cas spécial A : même entité + semantic différent
463
+ // → chercher une route composée dans le compilé
464
+ if (prev.entity === node.entity && node.semantic !== null) {
465
+ if (lastSemantic !== null && lastSemantic !== node.semantic) {
466
+ // Construire le label composé
467
+ // Convention : second terme sans _in (movies→people = 'actor', pas 'actor_in')
468
+ const secondLabel = node.semantic.endsWith('_in')
469
+ ? node.semantic.slice(0, -3)
470
+ : node.semantic
471
+ const composedLabel = `${lastSemantic}→${secondLabel}`
472
+ const composedRoute = (engine as any)?.compiledGraph?.routes?.find(
473
+ (r: any) =>
474
+ r.from === prev.entity &&
475
+ r.to === node.entity &&
476
+ r.composed &&
477
+ r.label === composedLabel
478
+ )
479
+
480
+ if (composedRoute) {
481
+ // Exécuter la route composée directement avec les IDs courants
482
+ const idConstraint =
483
+ currentIds && currentIds.length > 0
484
+ ? { _ids: currentIds, _fromEntity: prev.entity }
485
+ : null
486
+
487
+ lastResult = await this._fetchStep(
488
+ prev.entity,
489
+ node.entity,
490
+ node.filters,
491
+ composedLabel, // label composé → getRoute trouvera la route
492
+ idConstraint,
493
+ engine,
494
+ start
495
+ )
496
+ currentIds = lastResult.data.map((row: any) => row.id ?? row[Object.keys(row)[0]])
497
+ // Accumuler le chemin réel de la route composée
498
+ if (lastResult.path?.length > 1) resolvedPath.push(...lastResult.path.slice(1))
499
+ else resolvedPath.push(node.entity)
500
+ lastSemantic = null // reset après traversée composée
501
+ } else {
502
+ // Pas de route composée → [] silencieux
503
+ lastResult = {
504
+ from: prev.entity,
505
+ to: node.entity,
506
+ filters: {},
507
+ data: [],
508
+ path: [prev.entity],
509
+ timing: Date.now() - start
510
+ }
511
+ currentIds = []
512
+ lastSemantic = node.semantic
513
+ }
514
+ continue
515
+ }
516
+ lastSemantic = node.semantic
517
+ continue
518
+ }
519
+
520
+ // Construire les filtres : IDs précédents comme contrainte
521
+ // Court-circuit : si currentIds est vide, le résultat sera vide — inutile d'exécuter
522
+ if (currentIds !== null && currentIds.length === 0) {
523
+ lastResult = {
524
+ from: prev.entity,
525
+ to: node.entity,
526
+ filters: {},
527
+ data: [],
528
+ path: [prev.entity, node.entity],
529
+ timing: Date.now() - start
530
+ }
531
+ break
532
+ }
533
+
534
+ // Sécurité : limiter la taille du IN pour éviter les requêtes SQL trop longues
535
+ const MAX_IN_SIZE = 1000
536
+ const safeIds =
537
+ currentIds && currentIds.length > MAX_IN_SIZE
538
+ ? currentIds.slice(0, MAX_IN_SIZE)
539
+ : currentIds
540
+
541
+ const idConstraint =
542
+ safeIds && safeIds.length > 0 ? { _ids: safeIds, _fromEntity: prev.entity } : null
543
+
544
+ lastResult = await this._fetchStep(
545
+ prev.entity,
546
+ node.entity,
547
+ node.filters,
548
+ semantic,
549
+ idConstraint,
550
+ engine,
551
+ start
552
+ )
553
+
554
+ currentIds = lastResult.data.map((row: any) => row.id ?? row[Object.keys(row)[0]])
555
+ // Accumuler le chemin réel (sans répéter le premier nœud)
556
+ if (lastResult.path?.length > 1) resolvedPath.push(...lastResult.path.slice(1))
557
+ else resolvedPath.push(node.entity)
558
+ // Accumuler le semantic pour le breadcrumb
559
+ if (semantic) trailSemantics.push(semantic)
560
+ // Préserver le semantic pour l'étape suivante
561
+ lastSemantic = semantic
562
+ }
563
+
564
+ const semanticLabel = trailSemantics.length > 0 ? trailSemantics.join('→') : undefined
565
+
566
+ const base = lastResult ?? {
567
+ from: '',
568
+ to: '',
569
+ filters: {},
570
+ data: [],
571
+ path: [],
572
+ timing: Date.now() - start
573
+ }
574
+ return makeResult({
575
+ ...base,
576
+ path: resolvedPath.length ? resolvedPath : base.path,
577
+ semanticLabel
578
+ } as any)
579
+ }
580
+
581
+ /**
582
+ * _executeQueryCTE() — mode query SQL avec CTEs globales.
583
+ *
584
+ * Génère une seule requête SQL WITH ... AS (...) au lieu de N allers-retours.
585
+ * Évite les clauses IN géantes sur les tables volumineuses.
586
+ *
587
+ * dvdrental.customer('MARY').rental.film :
588
+ *
589
+ * WITH step0 AS (
590
+ * SELECT DISTINCT customer.* FROM customer WHERE customer.first_name ILIKE 'MARY'
591
+ * ),
592
+ * step1 AS (
593
+ * SELECT DISTINCT rental.*
594
+ * FROM rental
595
+ * INNER JOIN step0 ON rental.customer_id = step0.customer_id
596
+ * ),
597
+ * step2 AS (
598
+ * SELECT DISTINCT film.*
599
+ * FROM film
600
+ * INNER JOIN inventory ON film.film_id = inventory.film_id
601
+ * INNER JOIN step1 ON inventory.rental_id = step1.rental_id
602
+ * )
603
+ * SELECT * FROM step2
604
+ */
605
+ private async _executeQueryCTE(
606
+ trail: DomainNode[],
607
+ start: number,
608
+ provider: any,
609
+ compiled: any
610
+ ): Promise<LinkLabResult> {
611
+ const engine = new QueryEngine(compiled)
612
+
613
+ // Résoudre la PK d'une entité
614
+ const pkOf = (tableId: string): string => {
615
+ const node = compiled.nodes.find((n: any) => n.id === tableId)
616
+ const pk = (node as any)?.primaryKey
617
+ return Array.isArray(pk) ? pk[0] : (pk ?? `${tableId}_id`)
618
+ }
619
+
620
+ // Construire le WHERE depuis les filtres d'un nœud
621
+ const buildWhere = (entity: string, filters: Record<string, any>): string => {
622
+ const pk = pkOf(entity)
623
+ const clauses = Object.entries(filters).map(([k, v]) => {
624
+ const col = k === 'id' ? pk : k
625
+ if (v === null) return `${entity}.${col} IS NULL`
626
+ if (typeof v === 'object' && !Array.isArray(v)) {
627
+ const op = Object.keys(v)[0]
628
+ const val = v[op]
629
+ switch (op) {
630
+ case 'like':
631
+ return `${entity}.${col} ILIKE '%${val}%'`
632
+ case 'startsWith':
633
+ return `${entity}.${col} ILIKE '${val}%'`
634
+ case 'endsWith':
635
+ return `${entity}.${col} ILIKE '%${val}'`
636
+ case 'gt':
637
+ return `${entity}.${col} > ${val}`
638
+ case 'gte':
639
+ return `${entity}.${col} >= ${val}`
640
+ case 'lt':
641
+ return `${entity}.${col} < ${val}`
642
+ case 'lte':
643
+ return `${entity}.${col} <= ${val}`
644
+ case 'neq':
645
+ return `${entity}.${col} != ${typeof val === 'string' ? `'${val}'` : val}`
646
+ case 'in':
647
+ return `${entity}.${col} IN (${(val as any[]).map((x: any) => (typeof x === 'string' ? `'${x}'` : x)).join(',')})`
648
+ default:
649
+ return `${entity}.${col} = ${typeof val === 'string' ? `'${val}'` : val}`
650
+ }
651
+ }
652
+ if (typeof v === 'string') return `${entity}.${col} ILIKE '${v}'`
653
+ return `${entity}.${col} = ${v}`
654
+ })
655
+ return clauses.length > 0 ? clauses.join(' AND ') : ''
656
+ }
657
+
658
+ const ctes: string[] = []
659
+ const resolvedPath: string[] = []
660
+ let lastEntity = trail[0].entity
661
+ let lastSemantic: string | null = trail[0].semantic
662
+
663
+ // ── Step 0 : premier nœud — fetch direct avec filtres ─────────────────
664
+ const step0Entity = trail[0].entity
665
+ const step0Where = buildWhere(step0Entity, trail[0].filters)
666
+ ctes.push(
667
+ `step0 AS (\n SELECT DISTINCT ${step0Entity}.*` +
668
+ ` FROM ${step0Entity}` +
669
+ (step0Where ? `\n WHERE ${step0Where}` : '') +
670
+ `\n)`
671
+ )
672
+ resolvedPath.push(step0Entity)
673
+
674
+ // ── Steps suivants ─────────────────────────────────────────────────────
675
+ for (let i = 1; i < trail.length; i++) {
676
+ const node = trail[i]
677
+ const prev = trail[i - 1]
678
+
679
+ // Cas spécial : même entité + semantic différent → route composée
680
+ if (prev.entity === node.entity && node.semantic !== null) {
681
+ if (lastSemantic !== null && lastSemantic !== node.semantic) {
682
+ const secondLabel = node.semantic.endsWith('_in')
683
+ ? node.semantic.slice(0, -3)
684
+ : node.semantic
685
+ const composedLabel = `${lastSemantic}→${secondLabel}`
686
+ try {
687
+ const route = engine.getRoute(prev.entity, node.entity, composedLabel)
688
+ const stepIdx = ctes.length
689
+ const prevStep = `step${stepIdx - 1}`
690
+ const cte = buildCTEStep(
691
+ stepIdx,
692
+ node.entity,
693
+ route.primary,
694
+ prevStep,
695
+ prev.entity,
696
+ node.filters,
697
+ pkOf,
698
+ buildWhere
699
+ )
700
+ ctes.push(cte)
701
+ resolvedPath.push(...route.primary.path.slice(1))
702
+ lastEntity = node.entity
703
+ lastSemantic = null
704
+ continue
705
+ } catch {
706
+ /* pas de route composée → vide */
707
+ }
708
+
709
+ // Pas de route composée → CTE vide
710
+ const stepIdx = ctes.length
711
+ ctes.push(
712
+ `step${stepIdx} AS (\n SELECT DISTINCT ${node.entity}.* FROM ${node.entity} WHERE 1=0\n)`
713
+ )
714
+ resolvedPath.push(node.entity)
715
+ lastEntity = node.entity
716
+ lastSemantic = node.semantic
717
+ continue
718
+ }
719
+ lastSemantic = node.semantic
720
+ continue
721
+ }
722
+
723
+ // Cas normal : traversée from→to
724
+ const semantic = node.semantic ?? lastSemantic
725
+ try {
726
+ const route = engine.getRoute(prev.entity, node.entity, semantic ?? undefined)
727
+ const stepIdx = ctes.length
728
+ const prevStep = `step${stepIdx - 1}`
729
+ const cte = buildCTEStep(
730
+ stepIdx,
731
+ node.entity,
732
+ route.primary,
733
+ prevStep,
734
+ prev.entity,
735
+ node.filters,
736
+ pkOf,
737
+ buildWhere
738
+ )
739
+ ctes.push(cte)
740
+ resolvedPath.push(...route.primary.path.slice(1))
741
+ lastEntity = node.entity
742
+ lastSemantic = semantic
743
+ } catch {
744
+ // Route introuvable → résultat vide
745
+ const stepIdx = ctes.length
746
+ ctes.push(
747
+ `step${stepIdx} AS (\n SELECT DISTINCT ${node.entity}.* FROM ${node.entity} WHERE 1=0\n)`
748
+ )
749
+ resolvedPath.push(node.entity)
750
+ lastEntity = node.entity
751
+ lastSemantic = null
752
+ }
753
+ }
754
+
755
+ // ── Requête finale ─────────────────────────────────────────────────────
756
+ const finalStep = `step${ctes.length - 1}`
757
+ const sql = `WITH\n${ctes.map(c => ` ${c}`).join(',\n')}\nSELECT * FROM ${finalStep}`
758
+
759
+ if (process.env.LINKLAB_DEBUG) {
760
+ console.log(`[_executeQueryCTE]\n${sql}\n`)
761
+ }
762
+
763
+ const data = await provider.query(sql)
764
+ const from = trail[0].entity
765
+ const to = lastEntity
766
+
767
+ return makeResult({
768
+ from,
769
+ to,
770
+ filters: trail[0].filters,
771
+ data,
772
+ path: resolvedPath,
773
+ timing: Date.now() - start,
774
+ sql
775
+ } as any)
776
+ }
777
+
778
+ /**
779
+ * _fetchStep() — une étape du mode query cumulatif.
780
+ *
781
+ * Exécute la traversée from→to en filtrant sur les IDs de l'étape précédente.
782
+ */
783
+ private async _fetchStep(
784
+ fromEntity: string,
785
+ toEntity: string,
786
+ toFilters: Record<string, any>,
787
+ semantic: string | null,
788
+ idConstraint: { _ids: any[]; _fromEntity: string } | null,
789
+ engine: QueryEngine,
790
+ start: number
791
+ ): Promise<QueryResult> {
792
+ const { dataset, provider } = this._ctx
793
+
794
+ // Résoudre le semantic inversé si nécessaire
795
+ // Ex: lastSemantic='director_in' (people→movies) mais on va movies→people → utiliser 'director'
796
+ const resolvedSemantic = semantic ?? null
797
+
798
+ let data: any[]
799
+ let path: string[]
800
+
801
+ try {
802
+ const route = engine.getRoute(fromEntity, toEntity, resolvedSemantic ?? undefined)
803
+ path = route.primary.path
804
+ if (process.env.LINKLAB_DEBUG) {
805
+ console.log(
806
+ `[_fetchStep] ${fromEntity}→${toEntity} semantic=${resolvedSemantic} idConstraint=${JSON.stringify(idConstraint?._ids?.slice(0, 3))} route=${JSON.stringify(path)}`
807
+ )
808
+ }
809
+
810
+ if (provider) {
811
+ // Mode SQL : générer un SQL avec sous-requête IN
812
+ let sql = engine.generateSQL({
813
+ from: fromEntity,
814
+ to: toEntity,
815
+ ...(resolvedSemantic ? { semantic: resolvedSemantic } : {})
816
+ })
817
+
818
+ // Injecter la contrainte IN sur les IDs précédents
819
+ if (idConstraint && idConstraint._ids.length > 0) {
820
+ const pk = this._getPK(fromEntity)
821
+ const inList = idConstraint._ids
822
+ .map((id: any) => (typeof id === 'string' ? `'${id}'` : id))
823
+ .join(', ')
824
+ // Remplacer ou ajouter le WHERE avec la contrainte IN
825
+ if (sql.includes('WHERE')) {
826
+ sql = sql.replace('WHERE', `WHERE ${fromEntity}.${pk} IN (${inList}) AND`)
827
+ } else {
828
+ sql += `\nWHERE ${fromEntity}.${pk} IN (${inList})`
829
+ }
830
+ }
831
+
832
+ // Appliquer les filtres du nœud courant
833
+ if (Object.keys(toFilters).length > 0) {
834
+ const pk = this._getPK(toEntity)
835
+ const wheres = Object.entries(toFilters).map(([k, v]) => {
836
+ const col = k === 'id' ? pk : k
837
+ return v === null
838
+ ? `${toEntity}.${col} IS NULL`
839
+ : `${toEntity}.${col} = ${typeof v === 'string' ? `'${v}'` : v}`
840
+ })
841
+ if (sql.includes('WHERE')) {
842
+ sql += ` AND ${wheres.join(' AND ')}`
843
+ } else {
844
+ sql += `\nWHERE ${wheres.join(' AND ')}`
845
+ }
846
+ }
847
+
848
+ data = await provider.query(sql)
849
+ } else {
850
+ // Mode in-memory : passer les IDs via filters sur l'entité source
851
+ // executeInMemory filtre dataset[from] par filters, puis traverse vers to
852
+ const sourceFilters: Record<string, any> = {}
853
+
854
+ if (idConstraint && idConstraint._ids.length > 0) {
855
+ // Si un seul ID → filtre direct, sinon on pré-filtre le dataset
856
+ if (idConstraint._ids.length === 1) {
857
+ const pk = this._getPK(fromEntity)
858
+ sourceFilters[pk] = idConstraint._ids[0]
859
+ }
860
+ // Multi-IDs : on filtre le dataset manuellement
861
+ }
862
+
863
+ let filteredDataset = dataset!
864
+ if (idConstraint && idConstraint._ids.length > 1) {
865
+ const pk = this._getPK(fromEntity)
866
+ filteredDataset = {
867
+ ...dataset!,
868
+ [fromEntity]: (dataset![fromEntity] ?? []).filter((row: any) =>
869
+ idConstraint._ids.includes(row[pk] ?? row.id)
870
+ )
871
+ }
872
+ }
873
+
874
+ data = engine.executeInMemory(
875
+ {
876
+ from: fromEntity,
877
+ to: toEntity,
878
+ filters: sourceFilters,
879
+ ...(resolvedSemantic ? { semantic: resolvedSemantic } : {})
880
+ },
881
+ filteredDataset
882
+ )
883
+
884
+ if (process.env.LINKLAB_DEBUG) {
885
+ console.log(
886
+ `[_fetchStep inMemory] sourceFilters=${JSON.stringify(sourceFilters)} filteredDataset[${fromEntity}].length=${filteredDataset[fromEntity]?.length} result=${data.length}`
887
+ )
888
+ }
889
+ if (Object.keys(toFilters).length > 0) {
890
+ data = data.filter((row: any) => matchFilters(row, toFilters))
891
+ }
892
+ }
893
+ } catch {
894
+ // Route inconnue — retourner vide
895
+ data = []
896
+ path = [fromEntity, toEntity]
897
+ }
898
+
899
+ return {
900
+ from: fromEntity,
901
+ to: toEntity,
902
+ filters: toFilters,
903
+ data,
904
+ path,
905
+ timing: Date.now() - start
906
+ }
907
+ }
908
+
909
+ /** Retourne la clé primaire d'une entité */
910
+ private _getPK(entity: string): string {
911
+ const node = this._ctx.graphData.nodes.find((n: any) => n.id === entity)
912
+ const pk = (node as any)?.primaryKey
913
+ if (pk) {
914
+ if (Array.isArray(pk)) return pk[0]
915
+ return pk
916
+ }
917
+ // Inférer depuis les colonnes : chercher {entity}_id en priorité, puis *_id
918
+ const columns: Array<{ name: string }> = (node as any)?.columns ?? []
919
+ const entityPk = columns.find(c => c.name === `${entity}_id`)
920
+ if (entityPk) return entityPk.name
921
+ const anyPk = columns.find(
922
+ c => c.name.endsWith('_id') && !c.name.includes('_', c.name.indexOf('_') + 1)
923
+ )
924
+ if (anyPk) return anyPk.name
925
+ return 'id'
926
+ }
927
+
928
+ private async _fetchDirect(node: DomainNode, start: number): Promise<QueryResult> {
929
+ const { dataset, provider } = this._ctx
930
+
931
+ let data: any[]
932
+
933
+ if (provider) {
934
+ // Provider SQL — résoudre la vraie PK depuis le graph
935
+ const nodeSchema = this._ctx.graphData.nodes.find((n: any) => n.id === node.entity)
936
+ const pk = (nodeSchema as any)?.primaryKey ?? 'id'
937
+ const filters = node.filters
938
+ const wheres = Object.entries(filters).map(([k, v]) => {
939
+ const col = k === 'id' ? pk : k
940
+ if (v === null) return `${node.entity}.${col} IS NULL`
941
+ // Mini-DSL en SQL
942
+ if (typeof v === 'object' && !Array.isArray(v)) {
943
+ const op = Object.keys(v)[0]
944
+ const val = v[op]
945
+ switch (op) {
946
+ case 'like':
947
+ return `${node.entity}.${col} ILIKE '%${val}%'`
948
+ case 'startsWith':
949
+ return `${node.entity}.${col} ILIKE '${val}%'`
950
+ case 'endsWith':
951
+ return `${node.entity}.${col} ILIKE '%${val}'`
952
+ case 'gt':
953
+ return `${node.entity}.${col} > ${val}`
954
+ case 'gte':
955
+ return `${node.entity}.${col} >= ${val}`
956
+ case 'lt':
957
+ return `${node.entity}.${col} < ${val}`
958
+ case 'lte':
959
+ return `${node.entity}.${col} <= ${val}`
960
+ case 'neq':
961
+ return `${node.entity}.${col} != ${typeof val === 'string' ? `'${val}'` : val}`
962
+ case 'in':
963
+ return `${node.entity}.${col} IN (${(val as any[]).map((x: any) => (typeof x === 'string' ? `'${x}'` : x)).join(',')})`
964
+ default:
965
+ return `${node.entity}.${col} = ${typeof val === 'string' ? `'${val}'` : val}`
966
+ }
967
+ }
968
+ // String : ILIKE pour matching insensible à la casse
969
+ if (typeof v === 'string') return `${node.entity}.${col} ILIKE '${v}'`
970
+ return `${node.entity}.${col} = ${v}`
971
+ })
972
+ const sql =
973
+ `SELECT DISTINCT ${node.entity}.* FROM ${node.entity}` +
974
+ (wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : '')
975
+ data = await provider.query(sql)
976
+ } else {
977
+ const table = dataset?.[node.entity] ?? []
978
+ data =
979
+ Object.keys(node.filters).length > 0
980
+ ? table.filter(row => matchFilters(row, node.filters))
981
+ : table
982
+ }
983
+
984
+ return {
985
+ from: node.entity,
986
+ to: node.entity,
987
+ filters: node.filters,
988
+ data,
989
+ path: [node.entity],
990
+ timing: Date.now() - start
991
+ }
992
+ }
993
+
994
+ private async _fetchViaRoute(
995
+ anchor: DomainNode,
996
+ current: DomainNode,
997
+ trail: DomainNode[],
998
+ start: number
999
+ ): Promise<QueryResult> {
1000
+ const { compiled, dataset, provider } = this._ctx
1001
+
1002
+ if (!compiled || (!dataset && !provider)) {
1003
+ throw new Error(
1004
+ `Navigation ${anchor.entity}→${current.entity} nécessite un compiledGraph et un dataset ou provider.\n` +
1005
+ `Utilisez new Graph(source, { compiled, dataset }) ou new Graph(source, { provider }).`
1006
+ )
1007
+ }
1008
+
1009
+ const engine = new QueryEngine(compiled)
1010
+ const filters = anchor.filters
1011
+
1012
+ // Le label sémantique est porté par le nœud anchor (ex: 'director_in')
1013
+ // ou par le nœud current (ex: 'actor' dans movies(278).actors)
1014
+ const semantic = anchor.semantic ?? current.semantic ?? null
1015
+
1016
+ let data: any[]
1017
+ let path: string[]
1018
+
1019
+ // Décider si on utilise la route directe anchor→current ou la cascade via intermédiaires.
1020
+ //
1021
+ // On utilise la cascade uniquement si :
1022
+ // 1. Il y a des intermédiaires dans le trail
1023
+ // 2. La route directe ne passe par AUCUN des intermédiaires attendus
1024
+ // (indique une route sémantiquement incorrecte, ex: staff→address→customer
1025
+ // au lieu de staff→payment→rental→customer)
1026
+ //
1027
+ const intermediates = trail.slice(1, -1).map(n => n.entity)
1028
+ let useDirectRoute = true
1029
+
1030
+ if (intermediates.length > 0) {
1031
+ try {
1032
+ const route = engine.getRoute(anchor.entity, current.entity)
1033
+ const routePath = route.primary.path
1034
+ // Si la route directe ne passe par AUCUN intermédiaire attendu,
1035
+ // c'est probablement le mauvais chemin → forcer la cascade
1036
+ const hasAnyIntermediate = intermediates.some(mid => routePath.includes(mid))
1037
+ useDirectRoute = hasAnyIntermediate || routePath.length <= 2
1038
+ } catch {
1039
+ useDirectRoute = false
1040
+ }
1041
+ }
1042
+
1043
+ try {
1044
+ if (useDirectRoute) {
1045
+ // Route directe anchor→current (cas nominal)
1046
+ // Si semantic est présent, on l'utilise pour sélectionner la bonne route compilée
1047
+ path = engine.getRoute(anchor.entity, current.entity, semantic ?? undefined).primary.path
1048
+ if (provider) {
1049
+ const sql = engine.generateSQL({
1050
+ from: anchor.entity,
1051
+ to: current.entity,
1052
+ filters,
1053
+ ...(semantic ? { semantic } : {})
1054
+ })
1055
+ data = await provider.query(sql)
1056
+ } else {
1057
+ data = engine.executeInMemory(
1058
+ {
1059
+ from: anchor.entity,
1060
+ to: current.entity,
1061
+ filters,
1062
+ ...(semantic ? { semantic } : {})
1063
+ },
1064
+ dataset!
1065
+ )
1066
+ }
1067
+ } else {
1068
+ // Route via étapes intermédiaires explicites du trail
1069
+ // On construit un SQL en cascadant les routes step by step
1070
+ const fullPath: string[] = [anchor.entity]
1071
+ const allEdges: Array<{ fromCol: string; toCol: string }> = []
1072
+
1073
+ for (let i = 0; i < trail.length - 1; i++) {
1074
+ const from = trail[i].entity
1075
+ const to = trail[i + 1].entity
1076
+ try {
1077
+ const stepRoute = engine.getRoute(from, to)
1078
+ // Ajouter les nœuds du chemin (sans répéter le premier)
1079
+ fullPath.push(...stepRoute.primary.path.slice(1))
1080
+ allEdges.push(...stepRoute.primary.edges)
1081
+ } catch {
1082
+ // Pas de route entre ces deux entités — on continue sans
1083
+ fullPath.push(to)
1084
+ allEdges.push({ fromCol: 'id', toCol: from + '_id' })
1085
+ }
1086
+ }
1087
+
1088
+ // Construire le SQL à partir du chemin composite
1089
+ const graphData = this._ctx.graphData as any
1090
+ const pkOf = (tableId: string): string => {
1091
+ const node = graphData.nodes?.find((n: any) => n.id === tableId)
1092
+ const pk = node?.primaryKey
1093
+ if (Array.isArray(pk)) return pk[0]
1094
+ return pk ?? tableId + '_id'
1095
+ }
1096
+
1097
+ let sql = `SELECT DISTINCT ${current.entity}.*\nFROM ${fullPath[0]}`
1098
+ for (let i = 0; i < allEdges.length; i++) {
1099
+ const curr = fullPath[i]
1100
+ const next = fullPath[i + 1]
1101
+ const fc = allEdges[i].fromCol === 'id' ? pkOf(curr) : allEdges[i].fromCol
1102
+ const tc = allEdges[i].toCol === 'id' ? pkOf(next) : allEdges[i].toCol
1103
+ sql += `\n INNER JOIN ${next} ON ${curr}.${fc} = ${next}.${tc}`
1104
+ }
1105
+
1106
+ const sourcePK = pkOf(anchor.entity)
1107
+ const wheres = Object.entries(filters).map(([k, v]) => {
1108
+ const col = k === 'id' ? sourcePK : k
1109
+ return v === null
1110
+ ? `${anchor.entity}.${col} IS NULL`
1111
+ : `${anchor.entity}.${col} = ${typeof v === 'string' ? `'${v}'` : v}`
1112
+ })
1113
+ if (wheres.length > 0) sql += `\nWHERE ${wheres.join(' AND ')}`
1114
+
1115
+ path = [anchor.entity, ...intermediates, current.entity]
1116
+
1117
+ if (provider) {
1118
+ data = await provider.query(sql)
1119
+ } else {
1120
+ // In-memory fallback — cascade step by step
1121
+ let rows = (dataset ?? {})[anchor.entity] ?? []
1122
+ if (Object.keys(filters).length > 0)
1123
+ rows = rows.filter((r: any) => matchFilters(r, filters))
1124
+
1125
+ for (let i = 0; i < trail.length - 1; i++) {
1126
+ const from = trail[i].entity
1127
+ const to = trail[i + 1].entity
1128
+ rows = engine.executeInMemory({ from, to, filters: i === 0 ? filters : {} }, dataset!)
1129
+ }
1130
+ data = rows
1131
+ }
1132
+ }
1133
+ } catch (routeErr: any) {
1134
+ // Route inconnue — fetch direct sur l'entité courante
1135
+ if (process.env.LINKLAB_DEBUG) {
1136
+ console.warn(
1137
+ `[DomainNode] Route fallback ${anchor.entity}→${current.entity}: ${routeErr?.message}`
1138
+ )
1139
+ }
1140
+ if (provider) {
1141
+ const wheres = Object.entries(current.filters).map(([k, v]) =>
1142
+ v === null
1143
+ ? `${current.entity}.${k} IS NULL`
1144
+ : `${current.entity}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`
1145
+ )
1146
+ const sql =
1147
+ `SELECT ${current.entity}.* FROM ${current.entity}` +
1148
+ (wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : '')
1149
+ data = await provider.query(sql)
1150
+ } else {
1151
+ const table = (dataset ?? {})[current.entity] ?? []
1152
+ data = table.filter(row => matchFilters(row, current.filters))
1153
+ }
1154
+ path = [anchor.entity, current.entity]
1155
+ }
1156
+
1157
+ // Si la frame courante a ses propres filtres (ex: .movies(497))
1158
+ // on filtre les résultats supplémentaires
1159
+ if (Object.keys(current.filters).length > 0) {
1160
+ data = data.filter(row => matchFilters(row, current.filters))
1161
+ }
1162
+
1163
+ return {
1164
+ from: anchor.entity,
1165
+ to: current.entity,
1166
+ filters,
1167
+ data,
1168
+ path,
1169
+ timing: Date.now() - start
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ // ── DomainNode callable ───────────────────────────────────────────────────────
1175
+
1176
+ /**
1177
+ * makeCallableDomainNode — retourne un objet à la fois Function et DomainNode.
1178
+ *
1179
+ * Permet :
1180
+ * cinema.people → DomainNode (propriété)
1181
+ * cinema.people(278) → DomainNode avec filters={id:278} (appel)
1182
+ * cinema.people(278).movies → DomainNode chaîné
1183
+ *
1184
+ * cinema.directors('Nolan') → DomainNode avec entity='people', semantic='director_in'
1185
+ *
1186
+ * La fonction elle-même retourne un nouveau DomainNode avec les filtres résolus.
1187
+ */
1188
+ function makeCallableDomainNode(
1189
+ entity: string,
1190
+ filters: Record<string, any>,
1191
+ parent: DomainNode | null,
1192
+ ctx: DomainContext,
1193
+ semantic: string | null = null
1194
+ ): DomainNode & ((...args: any[]) => DomainNode) {
1195
+ // Créer le DomainNode de base (sans appel)
1196
+ const node = new DomainNode(entity, filters, parent, ctx, semantic)
1197
+
1198
+ // Envelopper dans une fonction pour permettre l'appel (people(278))
1199
+ const callable = function (...args: any[]): DomainNode {
1200
+ if (args.length === 0) return node
1201
+
1202
+ // Résolution des filtres depuis les arguments
1203
+ // Pour les labels sémantiques, l'entity cible est 'people' — on utilise sa semantic_key
1204
+ const resolved = resolveFilters(args[0], entity, ctx.graphData)
1205
+ return new DomainNode(entity, resolved, parent, ctx, semantic)
1206
+ }
1207
+
1208
+ // Copier les propriétés du Proxy DomainNode sur la fonction
1209
+ return new Proxy(callable as any, {
1210
+ get(_target, prop: string) {
1211
+ return (node as any)[prop]
1212
+ },
1213
+ apply(_target, _thisArg, args) {
1214
+ return callable(...args)
1215
+ }
1216
+ })
1217
+ }
1218
+
1219
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1220
+
1221
+ /**
1222
+ * Résout les arguments d'un appel en filtres.
1223
+ *
1224
+ * people(278) → { id: 278 }
1225
+ * people('Nolan') → { name: 'Nolan' } (via semantic_key)
1226
+ * people({ name: 'Nolan'}) → { name: 'Nolan' }
1227
+ */
1228
+ function resolveFilters(arg: any, entity: string, graphData: GraphData): Record<string, any> {
1229
+ // Objet → filtre direct
1230
+ if (arg !== null && typeof arg === 'object' && !Array.isArray(arg)) {
1231
+ return arg
1232
+ }
1233
+
1234
+ // Number → id
1235
+ if (typeof arg === 'number') {
1236
+ return { id: arg }
1237
+ }
1238
+
1239
+ // String → id si les PKs sont des strings, sinon semantic_key
1240
+ if (typeof arg === 'string') {
1241
+ // Chercher un node dont l'ID correspond (musicians: 'artist-will-smith')
1242
+ const nodes = resolveNodes(entity, graphData)
1243
+ const byId = nodes.find(n => n.id === arg)
1244
+ if (byId) return { id: arg }
1245
+
1246
+ // Sinon semantic_key : 'name' pour people, 'title' pour movies, etc.
1247
+ const semanticKey = inferSemanticKey(entity, graphData)
1248
+ return { [semanticKey]: arg }
1249
+ }
1250
+
1251
+ return {}
1252
+ }
1253
+
1254
+ /**
1255
+ * Infère la clé sémantique par défaut d'une entité.
1256
+ * Priorité : 'name' > 'title' > 'label' > premier champ string non-id.
1257
+ */
1258
+ function inferSemanticKey(entity: string, graphData: GraphData): string {
1259
+ const node = graphData.nodes.find(n => n.id === entity)
1260
+ if (!node) return 'name'
1261
+
1262
+ const columns: string[] =
1263
+ (node as any).columns?.map((c: any) => (typeof c === 'string' ? c : c.name)) ?? []
1264
+
1265
+ // Priorité : clés sémantiques connues
1266
+ for (const key of ['name', 'title', 'label', 'first_name', 'last_name', 'username', 'email']) {
1267
+ if (columns.includes(key)) return key
1268
+ }
1269
+
1270
+ // Premier champ non-id (filtre _id, Id, _key)
1271
+ const nonId = columns.find(
1272
+ c => c !== 'id' && !c.endsWith('Id') && !c.endsWith('_id') && !c.endsWith('_key')
1273
+ )
1274
+ return nonId ?? 'name'
1275
+ }
1276
+
1277
+ /**
1278
+ * matchFilters — filtre une row selon un objet de critères.
1279
+ * Supporte null (IS NULL en SQL).
1280
+ */
1281
+ function matchFilters(row: Record<string, any>, filters: Record<string, any>): boolean {
1282
+ return Object.entries(filters).every(([key, value]) => {
1283
+ if (value === null) return row[key] == null
1284
+ // Mini-DSL : { name: { like: 'Nolan' } } | { year: { gte: 2000 } } | { id: { in: [1,2] } }
1285
+ if (typeof value === 'object' && !Array.isArray(value)) {
1286
+ const op = Object.keys(value)[0]
1287
+ const val = value[op]
1288
+ switch (op) {
1289
+ case 'like':
1290
+ return (
1291
+ typeof row[key] === 'string' &&
1292
+ row[key].toLowerCase().includes(String(val).toLowerCase())
1293
+ )
1294
+ case 'startsWith':
1295
+ return (
1296
+ typeof row[key] === 'string' &&
1297
+ row[key].toLowerCase().startsWith(String(val).toLowerCase())
1298
+ )
1299
+ case 'endsWith':
1300
+ return (
1301
+ typeof row[key] === 'string' &&
1302
+ row[key].toLowerCase().endsWith(String(val).toLowerCase())
1303
+ )
1304
+ case 'gt':
1305
+ return row[key] > val
1306
+ case 'gte':
1307
+ return row[key] >= val
1308
+ case 'lt':
1309
+ return row[key] < val
1310
+ case 'lte':
1311
+ return row[key] <= val
1312
+ case 'in':
1313
+ return Array.isArray(val) && val.includes(row[key])
1314
+ case 'neq':
1315
+ return row[key] !== val
1316
+ default:
1317
+ return row[key] === val
1318
+ }
1319
+ }
1320
+ // Match exact
1321
+ return row[key] === value
1322
+ })
1323
+ }
1324
+
1325
+ /**
1326
+ * buildCTEStep — construit un CTE pour une étape du Trail.
1327
+ *
1328
+ * Route film→film_actor→actor :
1329
+ * path = [film, film_actor, actor]
1330
+ * edges = [{fromCol:'film_id', toCol:'id'}, {fromCol:'actor_id', toCol:'id'}]
1331
+ * prevStep = step0 (= film)
1332
+ *
1333
+ * Résultat :
1334
+ * step1 AS (
1335
+ * SELECT DISTINCT actor.*
1336
+ * FROM actor
1337
+ * INNER JOIN film_actor ON film_actor.actor_id = actor.id
1338
+ * INNER JOIN step0 ON step0.film_id = film_actor.id
1339
+ * )
1340
+ *
1341
+ * Stratégie : FROM toEntity, JOINs en ordre inverse du path.
1342
+ * Le CTE précédent remplace path[0] dans le dernier JOIN.
1343
+ */
1344
+ function buildCTEStep(
1345
+ stepIdx: number,
1346
+ toEntity: string,
1347
+ primary: any,
1348
+ prevStep: string,
1349
+ prevEntity: string,
1350
+ toFilters: Record<string, any>,
1351
+ pkOf: (t: string) => string,
1352
+ buildWhere: (entity: string, filters: Record<string, any>) => string
1353
+ ): string {
1354
+ const { path, edges } = primary
1355
+ // path = [from, ...intermediates, to]
1356
+ // edges[i] : path[i] → path[i+1]
1357
+
1358
+ const joins: string[] = []
1359
+
1360
+ // Parcourir les edges en ordre inverse : from toEntity vers fromEntity
1361
+ for (let i = edges.length - 1; i >= 0; i--) {
1362
+ const curr = path[i] // table "gauche" de l'edge
1363
+ const next = path[i + 1] // table "droite" de l'edge
1364
+ const edge = edges[i] as any
1365
+
1366
+ const fromCol = edge.fromCol === 'id' ? pkOf(curr) : edge.fromCol
1367
+ const toCol = edge.toCol === 'id' ? pkOf(next) : edge.toCol
1368
+
1369
+ const conditionSQL = edge.condition
1370
+ ? ' AND ' +
1371
+ Object.entries(edge.condition as Record<string, unknown>)
1372
+ .map(([k, v]) => `${curr}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
1373
+ .join(' AND ')
1374
+ : ''
1375
+
1376
+ if (i === 0) {
1377
+ // Dernier JOIN (premier edge) : remplacer curr par prevStep
1378
+ // edge : curr.fromCol = next.toCol
1379
+ // En partant de next (déjà dans le FROM ou jointé), on joint avec prevStep
1380
+ joins.push(
1381
+ `INNER JOIN ${prevStep} ON ${prevStep}.${fromCol} = ${next}.${toCol}${conditionSQL}`
1382
+ )
1383
+ } else {
1384
+ // Edge intermédiaire : curr rejoint next
1385
+ // On est en ordre inverse — curr n'est pas encore dans le FROM
1386
+ // On joint curr depuis next (qui est déjà présent)
1387
+ joins.push(`INNER JOIN ${curr} ON ${curr}.${fromCol} = ${next}.${toCol}${conditionSQL}`)
1388
+ }
1389
+ }
1390
+
1391
+ const where = buildWhere(toEntity, toFilters)
1392
+
1393
+ return (
1394
+ `step${stepIdx} AS (\n` +
1395
+ ` SELECT DISTINCT ${toEntity}.*\n` +
1396
+ ` FROM ${toEntity}\n` +
1397
+ ` ${joins.join('\n ')}` +
1398
+ (where ? `\n WHERE ${where}` : '') +
1399
+ `\n)`
1400
+ )
1401
+ }
1402
+
1403
+ // ── Export helper pour Graph.ts ───────────────────────────────────────────────
1404
+
1405
+ /**
1406
+ * createDomain — retourne le proxy sémantique (niveau 1).
1407
+ *
1408
+ * Le proxy expose :
1409
+ * - Les entités du graphe comme propriétés navigables (cinema.movies, dvd.film...)
1410
+ * - `.graph` — accès au Graph sous-jacent pour les niveaux 2/3/4
1411
+ *
1412
+ * C'est l'objet retourné par loadGraph() — point d'entrée principal de LinkLab.
1413
+ */
1414
+ export function createDomain(ctx: DomainContext, graphInstance?: any) {
1415
+ return new Proxy({} as any, {
1416
+ get(_target, prop: string) {
1417
+ // Accès au Graph sous-jacent — niveaux 2/3/4
1418
+ if (prop === 'graph') return graphInstance ?? null
1419
+
1420
+ // Mode nav — sous-proxy avec navMode=true (comportement original stateless)
1421
+ if (prop === 'nav') {
1422
+ return createDomain({ ...ctx, navMode: true }, graphInstance)
1423
+ }
1424
+
1425
+ if (typeof prop === 'symbol') return undefined
1426
+
1427
+ // Passer compiled pour permettre la résolution des labels sémantiques (cas 4)
1428
+ const resolved = resolveEntity(prop, ctx.graphData, ctx.compiled)
1429
+ if (resolved === null) return undefined
1430
+ return makeCallableDomainNode(resolved.entity, {}, null, ctx, resolved.semantic)
1431
+ }
1432
+ })
1433
+ }