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.
- datalex_cli/__init__.py +1 -0
- datalex_cli/datalex_cli.py +658 -0
- datalex_cli/main.py +2925 -0
- datalex_cli-0.1.1.dist-info/METADATA +228 -0
- datalex_cli-0.1.1.dist-info/RECORD +64 -0
- datalex_cli-0.1.1.dist-info/WHEEL +5 -0
- datalex_cli-0.1.1.dist-info/entry_points.txt +2 -0
- datalex_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- datalex_cli-0.1.1.dist-info/top_level.txt +2 -0
- datalex_core/__init__.py +94 -0
- datalex_core/_schemas/datalex/common.schema.json +127 -0
- datalex_core/_schemas/datalex/domain.schema.json +24 -0
- datalex_core/_schemas/datalex/entity.schema.json +158 -0
- datalex_core/_schemas/datalex/model.schema.json +141 -0
- datalex_core/_schemas/datalex/policy.schema.json +70 -0
- datalex_core/_schemas/datalex/project.schema.json +82 -0
- datalex_core/_schemas/datalex/snippet.schema.json +24 -0
- datalex_core/_schemas/datalex/source.schema.json +104 -0
- datalex_core/_schemas/datalex/term.schema.json +30 -0
- datalex_core/canonical.py +166 -0
- datalex_core/completion.py +204 -0
- datalex_core/connectors/__init__.py +39 -0
- datalex_core/connectors/base.py +417 -0
- datalex_core/connectors/bigquery.py +229 -0
- datalex_core/connectors/databricks.py +262 -0
- datalex_core/connectors/mysql.py +266 -0
- datalex_core/connectors/postgres.py +309 -0
- datalex_core/connectors/redshift.py +298 -0
- datalex_core/connectors/snowflake.py +336 -0
- datalex_core/connectors/sqlserver.py +425 -0
- datalex_core/datalex/__init__.py +26 -0
- datalex_core/datalex/diff.py +188 -0
- datalex_core/datalex/errors.py +85 -0
- datalex_core/datalex/loader.py +512 -0
- datalex_core/datalex/migrate_layout.py +382 -0
- datalex_core/datalex/parse_cache.py +102 -0
- datalex_core/datalex/project.py +214 -0
- datalex_core/datalex/types.py +224 -0
- datalex_core/dbt/__init__.py +18 -0
- datalex_core/dbt/emit.py +344 -0
- datalex_core/dbt/manifest.py +329 -0
- datalex_core/dbt/profiles.py +185 -0
- datalex_core/dbt/sync.py +279 -0
- datalex_core/dbt/warehouse.py +215 -0
- datalex_core/dialects/__init__.py +15 -0
- datalex_core/dialects/_common.py +48 -0
- datalex_core/dialects/base.py +47 -0
- datalex_core/dialects/postgres.py +164 -0
- datalex_core/dialects/registry.py +36 -0
- datalex_core/dialects/snowflake.py +129 -0
- datalex_core/diffing.py +358 -0
- datalex_core/docs_generator.py +797 -0
- datalex_core/doctor.py +181 -0
- datalex_core/generators.py +478 -0
- datalex_core/importers.py +1176 -0
- datalex_core/issues.py +23 -0
- datalex_core/loader.py +21 -0
- datalex_core/migrate.py +316 -0
- datalex_core/modeling.py +679 -0
- datalex_core/packages.py +430 -0
- datalex_core/policy.py +1037 -0
- datalex_core/resolver.py +456 -0
- datalex_core/schema.py +54 -0
- 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
|
+
)
|