@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,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queries.ts — Requêtes scénario Netflix
|
|
3
|
+
*
|
|
4
|
+
* Démontre la navigation dans un graphe généré automatiquement par le pipeline.
|
|
5
|
+
* Le graphe contient 7 nœuds et 66 arêtes dont 56 vues sémantiques issues de
|
|
6
|
+
* credits.jobId → jobs (actor, director, writer, screenplay...).
|
|
7
|
+
*
|
|
8
|
+
* Usage :
|
|
9
|
+
* tsx cli/run-scenario.ts scenarios/test-netflix --query directors-of-movie
|
|
10
|
+
* tsx cli/run-scenario.ts scenarios/test-netflix --query all
|
|
11
|
+
*
|
|
12
|
+
* Graphe disponible :
|
|
13
|
+
* Nœuds : movies, people, credits, jobs, departments, categories, users
|
|
14
|
+
* Arêtes physiques : credits→movies, credits→people, credits→jobs, jobs→departments
|
|
15
|
+
* Arêtes sémantiques : movies↔people via [actor | director | writer | screenplay ...]
|
|
16
|
+
* Arêtes virtuelles : movies↔categories (array inline)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PathQuery } from '../../types/index.js'
|
|
20
|
+
|
|
21
|
+
export interface NamedQuery {
|
|
22
|
+
name: string
|
|
23
|
+
description: string
|
|
24
|
+
query: PathQuery
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const netflixQueries: NamedQuery[] = [
|
|
28
|
+
|
|
29
|
+
// ============================================================
|
|
30
|
+
// NAVIGATION SÉMANTIQUE — Vues générées depuis credits.jobId
|
|
31
|
+
// ============================================================
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
name: 'directors-of-movie',
|
|
35
|
+
description: 'movies → people via vue sémantique "director"',
|
|
36
|
+
query: {
|
|
37
|
+
from: 'movies',
|
|
38
|
+
to: 'people',
|
|
39
|
+
via: ['director'],
|
|
40
|
+
maxPaths: 3
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
name: 'actors-of-movie',
|
|
46
|
+
description: 'movies → people via vue sémantique "actor"',
|
|
47
|
+
query: {
|
|
48
|
+
from: 'movies',
|
|
49
|
+
to: 'people',
|
|
50
|
+
via: ['actor'],
|
|
51
|
+
maxPaths: 3
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: 'movies-of-director',
|
|
57
|
+
description: 'people → movies via vue sémantique "director_in"',
|
|
58
|
+
query: {
|
|
59
|
+
from: 'people',
|
|
60
|
+
to: 'movies',
|
|
61
|
+
via: ['director_in'],
|
|
62
|
+
maxPaths: 3
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
name: 'movies-of-actor',
|
|
68
|
+
description: 'people → movies via vue sémantique "actor_in"',
|
|
69
|
+
query: {
|
|
70
|
+
from: 'people',
|
|
71
|
+
to: 'movies',
|
|
72
|
+
via: ['actor_in'],
|
|
73
|
+
maxPaths: 3
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// ============================================================
|
|
78
|
+
// NAVIGATION PHYSIQUE — Relations FK déclarées
|
|
79
|
+
// ============================================================
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
name: 'credits-to-jobs',
|
|
83
|
+
description: 'credits → jobs (FK physique credits.jobId → jobs.id)',
|
|
84
|
+
query: {
|
|
85
|
+
from: 'credits',
|
|
86
|
+
to: 'jobs',
|
|
87
|
+
maxPaths: 2
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
name: 'jobs-to-departments',
|
|
93
|
+
description: 'jobs → departments (FK physique jobs.departmentId → departments.id)',
|
|
94
|
+
query: {
|
|
95
|
+
from: 'jobs',
|
|
96
|
+
to: 'departments',
|
|
97
|
+
maxPaths: 2
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// ============================================================
|
|
102
|
+
// NAVIGATION VIRTUELLE — Array inline movies.categories
|
|
103
|
+
// ============================================================
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
name: 'movies-to-categories',
|
|
107
|
+
description: 'movies → categories (relation virtuelle depuis array inline)',
|
|
108
|
+
query: {
|
|
109
|
+
from: 'movies',
|
|
110
|
+
to: 'categories',
|
|
111
|
+
maxPaths: 2
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// ============================================================
|
|
116
|
+
// CHEMINS LONGS — Traversées multi-sauts
|
|
117
|
+
// ============================================================
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
name: 'movies-to-departments',
|
|
121
|
+
description: 'movies → departments (2 sauts : movies→credits→jobs→departments)',
|
|
122
|
+
query: {
|
|
123
|
+
from: 'movies',
|
|
124
|
+
to: 'departments',
|
|
125
|
+
maxPaths: 2
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
name: 'people-to-departments',
|
|
131
|
+
description: 'people → departments (via credits → jobs → departments)',
|
|
132
|
+
query: {
|
|
133
|
+
from: 'people',
|
|
134
|
+
to: 'departments',
|
|
135
|
+
maxPaths: 3
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// ============================================================
|
|
140
|
+
// CHEMIN MINIMAL — minHops
|
|
141
|
+
// ============================================================
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
name: 'people-to-movies-minhops',
|
|
145
|
+
description: 'people → movies chemin le plus court (minHops)',
|
|
146
|
+
query: {
|
|
147
|
+
from: 'people',
|
|
148
|
+
to: 'movies',
|
|
149
|
+
minHops: 1,
|
|
150
|
+
maxPaths: 5
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Requête par défaut (utilisée sans --query)
|
|
158
|
+
*/
|
|
159
|
+
export const defaultQuery = netflixQueries.find(q => q.name === 'directors-of-movie')!
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type { AnalyzedSchema, Dictionary, Relation } from '../types/index.js'
|
|
4
|
+
|
|
5
|
+
export class GraphBuilder {
|
|
6
|
+
/**
|
|
7
|
+
* Construit le dictionnaire final à partir du schéma analysé
|
|
8
|
+
*/
|
|
9
|
+
build(analyzed: AnalyzedSchema, dataPath: string): Dictionary {
|
|
10
|
+
const dictionary: Dictionary = {
|
|
11
|
+
tables: [],
|
|
12
|
+
relations: []
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 1. Déclarer les tables
|
|
16
|
+
for (const ent of analyzed.entities) {
|
|
17
|
+
dictionary.tables.push({
|
|
18
|
+
name: ent.name,
|
|
19
|
+
columns: ent.properties.map(p => p.name),
|
|
20
|
+
rowCount: ent.rowCount
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// 2. Transformer les PK/FK physiques en relations de base
|
|
24
|
+
for (const prop of ent.properties) {
|
|
25
|
+
if (prop.isFK && prop.references) {
|
|
26
|
+
dictionary.relations.push({
|
|
27
|
+
from: ent.name,
|
|
28
|
+
to: prop.references.table,
|
|
29
|
+
via: prop.name,
|
|
30
|
+
type: 'physical',
|
|
31
|
+
weight: analyzed.weights[`${ent.name}.${prop.name}`] || 1,
|
|
32
|
+
label: `FK_${prop.name}`
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Relation inverse (One-to-Many implicite)
|
|
36
|
+
dictionary.relations.push({
|
|
37
|
+
from: prop.references.table,
|
|
38
|
+
to: ent.name,
|
|
39
|
+
via: prop.name,
|
|
40
|
+
type: 'physical_reverse',
|
|
41
|
+
weight: (analyzed.weights[`${ent.name}.${prop.name}`] || 1) * 1.1, // Légèrement plus lourd de remonter
|
|
42
|
+
label: `LIST_OF_${ent.name.toUpperCase()}`
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Génération des Vues Sémantiques (L'intelligence métier)
|
|
49
|
+
this.injectVirtualViews(analyzed, dictionary, dataPath)
|
|
50
|
+
|
|
51
|
+
return dictionary
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private injectVirtualViews(analyzed: AnalyzedSchema, dict: Dictionary, dataPath: string) {
|
|
55
|
+
// On cherche le conseil de pivot que l'Analyzer a posé
|
|
56
|
+
const pivotAdvices = analyzed.advices.filter(a => a.action === 'SUGGEST_VIRTUAL_VIEWS')
|
|
57
|
+
|
|
58
|
+
for (const advice of pivotAdvices) {
|
|
59
|
+
const pivotTable = advice.target // ex: 'credits'
|
|
60
|
+
|
|
61
|
+
// On cherche quelle table sert de "Type" pour segmenter (ex: jobs)
|
|
62
|
+
const entity = analyzed.entities.find(e => e.name === pivotTable)
|
|
63
|
+
const typeFK = entity?.properties.find(p =>
|
|
64
|
+
/type|job|category|role/i.test(p.references?.table || '')
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if (typeFK && typeFK.references) {
|
|
68
|
+
const typeTableName = typeFK.references.table
|
|
69
|
+
const typeDataPath = path.join(dataPath, `${typeTableName}.json`)
|
|
70
|
+
|
|
71
|
+
if (fs.existsSync(typeDataPath)) {
|
|
72
|
+
const types = JSON.parse(fs.readFileSync(typeDataPath, 'utf-8'))
|
|
73
|
+
|
|
74
|
+
types.forEach((typeObj: any) => {
|
|
75
|
+
const roleName = typeObj.name.toLowerCase()
|
|
76
|
+
|
|
77
|
+
// Ignorer les rôles sans nom significatif
|
|
78
|
+
if (!roleName || /^unknow|^unknown|^n\/a/i.test(roleName)) return
|
|
79
|
+
|
|
80
|
+
// On crée l'arête sémantique : movies -> people (via credits filtré)
|
|
81
|
+
dict.relations.push({
|
|
82
|
+
from: 'movies',
|
|
83
|
+
to: 'people',
|
|
84
|
+
via: pivotTable,
|
|
85
|
+
type: 'semantic_view',
|
|
86
|
+
label: roleName,
|
|
87
|
+
condition: { [typeFK.name]: typeObj.id },
|
|
88
|
+
weight: 0.8 // Très léger car c'est une intention directe de l'utilisateur
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Et l'inverse : people -> movies (via credits filtré)
|
|
92
|
+
dict.relations.push({
|
|
93
|
+
from: 'people',
|
|
94
|
+
to: 'movies',
|
|
95
|
+
via: pivotTable,
|
|
96
|
+
type: 'semantic_view',
|
|
97
|
+
label: `${roleName}_in`,
|
|
98
|
+
condition: { [typeFK.name]: typeObj.id },
|
|
99
|
+
weight: 0.8
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonSchemaExtractor — Extraction de schéma depuis des fichiers JSON
|
|
3
|
+
*
|
|
4
|
+
* Inférence automatique :
|
|
5
|
+
* - Types de colonnes depuis le premier enregistrement
|
|
6
|
+
* - FK par convention de nommage (*Id → table cible) via SynonymResolver
|
|
7
|
+
* - Détection des arrays inline (ex: movies.categories)
|
|
8
|
+
*
|
|
9
|
+
* Synonymes :
|
|
10
|
+
* Délégués à SynonymResolver — config/synonyms.json + <dataPath>/synonyms.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs'
|
|
14
|
+
import path from 'path'
|
|
15
|
+
import type { TechnicalSchema, TechEntity, TechProperty } from '../types/index.js'
|
|
16
|
+
import { SynonymResolver } from './SynonymResolver.js'
|
|
17
|
+
|
|
18
|
+
export class JsonSchemaExtractor {
|
|
19
|
+
|
|
20
|
+
private resolver: SynonymResolver
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private dataPath: string,
|
|
24
|
+
configPath: string = path.join(process.cwd(), 'config')
|
|
25
|
+
) {
|
|
26
|
+
// Passe le dataPath comme projectPath — synonyms.json projet y est optionnel
|
|
27
|
+
this.resolver = new SynonymResolver(configPath, dataPath)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async extract(): Promise<TechnicalSchema> {
|
|
31
|
+
console.log(`📂 JsonSchemaExtractor — scanning : ${this.dataPath}`)
|
|
32
|
+
|
|
33
|
+
const files = fs.readdirSync(this.dataPath)
|
|
34
|
+
.filter(f => f.endsWith('.json') && f !== 'synonyms.json')
|
|
35
|
+
|
|
36
|
+
const entities: TechEntity[] = []
|
|
37
|
+
|
|
38
|
+
// Passe 1 — inférer entités et propriétés
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const tableName = path.basename(file, '.json')
|
|
41
|
+
const content = JSON.parse(fs.readFileSync(path.join(this.dataPath, file), 'utf-8'))
|
|
42
|
+
|
|
43
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
44
|
+
console.log(` ⏭️ ${file} — vide ou non-liste, ignoré`)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const properties = this.inferProperties(content[0])
|
|
49
|
+
const arrayCols = properties.filter(p => p.type === 'array').map(p => p.name)
|
|
50
|
+
|
|
51
|
+
entities.push({ name: tableName, properties, rowCount: content.length })
|
|
52
|
+
|
|
53
|
+
console.log(
|
|
54
|
+
` ✅ ${tableName} (${content.length} entrées` +
|
|
55
|
+
(arrayCols.length ? `, arrays: ${arrayCols.join(', ')}` : '') + ')'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Passe 2 — résoudre les FK via SynonymResolver
|
|
60
|
+
this.resolveForeignKeys(entities)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
source: {
|
|
64
|
+
type: 'json_files',
|
|
65
|
+
name: path.basename(this.dataPath),
|
|
66
|
+
generatedAt: new Date().toISOString()
|
|
67
|
+
},
|
|
68
|
+
entities
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private inferProperties(sample: Record<string, any>): TechProperty[] {
|
|
73
|
+
return Object.entries(sample).map(([key, value]) => ({
|
|
74
|
+
name: key,
|
|
75
|
+
type: Array.isArray(value) ? 'array'
|
|
76
|
+
: value === null ? 'null'
|
|
77
|
+
: typeof value === 'object' ? 'object'
|
|
78
|
+
: typeof value,
|
|
79
|
+
isPK: key === 'id',
|
|
80
|
+
isFK: false,
|
|
81
|
+
isIndexed: true,
|
|
82
|
+
nullable: value === null
|
|
83
|
+
}))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private resolveForeignKeys(entities: TechEntity[]): void {
|
|
87
|
+
const tableNames = entities.map(e => e.name)
|
|
88
|
+
|
|
89
|
+
for (const entity of entities) {
|
|
90
|
+
for (const prop of entity.properties) {
|
|
91
|
+
if (prop.isPK) continue
|
|
92
|
+
if (!prop.name.endsWith('Id') && !prop.name.endsWith('_id')) continue
|
|
93
|
+
|
|
94
|
+
const target = this.resolver.resolveColumn(prop.name, tableNames)
|
|
95
|
+
|
|
96
|
+
if (target) {
|
|
97
|
+
prop.isFK = true
|
|
98
|
+
prop.references = { table: target, column: 'id' }
|
|
99
|
+
console.log(` 🔗 ${entity.name}.${prop.name} → ${target}.id`)
|
|
100
|
+
} else {
|
|
101
|
+
const prefix = this.resolver.extractPrefix(prop.name)
|
|
102
|
+
console.log(` ❓ ${entity.name}.${prop.name} — "${prefix}" introuvable`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SchemaAnalyzer — Analyse sémantique d'un schéma technique
|
|
3
|
+
*
|
|
4
|
+
* Produit :
|
|
5
|
+
* - Poids par colonne (basés sur volumétrie et indexation)
|
|
6
|
+
* - Advices de performance (FK non indexées)
|
|
7
|
+
* - Détection de pivots sémantiques (tables de liaison)
|
|
8
|
+
* - Détection de FK implicites via SynonymResolver
|
|
9
|
+
* (colonnes *_id sans FK déclarée qui correspondent à une table existante)
|
|
10
|
+
*
|
|
11
|
+
* Compatible PostgreSQL et JSON — même logique quelque soit la source.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs'
|
|
15
|
+
import type { TechnicalSchema, TechEntity, AnalysisAdvice, AnalyzedSchema } from '../types/index.js'
|
|
16
|
+
import { SynonymResolver } from './SynonymResolver.js'
|
|
17
|
+
|
|
18
|
+
export class SchemaAnalyzer {
|
|
19
|
+
|
|
20
|
+
private advices: AnalysisAdvice[] = []
|
|
21
|
+
private weights: Record<string, number> = {}
|
|
22
|
+
private resolver: SynonymResolver
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
configPath: string = process.cwd() + '/config',
|
|
26
|
+
projectPath?: string
|
|
27
|
+
) {
|
|
28
|
+
this.resolver = new SynonymResolver(configPath, projectPath)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
analyze(schema: TechnicalSchema): AnalyzedSchema {
|
|
32
|
+
this.advices = []
|
|
33
|
+
this.weights = {}
|
|
34
|
+
|
|
35
|
+
for (const entity of schema.entities) {
|
|
36
|
+
this.analyzeEntity(entity)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.detectSemanticPivots(schema)
|
|
40
|
+
this.detectImplicitFKs(schema)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...schema,
|
|
44
|
+
advices: this.advices,
|
|
45
|
+
weights: this.weights,
|
|
46
|
+
implicitRelations: this.collectImplicitRelations(schema)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Analyse par entité ────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
private analyzeEntity(entity: TechEntity): void {
|
|
53
|
+
for (const prop of entity.properties) {
|
|
54
|
+
const key = `${entity.name}.${prop.name}`
|
|
55
|
+
let weight = 1
|
|
56
|
+
|
|
57
|
+
// FK non indexée — coût de traversée élevé
|
|
58
|
+
if (prop.isFK && !prop.isIndexed) {
|
|
59
|
+
weight = 10
|
|
60
|
+
this.advices.push({
|
|
61
|
+
type: 'PERFORMANCE',
|
|
62
|
+
level: 'WARNING',
|
|
63
|
+
target: key,
|
|
64
|
+
message: `FK '${prop.name}' non indexée dans '${entity.name}'.`,
|
|
65
|
+
action: `CREATE INDEX idx_${entity.name}_${prop.name} ON ${entity.name}(${prop.name});`
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Volumétrie — tables larges coûtent plus cher à traverser
|
|
70
|
+
if (entity.rowCount > 1000) {
|
|
71
|
+
weight += parseFloat(Math.log10(entity.rowCount / 100).toFixed(2))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.weights[key] = weight
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Pivots sémantiques ────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Détecte les tables de liaison (credits, film_category...)
|
|
82
|
+
* Pattern : table avec 2+ FK dont une vers une table de "types"
|
|
83
|
+
*/
|
|
84
|
+
private detectSemanticPivots(schema: TechnicalSchema): void {
|
|
85
|
+
for (const entity of schema.entities) {
|
|
86
|
+
const fks = entity.properties.filter(p => p.isFK)
|
|
87
|
+
if (fks.length < 2) continue
|
|
88
|
+
|
|
89
|
+
const typeFK = fks.find(fk =>
|
|
90
|
+
/type|job|category|dept|role|department/i.test(fk.references?.table ?? '')
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (typeFK) {
|
|
94
|
+
this.advices.push({
|
|
95
|
+
type: 'STRUCTURE',
|
|
96
|
+
level: 'INFO',
|
|
97
|
+
target: entity.name,
|
|
98
|
+
message: `Pivot sémantique — segmentation possible par '${typeFK.references!.table}'.`,
|
|
99
|
+
action: 'SUGGEST_VIRTUAL_VIEWS'
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── FK implicites ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Détecte les colonnes *_id sans FK déclarée qui correspondent
|
|
109
|
+
* à une table existante via SynonymResolver.
|
|
110
|
+
*
|
|
111
|
+
* Utile pour :
|
|
112
|
+
* - PostgreSQL : FK non déclarées (store.store_id non contrainte)
|
|
113
|
+
* - JSON : colonnes oubliées dans la passe 2 de JsonSchemaExtractor
|
|
114
|
+
*/
|
|
115
|
+
private detectImplicitFKs(schema: TechnicalSchema): void {
|
|
116
|
+
const tableNames = schema.entities.map(e => e.name)
|
|
117
|
+
|
|
118
|
+
for (const entity of schema.entities) {
|
|
119
|
+
for (const prop of entity.properties) {
|
|
120
|
+
// Ignorer PK et FK déjà déclarées
|
|
121
|
+
if (prop.isPK || prop.isFK) continue
|
|
122
|
+
if (!prop.name.endsWith('_id') && !prop.name.endsWith('Id')) continue
|
|
123
|
+
|
|
124
|
+
const target = this.resolver.resolveColumn(prop.name, tableNames)
|
|
125
|
+
if (!target) continue
|
|
126
|
+
|
|
127
|
+
// FK implicite trouvée
|
|
128
|
+
this.advices.push({
|
|
129
|
+
type: 'STRUCTURE',
|
|
130
|
+
level: 'INFO',
|
|
131
|
+
target: `${entity.name}.${prop.name}`,
|
|
132
|
+
message: `FK implicite détectée : '${entity.name}.${prop.name}' → '${target}'.`,
|
|
133
|
+
action: 'ADD_IMPLICIT_FK'
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collecte les FK implicites comme relations exploitables par GraphBuilder.
|
|
141
|
+
*/
|
|
142
|
+
private collectImplicitRelations(schema: TechnicalSchema): Array<{
|
|
143
|
+
fromTable: string
|
|
144
|
+
column: string
|
|
145
|
+
guessedTable: string
|
|
146
|
+
}> {
|
|
147
|
+
const tableNames = schema.entities.map(e => e.name)
|
|
148
|
+
const result: Array<{ fromTable: string; column: string; guessedTable: string }> = []
|
|
149
|
+
|
|
150
|
+
for (const entity of schema.entities) {
|
|
151
|
+
for (const prop of entity.properties) {
|
|
152
|
+
if (prop.isPK || prop.isFK) continue
|
|
153
|
+
if (!prop.name.endsWith('_id') && !prop.name.endsWith('Id')) continue
|
|
154
|
+
|
|
155
|
+
const target = this.resolver.resolveColumn(prop.name, tableNames)
|
|
156
|
+
if (target) {
|
|
157
|
+
result.push({
|
|
158
|
+
fromTable: entity.name,
|
|
159
|
+
column: prop.name,
|
|
160
|
+
guessedTable: target
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
saveAnalysis(analyzed: AnalyzedSchema, outputPath: string): void {
|
|
172
|
+
fs.writeFileSync(outputPath, JSON.stringify(analyzed, null, 2))
|
|
173
|
+
console.log(`💾 Analyse sauvegardée : ${outputPath}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import type { Provider, TechnicalSchema, TechEntity, TechProperty } from '../types/index.js'
|
|
3
|
+
|
|
4
|
+
export class SchemaExtractor {
|
|
5
|
+
constructor(private provider: Provider) {}
|
|
6
|
+
|
|
7
|
+
async extract(databaseName: string): Promise<TechnicalSchema> {
|
|
8
|
+
console.log(`🔍 Extraction du schéma technique pour : ${databaseName}`)
|
|
9
|
+
|
|
10
|
+
const tables = await this.getTables()
|
|
11
|
+
const entities: TechEntity[] = []
|
|
12
|
+
|
|
13
|
+
for (const tableName of tables) {
|
|
14
|
+
const properties = await this.getProperties(tableName)
|
|
15
|
+
const rowCount = await this.getRowCount(tableName)
|
|
16
|
+
|
|
17
|
+
entities.push({
|
|
18
|
+
name: tableName,
|
|
19
|
+
properties,
|
|
20
|
+
rowCount
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const schema: TechnicalSchema = {
|
|
25
|
+
source: {
|
|
26
|
+
type: 'postgresql',
|
|
27
|
+
name: databaseName,
|
|
28
|
+
generatedAt: new Date().toISOString()
|
|
29
|
+
},
|
|
30
|
+
entities
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fs.writeFileSync('./schema.json', JSON.stringify(schema, null, 2))
|
|
34
|
+
return schema
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async getTables(): Promise<string[]> {
|
|
38
|
+
const query = `
|
|
39
|
+
SELECT table_name FROM information_schema.tables
|
|
40
|
+
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
|
41
|
+
`
|
|
42
|
+
const rows = await this.provider.query<{ table_name: string }>(query)
|
|
43
|
+
return rows.map(r => r.table_name)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async getProperties(tableName: string): Promise<TechProperty[]> {
|
|
47
|
+
const query = `
|
|
48
|
+
SELECT
|
|
49
|
+
cols.column_name as name,
|
|
50
|
+
cols.data_type as type,
|
|
51
|
+
-- Détection Primary Key
|
|
52
|
+
EXISTS (
|
|
53
|
+
SELECT 1 FROM information_schema.table_constraints tc
|
|
54
|
+
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
|
|
55
|
+
WHERE tc.table_name = cols.table_name AND kcu.column_name = cols.column_name
|
|
56
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
57
|
+
) as is_pk,
|
|
58
|
+
-- Détection Foreign Key Target
|
|
59
|
+
ccu.table_name as fk_target_table,
|
|
60
|
+
ccu.column_name as fk_target_column,
|
|
61
|
+
-- Détection Index
|
|
62
|
+
EXISTS (
|
|
63
|
+
SELECT 1 FROM pg_index i
|
|
64
|
+
JOIN pg_class c ON c.oid = i.indrelid
|
|
65
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
|
|
66
|
+
WHERE c.relname = cols.table_name AND a.attname = cols.column_name
|
|
67
|
+
) as is_indexed
|
|
68
|
+
FROM information_schema.columns cols
|
|
69
|
+
LEFT JOIN information_schema.key_column_usage kcu
|
|
70
|
+
ON cols.table_name = kcu.table_name AND cols.column_name = kcu.column_name
|
|
71
|
+
LEFT JOIN information_schema.referential_constraints rc
|
|
72
|
+
ON kcu.constraint_name = rc.constraint_name
|
|
73
|
+
LEFT JOIN information_schema.constraint_column_usage ccu
|
|
74
|
+
ON rc.unique_constraint_name = ccu.constraint_name
|
|
75
|
+
WHERE cols.table_name = $1
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const rows = await this.provider.query<any>(query, [tableName])
|
|
79
|
+
|
|
80
|
+
return rows.map(r => ({
|
|
81
|
+
name: r.name,
|
|
82
|
+
type: r.type,
|
|
83
|
+
isPK: r.is_pk,
|
|
84
|
+
isFK: !!r.fk_target_table,
|
|
85
|
+
references: r.fk_target_table
|
|
86
|
+
? {
|
|
87
|
+
table: r.fk_target_table,
|
|
88
|
+
column: r.fk_target_column
|
|
89
|
+
}
|
|
90
|
+
: undefined,
|
|
91
|
+
isIndexed: r.is_indexed
|
|
92
|
+
}))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async getRowCount(tableName: string): Promise<number> {
|
|
96
|
+
const res = await this.provider.query<{ count: string }>(
|
|
97
|
+
`SELECT reltuples::bigint as count FROM pg_class WHERE relname = $1`,
|
|
98
|
+
[tableName]
|
|
99
|
+
)
|
|
100
|
+
return parseInt(res[0]?.count || '0', 10)
|
|
101
|
+
}
|
|
102
|
+
}
|