schemaingest 0.1.0__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.
- schemaingest/__init__.py +3 -0
- schemaingest/__main__.py +6 -0
- schemaingest/cli.py +99 -0
- schemaingest/endpoints.py +77 -0
- schemaingest/introspect/__init__.py +19 -0
- schemaingest/introspect/common.py +94 -0
- schemaingest/introspect/mysql.py +254 -0
- schemaingest/introspect/postgres.py +209 -0
- schemaingest/models.py +190 -0
- schemaingest/renderers.py +106 -0
- schemaingest/security.py +109 -0
- schemaingest/server.py +63 -0
- schemaingest-0.1.0.dist-info/METADATA +98 -0
- schemaingest-0.1.0.dist-info/RECORD +18 -0
- schemaingest-0.1.0.dist-info/WHEEL +5 -0
- schemaingest-0.1.0.dist-info/entry_points.txt +2 -0
- schemaingest-0.1.0.dist-info/licenses/LICENSE +21 -0
- schemaingest-0.1.0.dist-info/top_level.txt +1 -0
schemaingest/__init__.py
ADDED
schemaingest/__main__.py
ADDED
schemaingest/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CLI entry point for SchemaIngest agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from schemaingest import __version__
|
|
11
|
+
from schemaingest.security import generate_pairing_code, redact_password, set_pairing_code
|
|
12
|
+
|
|
13
|
+
WEB_UI_URL = "https://jinethbosilu.github.io/SchemaIngest/"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _banner(lines: list[str]) -> str:
|
|
17
|
+
"""A box sized to its contents. Plain ASCII, so it lines up on any console
|
|
18
|
+
(Windows code pages included) - emoji are double-width and would not."""
|
|
19
|
+
width = max(len(line) for line in lines) + 4
|
|
20
|
+
rule = "+" + "-" * width + "+"
|
|
21
|
+
body = [f"| {line.ljust(width - 4)} |" for line in lines]
|
|
22
|
+
return "\n".join([rule, *body, rule])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(__version__, prog_name="schemaingest")
|
|
27
|
+
def main():
|
|
28
|
+
"""SchemaIngest — local database schema introspection agent."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@main.command()
|
|
32
|
+
@click.option("--port", default=8420, show_default=True, help="Port to listen on (127.0.0.1 only).")
|
|
33
|
+
def agent(port: int):
|
|
34
|
+
"""Start the SchemaIngest agent server."""
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
from schemaingest.server import create_app
|
|
38
|
+
|
|
39
|
+
# The agent only ever binds to loopback: it holds database credentials.
|
|
40
|
+
host = "127.0.0.1"
|
|
41
|
+
code = generate_pairing_code()
|
|
42
|
+
set_pairing_code(code)
|
|
43
|
+
|
|
44
|
+
click.echo("")
|
|
45
|
+
click.echo(_banner([
|
|
46
|
+
f"SchemaIngest Agent v{__version__}",
|
|
47
|
+
"",
|
|
48
|
+
f"Pairing code: {code}",
|
|
49
|
+
"Enter this code in the web UI to connect.",
|
|
50
|
+
"",
|
|
51
|
+
f"Agent: http://{host}:{port}",
|
|
52
|
+
f"Web UI: {WEB_UI_URL}",
|
|
53
|
+
]))
|
|
54
|
+
click.echo("")
|
|
55
|
+
|
|
56
|
+
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@main.command()
|
|
60
|
+
@click.option(
|
|
61
|
+
"--conn", required=True,
|
|
62
|
+
help="Connection string: postgresql://user:pass@host/db or mysql://user:pass@host/db.",
|
|
63
|
+
)
|
|
64
|
+
@click.option("--out", required=True, type=click.Path(dir_okay=False), help="Output file path.")
|
|
65
|
+
@click.option(
|
|
66
|
+
"--schema", default=None,
|
|
67
|
+
help="Schema to introspect. PostgreSQL: defaults to public. MySQL: the database in --conn.",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--format", "fmt",
|
|
71
|
+
type=click.Choice(["json", "txt"]), default="json", show_default=True,
|
|
72
|
+
help="json: the full schema pack. txt: the compact text for pasting into an AI.",
|
|
73
|
+
)
|
|
74
|
+
def pull(conn: str, out: str, schema: str | None, fmt: str):
|
|
75
|
+
"""Export the schema without the web UI."""
|
|
76
|
+
from schemaingest.introspect import introspect
|
|
77
|
+
from schemaingest.models import ConnectRequest
|
|
78
|
+
from schemaingest.renderers import render_schema_txt
|
|
79
|
+
|
|
80
|
+
click.echo(f"Connecting to: {redact_password(conn)}")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
pack = introspect(ConnectRequest(connectionString=conn, schema=schema))
|
|
84
|
+
except Exception as e:
|
|
85
|
+
click.echo(f"Connection failed: {redact_password(str(e))}", err=True)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
89
|
+
if fmt == "txt":
|
|
90
|
+
f.write(render_schema_txt(pack))
|
|
91
|
+
else:
|
|
92
|
+
json.dump(pack.model_dump(by_alias=True), f, indent=2)
|
|
93
|
+
|
|
94
|
+
click.echo(f"Schema written to {out}")
|
|
95
|
+
click.echo(f" Tables: {len(pack.tables)}, Relationships: {len(pack.relationships)}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""FastAPI route handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from schemaingest.introspect import introspect
|
|
9
|
+
from schemaingest.models import ConnectRequest, PairRequest, SchemaPack
|
|
10
|
+
from schemaingest.renderers import render_erd_mermaid, render_schema_txt
|
|
11
|
+
from schemaingest.security import (
|
|
12
|
+
rate_limit_dep,
|
|
13
|
+
redact_password,
|
|
14
|
+
require_session,
|
|
15
|
+
verify_pairing_code,
|
|
16
|
+
)
|
|
17
|
+
from schemaingest import __version__
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Handlers are plain `def`: the database drivers block, so FastAPI runs them in its
|
|
23
|
+
# threadpool instead of on the event loop.
|
|
24
|
+
|
|
25
|
+
# ─── Health ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
@router.get("/health")
|
|
28
|
+
def health():
|
|
29
|
+
return {"status": "ok", "version": __version__}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─── Pairing ──────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@router.post("/pair/start", dependencies=[Depends(rate_limit_dep)])
|
|
35
|
+
def pair_start(body: PairRequest):
|
|
36
|
+
token = verify_pairing_code(body.code)
|
|
37
|
+
if token is None:
|
|
38
|
+
return JSONResponse(status_code=403, content={"detail": "Invalid pairing code"})
|
|
39
|
+
return {"sessionToken": token}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── Introspection ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def _do_introspect(req: ConnectRequest) -> SchemaPack:
|
|
45
|
+
"""Shared introspection helper."""
|
|
46
|
+
try:
|
|
47
|
+
return introspect(req)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
# Sanitise the error message to avoid leaking credentials
|
|
50
|
+
raise ValueError(redact_password(str(e))) from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post("/introspect", dependencies=[Depends(rate_limit_dep)])
|
|
54
|
+
def introspect_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
|
|
55
|
+
try:
|
|
56
|
+
pack = _do_introspect(body)
|
|
57
|
+
except ValueError as e:
|
|
58
|
+
return JSONResponse(status_code=400, content={"detail": str(e)})
|
|
59
|
+
return pack.model_dump(by_alias=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/render/schema.txt", dependencies=[Depends(rate_limit_dep)])
|
|
63
|
+
def render_schema_txt_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
|
|
64
|
+
try:
|
|
65
|
+
pack = _do_introspect(body)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
return JSONResponse(status_code=400, content={"detail": str(e)})
|
|
68
|
+
return {"text": render_schema_txt(pack)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.post("/render/erd.mmd", dependencies=[Depends(rate_limit_dep)])
|
|
72
|
+
def render_erd_mmd_endpoint(body: ConnectRequest, _token: str = Depends(require_session)):
|
|
73
|
+
try:
|
|
74
|
+
pack = _do_introspect(body)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
return JSONResponse(status_code=400, content={"detail": str(e)})
|
|
77
|
+
return {"mermaid": render_erd_mermaid(pack)}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Schema introspection, one module per database engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from schemaingest.models import ConnectRequest, SchemaPack
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def introspect(req: ConnectRequest) -> SchemaPack:
|
|
9
|
+
"""Introspect the database a connect request names, whichever engine it is."""
|
|
10
|
+
# Drivers load on demand, so one engine's driver failing to import does
|
|
11
|
+
# not take the other engine down with it.
|
|
12
|
+
if req.resolved_engine() == "mysql":
|
|
13
|
+
from schemaingest.introspect.mysql import introspect_mysql
|
|
14
|
+
|
|
15
|
+
return introspect_mysql(req.to_mysql_params(), schema=req.resolved_schema())
|
|
16
|
+
|
|
17
|
+
from schemaingest.introspect.postgres import introspect_postgres
|
|
18
|
+
|
|
19
|
+
return introspect_postgres(req.to_dsn(), schema=req.resolved_schema())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Pack assembly shared by every engine.
|
|
2
|
+
|
|
3
|
+
Each engine reads its own catalog and hands over plain rows; turning those
|
|
4
|
+
rows into columns, keys and relationships happens here, once."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Iterable, Optional, TypedDict
|
|
10
|
+
|
|
11
|
+
from schemaingest import __version__
|
|
12
|
+
from schemaingest.models import (
|
|
13
|
+
ColumnInfo,
|
|
14
|
+
ConstraintInfo,
|
|
15
|
+
DbMeta,
|
|
16
|
+
FkRef,
|
|
17
|
+
IndexInfo,
|
|
18
|
+
Relationship,
|
|
19
|
+
TableInfo,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RawColumn(TypedDict):
|
|
24
|
+
name: str
|
|
25
|
+
type: str
|
|
26
|
+
nullable: bool
|
|
27
|
+
default: Optional[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RawForeignKey(TypedDict):
|
|
31
|
+
"""One column pair of a foreign key; a composite key is several rows
|
|
32
|
+
sharing constraint_name, in key order."""
|
|
33
|
+
from_column: str
|
|
34
|
+
to_table: str
|
|
35
|
+
to_column: str
|
|
36
|
+
constraint_name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_table(
|
|
40
|
+
tname: str,
|
|
41
|
+
schema: str,
|
|
42
|
+
columns: Iterable[RawColumn],
|
|
43
|
+
pk_cols: list[str],
|
|
44
|
+
fks: Iterable[RawForeignKey],
|
|
45
|
+
constraints: list[ConstraintInfo],
|
|
46
|
+
indexes: list[IndexInfo],
|
|
47
|
+
) -> tuple[TableInfo, list[Relationship]]:
|
|
48
|
+
relationships: list[Relationship] = []
|
|
49
|
+
fk_map: dict[str, FkRef] = {}
|
|
50
|
+
for fk in fks:
|
|
51
|
+
# A column in two foreign keys keeps its first target for fkRef; every
|
|
52
|
+
# pairing is still listed in relationships.
|
|
53
|
+
fk_map.setdefault(fk["from_column"], FkRef(table=fk["to_table"], column=fk["to_column"]))
|
|
54
|
+
relationships.append(
|
|
55
|
+
Relationship(
|
|
56
|
+
fromTable=tname,
|
|
57
|
+
fromColumn=fk["from_column"],
|
|
58
|
+
toTable=fk["to_table"],
|
|
59
|
+
toColumn=fk["to_column"],
|
|
60
|
+
constraintName=fk["constraint_name"],
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
table = TableInfo(
|
|
65
|
+
name=tname,
|
|
66
|
+
schema=schema,
|
|
67
|
+
columns=[
|
|
68
|
+
ColumnInfo(
|
|
69
|
+
name=c["name"],
|
|
70
|
+
type=c["type"],
|
|
71
|
+
nullable=c["nullable"],
|
|
72
|
+
default=c["default"],
|
|
73
|
+
isPrimaryKey=c["name"] in pk_cols,
|
|
74
|
+
isForeignKey=c["name"] in fk_map,
|
|
75
|
+
fkRef=fk_map.get(c["name"]),
|
|
76
|
+
)
|
|
77
|
+
for c in columns
|
|
78
|
+
],
|
|
79
|
+
primaryKey=pk_cols,
|
|
80
|
+
indexes=indexes,
|
|
81
|
+
constraints=constraints,
|
|
82
|
+
)
|
|
83
|
+
return table, relationships
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_meta(engine: str, db_name: str, db_version: str, schema: str) -> DbMeta:
|
|
87
|
+
return DbMeta(
|
|
88
|
+
engine=engine,
|
|
89
|
+
dbName=db_name,
|
|
90
|
+
dbVersion=db_version,
|
|
91
|
+
schema=schema,
|
|
92
|
+
generatedAt=datetime.now(timezone.utc).isoformat(),
|
|
93
|
+
agentVersion=__version__,
|
|
94
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""MySQL and MariaDB introspection via information_schema.
|
|
2
|
+
|
|
3
|
+
information_schema is slow on MySQL - each view is materialised from the data
|
|
4
|
+
dictionary on every read - so every view is read once for the whole database
|
|
5
|
+
and grouped by table here, rather than queried table by table."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pymysql
|
|
13
|
+
import pymysql.cursors
|
|
14
|
+
|
|
15
|
+
from schemaingest.introspect.common import RawColumn, RawForeignKey, build_table, make_meta
|
|
16
|
+
from schemaingest.models import ConstraintInfo, IndexInfo, Relationship, SchemaPack, TableInfo
|
|
17
|
+
|
|
18
|
+
# ─── SQL queries ──────────────────────────────────────────────────────
|
|
19
|
+
# Every column is aliased: MySQL 8 returns information_schema names in
|
|
20
|
+
# upper case, MariaDB as written.
|
|
21
|
+
|
|
22
|
+
_TABLES_SQL = """
|
|
23
|
+
SELECT TABLE_NAME AS table_name
|
|
24
|
+
FROM information_schema.TABLES
|
|
25
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE'
|
|
26
|
+
ORDER BY TABLE_NAME;
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_COLUMNS_SQL = """
|
|
30
|
+
SELECT
|
|
31
|
+
TABLE_NAME AS table_name,
|
|
32
|
+
COLUMN_NAME AS column_name,
|
|
33
|
+
COLUMN_TYPE AS column_type,
|
|
34
|
+
IS_NULLABLE AS is_nullable,
|
|
35
|
+
COLUMN_DEFAULT AS column_default,
|
|
36
|
+
EXTRA AS extra
|
|
37
|
+
FROM information_schema.COLUMNS
|
|
38
|
+
WHERE TABLE_SCHEMA = %s
|
|
39
|
+
ORDER BY TABLE_NAME, ORDINAL_POSITION;
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Key columns of every PRIMARY KEY, UNIQUE and FOREIGN KEY constraint, in key
|
|
43
|
+
# order. A foreign key's rows pair up column by column, so a composite key
|
|
44
|
+
# comes out as one row per column pair.
|
|
45
|
+
_KEY_COLUMNS_SQL = """
|
|
46
|
+
SELECT
|
|
47
|
+
TABLE_NAME AS table_name,
|
|
48
|
+
CONSTRAINT_NAME AS constraint_name,
|
|
49
|
+
COLUMN_NAME AS column_name,
|
|
50
|
+
REFERENCED_TABLE_NAME AS to_table,
|
|
51
|
+
REFERENCED_COLUMN_NAME AS to_column
|
|
52
|
+
FROM information_schema.KEY_COLUMN_USAGE
|
|
53
|
+
WHERE TABLE_SCHEMA = %s
|
|
54
|
+
ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION;
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_TABLE_CONSTRAINTS_SQL = """
|
|
58
|
+
SELECT
|
|
59
|
+
TABLE_NAME AS table_name,
|
|
60
|
+
CONSTRAINT_NAME AS constraint_name,
|
|
61
|
+
CONSTRAINT_TYPE AS constraint_type
|
|
62
|
+
FROM information_schema.TABLE_CONSTRAINTS
|
|
63
|
+
WHERE TABLE_SCHEMA = %s AND CONSTRAINT_TYPE IN ('PRIMARY KEY', 'UNIQUE', 'FOREIGN KEY')
|
|
64
|
+
ORDER BY TABLE_NAME, CONSTRAINT_NAME;
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# MariaDB names a check per table and says which table in CHECK_CONSTRAINTS;
|
|
68
|
+
# MySQL names it per database, and the table comes from TABLE_CONSTRAINTS.
|
|
69
|
+
_CHECKS_MARIADB_SQL = """
|
|
70
|
+
SELECT
|
|
71
|
+
TABLE_NAME AS table_name,
|
|
72
|
+
CONSTRAINT_NAME AS constraint_name,
|
|
73
|
+
CHECK_CLAUSE AS definition
|
|
74
|
+
FROM information_schema.CHECK_CONSTRAINTS
|
|
75
|
+
WHERE CONSTRAINT_SCHEMA = %s
|
|
76
|
+
ORDER BY TABLE_NAME, CONSTRAINT_NAME;
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
_CHECKS_MYSQL_SQL = """
|
|
80
|
+
SELECT
|
|
81
|
+
tc.TABLE_NAME AS table_name,
|
|
82
|
+
tc.CONSTRAINT_NAME AS constraint_name,
|
|
83
|
+
cc.CHECK_CLAUSE AS definition
|
|
84
|
+
FROM information_schema.TABLE_CONSTRAINTS tc
|
|
85
|
+
JOIN information_schema.CHECK_CONSTRAINTS cc
|
|
86
|
+
ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
|
87
|
+
WHERE tc.TABLE_SCHEMA = %s AND tc.CONSTRAINT_TYPE = 'CHECK'
|
|
88
|
+
ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_NAME;
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
_INDEXES_SQL = """
|
|
92
|
+
SELECT
|
|
93
|
+
TABLE_NAME AS table_name,
|
|
94
|
+
INDEX_NAME AS index_name,
|
|
95
|
+
NON_UNIQUE AS non_unique,
|
|
96
|
+
COLUMN_NAME AS column_name,
|
|
97
|
+
SUB_PART AS sub_part,
|
|
98
|
+
{expression} AS expression
|
|
99
|
+
FROM information_schema.STATISTICS
|
|
100
|
+
WHERE TABLE_SCHEMA = %s
|
|
101
|
+
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
# Which optional information_schema columns this server has: CHECK_CONSTRAINTS
|
|
105
|
+
# arrived in MySQL 8.0.16 and MariaDB 10.2, STATISTICS.EXPRESSION (functional
|
|
106
|
+
# indexes) in MySQL 8.0.13, and MariaDB has none.
|
|
107
|
+
_FEATURES_SQL = """
|
|
108
|
+
SELECT UPPER(TABLE_NAME) AS table_name, UPPER(COLUMN_NAME) AS column_name
|
|
109
|
+
FROM information_schema.COLUMNS
|
|
110
|
+
WHERE TABLE_SCHEMA = 'information_schema'
|
|
111
|
+
AND UPPER(TABLE_NAME) IN ('CHECK_CONSTRAINTS', 'STATISTICS');
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─── Introspection logic ─────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _text(value: Any) -> Any:
|
|
118
|
+
"""MySQL 8 hands some information_schema columns back as bytes."""
|
|
119
|
+
return value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rows(cur, sql: str, schema: str) -> list[dict]:
|
|
123
|
+
cur.execute(sql, (schema,))
|
|
124
|
+
return [{k: _text(v) for k, v in row.items()} for row in cur.fetchall()]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _server_version(raw: str) -> tuple[str, bool]:
|
|
128
|
+
""""10.4.32-MariaDB-log" -> ("MariaDB 10.4.32", True); "8.4.2" -> ("MySQL 8.4.2", False)."""
|
|
129
|
+
is_mariadb = "mariadb" in raw.lower()
|
|
130
|
+
return f"{'MariaDB' if is_mariadb else 'MySQL'} {raw.split('-')[0]}", is_mariadb
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _column_default(row: dict, is_mariadb: bool) -> str | None:
|
|
134
|
+
if "auto_increment" in (row["extra"] or "").lower():
|
|
135
|
+
return "auto_increment"
|
|
136
|
+
default = row["column_default"]
|
|
137
|
+
# MariaDB reports "no default" on a nullable column as the text NULL, and
|
|
138
|
+
# quotes real string defaults, so the bare word is never a literal there.
|
|
139
|
+
if is_mariadb and default == "NULL":
|
|
140
|
+
return None
|
|
141
|
+
return None if default is None else str(default)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def introspect_mysql(params: dict[str, Any], schema: str) -> SchemaPack:
|
|
145
|
+
"""Connect to MySQL or MariaDB and introspect one database.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
params: Keyword arguments for pymysql.connect (see ConnectRequest.to_mysql_params).
|
|
149
|
+
schema: The database to introspect - in MySQL a database is a schema.
|
|
150
|
+
"""
|
|
151
|
+
conn = pymysql.connect(
|
|
152
|
+
**params,
|
|
153
|
+
charset="utf8mb4",
|
|
154
|
+
connect_timeout=10,
|
|
155
|
+
autocommit=True,
|
|
156
|
+
cursorclass=pymysql.cursors.DictCursor,
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
with conn.cursor() as cur:
|
|
160
|
+
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
|
161
|
+
cur.execute("SELECT VERSION() AS version")
|
|
162
|
+
db_version, is_mariadb = _server_version(_text(cur.fetchone()["version"]))
|
|
163
|
+
|
|
164
|
+
cur.execute(_FEATURES_SQL)
|
|
165
|
+
features = {(_text(r["table_name"]), _text(r["column_name"])) for r in cur.fetchall()}
|
|
166
|
+
has_checks = any(t == "CHECK_CONSTRAINTS" for t, _ in features)
|
|
167
|
+
has_expressions = ("STATISTICS", "EXPRESSION") in features
|
|
168
|
+
|
|
169
|
+
# Sorted here: ORDER BY follows the server's collation, which puts
|
|
170
|
+
# "orders" before "order_lines" on some servers and not others.
|
|
171
|
+
table_names = sorted(r["table_name"] for r in _rows(cur, _TABLES_SQL, schema))
|
|
172
|
+
columns = _rows(cur, _COLUMNS_SQL, schema)
|
|
173
|
+
key_columns = _rows(cur, _KEY_COLUMNS_SQL, schema)
|
|
174
|
+
table_constraints = _rows(cur, _TABLE_CONSTRAINTS_SQL, schema)
|
|
175
|
+
checks = []
|
|
176
|
+
if has_checks:
|
|
177
|
+
has_table_name = ("CHECK_CONSTRAINTS", "TABLE_NAME") in features
|
|
178
|
+
checks = _rows(cur, _CHECKS_MARIADB_SQL if has_table_name else _CHECKS_MYSQL_SQL, schema)
|
|
179
|
+
index_rows = _rows(
|
|
180
|
+
cur,
|
|
181
|
+
_INDEXES_SQL.format(expression="EXPRESSION" if has_expressions else "NULL"),
|
|
182
|
+
schema,
|
|
183
|
+
)
|
|
184
|
+
finally:
|
|
185
|
+
conn.close()
|
|
186
|
+
|
|
187
|
+
by_table: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
|
|
188
|
+
|
|
189
|
+
for c in columns:
|
|
190
|
+
by_table[c["table_name"]]["columns"].append(RawColumn(
|
|
191
|
+
name=c["column_name"],
|
|
192
|
+
type=c["column_type"],
|
|
193
|
+
nullable=c["is_nullable"] == "YES",
|
|
194
|
+
default=_column_default(c, is_mariadb),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
# Constraint columns, in key order, and the foreign keys among them.
|
|
198
|
+
constraint_cols: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
199
|
+
for k in key_columns:
|
|
200
|
+
constraint_cols[(k["table_name"], k["constraint_name"])].append(k["column_name"])
|
|
201
|
+
if k["to_table"] is not None:
|
|
202
|
+
by_table[k["table_name"]]["fks"].append(RawForeignKey(
|
|
203
|
+
from_column=k["column_name"],
|
|
204
|
+
to_table=k["to_table"],
|
|
205
|
+
to_column=k["to_column"],
|
|
206
|
+
constraint_name=k["constraint_name"],
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
constraints: list[tuple[str, ConstraintInfo]] = [
|
|
210
|
+
(tc["table_name"], ConstraintInfo(
|
|
211
|
+
name=tc["constraint_name"],
|
|
212
|
+
type=tc["constraint_type"],
|
|
213
|
+
columns=constraint_cols[(tc["table_name"], tc["constraint_name"])],
|
|
214
|
+
))
|
|
215
|
+
for tc in table_constraints
|
|
216
|
+
]
|
|
217
|
+
# MySQL does not say which columns a check reads.
|
|
218
|
+
constraints += [
|
|
219
|
+
(ck["table_name"], ConstraintInfo(
|
|
220
|
+
name=ck["constraint_name"], type="CHECK", columns=[], definition=ck["definition"],
|
|
221
|
+
))
|
|
222
|
+
for ck in checks
|
|
223
|
+
]
|
|
224
|
+
for tname, con in sorted(constraints, key=lambda tc: (tc[0], tc[1].name)):
|
|
225
|
+
by_table[tname]["constraints"].append(con)
|
|
226
|
+
|
|
227
|
+
indexes: dict[tuple[str, str], IndexInfo] = {}
|
|
228
|
+
for ix in index_rows:
|
|
229
|
+
key = (ix["table_name"], ix["index_name"])
|
|
230
|
+
if key not in indexes:
|
|
231
|
+
indexes[key] = IndexInfo(name=ix["index_name"], columns=[], isUnique=int(ix["non_unique"]) == 0)
|
|
232
|
+
by_table[ix["table_name"]]["indexes"].append(indexes[key])
|
|
233
|
+
if ix["column_name"] is None:
|
|
234
|
+
part = ix["expression"] or "?" # a functional index on a server we could not read
|
|
235
|
+
elif ix["sub_part"] is not None:
|
|
236
|
+
part = f"{ix['column_name']}({ix['sub_part']})" # a prefix index
|
|
237
|
+
else:
|
|
238
|
+
part = ix["column_name"]
|
|
239
|
+
indexes[key].columns.append(part)
|
|
240
|
+
if ix["index_name"] == "PRIMARY":
|
|
241
|
+
by_table[ix["table_name"]]["pk"].append(ix["column_name"])
|
|
242
|
+
|
|
243
|
+
tables: list[TableInfo] = []
|
|
244
|
+
all_relationships: list[Relationship] = []
|
|
245
|
+
for tname in table_names:
|
|
246
|
+
t = by_table[tname]
|
|
247
|
+
table, relationships = build_table(
|
|
248
|
+
tname, schema, t["columns"], t["pk"], t["fks"], t["constraints"], t["indexes"],
|
|
249
|
+
)
|
|
250
|
+
tables.append(table)
|
|
251
|
+
all_relationships.extend(relationships)
|
|
252
|
+
|
|
253
|
+
meta = make_meta("mysql", schema, db_version, schema)
|
|
254
|
+
return SchemaPack(meta=meta, tables=tables, relationships=all_relationships)
|