datalex-cli 0.1.1__py3-none-any.whl

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 (64) hide show
  1. datalex_cli/__init__.py +1 -0
  2. datalex_cli/datalex_cli.py +658 -0
  3. datalex_cli/main.py +2925 -0
  4. datalex_cli-0.1.1.dist-info/METADATA +228 -0
  5. datalex_cli-0.1.1.dist-info/RECORD +64 -0
  6. datalex_cli-0.1.1.dist-info/WHEEL +5 -0
  7. datalex_cli-0.1.1.dist-info/entry_points.txt +2 -0
  8. datalex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
  9. datalex_cli-0.1.1.dist-info/top_level.txt +2 -0
  10. datalex_core/__init__.py +94 -0
  11. datalex_core/_schemas/datalex/common.schema.json +127 -0
  12. datalex_core/_schemas/datalex/domain.schema.json +24 -0
  13. datalex_core/_schemas/datalex/entity.schema.json +158 -0
  14. datalex_core/_schemas/datalex/model.schema.json +141 -0
  15. datalex_core/_schemas/datalex/policy.schema.json +70 -0
  16. datalex_core/_schemas/datalex/project.schema.json +82 -0
  17. datalex_core/_schemas/datalex/snippet.schema.json +24 -0
  18. datalex_core/_schemas/datalex/source.schema.json +104 -0
  19. datalex_core/_schemas/datalex/term.schema.json +30 -0
  20. datalex_core/canonical.py +166 -0
  21. datalex_core/completion.py +204 -0
  22. datalex_core/connectors/__init__.py +39 -0
  23. datalex_core/connectors/base.py +417 -0
  24. datalex_core/connectors/bigquery.py +229 -0
  25. datalex_core/connectors/databricks.py +262 -0
  26. datalex_core/connectors/mysql.py +266 -0
  27. datalex_core/connectors/postgres.py +309 -0
  28. datalex_core/connectors/redshift.py +298 -0
  29. datalex_core/connectors/snowflake.py +336 -0
  30. datalex_core/connectors/sqlserver.py +425 -0
  31. datalex_core/datalex/__init__.py +26 -0
  32. datalex_core/datalex/diff.py +188 -0
  33. datalex_core/datalex/errors.py +85 -0
  34. datalex_core/datalex/loader.py +512 -0
  35. datalex_core/datalex/migrate_layout.py +382 -0
  36. datalex_core/datalex/parse_cache.py +102 -0
  37. datalex_core/datalex/project.py +214 -0
  38. datalex_core/datalex/types.py +224 -0
  39. datalex_core/dbt/__init__.py +18 -0
  40. datalex_core/dbt/emit.py +344 -0
  41. datalex_core/dbt/manifest.py +329 -0
  42. datalex_core/dbt/profiles.py +185 -0
  43. datalex_core/dbt/sync.py +279 -0
  44. datalex_core/dbt/warehouse.py +215 -0
  45. datalex_core/dialects/__init__.py +15 -0
  46. datalex_core/dialects/_common.py +48 -0
  47. datalex_core/dialects/base.py +47 -0
  48. datalex_core/dialects/postgres.py +164 -0
  49. datalex_core/dialects/registry.py +36 -0
  50. datalex_core/dialects/snowflake.py +129 -0
  51. datalex_core/diffing.py +358 -0
  52. datalex_core/docs_generator.py +797 -0
  53. datalex_core/doctor.py +181 -0
  54. datalex_core/generators.py +478 -0
  55. datalex_core/importers.py +1176 -0
  56. datalex_core/issues.py +23 -0
  57. datalex_core/loader.py +21 -0
  58. datalex_core/migrate.py +316 -0
  59. datalex_core/modeling.py +679 -0
  60. datalex_core/packages.py +430 -0
  61. datalex_core/policy.py +1037 -0
  62. datalex_core/resolver.py +456 -0
  63. datalex_core/schema.py +54 -0
  64. datalex_core/semantic.py +1561 -0
