@linklabjs/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +411 -0
  2. package/package.json +48 -0
  3. package/src/api/DomainNode.ts +1433 -0
  4. package/src/api/Graph.ts +271 -0
  5. package/src/api/PathBuilder.ts +247 -0
  6. package/src/api/index.ts +15 -0
  7. package/src/api/loadGraph.ts +207 -0
  8. package/src/api/test-api.ts +153 -0
  9. package/src/api/test-domain.ts +119 -0
  10. package/src/api/types.ts +88 -0
  11. package/src/config/synonyms.json +28 -0
  12. package/src/core/EventBus.ts +187 -0
  13. package/src/core/GraphEvents.ts +153 -0
  14. package/src/core/PathFinder.ts +283 -0
  15. package/src/formatters/BaseFormatter.ts +17 -0
  16. package/src/graph/GraphAssembler.ts +50 -0
  17. package/src/graph/GraphCompiler.ts +412 -0
  18. package/src/graph/GraphExtractor.ts +191 -0
  19. package/src/graph/GraphOptimizer.ts +404 -0
  20. package/src/graph/GraphTrainer.ts +247 -0
  21. package/src/http/LinkBuilder.ts +244 -0
  22. package/src/http/TrailRequest.ts +48 -0
  23. package/src/http/example-netflix.ts +59 -0
  24. package/src/http/hateoas/README.md +87 -0
  25. package/src/http/index.ts +33 -0
  26. package/src/http/plugin.ts +360 -0
  27. package/src/index.ts +121 -0
  28. package/src/instrumentation/TelemetryShim.ts +172 -0
  29. package/src/navigation/NavigationEngine.ts +441 -0
  30. package/src/navigation/Resolver.ts +134 -0
  31. package/src/navigation/Scheduler.ts +136 -0
  32. package/src/navigation/Trail.ts +252 -0
  33. package/src/navigation/TrailParser.ts +207 -0
  34. package/src/navigation/index.ts +11 -0
  35. package/src/providers/MockProvider.ts +68 -0
  36. package/src/providers/PostgresProvider.ts +187 -0
  37. package/src/runtime/CompiledGraphEngine.ts +274 -0
  38. package/src/runtime/DataLoader.ts +236 -0
  39. package/src/runtime/Engine.ts +163 -0
  40. package/src/runtime/QueryEngine.ts +222 -0
  41. package/src/scenarios/test-metro-paris/config.json +6 -0
  42. package/src/scenarios/test-metro-paris/graph.json +16325 -0
  43. package/src/scenarios/test-metro-paris/queries.ts +152 -0
  44. package/src/scenarios/test-metro-paris/stack.json +1 -0
  45. package/src/scenarios/test-musicians/config.json +10 -0
  46. package/src/scenarios/test-musicians/graph.json +20 -0
  47. package/src/scenarios/test-musicians/stack.json +1 -0
  48. package/src/scenarios/test-netflix/MIGRATION.md +23 -0
  49. package/src/scenarios/test-netflix/README.md +138 -0
  50. package/src/scenarios/test-netflix/actions.ts +92 -0
  51. package/src/scenarios/test-netflix/config.json +6 -0
  52. package/src/scenarios/test-netflix/data/categories.json +1 -0
  53. package/src/scenarios/test-netflix/data/companies.json +1 -0
  54. package/src/scenarios/test-netflix/data/credits.json +19797 -0
  55. package/src/scenarios/test-netflix/data/departments.json +18 -0
  56. package/src/scenarios/test-netflix/data/jobs.json +142 -0
  57. package/src/scenarios/test-netflix/data/movies.json +3497 -0
  58. package/src/scenarios/test-netflix/data/people.json +1 -0
  59. package/src/scenarios/test-netflix/data/synonyms.json +8 -0
  60. package/src/scenarios/test-netflix/data/users.json +70 -0
  61. package/src/scenarios/test-netflix/graph.json +1017 -0
  62. package/src/scenarios/test-netflix/queries.ts +159 -0
  63. package/src/scenarios/test-netflix/stack.json +14 -0
  64. package/src/schema/GraphBuilder.ts +106 -0
  65. package/src/schema/JsonSchemaExtractor.ts +107 -0
  66. package/src/schema/SchemaAnalyzer.ts +175 -0
  67. package/src/schema/SchemaExtractor.ts +102 -0
  68. package/src/schema/SynonymResolver.ts +143 -0
  69. package/src/scripts/dictionary.json +796 -0
  70. package/src/scripts/graph.json +664 -0
  71. package/src/scripts/regenerate.ts +248 -0
  72. package/src/types/index.ts +506 -0
@@ -0,0 +1,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,14 @@
1
+ [
2
+ {
3
+ "entity": "Directors",
4
+ "id": 2,
5
+ "state": "RESOLVED",
6
+ "purpose": "explore"
7
+ },
8
+ {
9
+ "entity": "Movies",
10
+ "id": null,
11
+ "state": "UNRESOLVED",
12
+ "purpose": "list"
13
+ }
14
+ ]
@@ -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
+ }