@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.
Files changed (206) hide show
  1. package/dist/api/DomainNode.d.ts +154 -0
  2. package/dist/api/DomainNode.d.ts.map +1 -0
  3. package/dist/api/DomainNode.js +1157 -0
  4. package/dist/api/DomainNode.js.map +1 -0
  5. package/dist/api/Graph.d.ts +117 -0
  6. package/dist/api/Graph.d.ts.map +1 -0
  7. package/dist/api/Graph.js +212 -0
  8. package/dist/api/Graph.js.map +1 -0
  9. package/dist/api/PathBuilder.d.ts +76 -0
  10. package/dist/api/PathBuilder.d.ts.map +1 -0
  11. package/dist/api/PathBuilder.js +182 -0
  12. package/dist/api/PathBuilder.js.map +1 -0
  13. package/dist/api/index.d.ts +8 -0
  14. package/dist/api/index.d.ts.map +1 -0
  15. package/dist/api/index.js +7 -0
  16. package/dist/api/index.js.map +1 -0
  17. package/dist/api/loadGraph.d.ts +57 -0
  18. package/dist/api/loadGraph.d.ts.map +1 -0
  19. package/dist/api/loadGraph.js +153 -0
  20. package/dist/api/loadGraph.js.map +1 -0
  21. package/dist/api/test-api.d.ts +9 -0
  22. package/dist/api/test-api.d.ts.map +1 -0
  23. package/dist/api/test-api.js +133 -0
  24. package/dist/api/test-api.js.map +1 -0
  25. package/dist/api/test-domain.d.ts +13 -0
  26. package/dist/api/test-domain.d.ts.map +1 -0
  27. package/dist/api/test-domain.js +105 -0
  28. package/dist/api/test-domain.js.map +1 -0
  29. package/dist/api/types.d.ts +69 -0
  30. package/dist/api/types.d.ts.map +1 -0
  31. package/dist/api/types.js +22 -0
  32. package/dist/api/types.js.map +1 -0
  33. package/dist/config/synonyms.json +25 -0
  34. package/dist/core/EventBus.d.ts +56 -0
  35. package/dist/core/EventBus.d.ts.map +1 -0
  36. package/dist/core/EventBus.js +147 -0
  37. package/dist/core/EventBus.js.map +1 -0
  38. package/dist/core/GraphEvents.d.ts +118 -0
  39. package/dist/core/GraphEvents.d.ts.map +1 -0
  40. package/dist/core/GraphEvents.js +23 -0
  41. package/dist/core/GraphEvents.js.map +1 -0
  42. package/dist/core/PathFinder.d.ts +43 -0
  43. package/dist/core/PathFinder.d.ts.map +1 -0
  44. package/dist/core/PathFinder.js +264 -0
  45. package/dist/core/PathFinder.js.map +1 -0
  46. package/dist/formatters/BaseFormatter.d.ts +15 -0
  47. package/dist/formatters/BaseFormatter.d.ts.map +1 -0
  48. package/dist/formatters/BaseFormatter.js +9 -0
  49. package/dist/formatters/BaseFormatter.js.map +1 -0
  50. package/dist/graph/GraphAssembler.d.ts +14 -0
  51. package/dist/graph/GraphAssembler.d.ts.map +1 -0
  52. package/dist/graph/GraphAssembler.js +44 -0
  53. package/dist/graph/GraphAssembler.js.map +1 -0
  54. package/dist/graph/GraphCompiler.d.ts +37 -0
  55. package/dist/graph/GraphCompiler.d.ts.map +1 -0
  56. package/dist/graph/GraphCompiler.js +355 -0
  57. package/dist/graph/GraphCompiler.js.map +1 -0
  58. package/dist/graph/GraphExtractor.d.ts +21 -0
  59. package/dist/graph/GraphExtractor.d.ts.map +1 -0
  60. package/dist/graph/GraphExtractor.js +145 -0
  61. package/dist/graph/GraphExtractor.js.map +1 -0
  62. package/dist/graph/GraphOptimizer.d.ts +104 -0
  63. package/dist/graph/GraphOptimizer.d.ts.map +1 -0
  64. package/dist/graph/GraphOptimizer.js +306 -0
  65. package/dist/graph/GraphOptimizer.js.map +1 -0
  66. package/dist/graph/GraphTrainer.d.ts +52 -0
  67. package/dist/graph/GraphTrainer.d.ts.map +1 -0
  68. package/dist/graph/GraphTrainer.js +188 -0
  69. package/dist/graph/GraphTrainer.js.map +1 -0
  70. package/dist/http/LinkBuilder.d.ts +82 -0
  71. package/dist/http/LinkBuilder.d.ts.map +1 -0
  72. package/dist/http/LinkBuilder.js +190 -0
  73. package/dist/http/LinkBuilder.js.map +1 -0
  74. package/dist/http/TrailRequest.d.ts +39 -0
  75. package/dist/http/TrailRequest.d.ts.map +1 -0
  76. package/dist/http/TrailRequest.js +22 -0
  77. package/dist/http/TrailRequest.js.map +1 -0
  78. package/dist/http/example-netflix.d.ts +6 -0
  79. package/dist/http/example-netflix.d.ts.map +1 -0
  80. package/dist/http/example-netflix.js +52 -0
  81. package/dist/http/example-netflix.js.map +1 -0
  82. package/dist/http/index.d.ts +32 -0
  83. package/dist/http/index.d.ts.map +1 -0
  84. package/dist/http/index.js +27 -0
  85. package/dist/http/index.js.map +1 -0
  86. package/dist/http/plugin.d.ts +110 -0
  87. package/dist/http/plugin.d.ts.map +1 -0
  88. package/dist/http/plugin.js +217 -0
  89. package/dist/http/plugin.js.map +1 -0
  90. package/dist/index.d.ts +55 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +71 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/instrumentation/TelemetryShim.d.ts +114 -0
  95. package/dist/instrumentation/TelemetryShim.d.ts.map +1 -0
  96. package/dist/instrumentation/TelemetryShim.js +107 -0
  97. package/dist/instrumentation/TelemetryShim.js.map +1 -0
  98. package/dist/navigation/NavigationEngine.d.ts +69 -0
  99. package/dist/navigation/NavigationEngine.d.ts.map +1 -0
  100. package/dist/navigation/NavigationEngine.js +361 -0
  101. package/dist/navigation/NavigationEngine.js.map +1 -0
  102. package/dist/navigation/Resolver.d.ts +35 -0
  103. package/dist/navigation/Resolver.d.ts.map +1 -0
  104. package/dist/navigation/Resolver.js +113 -0
  105. package/dist/navigation/Resolver.js.map +1 -0
  106. package/dist/navigation/Scheduler.d.ts +36 -0
  107. package/dist/navigation/Scheduler.d.ts.map +1 -0
  108. package/dist/navigation/Scheduler.js +107 -0
  109. package/dist/navigation/Scheduler.js.map +1 -0
  110. package/dist/navigation/Trail.d.ts +129 -0
  111. package/dist/navigation/Trail.d.ts.map +1 -0
  112. package/dist/navigation/Trail.js +202 -0
  113. package/dist/navigation/Trail.js.map +1 -0
  114. package/dist/navigation/TrailParser.d.ts +96 -0
  115. package/dist/navigation/TrailParser.d.ts.map +1 -0
  116. package/dist/navigation/TrailParser.js +180 -0
  117. package/dist/navigation/TrailParser.js.map +1 -0
  118. package/dist/navigation/index.d.ts +10 -0
  119. package/dist/navigation/index.d.ts.map +1 -0
  120. package/dist/navigation/index.js +9 -0
  121. package/dist/navigation/index.js.map +1 -0
  122. package/dist/providers/MockProvider.d.ts +29 -0
  123. package/dist/providers/MockProvider.d.ts.map +1 -0
  124. package/dist/providers/MockProvider.js +55 -0
  125. package/dist/providers/MockProvider.js.map +1 -0
  126. package/dist/providers/PostgresProvider.d.ts +46 -0
  127. package/dist/providers/PostgresProvider.d.ts.map +1 -0
  128. package/dist/providers/PostgresProvider.js +152 -0
  129. package/dist/providers/PostgresProvider.js.map +1 -0
  130. package/dist/runtime/CompiledGraphEngine.d.ts +74 -0
  131. package/dist/runtime/CompiledGraphEngine.d.ts.map +1 -0
  132. package/dist/runtime/CompiledGraphEngine.js +211 -0
  133. package/dist/runtime/CompiledGraphEngine.js.map +1 -0
  134. package/dist/runtime/DataLoader.d.ts +90 -0
  135. package/dist/runtime/DataLoader.d.ts.map +1 -0
  136. package/dist/runtime/DataLoader.js +178 -0
  137. package/dist/runtime/DataLoader.js.map +1 -0
  138. package/dist/runtime/Engine.d.ts +36 -0
  139. package/dist/runtime/Engine.d.ts.map +1 -0
  140. package/dist/runtime/Engine.js +128 -0
  141. package/dist/runtime/Engine.js.map +1 -0
  142. package/dist/runtime/QueryEngine.d.ts +80 -0
  143. package/dist/runtime/QueryEngine.d.ts.map +1 -0
  144. package/dist/runtime/QueryEngine.js +188 -0
  145. package/dist/runtime/QueryEngine.js.map +1 -0
  146. package/dist/scenarios/test-metro-paris/config.json +6 -0
  147. package/dist/scenarios/test-metro-paris/graph.json +16325 -0
  148. package/dist/scenarios/test-metro-paris/queries.d.ts +22 -0
  149. package/dist/scenarios/test-metro-paris/queries.d.ts.map +1 -0
  150. package/dist/scenarios/test-metro-paris/queries.js +128 -0
  151. package/dist/scenarios/test-metro-paris/queries.js.map +1 -0
  152. package/dist/scenarios/test-metro-paris/stack.json +1 -0
  153. package/dist/scenarios/test-musicians/config.json +10 -0
  154. package/dist/scenarios/test-musicians/graph.json +20 -0
  155. package/dist/scenarios/test-musicians/stack.json +1 -0
  156. package/dist/scenarios/test-netflix/actions.d.ts +14 -0
  157. package/dist/scenarios/test-netflix/actions.d.ts.map +1 -0
  158. package/dist/scenarios/test-netflix/actions.js +86 -0
  159. package/dist/scenarios/test-netflix/actions.js.map +1 -0
  160. package/dist/scenarios/test-netflix/config.json +6 -0
  161. package/dist/scenarios/test-netflix/data/categories.json +1 -0
  162. package/dist/scenarios/test-netflix/data/companies.json +1 -0
  163. package/dist/scenarios/test-netflix/data/credits.json +19797 -0
  164. package/dist/scenarios/test-netflix/data/departments.json +18 -0
  165. package/dist/scenarios/test-netflix/data/jobs.json +142 -0
  166. package/dist/scenarios/test-netflix/data/movies.json +3497 -0
  167. package/dist/scenarios/test-netflix/data/people.json +1 -0
  168. package/dist/scenarios/test-netflix/data/synonyms.json +7 -0
  169. package/dist/scenarios/test-netflix/data/users.json +70 -0
  170. package/dist/scenarios/test-netflix/graph.json +1017 -0
  171. package/dist/scenarios/test-netflix/queries.d.ts +29 -0
  172. package/dist/scenarios/test-netflix/queries.d.ts.map +1 -0
  173. package/dist/scenarios/test-netflix/queries.js +134 -0
  174. package/dist/scenarios/test-netflix/queries.js.map +1 -0
  175. package/dist/scenarios/test-netflix/stack.json +14 -0
  176. package/dist/schema/GraphBuilder.d.ts +9 -0
  177. package/dist/schema/GraphBuilder.d.ts.map +1 -0
  178. package/dist/schema/GraphBuilder.js +90 -0
  179. package/dist/schema/GraphBuilder.js.map +1 -0
  180. package/dist/schema/JsonSchemaExtractor.d.ts +21 -0
  181. package/dist/schema/JsonSchemaExtractor.d.ts.map +1 -0
  182. package/dist/schema/JsonSchemaExtractor.js +88 -0
  183. package/dist/schema/JsonSchemaExtractor.js.map +1 -0
  184. package/dist/schema/SchemaAnalyzer.d.ts +41 -0
  185. package/dist/schema/SchemaAnalyzer.d.ts.map +1 -0
  186. package/dist/schema/SchemaAnalyzer.js +144 -0
  187. package/dist/schema/SchemaAnalyzer.js.map +1 -0
  188. package/dist/schema/SchemaExtractor.d.ts +10 -0
  189. package/dist/schema/SchemaExtractor.d.ts.map +1 -0
  190. package/dist/schema/SchemaExtractor.js +90 -0
  191. package/dist/schema/SchemaExtractor.js.map +1 -0
  192. package/dist/schema/SynonymResolver.d.ts +55 -0
  193. package/dist/schema/SynonymResolver.d.ts.map +1 -0
  194. package/dist/schema/SynonymResolver.js +121 -0
  195. package/dist/schema/SynonymResolver.js.map +1 -0
  196. package/dist/scripts/dictionary.json +796 -0
  197. package/dist/scripts/graph.json +664 -0
  198. package/dist/scripts/regenerate.d.ts +23 -0
  199. package/dist/scripts/regenerate.d.ts.map +1 -0
  200. package/dist/scripts/regenerate.js +206 -0
  201. package/dist/scripts/regenerate.js.map +1 -0
  202. package/dist/types/index.d.ts +394 -0
  203. package/dist/types/index.d.ts.map +1 -0
  204. package/dist/types/index.js +21 -0
  205. package/dist/types/index.js.map +1 -0
  206. 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