@linklabjs/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +411 -0
- package/package.json +48 -0
- package/src/api/DomainNode.ts +1433 -0
- package/src/api/Graph.ts +271 -0
- package/src/api/PathBuilder.ts +247 -0
- package/src/api/index.ts +15 -0
- package/src/api/loadGraph.ts +207 -0
- package/src/api/test-api.ts +153 -0
- package/src/api/test-domain.ts +119 -0
- package/src/api/types.ts +88 -0
- package/src/config/synonyms.json +28 -0
- package/src/core/EventBus.ts +187 -0
- package/src/core/GraphEvents.ts +153 -0
- package/src/core/PathFinder.ts +283 -0
- package/src/formatters/BaseFormatter.ts +17 -0
- package/src/graph/GraphAssembler.ts +50 -0
- package/src/graph/GraphCompiler.ts +412 -0
- package/src/graph/GraphExtractor.ts +191 -0
- package/src/graph/GraphOptimizer.ts +404 -0
- package/src/graph/GraphTrainer.ts +247 -0
- package/src/http/LinkBuilder.ts +244 -0
- package/src/http/TrailRequest.ts +48 -0
- package/src/http/example-netflix.ts +59 -0
- package/src/http/hateoas/README.md +87 -0
- package/src/http/index.ts +33 -0
- package/src/http/plugin.ts +360 -0
- package/src/index.ts +121 -0
- package/src/instrumentation/TelemetryShim.ts +172 -0
- package/src/navigation/NavigationEngine.ts +441 -0
- package/src/navigation/Resolver.ts +134 -0
- package/src/navigation/Scheduler.ts +136 -0
- package/src/navigation/Trail.ts +252 -0
- package/src/navigation/TrailParser.ts +207 -0
- package/src/navigation/index.ts +11 -0
- package/src/providers/MockProvider.ts +68 -0
- package/src/providers/PostgresProvider.ts +187 -0
- package/src/runtime/CompiledGraphEngine.ts +274 -0
- package/src/runtime/DataLoader.ts +236 -0
- package/src/runtime/Engine.ts +163 -0
- package/src/runtime/QueryEngine.ts +222 -0
- package/src/scenarios/test-metro-paris/config.json +6 -0
- package/src/scenarios/test-metro-paris/graph.json +16325 -0
- package/src/scenarios/test-metro-paris/queries.ts +152 -0
- package/src/scenarios/test-metro-paris/stack.json +1 -0
- package/src/scenarios/test-musicians/config.json +10 -0
- package/src/scenarios/test-musicians/graph.json +20 -0
- package/src/scenarios/test-musicians/stack.json +1 -0
- package/src/scenarios/test-netflix/MIGRATION.md +23 -0
- package/src/scenarios/test-netflix/README.md +138 -0
- package/src/scenarios/test-netflix/actions.ts +92 -0
- package/src/scenarios/test-netflix/config.json +6 -0
- package/src/scenarios/test-netflix/data/categories.json +1 -0
- package/src/scenarios/test-netflix/data/companies.json +1 -0
- package/src/scenarios/test-netflix/data/credits.json +19797 -0
- package/src/scenarios/test-netflix/data/departments.json +18 -0
- package/src/scenarios/test-netflix/data/jobs.json +142 -0
- package/src/scenarios/test-netflix/data/movies.json +3497 -0
- package/src/scenarios/test-netflix/data/people.json +1 -0
- package/src/scenarios/test-netflix/data/synonyms.json +8 -0
- package/src/scenarios/test-netflix/data/users.json +70 -0
- package/src/scenarios/test-netflix/graph.json +1017 -0
- package/src/scenarios/test-netflix/queries.ts +159 -0
- package/src/scenarios/test-netflix/stack.json +14 -0
- package/src/schema/GraphBuilder.ts +106 -0
- package/src/schema/JsonSchemaExtractor.ts +107 -0
- package/src/schema/SchemaAnalyzer.ts +175 -0
- package/src/schema/SchemaExtractor.ts +102 -0
- package/src/schema/SynonymResolver.ts +143 -0
- package/src/scripts/dictionary.json +796 -0
- package/src/scripts/graph.json +664 -0
- package/src/scripts/regenerate.ts +248 -0
- package/src/types/index.ts +506 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine - Core runtime engine with LRU cache
|
|
3
|
+
*
|
|
4
|
+
* Bus exposés :
|
|
5
|
+
* engine.events — cache.hit, cache.miss
|
|
6
|
+
* engine.errors — (extensible)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Provider, EngineConfig, CacheStats } from '../types/index.js'
|
|
10
|
+
import { EventBus, ErrorBus } from '../core/EventBus.js'
|
|
11
|
+
import type { CacheHitPayload, CacheMissPayload, GraphErrors } from '../core/GraphEvents.js'
|
|
12
|
+
|
|
13
|
+
interface CacheEntry<T = any> {
|
|
14
|
+
key: string
|
|
15
|
+
value: T
|
|
16
|
+
size: number
|
|
17
|
+
accessCount: number
|
|
18
|
+
lastAccess: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Events émis par Engine (sous-ensemble de GraphEventMap)
|
|
22
|
+
interface EngineEventMap {
|
|
23
|
+
'cache.hit': CacheHitPayload
|
|
24
|
+
'cache.miss': CacheMissPayload
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Engine {
|
|
28
|
+
private provider: Provider
|
|
29
|
+
public maxSize: number
|
|
30
|
+
private cache: Map<string, CacheEntry>
|
|
31
|
+
private hits: number
|
|
32
|
+
private misses: number
|
|
33
|
+
|
|
34
|
+
// ── Bus ──────────────────────────────────────────────────────
|
|
35
|
+
public readonly events: EventBus<EngineEventMap>
|
|
36
|
+
public readonly errors: ErrorBus<GraphErrors>
|
|
37
|
+
|
|
38
|
+
constructor(provider: Provider, config: EngineConfig = {}) {
|
|
39
|
+
this.provider = provider
|
|
40
|
+
this.maxSize = config.cache?.maxSize ?? 10 * 1024 * 1024
|
|
41
|
+
this.cache = new Map()
|
|
42
|
+
this.hits = 0
|
|
43
|
+
this.misses = 0
|
|
44
|
+
this.events = new EventBus<EngineEventMap>()
|
|
45
|
+
this.errors = new ErrorBus<GraphErrors>()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get<T = any>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
49
|
+
const cached = this.cache.get(key)
|
|
50
|
+
|
|
51
|
+
if (cached) {
|
|
52
|
+
this.hits++
|
|
53
|
+
cached.accessCount++
|
|
54
|
+
cached.lastAccess = Date.now()
|
|
55
|
+
|
|
56
|
+
// ── Event : cache.hit ──────────────────────────────────
|
|
57
|
+
this.events.emit('cache.hit', {
|
|
58
|
+
key,
|
|
59
|
+
accessCount: cached.accessCount,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return cached.value as T
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.misses++
|
|
66
|
+
|
|
67
|
+
// ── Event : cache.miss ─────────────────────────────────
|
|
68
|
+
this.events.emit('cache.miss', {
|
|
69
|
+
key,
|
|
70
|
+
requestedAt: Date.now(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const value = await fetcher()
|
|
74
|
+
this.set(key, value)
|
|
75
|
+
return value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set<T = any>(key: string, value: T): void {
|
|
79
|
+
const size = this.estimateSize(value)
|
|
80
|
+
this.evictIfNeeded(size)
|
|
81
|
+
|
|
82
|
+
this.cache.set(key, {
|
|
83
|
+
key,
|
|
84
|
+
value,
|
|
85
|
+
size,
|
|
86
|
+
accessCount: 1,
|
|
87
|
+
lastAccess: Date.now(),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const formatted = this.formatSize(size)
|
|
91
|
+
const maxFormatted = this.formatSize(this.maxSize)
|
|
92
|
+
console.log(`💾 RAM CACHED: ${key} (${formatted}/${maxFormatted})`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private evictIfNeeded(neededSize: number): void {
|
|
96
|
+
const currentSize = this.getCurrentSize()
|
|
97
|
+
if (currentSize + neededSize <= this.maxSize) return
|
|
98
|
+
|
|
99
|
+
const entries = Array.from(this.cache.values()).sort((a, b) => {
|
|
100
|
+
const scoreA = a.accessCount * 1000 + a.lastAccess
|
|
101
|
+
const scoreB = b.accessCount * 1000 + b.lastAccess
|
|
102
|
+
return scoreA - scoreB
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
let freedSize = 0
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (currentSize - freedSize + neededSize <= this.maxSize) break
|
|
108
|
+
this.cache.delete(entry.key)
|
|
109
|
+
freedSize += entry.size
|
|
110
|
+
console.log(`🗑️ RAM EVICTED: ${entry.key}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clearCache(): void {
|
|
115
|
+
this.cache.clear()
|
|
116
|
+
this.hits = 0
|
|
117
|
+
this.misses = 0
|
|
118
|
+
console.log('💾 RAM CLEARED')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getStats(): CacheStats {
|
|
122
|
+
const size = this.getCurrentSize()
|
|
123
|
+
const totalAccesses = this.hits + this.misses
|
|
124
|
+
const hitRate = totalAccesses > 0
|
|
125
|
+
? ((this.hits / totalAccesses) * 100).toFixed(1) + '%'
|
|
126
|
+
: '0%'
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
entries: this.cache.size,
|
|
130
|
+
size,
|
|
131
|
+
sizeFormatted: this.formatSize(size),
|
|
132
|
+
maxSize: this.maxSize,
|
|
133
|
+
usage: ((size / this.maxSize) * 100).toFixed(1) + '%',
|
|
134
|
+
hits: this.hits,
|
|
135
|
+
misses: this.misses,
|
|
136
|
+
hitRate,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private getCurrentSize(): number {
|
|
141
|
+
let total = 0
|
|
142
|
+
for (const entry of this.cache.values()) total += entry.size
|
|
143
|
+
return total
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private estimateSize(obj: any): number {
|
|
147
|
+
return JSON.stringify(obj).length
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private formatSize(bytes: number): string {
|
|
151
|
+
if (bytes < 1024) return `${bytes} B`
|
|
152
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
153
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
|
157
|
+
return this.provider.query<T>(sql, params)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async close(): Promise<void> {
|
|
161
|
+
await this.provider.close()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryEngine — patch generateSQL pour les routes sémantiques
|
|
3
|
+
*
|
|
4
|
+
* Ajouter ce patch dans generateSQL() de QueryEngine.ts,
|
|
5
|
+
* dans la boucle qui construit les JOIN :
|
|
6
|
+
*
|
|
7
|
+
* for (let i = 0; i < edges.length; i++) {
|
|
8
|
+
* const curr = path[i]
|
|
9
|
+
* const next = path[i + 1]
|
|
10
|
+
* const edge = edges[i]
|
|
11
|
+
*
|
|
12
|
+
* const fromCol = edge.fromCol === 'id' ? pkOf(curr) : edge.fromCol
|
|
13
|
+
* const toCol = edge.toCol === 'id' ? pkOf(next) : edge.toCol
|
|
14
|
+
*
|
|
15
|
+
* // ── PATCH : condition semantic_view ────────────────────────────────
|
|
16
|
+
* const conditionSQL = edge.condition
|
|
17
|
+
* ? ' AND ' + Object.entries(edge.condition)
|
|
18
|
+
* .map(([k, v]) => `${next}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
|
|
19
|
+
* .join(' AND ')
|
|
20
|
+
* : ''
|
|
21
|
+
* // ──────────────────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* sql += `\n INNER JOIN ${next} ON ${curr}.${fromCol} = ${next}.${toCol}${conditionSQL}`
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Résultat pour movies→people[actor] (jobId:1) :
|
|
27
|
+
*
|
|
28
|
+
* SELECT DISTINCT people.*
|
|
29
|
+
* FROM movies
|
|
30
|
+
* INNER JOIN credits ON movies.id = credits.movieId
|
|
31
|
+
* INNER JOIN people ON credits.personId = people.id AND people.jobId = 1
|
|
32
|
+
*
|
|
33
|
+
* Note : la condition est sur la table de jonction (credits), pas sur people.
|
|
34
|
+
* Le patch ci-dessus est une approximation — la condition correcte est :
|
|
35
|
+
*
|
|
36
|
+
* INNER JOIN credits ON movies.id = credits.movieId AND credits.jobId = 1
|
|
37
|
+
* INNER JOIN people ON credits.personId = people.id
|
|
38
|
+
*
|
|
39
|
+
* Voir generateSQLSemantic() ci-dessous pour la version correcte.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { CompiledGraph, RouteInfo } from '../types/index.js'
|
|
43
|
+
import { shim } from '../instrumentation/TelemetryShim.js'
|
|
44
|
+
|
|
45
|
+
export interface QueryOptions {
|
|
46
|
+
from: string
|
|
47
|
+
to: string
|
|
48
|
+
filters?: Record<string, any>
|
|
49
|
+
trail?: string
|
|
50
|
+
traceId?: string
|
|
51
|
+
// ── NOUVEAU : forcer une route sémantique spécifique
|
|
52
|
+
semantic?: string // ex: 'actor', 'director' — choisit la semantic_view correspondante
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class QueryEngine {
|
|
56
|
+
// public pour que DataLoader puisse accéder aux nodes (résolution PK)
|
|
57
|
+
constructor(public compiledGraph: CompiledGraph) {}
|
|
58
|
+
|
|
59
|
+
public getRoute(from: string, to: string, semantic?: string): RouteInfo {
|
|
60
|
+
// Si semantic fourni → chercher la route sémantique correspondante
|
|
61
|
+
if (semantic) {
|
|
62
|
+
const semRoute = (this.compiledGraph.routes as any[]).find(
|
|
63
|
+
r => r.from === from && r.to === to && r.semantic && r.label === semantic
|
|
64
|
+
)
|
|
65
|
+
if (semRoute) return semRoute
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Route physique par défaut (première trouvée)
|
|
69
|
+
const route = this.compiledGraph.routes.find(r => r.from === from && r.to === to)
|
|
70
|
+
if (!route) throw new Error(`LinkLab: No route found between ${from} and ${to}`)
|
|
71
|
+
return route
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public generateSQL(options: QueryOptions): string {
|
|
75
|
+
const { from, to, filters = {}, semantic } = options
|
|
76
|
+
const route = this.getRoute(from, to, semantic)
|
|
77
|
+
const { path, edges } = route.primary
|
|
78
|
+
|
|
79
|
+
const pkOf = (tableId: string): string => {
|
|
80
|
+
const node = this.compiledGraph.nodes.find((n: any) => n.id === tableId)
|
|
81
|
+
const pk = (node as any)?.primaryKey
|
|
82
|
+
return Array.isArray(pk) ? pk[0] : (pk ?? tableId + '_id')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let sql = `SELECT DISTINCT ${to}.*\nFROM ${from}`
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < edges.length; i++) {
|
|
88
|
+
const curr = path[i]
|
|
89
|
+
const next = path[i + 1]
|
|
90
|
+
const edge = edges[i] as any
|
|
91
|
+
|
|
92
|
+
const fromCol = edge.fromCol === 'id' ? pkOf(curr) : edge.fromCol
|
|
93
|
+
const toCol = edge.toCol === 'id' ? pkOf(next) : edge.toCol
|
|
94
|
+
|
|
95
|
+
// Condition semantic_view — appliquée sur la table de jonction (curr)
|
|
96
|
+
// ex: credits.jobId = 1 (pas sur people)
|
|
97
|
+
const conditionSQL = edge.condition
|
|
98
|
+
? ' AND ' + Object.entries(edge.condition as Record<string, unknown>)
|
|
99
|
+
.map(([k, v]) => `${curr}.${k} = ${typeof v === 'string' ? `'${v}'` : v}`)
|
|
100
|
+
.join(' AND ')
|
|
101
|
+
: ''
|
|
102
|
+
|
|
103
|
+
sql += `\n INNER JOIN ${next} ON ${curr}.${fromCol} = ${next}.${toCol}${conditionSQL}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sourcePK = pkOf(from)
|
|
107
|
+
const whereClauses = Object.entries(filters).map(([key, val]) => {
|
|
108
|
+
const col = key === 'id' ? sourcePK : key
|
|
109
|
+
const v = val === null ? null : (typeof val === 'string' ? `'${val}'` : val)
|
|
110
|
+
return v === null ? `${from}.${col} IS NULL` : `${from}.${col} = ${v}`
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (whereClauses.length > 0) sql += `\nWHERE ${whereClauses.join(' AND ')}`
|
|
114
|
+
|
|
115
|
+
return sql
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public executeInMemory(options: QueryOptions, dataset: Record<string, any[]>) {
|
|
119
|
+
const { from, to, filters = {}, trail, traceId, semantic } = options
|
|
120
|
+
|
|
121
|
+
const spanBuilder = shim.startSpan({ trail: trail ?? `${from}.${to}`, from, to, filters, traceId })
|
|
122
|
+
spanBuilder?.stepStart('QueryEngine')
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = this._executeInMemoryCore(from, to, filters, dataset, semantic)
|
|
126
|
+
|
|
127
|
+
spanBuilder?.stepEnd('QueryEngine')
|
|
128
|
+
if (spanBuilder) {
|
|
129
|
+
try {
|
|
130
|
+
const route = this.getRoute(from, to, semantic)
|
|
131
|
+
;(spanBuilder as any).withPath?.(route.primary.path)
|
|
132
|
+
} catch {}
|
|
133
|
+
const span = spanBuilder.end({ rowCount: result.length })
|
|
134
|
+
shim.emitEnd(span)
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
} catch (err) {
|
|
138
|
+
spanBuilder?.stepEnd('QueryEngine')
|
|
139
|
+
if (spanBuilder) {
|
|
140
|
+
const span = spanBuilder.endWithError(err as Error, {
|
|
141
|
+
compiledGraphHash: (this.compiledGraph as any).version ?? 'unknown',
|
|
142
|
+
weights: {}, cacheState: { l1HitRate:0, l2HitRate:0, globalHitRate:0, yoyoEvents:0 },
|
|
143
|
+
})
|
|
144
|
+
shim.emitError(span)
|
|
145
|
+
}
|
|
146
|
+
throw err
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private _executeInMemoryCore(
|
|
151
|
+
from: string, to: string,
|
|
152
|
+
filters: Record<string, any>,
|
|
153
|
+
dataset: Record<string, any[]>,
|
|
154
|
+
semantic?: string
|
|
155
|
+
): any[] {
|
|
156
|
+
const route = this.getRoute(from, to, semantic)
|
|
157
|
+
const { path, edges } = route.primary
|
|
158
|
+
|
|
159
|
+
// Appliquer les filtres sur la table source
|
|
160
|
+
const sourceRows = dataset[from] ?? []
|
|
161
|
+
const filtered = Object.entries(filters).reduce((rows, [key, val]) => {
|
|
162
|
+
return rows.filter((r: any) => r[key] === val)
|
|
163
|
+
}, sourceRows)
|
|
164
|
+
|
|
165
|
+
// Jointures successives
|
|
166
|
+
let current: any[] = filtered
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < edges.length; i++) {
|
|
169
|
+
const currTable = path[i]
|
|
170
|
+
const nextTable = path[i + 1]
|
|
171
|
+
const edge = edges[i] as any
|
|
172
|
+
const nextRows = dataset[nextTable] ?? []
|
|
173
|
+
|
|
174
|
+
const fromCol = edge.fromCol === 'id' ? 'id' : edge.fromCol
|
|
175
|
+
const toCol = edge.toCol === 'id' ? 'id' : edge.toCol
|
|
176
|
+
|
|
177
|
+
// Condition semantic_view (ex: { jobId: 1 })
|
|
178
|
+
const condition: Record<string, unknown> = edge.condition ?? {}
|
|
179
|
+
|
|
180
|
+
current = current.flatMap(row => {
|
|
181
|
+
const val = row[fromCol]
|
|
182
|
+
return nextRows.filter((next: any) => {
|
|
183
|
+
if (next[toCol] !== val) return false
|
|
184
|
+
// Condition semantic_view appliquée sur 'next' (table de jonction)
|
|
185
|
+
// ex: credits.jobId = 1 — credits est la table 'next' à ce step
|
|
186
|
+
for (const [k, v] of Object.entries(condition)) {
|
|
187
|
+
if (next[k] !== v) return false
|
|
188
|
+
}
|
|
189
|
+
return true
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Dédoublonnage sur id
|
|
194
|
+
const seen = new Set<unknown>()
|
|
195
|
+
current = current.filter(r => {
|
|
196
|
+
if (seen.has(r.id)) return false
|
|
197
|
+
seen.add(r.id)
|
|
198
|
+
return true
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return current
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
public generateJSONPipeline(options: QueryOptions) {
|
|
206
|
+
const { from, to, filters = {}, semantic } = options
|
|
207
|
+
const route = this.getRoute(from, to, semantic)
|
|
208
|
+
const { path, edges } = route.primary
|
|
209
|
+
return {
|
|
210
|
+
metadata: { from, to, steps: path.length, semantic: semantic ?? null },
|
|
211
|
+
executionPlan: path.map((table, index) => {
|
|
212
|
+
const isSource = index === 0
|
|
213
|
+
return {
|
|
214
|
+
step: index + 1,
|
|
215
|
+
action: isSource ? 'FETCH_AND_FILTER' : 'JOIN',
|
|
216
|
+
table,
|
|
217
|
+
config: isSource ? { filters } : { joinWith: path[index-1], on: edges[index-1] },
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|