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