schemaingest 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- schemaingest-0.1.0/MANIFEST.in +2 -0
- schemaingest-0.1.0/PKG-INFO +98 -0
- schemaingest-0.1.0/README.md +60 -0
- schemaingest-0.1.0/pyproject.toml +64 -0
- schemaingest-0.1.0/schemaingest/__init__.py +3 -0
- schemaingest-0.1.0/schemaingest/__main__.py +6 -0
- schemaingest-0.1.0/schemaingest/cli.py +99 -0
- schemaingest-0.1.0/schemaingest/endpoints.py +77 -0
- schemaingest-0.1.0/schemaingest/introspect/__init__.py +19 -0
- schemaingest-0.1.0/schemaingest/introspect/common.py +94 -0
- schemaingest-0.1.0/schemaingest/introspect/mysql.py +254 -0
- schemaingest-0.1.0/schemaingest/introspect/postgres.py +209 -0
- schemaingest-0.1.0/schemaingest/models.py +190 -0
- schemaingest-0.1.0/schemaingest/renderers.py +106 -0
- schemaingest-0.1.0/schemaingest/security.py +109 -0
- schemaingest-0.1.0/schemaingest/server.py +63 -0
- schemaingest-0.1.0/schemaingest.egg-info/PKG-INFO +98 -0
- schemaingest-0.1.0/schemaingest.egg-info/SOURCES.txt +29 -0
- schemaingest-0.1.0/schemaingest.egg-info/dependency_links.txt +1 -0
- schemaingest-0.1.0/schemaingest.egg-info/entry_points.txt +2 -0
- schemaingest-0.1.0/schemaingest.egg-info/requires.txt +11 -0
- schemaingest-0.1.0/schemaingest.egg-info/top_level.txt +1 -0
- schemaingest-0.1.0/setup.cfg +4 -0
- schemaingest-0.1.0/tests/conftest.py +68 -0
- schemaingest-0.1.0/tests/test_api.py +116 -0
- schemaingest-0.1.0/tests/test_introspect_mysql.py +200 -0
- schemaingest-0.1.0/tests/test_introspect_pg.py +131 -0
- schemaingest-0.1.0/tests/test_models.py +102 -0
- schemaingest-0.1.0/tests/test_renderers.py +38 -0
- schemaingest-0.1.0/tests/test_security.py +58 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jineth Bosilu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: schemaingest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Introspect your PostgreSQL or MySQL/MariaDB schema through a web UI, without credentials leaving your machine.
|
|
5
|
+
Author: Jineth Bosilu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/JinethBosilu/SchemaIngest
|
|
8
|
+
Project-URL: Web UI, https://jinethbosilu.github.io/SchemaIngest/
|
|
9
|
+
Project-URL: Issues, https://github.com/JinethBosilu/SchemaIngest/issues
|
|
10
|
+
Keywords: postgres,postgresql,mysql,mariadb,schema,introspection,erd,database,llm
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Database
|
|
23
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: fastapi>=0.104.0
|
|
28
|
+
Requires-Dist: uvicorn[standard]>=0.24.0
|
|
29
|
+
Requires-Dist: psycopg2-binary>=2.9.9
|
|
30
|
+
Requires-Dist: PyMySQL[rsa]>=1.1
|
|
31
|
+
Requires-Dist: pydantic>=2.5.0
|
|
32
|
+
Requires-Dist: click>=8.1.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
35
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
36
|
+
Requires-Dist: httpx2>=2.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# SchemaIngest
|
|
40
|
+
|
|
41
|
+
**A "gitingest"-style tool for databases.** Browse your PostgreSQL, MySQL or MariaDB schema in a web UI (tables,
|
|
42
|
+
columns, keys, indexes and an interactive relationship diagram), and copy a compact,
|
|
43
|
+
token-efficient description of it for an AI. Your credentials never leave your machine.
|
|
44
|
+
|
|
45
|
+
This package is the **local agent**. It runs on `127.0.0.1`, connects to your database
|
|
46
|
+
read-only, and serves the schema to the web UI at
|
|
47
|
+
<https://jinethbosilu.github.io/SchemaIngest/>.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx install schemaingest # or: pip install schemaingest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.10+.
|
|
56
|
+
|
|
57
|
+
## Use it with the web UI
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
schemaingest agent
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The terminal shows a 6-digit **pairing code**. Open
|
|
64
|
+
<https://jinethbosilu.github.io/SchemaIngest/>, enter the code, then pick your database engine
|
|
65
|
+
and enter its connection details. They go only to the agent on `localhost`.
|
|
66
|
+
|
|
67
|
+
Chrome may ask whether the page can access devices on your local network. Allow it: that
|
|
68
|
+
is the browser checking before a public site talks to the agent on `127.0.0.1`.
|
|
69
|
+
|
|
70
|
+
## Use it without the web UI
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Full schema pack as JSON
|
|
74
|
+
schemaingest pull --conn "postgresql://user:pass@localhost:5432/mydb" --out schema.json
|
|
75
|
+
|
|
76
|
+
# Compact text, ready to paste into an AI
|
|
77
|
+
schemaingest pull --conn "postgresql://user:pass@localhost:5432/mydb" --out schema.txt --format txt
|
|
78
|
+
|
|
79
|
+
# MySQL or MariaDB: the scheme picks the engine
|
|
80
|
+
schemaingest pull --conn "mysql://user:pass@localhost:3306/mydb" --out schema.txt --format txt
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For PostgreSQL, `--schema` picks a schema other than `public`. In MySQL a database is a
|
|
84
|
+
schema, so the database in the connection string is the one introspected. MySQL
|
|
85
|
+
connection strings accept `?ssl-mode=` with MySQL's values (`DISABLED`, `PREFERRED`,
|
|
86
|
+
`REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY`).
|
|
87
|
+
|
|
88
|
+
## Security
|
|
89
|
+
|
|
90
|
+
- Binds to `127.0.0.1` only.
|
|
91
|
+
- CORS allows only the GitHub Pages UI and local development origins.
|
|
92
|
+
- A pairing code is required before any session is issued; sessions expire after 4 hours.
|
|
93
|
+
- Connections are opened read-only.
|
|
94
|
+
- Passwords are redacted from every printed error, and request bodies are never logged.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SchemaIngest
|
|
2
|
+
|
|
3
|
+
**A "gitingest"-style tool for databases.** Browse your PostgreSQL, MySQL or MariaDB schema in a web UI (tables,
|
|
4
|
+
columns, keys, indexes and an interactive relationship diagram), and copy a compact,
|
|
5
|
+
token-efficient description of it for an AI. Your credentials never leave your machine.
|
|
6
|
+
|
|
7
|
+
This package is the **local agent**. It runs on `127.0.0.1`, connects to your database
|
|
8
|
+
read-only, and serves the schema to the web UI at
|
|
9
|
+
<https://jinethbosilu.github.io/SchemaIngest/>.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install schemaingest # or: pip install schemaingest
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+.
|
|
18
|
+
|
|
19
|
+
## Use it with the web UI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
schemaingest agent
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The terminal shows a 6-digit **pairing code**. Open
|
|
26
|
+
<https://jinethbosilu.github.io/SchemaIngest/>, enter the code, then pick your database engine
|
|
27
|
+
and enter its connection details. They go only to the agent on `localhost`.
|
|
28
|
+
|
|
29
|
+
Chrome may ask whether the page can access devices on your local network. Allow it: that
|
|
30
|
+
is the browser checking before a public site talks to the agent on `127.0.0.1`.
|
|
31
|
+
|
|
32
|
+
## Use it without the web UI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Full schema pack as JSON
|
|
36
|
+
schemaingest pull --conn "postgresql://user:pass@localhost:5432/mydb" --out schema.json
|
|
37
|
+
|
|
38
|
+
# Compact text, ready to paste into an AI
|
|
39
|
+
schemaingest pull --conn "postgresql://user:pass@localhost:5432/mydb" --out schema.txt --format txt
|
|
40
|
+
|
|
41
|
+
# MySQL or MariaDB: the scheme picks the engine
|
|
42
|
+
schemaingest pull --conn "mysql://user:pass@localhost:3306/mydb" --out schema.txt --format txt
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For PostgreSQL, `--schema` picks a schema other than `public`. In MySQL a database is a
|
|
46
|
+
schema, so the database in the connection string is the one introspected. MySQL
|
|
47
|
+
connection strings accept `?ssl-mode=` with MySQL's values (`DISABLED`, `PREFERRED`,
|
|
48
|
+
`REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY`).
|
|
49
|
+
|
|
50
|
+
## Security
|
|
51
|
+
|
|
52
|
+
- Binds to `127.0.0.1` only.
|
|
53
|
+
- CORS allows only the GitHub Pages UI and local development origins.
|
|
54
|
+
- A pairing code is required before any session is issued; sessions expire after 4 hours.
|
|
55
|
+
- Connections are opened read-only.
|
|
56
|
+
- Passwords are redacted from every printed error, and request bodies are never logged.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "schemaingest"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Introspect your PostgreSQL or MySQL/MariaDB schema through a web UI, without credentials leaving your machine."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Jineth Bosilu"}
|
|
15
|
+
]
|
|
16
|
+
keywords = ["postgres", "postgresql", "mysql", "mariadb", "schema", "introspection", "erd", "database", "llm"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Database",
|
|
30
|
+
"Topic :: Software Development :: Documentation",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"fastapi>=0.104.0",
|
|
34
|
+
"uvicorn[standard]>=0.24.0",
|
|
35
|
+
"psycopg2-binary>=2.9.9",
|
|
36
|
+
"PyMySQL[rsa]>=1.1", # rsa: MySQL 8's default caching_sha2_password login
|
|
37
|
+
"pydantic>=2.5.0",
|
|
38
|
+
"click>=8.1.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
"httpx>=0.27", # test client for older Starlette
|
|
45
|
+
"httpx2>=2.0", # test client for Starlette 1.x
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://github.com/JinethBosilu/SchemaIngest"
|
|
50
|
+
"Web UI" = "https://jinethbosilu.github.io/SchemaIngest/"
|
|
51
|
+
Issues = "https://github.com/JinethBosilu/SchemaIngest/issues"
|
|
52
|
+
|
|
53
|
+
[project.scripts]
|
|
54
|
+
schemaingest = "schemaingest.cli:main"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.dynamic]
|
|
57
|
+
version = {attr = "schemaingest.__version__"}
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["."]
|
|
61
|
+
include = ["schemaingest*"]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
)
|