@@ -0,0 +1,309 @@
1
+ """PostgreSQL connector — pulls schema from information_schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Any, Dict, List, Tuple
7
+
8
+ from datalex_core.connectors.base import BaseConnector, ConnectorConfig, ConnectorResult
9
+
10
+
11
+ _PG_TYPE_MAP = {
12
+ "integer": "integer",
13
+ "bigint": "bigint",
14
+ "smallint": "smallint",
15
+ "serial": "integer",
16
+ "bigserial": "bigint",
17
+ "numeric": "decimal",
18
+ "real": "float",
19
+ "double precision": "float",
20
+ "boolean": "boolean",
21
+ "character varying": "string",
22
+ "varchar": "string",
23
+ "character": "string",
24
+ "char": "string",
25
+ "text": "text",
26
+ "date": "date",
27
+ "timestamp without time zone": "timestamp",
28
+ "timestamp with time zone": "timestamp",
29
+ "time without time zone": "time",
30
+ "time with time zone": "time",
31
+ "uuid": "uuid",
32
+ "json": "json",
33
+ "jsonb": "json",
34
+ "bytea": "binary",
35
+ "inet": "string",
36
+ "cidr": "string",
37
+ "macaddr": "string",
38
+ "interval": "string",
39
+ "array": "json",
40
+ "xml": "string",
41
+ "money": "decimal",
42
+ "bit": "string",
43
+ "bit varying": "string",
44
+ "point": "string",
45
+ "line": "string",
46
+ "polygon": "string",
47
+ "tsvector": "string",
48
+ "tsquery": "string",
49
+ }
50
+
51
+
52
+ class PostgresConnector(BaseConnector):
53
+ connector_type = "postgres"
54
+ display_name = "PostgreSQL"
55
+ required_package = "psycopg2"
56
+
57
+ def test_connection(self, config: ConnectorConfig) -> Tuple[bool, str]:
58
+ try:
59
+ import psycopg2
60
+ conn = psycopg2.connect(
61
+ host=config.host,
62
+ port=config.port or 5432,
63
+ dbname=config.database,
64
+ user=config.user,
65
+ password=config.password,
66
+ )
67
+ conn.close()
68
+ return True, "Connection successful"
69
+ except ImportError:
70
+ return False, "psycopg2 not installed. Run: pip install psycopg2-binary"
71
+ except Exception as e:
72
+ return False, f"Connection failed: {e}"
73
+
74
+ def _connect(self, config: ConnectorConfig):
75
+ import psycopg2
76
+ return psycopg2.connect(
77
+ host=config.host,
78
+ port=config.port or 5432,
79
+ dbname=config.database,
80
+ user=config.user,
81
+ password=config.password,
82
+ )
83
+
84
+ def list_schemas(self, config: ConnectorConfig) -> List[Dict[str, Any]]:
85
+ conn = self._connect(config)
86
+ try:
87
+ cur = conn.cursor()
88
+ cur.execute("""
89
+ SELECT s.schema_name,
90
+ COUNT(t.table_name) AS table_count
91
+ FROM information_schema.schemata s
92
+ LEFT JOIN information_schema.tables t
93
+ ON t.table_schema = s.schema_name
94
+ AND t.table_type IN ('BASE TABLE', 'VIEW')
95
+ WHERE s.schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
96
+ GROUP BY s.schema_name
97
+ ORDER BY s.schema_name
98
+ """)
99
+ return [{"name": row[0], "table_count": row[1]} for row in cur.fetchall()]
100
+ finally:
101
+ conn.close()
102
+
103
+ def list_tables(self, config: ConnectorConfig) -> List[Dict[str, Any]]:
104
+ schema = config.schema or "public"
105
+ conn = self._connect(config)
106
+ try:
107
+ cur = conn.cursor()
108
+ cur.execute("""
109
+ SELECT t.table_name, t.table_type,
110
+ (SELECT COUNT(*) FROM information_schema.columns c
111
+ WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name) AS col_count
112
+ FROM information_schema.tables t
113
+ WHERE t.table_schema = %s
114
+ AND t.table_type IN ('BASE TABLE', 'VIEW')
115
+ ORDER BY t.table_name
116
+ """, (schema,))
117
+ results = []
118
+ for row in cur.fetchall():
119
+ ttype = "view" if "VIEW" in row[1] else "table"
120
+ results.append({"name": row[0], "type": ttype, "column_count": row[2], "row_count": None})
121
+ return results
122
+ finally:
123
+ conn.close()
124
+
125
+ def pull_schema(self, config: ConnectorConfig) -> ConnectorResult:
126
+ conn = self._connect(config)
127
+ try:
128
+ return self._pull(conn, config)
129
+ finally:
130
+ conn.close()
131
+
132
+ def _pull(self, conn: Any, config: ConnectorConfig) -> ConnectorResult:
133
+ model = self._build_model(config)
134
+ schema_filter = config.schema or "public"
135
+ cur = conn.cursor()
136
+ warnings: List[str] = []
137
+
138
+ # --- Tables ---
139
+ cur.execute("""
140
+ SELECT table_name, table_type
141
+ FROM information_schema.tables
142
+ WHERE table_schema = %s
143
+ AND table_type IN ('BASE TABLE', 'VIEW')
144
+ ORDER BY table_name
145
+ """, (schema_filter,))
146
+ tables = cur.fetchall()
147
+
148
+ table_entities: Dict[str, Dict[str, Any]] = {}
149
+ for table_name, table_type in tables:
150
+ if not self._should_include_table(table_name, config):
151
+ continue
152
+ entity_name = self._entity_name(table_name)
153
+ entity_type = "view" if table_type == "VIEW" else "table"
154
+ table_entities[table_name] = {
155
+ "name": entity_name,
156
+ "physical_name": table_name,
157
+ "type": entity_type,
158
+ "description": f"Pulled from PostgreSQL {config.database}.{schema_filter}.{table_name} on {date.today().isoformat()}",
159
+ "fields": [],
160
+ }
161
+ if schema_filter != "public":
162
+ table_entities[table_name]["schema"] = schema_filter
163
+
164
+ # --- Columns ---
165
+ cur.execute("""
166
+ SELECT table_name, column_name, data_type, is_nullable,
167
+ column_default, character_maximum_length,
168
+ numeric_precision, numeric_scale, udt_name
169
+ FROM information_schema.columns
170
+ WHERE table_schema = %s
171
+ ORDER BY table_name, ordinal_position
172
+ """, (schema_filter,))
173
+ columns = cur.fetchall()
174
+ total_columns = 0
175
+
176
+ for row in columns:
177
+ tname, col_name, data_type, is_nullable, col_default, char_max_len, num_prec, num_scale, udt_name = row
178
+ if tname not in table_entities:
179
+ continue
180
+
181
+ dl_type = _PG_TYPE_MAP.get(data_type, "string")
182
+ if data_type == "numeric" and num_prec:
183
+ dl_type = f"decimal({num_prec},{num_scale or 0})"
184
+ if data_type in ("character varying", "varchar") and char_max_len:
185
+ dl_type = f"varchar({char_max_len})"
186
+ if data_type == "USER-DEFINED":
187
+ dl_type = udt_name or "string"
188
+
189
+ field: Dict[str, Any] = {
190
+ "name": col_name,
191
+ "type": dl_type,
192
+ "nullable": is_nullable == "YES",
193
+ }
194
+ if col_default is not None:
195
+ cleaned = str(col_default).split("::")[0].strip("'")
196
+ if not cleaned.startswith("nextval("):
197
+ field["default"] = cleaned
198
+
199
+ table_entities[tname]["fields"].append(field)
200
+ total_columns += 1
201
+
202
+ # --- Primary keys ---
203
+ cur.execute("""
204
+ SELECT tc.table_name, kcu.column_name
205
+ FROM information_schema.table_constraints tc
206
+ JOIN information_schema.key_column_usage kcu
207
+ ON tc.constraint_name = kcu.constraint_name
208
+ AND tc.table_schema = kcu.table_schema
209
+ WHERE tc.constraint_type = 'PRIMARY KEY'
210
+ AND tc.table_schema = %s
211
+ """, (schema_filter,))
212
+ for tname, col_name in cur.fetchall():
213
+ if tname in table_entities:
214
+ for f in table_entities[tname]["fields"]:
215
+ if f["name"] == col_name:
216
+ f["primary_key"] = True
217
+ f["nullable"] = False
218
+
219
+ # --- Unique constraints ---
220
+ cur.execute("""
221
+ SELECT tc.table_name, kcu.column_name
222
+ FROM information_schema.table_constraints tc
223
+ JOIN information_schema.key_column_usage kcu
224
+ ON tc.constraint_name = kcu.constraint_name
225
+ AND tc.table_schema = kcu.table_schema
226
+ WHERE tc.constraint_type = 'UNIQUE'
227
+ AND tc.table_schema = %s
228
+ """, (schema_filter,))
229
+ for tname, col_name in cur.fetchall():
230
+ if tname in table_entities:
231
+ for f in table_entities[tname]["fields"]:
232
+ if f["name"] == col_name:
233
+ f["unique"] = True
234
+
235
+ # --- Foreign keys ---
236
+ cur.execute("""
237
+ SELECT
238
+ kcu.table_name AS child_table,
239
+ kcu.column_name AS child_column,
240
+ ccu.table_name AS parent_table,
241
+ ccu.column_name AS parent_column,
242
+ tc.constraint_name
243
+ FROM information_schema.table_constraints tc
244
+ JOIN information_schema.key_column_usage kcu
245
+ ON tc.constraint_name = kcu.constraint_name
246
+ AND tc.table_schema = kcu.table_schema
247
+ JOIN information_schema.constraint_column_usage ccu
248
+ ON tc.constraint_name = ccu.constraint_name
249
+ AND tc.table_schema = ccu.table_schema
250
+ WHERE tc.constraint_type = 'FOREIGN KEY'
251
+ AND tc.table_schema = %s
252
+ """, (schema_filter,))
253
+ fk_rows = cur.fetchall()
254
+ relationships: List[Dict[str, Any]] = []
255
+ for child_table, child_col, parent_table, parent_col, constraint_name in fk_rows:
256
+ if child_table in table_entities:
257
+ for f in table_entities[child_table]["fields"]:
258
+ if f["name"] == child_col:
259
+ f["foreign_key"] = True
260
+ parent_entity = self._entity_name(parent_table)
261
+ child_entity = self._entity_name(child_table)
262
+ relationships.append({
263
+ "name": constraint_name or f"{parent_entity.lower()}_{child_entity.lower()}_{child_col}_fk",
264
+ "from": f"{parent_entity}.{parent_col}",
265
+ "to": f"{child_entity}.{child_col}",
266
+ "cardinality": "one_to_many",
267
+ })
268
+
269
+ # --- Indexes ---
270
+ cur.execute("""
271
+ SELECT indexname, tablename, indexdef
272
+ FROM pg_indexes
273
+ WHERE schemaname = %s
274
+ ORDER BY tablename, indexname
275
+ """, (schema_filter,))
276
+ indexes: List[Dict[str, Any]] = []
277
+ for idx_name, tname, idx_def in cur.fetchall():
278
+ if tname not in table_entities:
279
+ continue
280
+ if "_pkey" in idx_name:
281
+ continue
282
+ is_unique = "UNIQUE" in (idx_def or "").upper()
283
+ import re
284
+ cols_match = re.search(r"\(([^)]+)\)", idx_def or "")
285
+ cols = []
286
+ if cols_match:
287
+ cols = [c.strip().split()[0] for c in cols_match.group(1).split(",")]
288
+ entity_name = self._entity_name(tname)
289
+ indexes.append({
290
+ "name": idx_name,
291
+ "entity": entity_name,
292
+ "fields": cols,
293
+ "unique": is_unique,
294
+ })
295
+
296
+ model["entities"] = list(table_entities.values())
297
+ model["relationships"] = relationships
298
+ model["indexes"] = indexes
299
+
300
+ cur.close()
301
+
302
+ return ConnectorResult(
303
+ model=model,
304
+ tables_found=len(table_entities),
305
+ columns_found=total_columns,
306
+ relationships_found=len(relationships),
307
+ indexes_found=len(indexes),
308
+ warnings=warnings,
309
+ )
@@ -0,0 +1,298 @@
1
+ """Amazon Redshift connector — pulls schema from information_schema with inference fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Any, Dict, List, Tuple
7
+
8
+ from datalex_core.connectors.base import (
9
+ BaseConnector,
10
+ ConnectorConfig,
11
+ ConnectorResult,
12
+ infer_primary_keys,
13
+ infer_relationships,
14
+ )
15
+
16
+
17
+ _RS_TYPE_MAP = {
18
+ "smallint": "smallint",
19
+ "integer": "integer",
20
+ "bigint": "bigint",
21
+ "decimal": "decimal",
22
+ "numeric": "decimal",
23
+ "real": "float",
24
+ "double precision": "float",
25
+ "boolean": "boolean",
26
+ "character varying": "string",
27
+ "varchar": "string",
28
+ "character": "string",
29
+ "char": "string",
30
+ "text": "text",
31
+ "date": "date",
32
+ "timestamp without time zone": "timestamp",
33
+ "timestamp with time zone": "timestamp",
34
+ "time without time zone": "time",
35
+ "time with time zone": "time",
36
+ "super": "json",
37
+ "varbyte": "binary",
38
+ "binary varying": "binary",
39
+ "geometry": "string",
40
+ "geography": "string",
41
+ "hllsketch": "string",
42
+ }
43
+
44
+
45
+ class RedshiftConnector(BaseConnector):
46
+ connector_type = "redshift"
47
+ display_name = "Amazon Redshift"
48
+ required_package = "redshift_connector"
49
+
50
+ def test_connection(self, config: ConnectorConfig) -> Tuple[bool, str]:
51
+ try:
52
+ conn = self._connect(config)
53
+ conn.close()
54
+ return True, "Connection successful"
55
+ except ImportError:
56
+ return False, "redshift-connector not installed. Run: pip install redshift-connector"
57
+ except Exception as e:
58
+ return False, f"Connection failed: {e}"
59
+
60
+ def _connect(self, config: ConnectorConfig):
61
+ import redshift_connector
62
+
63
+ return redshift_connector.connect(
64
+ host=config.host,
65
+ port=config.port or 5439,
66
+ database=config.database,
67
+ user=config.user,
68
+ password=config.password,
69
+ timeout=10,
70
+ )
71
+
72
+ def list_schemas(self, config: ConnectorConfig) -> List[Dict[str, Any]]:
73
+ conn = self._connect(config)
74
+ try:
75
+ cur = conn.cursor()
76
+ cur.execute(
77
+ """
78
+ SELECT n.nspname AS schema_name,
79
+ COUNT(t.table_name) AS table_count
80
+ FROM pg_namespace n
81
+ LEFT JOIN information_schema.tables t
82
+ ON t.table_schema = n.nspname
83
+ AND t.table_type IN ('BASE TABLE', 'VIEW')
84
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_internal')
85
+ AND n.nspname NOT LIKE 'pg_temp_%'
86
+ GROUP BY n.nspname
87
+ ORDER BY n.nspname
88
+ """
89
+ )
90
+ return [{"name": row[0], "table_count": int(row[1] or 0)} for row in cur.fetchall()]
91
+ finally:
92
+ conn.close()
93
+
94
+ def list_tables(self, config: ConnectorConfig) -> List[Dict[str, Any]]:
95
+ schema = config.schema or "public"
96
+ conn = self._connect(config)
97
+ try:
98
+ cur = conn.cursor()
99
+ cur.execute(
100
+ """
101
+ SELECT t.table_name, t.table_type,
102
+ (
103
+ SELECT COUNT(*)
104
+ FROM information_schema.columns c
105
+ WHERE c.table_schema = t.table_schema
106
+ AND c.table_name = t.table_name
107
+ ) AS col_count
108
+ FROM information_schema.tables t
109
+ WHERE t.table_schema = %s
110
+ AND t.table_type IN ('BASE TABLE', 'VIEW')
111
+ ORDER BY t.table_name
112
+ """,
113
+ (schema,),
114
+ )
115
+ results = []
116
+ for row in cur.fetchall():
117
+ ttype = "view" if "VIEW" in str(row[1]).upper() else "table"
118
+ results.append({
119
+ "name": row[0],
120
+ "type": ttype,
121
+ "column_count": int(row[2] or 0),
122
+ "row_count": None,
123
+ })
124
+ return results
125
+ finally:
126
+ conn.close()
127
+
128
+ def pull_schema(self, config: ConnectorConfig) -> ConnectorResult:
129
+ conn = self._connect(config)
130
+ try:
131
+ return self._pull(conn, config)
132
+ finally:
133
+ conn.close()
134
+
135
+ def _pull(self, conn: Any, config: ConnectorConfig) -> ConnectorResult:
136
+ model = self._build_model(config)
137
+ schema_filter = config.schema or "public"
138
+ cur = conn.cursor()
139
+ warnings: List[str] = []
140
+
141
+ cur.execute(
142
+ """
143
+ SELECT table_name, table_type
144
+ FROM information_schema.tables
145
+ WHERE table_schema = %s
146
+ AND table_type IN ('BASE TABLE', 'VIEW')
147
+ ORDER BY table_name
148
+ """,
149
+ (schema_filter,),
150
+ )
151
+ tables = cur.fetchall()
152
+
153
+ table_entities: Dict[str, Dict[str, Any]] = {}
154
+ for table_name, table_type in tables:
155
+ if not self._should_include_table(table_name, config):
156
+ continue
157
+ entity_name = self._entity_name(table_name)
158
+ entity_type = "view" if str(table_type).upper() == "VIEW" else "table"
159
+ table_entities[table_name] = {
160
+ "name": entity_name,
161
+ "physical_name": table_name,
162
+ "type": entity_type,
163
+ "description": f"Pulled from Redshift {config.database}.{schema_filter}.{table_name} on {date.today().isoformat()}",
164
+ "fields": [],
165
+ }
166
+ if schema_filter != "public":
167
+ table_entities[table_name]["schema"] = schema_filter
168
+
169
+ cur.execute(
170
+ """
171
+ SELECT table_name, column_name, data_type, is_nullable,
172
+ column_default, character_maximum_length,
173
+ numeric_precision, numeric_scale
174
+ FROM information_schema.columns
175
+ WHERE table_schema = %s
176
+ ORDER BY table_name, ordinal_position
177
+ """,
178
+ (schema_filter,),
179
+ )
180
+ total_columns = 0
181
+
182
+ for row in cur.fetchall():
183
+ tname, col_name, data_type, is_nullable, col_default, char_max_len, num_prec, num_scale = row
184
+ if tname not in table_entities:
185
+ continue
186
+
187
+ dl_type = _RS_TYPE_MAP.get((data_type or "").lower(), "string")
188
+ if str(data_type).lower() in ("decimal", "numeric") and num_prec:
189
+ dl_type = f"decimal({int(num_prec)},{int(num_scale or 0)})"
190
+ if str(data_type).lower() in ("character varying", "varchar") and char_max_len:
191
+ try:
192
+ dl_type = f"varchar({int(char_max_len)})"
193
+ except Exception:
194
+ dl_type = "string"
195
+
196
+ field: Dict[str, Any] = {
197
+ "name": col_name,
198
+ "type": dl_type,
199
+ "nullable": str(is_nullable).upper() == "YES",
200
+ }
201
+ if col_default is not None:
202
+ cleaned = str(col_default).split("::")[0].strip("'")
203
+ if cleaned:
204
+ field["default"] = cleaned
205
+
206
+ table_entities[tname]["fields"].append(field)
207
+ total_columns += 1
208
+
209
+ cur.execute(
210
+ """
211
+ SELECT tc.table_name, kcu.column_name
212
+ FROM information_schema.table_constraints tc
213
+ JOIN information_schema.key_column_usage kcu
214
+ ON tc.constraint_name = kcu.constraint_name
215
+ AND tc.table_schema = kcu.table_schema
216
+ WHERE tc.constraint_type = 'PRIMARY KEY'
217
+ AND tc.table_schema = %s
218
+ """,
219
+ (schema_filter,),
220
+ )
221
+ for tname, col_name in cur.fetchall():
222
+ if tname in table_entities:
223
+ for f in table_entities[tname]["fields"]:
224
+ if f["name"] == col_name:
225
+ f["primary_key"] = True
226
+ f["nullable"] = False
227
+
228
+ cur.execute(
229
+ """
230
+ SELECT
231
+ kcu.table_name AS child_table,
232
+ kcu.column_name AS child_column,
233
+ ccu.table_name AS parent_table,
234
+ ccu.column_name AS parent_column,
235
+ tc.constraint_name
236
+ FROM information_schema.table_constraints tc
237
+ JOIN information_schema.key_column_usage kcu
238
+ ON tc.constraint_name = kcu.constraint_name
239
+ AND tc.table_schema = kcu.table_schema
240
+ JOIN information_schema.constraint_column_usage ccu
241
+ ON tc.constraint_name = ccu.constraint_name
242
+ AND tc.table_schema = ccu.table_schema
243
+ WHERE tc.constraint_type = 'FOREIGN KEY'
244
+ AND tc.table_schema = %s
245
+ """,
246
+ (schema_filter,),
247
+ )
248
+ fk_rows = cur.fetchall()
249
+ relationships: List[Dict[str, Any]] = []
250
+ for child_table, child_col, parent_table, parent_col, constraint_name in fk_rows:
251
+ if child_table in table_entities:
252
+ for f in table_entities[child_table]["fields"]:
253
+ if f["name"] == child_col:
254
+ f["foreign_key"] = True
255
+ parent_entity = self._entity_name(parent_table)
256
+ child_entity = self._entity_name(child_table)
257
+ relationships.append(
258
+ {
259
+ "name": constraint_name or f"{parent_entity.lower()}_{child_entity.lower()}_{child_col}_fk",
260
+ "from": f"{parent_entity}.{parent_col}",
261
+ "to": f"{child_entity}.{child_col}",
262
+ "cardinality": "one_to_many",
263
+ }
264
+ )
265
+
266
+ entities_list = list(table_entities.values())
267
+
268
+ has_any_pk = any(
269
+ f.get("primary_key") for ent in entities_list for f in ent.get("fields", [])
270
+ )
271
+ if not has_any_pk:
272
+ entities_list, pk_msgs = infer_primary_keys(entities_list)
273
+ warnings.extend(pk_msgs)
274
+
275
+ if not relationships:
276
+ inferred_rels, fk_msgs = infer_relationships(entities_list, relationships)
277
+ relationships.extend(inferred_rels)
278
+ warnings.extend(fk_msgs)
279
+ if inferred_rels:
280
+ warnings.insert(
281
+ 0,
282
+ f"No FK constraints found — inferred {len(inferred_rels)} relationships from column naming patterns.",
283
+ )
284
+
285
+ model["entities"] = entities_list
286
+ model["relationships"] = relationships
287
+ model["indexes"] = []
288
+
289
+ cur.close()
290
+
291
+ return ConnectorResult(
292
+ model=model,
293
+ tables_found=len(table_entities),
294
+ columns_found=total_columns,
295
+ relationships_found=len(relationships),
296
+ indexes_found=0,
297
+ warnings=warnings,
298
+ )