erdscope 0.1.0__tar.gz → 0.2.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.
- {erdscope-0.1.0/erdscope.egg-info → erdscope-0.2.0}/PKG-INFO +48 -17
- {erdscope-0.1.0 → erdscope-0.2.0}/README.md +43 -15
- {erdscope-0.1.0 → erdscope-0.2.0}/erd.py +185 -24
- {erdscope-0.1.0 → erdscope-0.2.0/erdscope.egg-info}/PKG-INFO +48 -17
- {erdscope-0.1.0 → erdscope-0.2.0}/erdscope.egg-info/requires.txt +4 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/pyproject.toml +4 -3
- {erdscope-0.1.0 → erdscope-0.2.0}/tests/test_erd.py +147 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/LICENSE +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/erdscope.egg-info/SOURCES.txt +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/erdscope.egg-info/dependency_links.txt +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/erdscope.egg-info/entry_points.txt +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/erdscope.egg-info/top_level.txt +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/setup.cfg +0 -0
- {erdscope-0.1.0 → erdscope-0.2.0}/tests/test_e2e.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: erdscope
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies
|
|
5
5
|
Author: tas6
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/orapli/erdscope
|
|
@@ -22,17 +22,30 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Provides-Extra: mysql
|
|
24
24
|
Requires-Dist: PyMySQL; extra == "mysql"
|
|
25
|
+
Provides-Extra: postgres
|
|
26
|
+
Requires-Dist: psycopg[binary]; extra == "postgres"
|
|
25
27
|
Provides-Extra: yaml
|
|
26
28
|
Requires-Dist: PyYAML; extra == "yaml"
|
|
27
29
|
Provides-Extra: all
|
|
28
30
|
Requires-Dist: PyMySQL; extra == "all"
|
|
31
|
+
Requires-Dist: psycopg[binary]; extra == "all"
|
|
29
32
|
Requires-Dist: PyYAML; extra == "all"
|
|
30
33
|
Dynamic: license-file
|
|
31
34
|
|
|
32
35
|
# erdscope
|
|
33
36
|
|
|
37
|
+
[](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/erdscope/)
|
|
39
|
+
|
|
34
40
|
Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
|
|
35
|
-
workbook** — from a live MySQL database, with a single-file,
|
|
41
|
+
workbook** — from a live MySQL or PostgreSQL database, with a single-file,
|
|
42
|
+
zero-dependency Python CLI.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install erdscope
|
|
46
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
47
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
48
|
+
```
|
|
36
49
|
|
|
37
50
|
The database is the source of truth (tables, columns, comments, indexes, real foreign
|
|
38
51
|
keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
|
|
@@ -52,17 +65,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
|
|
|
52
65
|
**[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
|
|
53
66
|
CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
|
|
54
67
|
|
|
55
|
-
##
|
|
68
|
+
## Install & usage
|
|
69
|
+
|
|
70
|
+
`pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
|
|
71
|
+
Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
|
|
72
|
+
and run it with any Python 3.9+:
|
|
56
73
|
|
|
57
74
|
```bash
|
|
58
|
-
|
|
75
|
+
curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
|
|
76
|
+
python3 erd.py ... # identical to the erdscope command below
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
81
|
+
|
|
82
|
+
# PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
|
|
83
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
59
84
|
|
|
60
85
|
# enrich with association semantics parsed from application code (optional)
|
|
61
|
-
|
|
86
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
62
87
|
--models /path/to/rails/app -o erd.html
|
|
63
88
|
|
|
64
89
|
# also write a table-definition workbook
|
|
65
|
-
|
|
90
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
66
91
|
--excel table_definitions.xlsx -o erd.html
|
|
67
92
|
```
|
|
68
93
|
|
|
@@ -70,7 +95,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
|
|
|
70
95
|
|
|
71
96
|
```bash
|
|
72
97
|
ssh -N -L 3307:db-host:3306 bastion &
|
|
73
|
-
|
|
98
|
+
erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
|
|
74
99
|
```
|
|
75
100
|
|
|
76
101
|
Use a read-only account, and leave the password out of the URL — if it's not in
|
|
@@ -99,8 +124,8 @@ itself is made.
|
|
|
99
124
|
Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
|
|
100
125
|
(or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
|
|
101
126
|
Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
|
|
102
|
-
connection is config-only as `host`/`port`/`user`/`database` — deliberately with
|
|
103
|
-
password field — and `relations` manually declares relations no FK, code, or inference
|
|
127
|
+
connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
|
|
128
|
+
no password field — and `relations` manually declares relations no FK, code, or inference
|
|
104
129
|
can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
|
|
105
130
|
list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
|
|
106
131
|
annotated sample based on the live demo's schema.
|
|
@@ -109,14 +134,17 @@ annotated sample based on the live demo's schema.
|
|
|
109
134
|
|
|
110
135
|
`erd.py` runs with **zero required dependencies** — everything below is optional, and
|
|
111
136
|
the tool degrades gracefully (falls back, or fails with a clear message) when a piece
|
|
112
|
-
is missing.
|
|
137
|
+
is missing. If you installed via pip, extras pull them in for you:
|
|
138
|
+
`pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
|
|
139
|
+
`'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
|
|
113
140
|
|
|
114
141
|
| Library | Used for | If not installed |
|
|
115
142
|
|---|---|---|
|
|
116
|
-
| [PyMySQL](https://pypi.org/project/PyMySQL/) |
|
|
143
|
+
| [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
|
|
144
|
+
| [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
|
|
117
145
|
| [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
|
|
118
146
|
|
|
119
|
-
Excel output (`--excel`) needs
|
|
147
|
+
Excel output (`--excel`) needs none of these — it's written directly via the stdlib
|
|
120
148
|
`zipfile`/XML, not a spreadsheet library.
|
|
121
149
|
|
|
122
150
|
Test-only, and only if you run that particular suite:
|
|
@@ -131,7 +159,8 @@ Test-only, and only if you run that particular suite:
|
|
|
131
159
|
Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
|
|
132
160
|
|
|
133
161
|
- **Database truth** — tables, columns (full SQL types, defaults, extras), table and
|
|
134
|
-
column comments, indexes, and real FK constraints, read from
|
|
162
|
+
column comments, indexes, and real FK constraints, read from the database catalog
|
|
163
|
+
(`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
|
|
135
164
|
- **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
|
|
136
165
|
declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
|
|
137
166
|
- **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
|
|
@@ -146,7 +175,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
|
|
|
146
175
|
- **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
|
|
147
176
|
logical name (e.g. `users(Customer accounts)`), with independent display modes for
|
|
148
177
|
the live view and exports
|
|
149
|
-
- **Extras** — dark mode, print stylesheet,
|
|
178
|
+
- **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
|
|
179
|
+
resizable/collapsible panes
|
|
150
180
|
|
|
151
181
|
## Tests
|
|
152
182
|
|
|
@@ -170,8 +200,9 @@ python3 -m unittest tests.test_e2e -v
|
|
|
170
200
|
## Extending
|
|
171
201
|
|
|
172
202
|
Everything downstream (UI, layouts, exports) consumes the intermediate representation
|
|
173
|
-
documented at the top of `erd.py`. Adding
|
|
174
|
-
`parse_postgres()`
|
|
203
|
+
documented at the top of `erd.py`. Adding another database engine means adding one
|
|
204
|
+
parser that produces that shape — `parse_postgres()` (which reuses the MySQL
|
|
205
|
+
adapter's IR builder wholesale) is the working example of the pattern.
|
|
175
206
|
|
|
176
207
|
## License
|
|
177
208
|
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
# erdscope
|
|
2
2
|
|
|
3
|
+
[](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/erdscope/)
|
|
5
|
+
|
|
3
6
|
Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
|
|
4
|
-
workbook** — from a live MySQL database, with a single-file,
|
|
7
|
+
workbook** — from a live MySQL or PostgreSQL database, with a single-file,
|
|
8
|
+
zero-dependency Python CLI.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install erdscope
|
|
12
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
13
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
14
|
+
```
|
|
5
15
|
|
|
6
16
|
The database is the source of truth (tables, columns, comments, indexes, real foreign
|
|
7
17
|
keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
|
|
@@ -21,17 +31,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
|
|
|
21
31
|
**[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
|
|
22
32
|
CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
|
|
23
33
|
|
|
24
|
-
##
|
|
34
|
+
## Install & usage
|
|
35
|
+
|
|
36
|
+
`pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
|
|
37
|
+
Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
|
|
38
|
+
and run it with any Python 3.9+:
|
|
25
39
|
|
|
26
40
|
```bash
|
|
27
|
-
|
|
41
|
+
curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
|
|
42
|
+
python3 erd.py ... # identical to the erdscope command below
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
47
|
+
|
|
48
|
+
# PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
|
|
49
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
28
50
|
|
|
29
51
|
# enrich with association semantics parsed from application code (optional)
|
|
30
|
-
|
|
52
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
31
53
|
--models /path/to/rails/app -o erd.html
|
|
32
54
|
|
|
33
55
|
# also write a table-definition workbook
|
|
34
|
-
|
|
56
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
35
57
|
--excel table_definitions.xlsx -o erd.html
|
|
36
58
|
```
|
|
37
59
|
|
|
@@ -39,7 +61,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
|
|
|
39
61
|
|
|
40
62
|
```bash
|
|
41
63
|
ssh -N -L 3307:db-host:3306 bastion &
|
|
42
|
-
|
|
64
|
+
erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
|
|
43
65
|
```
|
|
44
66
|
|
|
45
67
|
Use a read-only account, and leave the password out of the URL — if it's not in
|
|
@@ -68,8 +90,8 @@ itself is made.
|
|
|
68
90
|
Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
|
|
69
91
|
(or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
|
|
70
92
|
Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
|
|
71
|
-
connection is config-only as `host`/`port`/`user`/`database` — deliberately with
|
|
72
|
-
password field — and `relations` manually declares relations no FK, code, or inference
|
|
93
|
+
connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
|
|
94
|
+
no password field — and `relations` manually declares relations no FK, code, or inference
|
|
73
95
|
can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
|
|
74
96
|
list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
|
|
75
97
|
annotated sample based on the live demo's schema.
|
|
@@ -78,14 +100,17 @@ annotated sample based on the live demo's schema.
|
|
|
78
100
|
|
|
79
101
|
`erd.py` runs with **zero required dependencies** — everything below is optional, and
|
|
80
102
|
the tool degrades gracefully (falls back, or fails with a clear message) when a piece
|
|
81
|
-
is missing.
|
|
103
|
+
is missing. If you installed via pip, extras pull them in for you:
|
|
104
|
+
`pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
|
|
105
|
+
`'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
|
|
82
106
|
|
|
83
107
|
| Library | Used for | If not installed |
|
|
84
108
|
|---|---|---|
|
|
85
|
-
| [PyMySQL](https://pypi.org/project/PyMySQL/) |
|
|
109
|
+
| [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
|
|
110
|
+
| [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
|
|
86
111
|
| [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
|
|
87
112
|
|
|
88
|
-
Excel output (`--excel`) needs
|
|
113
|
+
Excel output (`--excel`) needs none of these — it's written directly via the stdlib
|
|
89
114
|
`zipfile`/XML, not a spreadsheet library.
|
|
90
115
|
|
|
91
116
|
Test-only, and only if you run that particular suite:
|
|
@@ -100,7 +125,8 @@ Test-only, and only if you run that particular suite:
|
|
|
100
125
|
Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
|
|
101
126
|
|
|
102
127
|
- **Database truth** — tables, columns (full SQL types, defaults, extras), table and
|
|
103
|
-
column comments, indexes, and real FK constraints, read from
|
|
128
|
+
column comments, indexes, and real FK constraints, read from the database catalog
|
|
129
|
+
(`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
|
|
104
130
|
- **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
|
|
105
131
|
declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
|
|
106
132
|
- **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
|
|
@@ -115,7 +141,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
|
|
|
115
141
|
- **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
|
|
116
142
|
logical name (e.g. `users(Customer accounts)`), with independent display modes for
|
|
117
143
|
the live view and exports
|
|
118
|
-
- **Extras** — dark mode, print stylesheet,
|
|
144
|
+
- **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
|
|
145
|
+
resizable/collapsible panes
|
|
119
146
|
|
|
120
147
|
## Tests
|
|
121
148
|
|
|
@@ -139,8 +166,9 @@ python3 -m unittest tests.test_e2e -v
|
|
|
139
166
|
## Extending
|
|
140
167
|
|
|
141
168
|
Everything downstream (UI, layouts, exports) consumes the intermediate representation
|
|
142
|
-
documented at the top of `erd.py`. Adding
|
|
143
|
-
`parse_postgres()`
|
|
169
|
+
documented at the top of `erd.py`. Adding another database engine means adding one
|
|
170
|
+
parser that produces that shape — `parse_postgres()` (which reuses the MySQL
|
|
171
|
+
adapter's IR builder wholesale) is the working example of the pattern.
|
|
144
172
|
|
|
145
173
|
## License
|
|
146
174
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
erdscope — interactive ER diagrams (and Excel table definitions) from a live database
|
|
4
4
|
Usage:
|
|
5
5
|
python3 erd.py mysql://readonly@host:3306/dbname [-o erd.html]
|
|
6
|
+
python3 erd.py postgres://readonly@host:5432/dbname[?schema=name]
|
|
6
7
|
[--models /path/to/app] [--excel defs.xlsx]
|
|
7
8
|
|
|
8
9
|
The database is the required source of truth; --models optionally overlays
|
|
@@ -105,11 +106,14 @@ def mysql_query_rows(url, sql):
|
|
|
105
106
|
for line in r.stdout.splitlines()]
|
|
106
107
|
|
|
107
108
|
_MYSQL_TSV_ESCAPES = {'0': '\0', 'b': '\b', 'n': '\n', 'r': '\r', 't': '\t', '\\': '\\'}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
# COPY TO STDOUT (the psql CLI path) escapes the same characters plus \f \v
|
|
110
|
+
_COPY_TSV_ESCAPES = {**_MYSQL_TSV_ESCAPES, 'f': '\f', 'v': '\v'}
|
|
111
|
+
|
|
112
|
+
def _unescape_tsv_field(s, escapes):
|
|
113
|
+
"""Undo tab-separated-output escaping (mysql --batch, or COPY TO STDOUT
|
|
114
|
+
text format — they share the same core contract). \\N alone means SQL
|
|
115
|
+
NULL (mapped to '' — same convention the driver paths already use for
|
|
116
|
+
None); elsewhere the entries of `escapes` decode to their literal
|
|
113
117
|
characters. A single left-to-right pass (not chained .replace() calls)
|
|
114
118
|
is required: independent replacements can misfire on adjacent escapes,
|
|
115
119
|
e.g. an escaped backslash immediately followed by an escaped tab."""
|
|
@@ -120,14 +124,20 @@ def _unescape_mysql_field(s):
|
|
|
120
124
|
out, i, n = [], 0, len(s)
|
|
121
125
|
while i < n:
|
|
122
126
|
c = s[i]
|
|
123
|
-
if c == '\\' and i + 1 < n and s[i + 1] in
|
|
124
|
-
out.append(
|
|
127
|
+
if c == '\\' and i + 1 < n and s[i + 1] in escapes:
|
|
128
|
+
out.append(escapes[s[i + 1]])
|
|
125
129
|
i += 2
|
|
126
130
|
else:
|
|
127
131
|
out.append(c)
|
|
128
132
|
i += 1
|
|
129
133
|
return ''.join(out)
|
|
130
134
|
|
|
135
|
+
def _unescape_mysql_field(s):
|
|
136
|
+
return _unescape_tsv_field(s, _MYSQL_TSV_ESCAPES)
|
|
137
|
+
|
|
138
|
+
def _unescape_copy_field(s):
|
|
139
|
+
return _unescape_tsv_field(s, _COPY_TSV_ESCAPES)
|
|
140
|
+
|
|
131
141
|
def mysql_ir(table_rows, col_rows, fk_rows, index_rows):
|
|
132
142
|
"""Build the IR from information_schema rows (pure; unit-testable).
|
|
133
143
|
|
|
@@ -221,6 +231,143 @@ def parse_mysql(url):
|
|
|
221
231
|
f"ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX")
|
|
222
232
|
return mysql_ir(table_rows, col_rows, fk_rows, index_rows)
|
|
223
233
|
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# PostgreSQL adapter (pg_catalog via psycopg/psycopg2 or the psql CLI)
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
def postgres_query_rows(url, sql):
|
|
238
|
+
"""Run a query and return rows as tuples of strings ('' for NULL).
|
|
239
|
+
Prefers psycopg (v3), then psycopg2, when installed; otherwise shells
|
|
240
|
+
out to the psql CLI — same dependency-free spirit as mysql_query_rows.
|
|
241
|
+
The CLI path wraps the query in COPY (...) TO STDOUT, whose text format
|
|
242
|
+
escapes tabs/newlines/backslashes and spells NULL as \\N — the same
|
|
243
|
+
framing contract mysql --batch provides, so free-text comments can't
|
|
244
|
+
corrupt the field/record structure."""
|
|
245
|
+
u = urlparse(url)
|
|
246
|
+
host, port, db = u.hostname or '127.0.0.1', u.port or 5432, u.path.lstrip('/')
|
|
247
|
+
password = u.password or os.environ.get('PGPASSWORD', '')
|
|
248
|
+
driver = None
|
|
249
|
+
try:
|
|
250
|
+
import psycopg as driver # psycopg 3
|
|
251
|
+
except ImportError:
|
|
252
|
+
try:
|
|
253
|
+
import psycopg2 as driver
|
|
254
|
+
except ImportError:
|
|
255
|
+
pass
|
|
256
|
+
if driver:
|
|
257
|
+
try:
|
|
258
|
+
conn = driver.connect(host=host, port=port, user=u.username,
|
|
259
|
+
password=password, dbname=db)
|
|
260
|
+
except driver.Error as e:
|
|
261
|
+
sys.exit(f'Error: could not connect to PostgreSQL at {host}:{port}: {e}')
|
|
262
|
+
try:
|
|
263
|
+
with conn.cursor() as cur:
|
|
264
|
+
cur.execute(sql)
|
|
265
|
+
return [tuple('' if v is None else str(v) for v in r)
|
|
266
|
+
for r in cur.fetchall()]
|
|
267
|
+
except driver.Error as e:
|
|
268
|
+
sys.exit(f'Error: postgres query failed: {e}')
|
|
269
|
+
finally:
|
|
270
|
+
conn.close()
|
|
271
|
+
cmd = ['psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', host, '-p', str(port)]
|
|
272
|
+
if u.username:
|
|
273
|
+
cmd += ['-U', u.username]
|
|
274
|
+
cmd += ['-d', db, '-c', f'COPY ({sql}) TO STDOUT']
|
|
275
|
+
env = dict(os.environ)
|
|
276
|
+
if password:
|
|
277
|
+
env['PGPASSWORD'] = password # keep the password off the argv
|
|
278
|
+
try:
|
|
279
|
+
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
280
|
+
except FileNotFoundError:
|
|
281
|
+
sys.exit('Error: neither psycopg/psycopg2 nor the psql CLI is available '
|
|
282
|
+
'(pip install "psycopg[binary]", or install a PostgreSQL client)')
|
|
283
|
+
if r.returncode != 0:
|
|
284
|
+
sys.exit(f'Error: postgres query failed: {r.stderr.strip()}')
|
|
285
|
+
return [tuple(_unescape_copy_field(v) for v in line.split('\t'))
|
|
286
|
+
for line in r.stdout.splitlines()]
|
|
287
|
+
|
|
288
|
+
def parse_postgres(url):
|
|
289
|
+
"""Read the schema from a live PostgreSQL database and build the IR.
|
|
290
|
+
|
|
291
|
+
Shapes pg_catalog query results into the exact information_schema row
|
|
292
|
+
layout mysql_ir() consumes, so the (engine-agnostic) IR builder — PK
|
|
293
|
+
detection, unique-index 1:1 promotion, index assembly — is shared, not
|
|
294
|
+
duplicated. Notes vs. MySQL:
|
|
295
|
+
- schema defaults to 'public'; override with ?schema=name in the URL
|
|
296
|
+
- partitioned parents (relkind 'p') are included, their partitions and
|
|
297
|
+
views are not — matching MySQL's BASE TABLE filter in spirit
|
|
298
|
+
- identity/serial columns land in `extra` (like auto_increment), and a
|
|
299
|
+
serial's noisy nextval(...) default is blanked for display parity
|
|
300
|
+
"""
|
|
301
|
+
u = urlparse(url)
|
|
302
|
+
db = u.path.lstrip('/')
|
|
303
|
+
if not re.fullmatch(r'\w+', db or ''):
|
|
304
|
+
sys.exit('Error: the postgres URL must include a database name, '
|
|
305
|
+
'e.g. postgres://readonly@127.0.0.1:5432/myapp_production')
|
|
306
|
+
schema = 'public'
|
|
307
|
+
for part in u.query.split('&'):
|
|
308
|
+
if part.startswith('schema='):
|
|
309
|
+
schema = part[len('schema='):]
|
|
310
|
+
if not re.fullmatch(r'\w+', schema):
|
|
311
|
+
sys.exit(f'Error: invalid schema name {schema!r} in the postgres URL')
|
|
312
|
+
# No password in the URL and none set via PGPASSWORD: prompt (interactive
|
|
313
|
+
# only, same as the MySQL path). An empty answer leaves PGPASSWORD unset
|
|
314
|
+
# so libpq's own ~/.pgpass lookup still applies.
|
|
315
|
+
if u.password is None and not os.environ.get('PGPASSWORD') and sys.stdin.isatty():
|
|
316
|
+
pw = getpass.getpass(
|
|
317
|
+
f"PostgreSQL password for {u.username or 'postgres'}@{u.hostname or '127.0.0.1'}: ")
|
|
318
|
+
if pw:
|
|
319
|
+
os.environ['PGPASSWORD'] = pw
|
|
320
|
+
base = (f"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
321
|
+
f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
|
|
322
|
+
f"AND NOT c.relispartition")
|
|
323
|
+
table_rows = postgres_query_rows(url,
|
|
324
|
+
f"SELECT c.relname, COALESCE(obj_description(c.oid, 'pg_class'), '') "
|
|
325
|
+
f"{base} ORDER BY c.relname")
|
|
326
|
+
col_rows = postgres_query_rows(url,
|
|
327
|
+
f"SELECT c.relname, a.attname, "
|
|
328
|
+
f"format_type(a.atttypid, NULL), "
|
|
329
|
+
f"format_type(a.atttypid, a.atttypmod), "
|
|
330
|
+
f"CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END, "
|
|
331
|
+
f"CASE WHEN EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid "
|
|
332
|
+
f"AND i.indisprimary AND a.attnum = ANY(i.indkey)) THEN 'PRI' ELSE '' END, "
|
|
333
|
+
f"CASE WHEN pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval(%' THEN '' "
|
|
334
|
+
f"ELSE COALESCE(pg_get_expr(ad.adbin, ad.adrelid), '') END, "
|
|
335
|
+
f"CASE WHEN a.attidentity <> '' THEN 'identity' "
|
|
336
|
+
f"WHEN pg_get_expr(ad.adbin, ad.adrelid) LIKE 'nextval(%' THEN 'serial' "
|
|
337
|
+
f"ELSE '' END, "
|
|
338
|
+
f"COALESCE(col_description(c.oid, a.attnum), '') "
|
|
339
|
+
f"FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid "
|
|
340
|
+
f"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
341
|
+
f"LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum "
|
|
342
|
+
f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
|
|
343
|
+
f"AND NOT c.relispartition AND a.attnum > 0 AND NOT a.attisdropped "
|
|
344
|
+
f"ORDER BY c.relname, a.attnum")
|
|
345
|
+
fk_rows = postgres_query_rows(url,
|
|
346
|
+
f"SELECT conrel.relname, a.attname, confrel.relname "
|
|
347
|
+
f"FROM pg_constraint con "
|
|
348
|
+
f"JOIN pg_class conrel ON conrel.oid = con.conrelid "
|
|
349
|
+
f"JOIN pg_class confrel ON confrel.oid = con.confrelid "
|
|
350
|
+
f"JOIN pg_namespace n ON n.oid = conrel.relnamespace "
|
|
351
|
+
f"CROSS JOIN LATERAL unnest(con.conkey) AS k(attnum) "
|
|
352
|
+
f"JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum "
|
|
353
|
+
f"WHERE con.contype = 'f' AND n.nspname = '{schema}' "
|
|
354
|
+
f"ORDER BY conrel.relname, a.attname")
|
|
355
|
+
index_rows = postgres_query_rows(url,
|
|
356
|
+
f"SELECT c.relname, i.relname, "
|
|
357
|
+
f"CASE WHEN ix.indisunique THEN 0 ELSE 1 END, k.ord, "
|
|
358
|
+
f"COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.ord::int, true)) "
|
|
359
|
+
f"FROM pg_index ix "
|
|
360
|
+
f"JOIN pg_class c ON c.oid = ix.indrelid "
|
|
361
|
+
f"JOIN pg_class i ON i.oid = ix.indexrelid "
|
|
362
|
+
f"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
363
|
+
f"CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) "
|
|
364
|
+
f"LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum "
|
|
365
|
+
f"AND k.attnum > 0 "
|
|
366
|
+
f"WHERE n.nspname = '{schema}' AND c.relkind IN ('r', 'p') "
|
|
367
|
+
f"AND NOT c.relispartition "
|
|
368
|
+
f"ORDER BY c.relname, i.relname, k.ord")
|
|
369
|
+
return mysql_ir(table_rows, col_rows, fk_rows, index_rows)
|
|
370
|
+
|
|
224
371
|
def dedupe_db_fk(tables):
|
|
225
372
|
"""After merging code-declared associations, drop DB-FK associations for
|
|
226
373
|
pairs that an explicit association already covers (either direction).
|
|
@@ -4777,7 +4924,7 @@ CONFIG_DEFAULTS = {
|
|
|
4777
4924
|
# accidentally fill in when the pieces are separate. One config file per
|
|
4778
4925
|
# database (e.g. erdscope.staging.json / erdscope.prod.json) is the
|
|
4779
4926
|
# intended way to point --config at different targets.
|
|
4780
|
-
CONFIG_CONNECTION_KEYS = {'host', 'port', 'user', 'database'}
|
|
4927
|
+
CONFIG_CONNECTION_KEYS = {'engine', 'host', 'port', 'user', 'database'}
|
|
4781
4928
|
CONFIG_PASSWORD_KEYS = {'password', 'passwd', 'pwd', 'url', 'database_url'}
|
|
4782
4929
|
# Expected type per key, checked at load time — YAML/JSON scalars are
|
|
4783
4930
|
# exactly where a config typo goes undetected otherwise: max_rows as the
|
|
@@ -4871,15 +5018,19 @@ def _check_config_types(config, path):
|
|
|
4871
5018
|
_SAFE_HOST_OR_USER = re.compile(r'[\w.\-]+')
|
|
4872
5019
|
|
|
4873
5020
|
def assemble_config_url(config):
|
|
4874
|
-
"""Build a mysql:// URL
|
|
4875
|
-
|
|
4876
|
-
|
|
5021
|
+
"""Build a mysql:// or postgres:// URL (per the config's `engine`, default
|
|
5022
|
+
mysql) from the config's host/port/user/database fields, or None if
|
|
5023
|
+
`database` wasn't given (no connection info in the config at all).
|
|
5024
|
+
Each part is validated against a safe charset before being pasted
|
|
4877
5025
|
into the URL string — host/user containing `/`, `@`, or `:` would
|
|
4878
5026
|
silently shift what urlparse reads as the host/port/path when the
|
|
4879
5027
|
assembled string is re-parsed downstream (verified empirically: a host
|
|
4880
5028
|
of "x@evil" produces a URL whose username becomes "x" and whose actual
|
|
4881
5029
|
host becomes "evil"), and there is no decoding step anywhere downstream
|
|
4882
5030
|
to undo percent-encoding, so quoting isn't a fix either."""
|
|
5031
|
+
engine = config.get('engine', 'mysql')
|
|
5032
|
+
if engine not in ('mysql', 'postgres', 'postgresql'):
|
|
5033
|
+
sys.exit(f'Error: config `engine` must be "mysql" or "postgres", got {engine!r}')
|
|
4883
5034
|
db = config.get('database')
|
|
4884
5035
|
if db is None: # absent, or explicitly blank (e.g. a bare `database:` in YAML)
|
|
4885
5036
|
return None
|
|
@@ -4890,7 +5041,7 @@ def assemble_config_url(config):
|
|
|
4890
5041
|
sys.exit(f'Error: config `host` {host!r} has unsupported characters (letters/'
|
|
4891
5042
|
f'digits/./- only here — IPv6 and other exotic hosts need the CLI '
|
|
4892
5043
|
f'argument instead, not the config file)')
|
|
4893
|
-
port = config.get('port', 3306)
|
|
5044
|
+
port = config.get('port', 3306 if engine == 'mysql' else 5432)
|
|
4894
5045
|
if isinstance(port, bool) or (isinstance(port, float) and not port.is_integer()):
|
|
4895
5046
|
sys.exit(f'Error: config `port` {port!r} is not a valid port number')
|
|
4896
5047
|
try:
|
|
@@ -4909,7 +5060,8 @@ def assemble_config_url(config):
|
|
|
4909
5060
|
sys.exit(f'Error: config `user` {user!r} has unsupported characters '
|
|
4910
5061
|
f'(letters/digits/./- only)')
|
|
4911
5062
|
auth = f'{user}@'
|
|
4912
|
-
|
|
5063
|
+
scheme = 'mysql' if engine == 'mysql' else 'postgres'
|
|
5064
|
+
return f'{scheme}://{auth}{host}:{port}/{db}'
|
|
4913
5065
|
|
|
4914
5066
|
# ---------------------------------------------------------------------------
|
|
4915
5067
|
# CLI
|
|
@@ -4919,11 +5071,14 @@ def main():
|
|
|
4919
5071
|
description='Generate an interactive ER diagram from a live database, '
|
|
4920
5072
|
'optionally enriched with association semantics parsed from '
|
|
4921
5073
|
'application code (Rails / Prisma / Django)')
|
|
4922
|
-
p.add_argument('database', metavar='mysql://user@host
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
'
|
|
4926
|
-
'
|
|
5074
|
+
p.add_argument('database', metavar='mysql://user@host/db | postgres://user@host/db',
|
|
5075
|
+
nargs='?',
|
|
5076
|
+
help='Database connection URL (postgres:// takes an optional '
|
|
5077
|
+
'?schema=name, default public). Can also be assembled from '
|
|
5078
|
+
'`engine`/`host`/`port`/`user`/`database` in the config file (no '
|
|
5079
|
+
'password field there — use MYSQL_PWD/PGPASSWORD, '
|
|
5080
|
+
'~/.my.cnf/~/.pgpass, or the interactive prompt). A read-only '
|
|
5081
|
+
'account is recommended')
|
|
4927
5082
|
# SUPPRESS on every config-mirrorable flag so we can tell "explicitly
|
|
4928
5083
|
# passed on the CLI" (attribute present) from "left to the config file /
|
|
4929
5084
|
# built-in default" (attribute absent) — see the merge loop below.
|
|
@@ -4970,10 +5125,16 @@ def main():
|
|
|
4970
5125
|
|
|
4971
5126
|
config = load_config(args)
|
|
4972
5127
|
url = args.database or assemble_config_url(config)
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
5128
|
+
scheme = (url or '').split('://', 1)[0]
|
|
5129
|
+
if scheme == 'mysql':
|
|
5130
|
+
parse_db, engine_name = parse_mysql, 'MySQL'
|
|
5131
|
+
elif scheme in ('postgres', 'postgresql'):
|
|
5132
|
+
parse_db, engine_name = parse_postgres, 'PostgreSQL'
|
|
5133
|
+
else:
|
|
5134
|
+
sys.exit('Error: a database URL is required (mysql:// or postgres://) — pass it '
|
|
5135
|
+
'as the CLI argument, or set `database` (and optionally engine/host/user/'
|
|
5136
|
+
'port) in the config file, e.g. mysql://readonly@127.0.0.1:3306/myapp or '
|
|
5137
|
+
'postgres://readonly@127.0.0.1:5432/myapp')
|
|
4977
5138
|
|
|
4978
5139
|
if hasattr(args, 'table_map'):
|
|
4979
5140
|
tm = {}
|
|
@@ -4991,8 +5152,8 @@ def main():
|
|
|
4991
5152
|
setattr(args, key, config.get(key, default))
|
|
4992
5153
|
relations = config.get('relations', []) # shape already validated by load_config()
|
|
4993
5154
|
|
|
4994
|
-
tables =
|
|
4995
|
-
print(f'Fetched {len(tables)} tables from
|
|
5155
|
+
tables = parse_db(url)
|
|
5156
|
+
print(f'Fetched {len(tables)} tables from {engine_name}', file=sys.stderr)
|
|
4996
5157
|
|
|
4997
5158
|
if args.models:
|
|
4998
5159
|
mroot = Path(args.models).expanduser().resolve()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: erdscope
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies
|
|
5
5
|
Author: tas6
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/orapli/erdscope
|
|
@@ -22,17 +22,30 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Provides-Extra: mysql
|
|
24
24
|
Requires-Dist: PyMySQL; extra == "mysql"
|
|
25
|
+
Provides-Extra: postgres
|
|
26
|
+
Requires-Dist: psycopg[binary]; extra == "postgres"
|
|
25
27
|
Provides-Extra: yaml
|
|
26
28
|
Requires-Dist: PyYAML; extra == "yaml"
|
|
27
29
|
Provides-Extra: all
|
|
28
30
|
Requires-Dist: PyMySQL; extra == "all"
|
|
31
|
+
Requires-Dist: psycopg[binary]; extra == "all"
|
|
29
32
|
Requires-Dist: PyYAML; extra == "all"
|
|
30
33
|
Dynamic: license-file
|
|
31
34
|
|
|
32
35
|
# erdscope
|
|
33
36
|
|
|
37
|
+
[](https://github.com/orapli/erdscope/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/erdscope/)
|
|
39
|
+
|
|
34
40
|
Generate a **self-contained, interactive ER diagram** — and an **Excel table-definition
|
|
35
|
-
workbook** — from a live MySQL database, with a single-file,
|
|
41
|
+
workbook** — from a live MySQL or PostgreSQL database, with a single-file,
|
|
42
|
+
zero-dependency Python CLI.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install erdscope
|
|
46
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
47
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
48
|
+
```
|
|
36
49
|
|
|
37
50
|
The database is the source of truth (tables, columns, comments, indexes, real foreign
|
|
38
51
|
keys). Application code (Rails / Prisma / Django) can optionally be layered on top to
|
|
@@ -52,17 +65,29 @@ Regenerate it anytime with `python3 docs/gen_demo.py`.
|
|
|
52
65
|
**[Read the user manual →](https://orapli.github.io/erdscope/manual.html)** — installation,
|
|
53
66
|
CLI/config reference, a full viewer guide, and troubleshooting. ([日本語版 →](https://orapli.github.io/erdscope/manual.ja.html))
|
|
54
67
|
|
|
55
|
-
##
|
|
68
|
+
## Install & usage
|
|
69
|
+
|
|
70
|
+
`pip install erdscope` (or `pipx install erdscope`) gives you the `erdscope` command.
|
|
71
|
+
Prefer not to install anything? `erd.py` is a single, dependency-free file — grab it
|
|
72
|
+
and run it with any Python 3.9+:
|
|
56
73
|
|
|
57
74
|
```bash
|
|
58
|
-
|
|
75
|
+
curl -O https://raw.githubusercontent.com/orapli/erdscope/main/erd.py
|
|
76
|
+
python3 erd.py ... # identical to the erdscope command below
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production -o erd.html
|
|
81
|
+
|
|
82
|
+
# PostgreSQL: same thing (schema defaults to public; override with ?schema=name)
|
|
83
|
+
erdscope postgres://readonly@127.0.0.1:5432/myapp_production -o erd.html
|
|
59
84
|
|
|
60
85
|
# enrich with association semantics parsed from application code (optional)
|
|
61
|
-
|
|
86
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
62
87
|
--models /path/to/rails/app -o erd.html
|
|
63
88
|
|
|
64
89
|
# also write a table-definition workbook
|
|
65
|
-
|
|
90
|
+
erdscope mysql://readonly@127.0.0.1:3306/myapp_production \
|
|
66
91
|
--excel table_definitions.xlsx -o erd.html
|
|
67
92
|
```
|
|
68
93
|
|
|
@@ -70,7 +95,7 @@ Behind a bastion? Open an SSH tunnel first and point at localhost:
|
|
|
70
95
|
|
|
71
96
|
```bash
|
|
72
97
|
ssh -N -L 3307:db-host:3306 bastion &
|
|
73
|
-
|
|
98
|
+
erdscope mysql://readonly@127.0.0.1:3307/myapp_production -o erd.html
|
|
74
99
|
```
|
|
75
100
|
|
|
76
101
|
Use a read-only account, and leave the password out of the URL — if it's not in
|
|
@@ -99,8 +124,8 @@ itself is made.
|
|
|
99
124
|
Once the flag list above gets long, put it in a config file instead — `.erdscope.json`
|
|
100
125
|
(or `.yml`/`.yaml` with PyYAML) next to where you run the tool is picked up automatically.
|
|
101
126
|
Most keys mirror a CLI option (an explicit flag always wins over the config); the DB
|
|
102
|
-
connection is config-only as `host`/`port`/`user`/`database` — deliberately with
|
|
103
|
-
password field — and `relations` manually declares relations no FK, code, or inference
|
|
127
|
+
connection is config-only as `engine`/`host`/`port`/`user`/`database` — deliberately with
|
|
128
|
+
no password field — and `relations` manually declares relations no FK, code, or inference
|
|
104
129
|
can find. See the [Config file chapter of the manual](https://orapli.github.io/erdscope/manual.html#config-file) for the full key
|
|
105
130
|
list and semantics, and [`erdscope.example.yml`](erdscope.example.yml) for a fully
|
|
106
131
|
annotated sample based on the live demo's schema.
|
|
@@ -109,14 +134,17 @@ annotated sample based on the live demo's schema.
|
|
|
109
134
|
|
|
110
135
|
`erd.py` runs with **zero required dependencies** — everything below is optional, and
|
|
111
136
|
the tool degrades gracefully (falls back, or fails with a clear message) when a piece
|
|
112
|
-
is missing.
|
|
137
|
+
is missing. If you installed via pip, extras pull them in for you:
|
|
138
|
+
`pip install 'erdscope[mysql]'` (PyMySQL), `'erdscope[postgres]'` (psycopg),
|
|
139
|
+
`'erdscope[yaml]'` (PyYAML), or `'erdscope[all]'`.
|
|
113
140
|
|
|
114
141
|
| Library | Used for | If not installed |
|
|
115
142
|
|---|---|---|
|
|
116
|
-
| [PyMySQL](https://pypi.org/project/PyMySQL/) |
|
|
143
|
+
| [PyMySQL](https://pypi.org/project/PyMySQL/) | MySQL connections | Falls back to shelling out to the `mysql` CLI (must be on `PATH`) |
|
|
144
|
+
| [psycopg](https://pypi.org/project/psycopg/) (or psycopg2) | PostgreSQL connections | Falls back to shelling out to the `psql` CLI (must be on `PATH`) |
|
|
117
145
|
| [PyYAML](https://pypi.org/project/PyYAML/) | Reading a `.yml`/`.yaml` config file | A `.json` config still works with no dependency; pointing `--config`/auto-discovery at a `.yml`/`.yaml` file without PyYAML installed exits with a clear error |
|
|
118
146
|
|
|
119
|
-
Excel output (`--excel`) needs
|
|
147
|
+
Excel output (`--excel`) needs none of these — it's written directly via the stdlib
|
|
120
148
|
`zipfile`/XML, not a spreadsheet library.
|
|
121
149
|
|
|
122
150
|
Test-only, and only if you run that particular suite:
|
|
@@ -131,7 +159,8 @@ Test-only, and only if you run that particular suite:
|
|
|
131
159
|
Feature highlights — each link goes to the relevant [manual](https://orapli.github.io/erdscope/manual.html) chapter:
|
|
132
160
|
|
|
133
161
|
- **Database truth** — tables, columns (full SQL types, defaults, extras), table and
|
|
134
|
-
column comments, indexes, and real FK constraints, read from
|
|
162
|
+
column comments, indexes, and real FK constraints, read from the database catalog
|
|
163
|
+
(`information_schema` on MySQL, `pg_catalog` on PostgreSQL)
|
|
135
164
|
- **Code semantics on top** — `--models` merges Rails / Prisma / Django associations;
|
|
136
165
|
declared, DB-FK, and inferred edges stay [visually distinct](https://orapli.github.io/erdscope/manual.html#viewer-edges)
|
|
137
166
|
- **[Interactive exploration](https://orapli.github.io/erdscope/manual.html#viewer-guide)** — focus with depth and dependency
|
|
@@ -146,7 +175,8 @@ Feature highlights — each link goes to the relevant [manual](https://orapli.gi
|
|
|
146
175
|
- **[Logical names](https://orapli.github.io/erdscope/manual.html#viewer-names)** — a table's DB comment doubles as a searchable
|
|
147
176
|
logical name (e.g. `users(Customer accounts)`), with independent display modes for
|
|
148
177
|
the live view and exports
|
|
149
|
-
- **Extras** — dark mode, print stylesheet,
|
|
178
|
+
- **Extras** — a built-in `?` shortcuts/help popup, dark mode, print stylesheet,
|
|
179
|
+
resizable/collapsible panes
|
|
150
180
|
|
|
151
181
|
## Tests
|
|
152
182
|
|
|
@@ -170,8 +200,9 @@ python3 -m unittest tests.test_e2e -v
|
|
|
170
200
|
## Extending
|
|
171
201
|
|
|
172
202
|
Everything downstream (UI, layouts, exports) consumes the intermediate representation
|
|
173
|
-
documented at the top of `erd.py`. Adding
|
|
174
|
-
`parse_postgres()`
|
|
203
|
+
documented at the top of `erd.py`. Adding another database engine means adding one
|
|
204
|
+
parser that produces that shape — `parse_postgres()` (which reuses the MySQL
|
|
205
|
+
adapter's IR builder wholesale) is the working example of the pattern.
|
|
175
206
|
|
|
176
207
|
## License
|
|
177
208
|
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "erdscope"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL database — single file, zero required dependencies"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a live MySQL or PostgreSQL database — single file, zero required dependencies"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
@@ -24,8 +24,9 @@ classifiers = [
|
|
|
24
24
|
|
|
25
25
|
[project.optional-dependencies]
|
|
26
26
|
mysql = ["PyMySQL"]
|
|
27
|
+
postgres = ["psycopg[binary]"]
|
|
27
28
|
yaml = ["PyYAML"]
|
|
28
|
-
all = ["PyMySQL", "PyYAML"]
|
|
29
|
+
all = ["PyMySQL", "psycopg[binary]", "PyYAML"]
|
|
29
30
|
|
|
30
31
|
[project.scripts]
|
|
31
32
|
erdscope = "erd:main"
|
|
@@ -1449,5 +1449,152 @@ class TestAssembleConfigUrl(unittest.TestCase):
|
|
|
1449
1449
|
erd.assemble_config_url({'host': 'h', 'database': 'shop', 'port': 3306.9})
|
|
1450
1450
|
|
|
1451
1451
|
|
|
1452
|
+
class TestPostgresQueryRows(unittest.TestCase):
|
|
1453
|
+
"""postgres_query_rows prefers psycopg (v3), then psycopg2; the last
|
|
1454
|
+
resort is the psql CLI wrapping the query in COPY (...) TO STDOUT.
|
|
1455
|
+
Mirrors TestPyMysqlErrorHandling: driver failures must exit cleanly,
|
|
1456
|
+
and the CLI path must unescape COPY's text format."""
|
|
1457
|
+
def setUp(self):
|
|
1458
|
+
class FakePgError(Exception):
|
|
1459
|
+
pass
|
|
1460
|
+
self.FakePgError = FakePgError
|
|
1461
|
+
self.fake_psycopg = types.SimpleNamespace(Error=FakePgError, connect=None)
|
|
1462
|
+
self.addCleanup(sys.modules.pop, 'psycopg', None)
|
|
1463
|
+
sys.modules['psycopg'] = self.fake_psycopg
|
|
1464
|
+
|
|
1465
|
+
def test_connect_failure_exits_cleanly(self):
|
|
1466
|
+
def fail_connect(**kw):
|
|
1467
|
+
raise self.FakePgError('password authentication failed for user "x"')
|
|
1468
|
+
self.fake_psycopg.connect = fail_connect
|
|
1469
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1470
|
+
erd.postgres_query_rows('postgres://x@127.0.0.1:5432/db', 'SELECT 1')
|
|
1471
|
+
self.assertIn('authentication failed', str(cm.exception))
|
|
1472
|
+
|
|
1473
|
+
def test_query_failure_exits_cleanly(self):
|
|
1474
|
+
class FakeCursor:
|
|
1475
|
+
def __enter__(self): return self
|
|
1476
|
+
def __exit__(self, *a): return False
|
|
1477
|
+
def execute(self, sql):
|
|
1478
|
+
raise self.outer.FakePgError('relation does not exist')
|
|
1479
|
+
FakeCursor.outer = self
|
|
1480
|
+
class FakeConn:
|
|
1481
|
+
def cursor(self): return FakeCursor()
|
|
1482
|
+
def close(self): pass
|
|
1483
|
+
self.fake_psycopg.connect = lambda **kw: FakeConn()
|
|
1484
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1485
|
+
erd.postgres_query_rows('postgres://x@127.0.0.1:5432/db', 'SELECT 1')
|
|
1486
|
+
self.assertIn('relation does not exist', str(cm.exception))
|
|
1487
|
+
|
|
1488
|
+
def test_none_values_become_empty_strings(self):
|
|
1489
|
+
class FakeCursor:
|
|
1490
|
+
def __enter__(self): return self
|
|
1491
|
+
def __exit__(self, *a): return False
|
|
1492
|
+
def execute(self, sql): pass
|
|
1493
|
+
def fetchall(self): return [('users', None), (None, 42)]
|
|
1494
|
+
class FakeConn:
|
|
1495
|
+
def cursor(self): return FakeCursor()
|
|
1496
|
+
def close(self): pass
|
|
1497
|
+
self.fake_psycopg.connect = lambda **kw: FakeConn()
|
|
1498
|
+
rows = erd.postgres_query_rows('postgres://x@h:5432/db', 'SELECT 1')
|
|
1499
|
+
self.assertEqual(rows, [('users', ''), ('', '42')])
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
class TestPostgresCliFallback(unittest.TestCase):
|
|
1503
|
+
"""The psql path, forced by making both driver imports fail
|
|
1504
|
+
(sys.modules[name] = None makes `import name` raise ImportError)."""
|
|
1505
|
+
def setUp(self):
|
|
1506
|
+
for mod in ('psycopg', 'psycopg2'):
|
|
1507
|
+
self.addCleanup(sys.modules.pop, mod, None)
|
|
1508
|
+
sys.modules[mod] = None
|
|
1509
|
+
|
|
1510
|
+
def test_missing_psql_exits_with_install_hint(self):
|
|
1511
|
+
def no_psql(cmd, **kw):
|
|
1512
|
+
raise FileNotFoundError('psql')
|
|
1513
|
+
orig = erd.subprocess.run
|
|
1514
|
+
erd.subprocess.run = no_psql
|
|
1515
|
+
try:
|
|
1516
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1517
|
+
erd.postgres_query_rows('postgres://x@h:5432/db', 'SELECT 1')
|
|
1518
|
+
finally:
|
|
1519
|
+
erd.subprocess.run = orig
|
|
1520
|
+
self.assertIn('psycopg', str(cm.exception))
|
|
1521
|
+
self.assertIn('psql', str(cm.exception))
|
|
1522
|
+
|
|
1523
|
+
def test_copy_output_is_unescaped_and_wrapped_in_copy(self):
|
|
1524
|
+
captured = {}
|
|
1525
|
+
def fake_run(cmd, **kw):
|
|
1526
|
+
captured['cmd'] = cmd
|
|
1527
|
+
return types.SimpleNamespace(
|
|
1528
|
+
returncode=0,
|
|
1529
|
+
stdout='users\tA comment with a\\ttab\n\\N\t\\N\n',
|
|
1530
|
+
stderr='')
|
|
1531
|
+
orig = erd.subprocess.run
|
|
1532
|
+
erd.subprocess.run = fake_run
|
|
1533
|
+
try:
|
|
1534
|
+
rows = erd.postgres_query_rows('postgres://ro@h:5433/db', 'SELECT x')
|
|
1535
|
+
finally:
|
|
1536
|
+
erd.subprocess.run = orig
|
|
1537
|
+
self.assertEqual(rows, [('users', 'A comment with a\ttab'), ('', '')])
|
|
1538
|
+
joined = ' '.join(captured['cmd'])
|
|
1539
|
+
self.assertIn('COPY (SELECT x) TO STDOUT', joined)
|
|
1540
|
+
self.assertIn('-p 5433', joined)
|
|
1541
|
+
self.assertIn('-U ro', joined)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
class TestUnescapeCopyField(unittest.TestCase):
|
|
1545
|
+
"""COPY TO STDOUT text format shares mysql --batch's escape contract
|
|
1546
|
+
and adds \\f and \\v."""
|
|
1547
|
+
def test_null_marker(self):
|
|
1548
|
+
self.assertEqual(erd._unescape_copy_field('\\N'), '')
|
|
1549
|
+
|
|
1550
|
+
def test_formfeed_and_vertical_tab(self):
|
|
1551
|
+
self.assertEqual(erd._unescape_copy_field('a\\fb\\vc'), 'a\fb\vc')
|
|
1552
|
+
|
|
1553
|
+
def test_shared_escapes_still_decode(self):
|
|
1554
|
+
self.assertEqual(erd._unescape_copy_field('a\\tb\\nc'), 'a\tb\nc')
|
|
1555
|
+
|
|
1556
|
+
def test_escaped_backslash_then_escape_char(self):
|
|
1557
|
+
self.assertEqual(erd._unescape_copy_field('a\\\\tb'), 'a\\tb')
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
class TestPostgresUrlAndSchema(unittest.TestCase):
|
|
1561
|
+
def test_missing_database_name_exits(self):
|
|
1562
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1563
|
+
erd.parse_postgres('postgres://ro@h:5432/')
|
|
1564
|
+
self.assertIn('database name', str(cm.exception))
|
|
1565
|
+
|
|
1566
|
+
def test_invalid_schema_param_exits(self):
|
|
1567
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1568
|
+
erd.parse_postgres('postgres://ro@h:5432/db?schema=bad-name')
|
|
1569
|
+
self.assertIn('schema', str(cm.exception))
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
class TestPostgresConfigUrl(unittest.TestCase):
|
|
1573
|
+
"""`engine: postgres` in the config switches the assembled URL's scheme
|
|
1574
|
+
and default port; mysql stays the default for existing configs."""
|
|
1575
|
+
def test_engine_postgres_assembles_postgres_url_with_5432_default(self):
|
|
1576
|
+
url = erd.assemble_config_url(
|
|
1577
|
+
{'engine': 'postgres', 'user': 'ro', 'host': 'h', 'database': 'shop'})
|
|
1578
|
+
self.assertEqual(url, 'postgres://ro@h:5432/shop')
|
|
1579
|
+
|
|
1580
|
+
def test_engine_postgresql_alias_accepted(self):
|
|
1581
|
+
url = erd.assemble_config_url({'engine': 'postgresql', 'database': 'shop'})
|
|
1582
|
+
self.assertEqual(url, 'postgres://127.0.0.1:5432/shop')
|
|
1583
|
+
|
|
1584
|
+
def test_engine_defaults_to_mysql(self):
|
|
1585
|
+
url = erd.assemble_config_url({'database': 'shop'})
|
|
1586
|
+
self.assertEqual(url, 'mysql://127.0.0.1:3306/shop')
|
|
1587
|
+
|
|
1588
|
+
def test_unknown_engine_exits(self):
|
|
1589
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1590
|
+
erd.assemble_config_url({'engine': 'oracle', 'database': 'shop'})
|
|
1591
|
+
self.assertIn('engine', str(cm.exception))
|
|
1592
|
+
|
|
1593
|
+
def test_explicit_port_wins_over_engine_default(self):
|
|
1594
|
+
url = erd.assemble_config_url(
|
|
1595
|
+
{'engine': 'postgres', 'database': 'shop', 'port': 6432})
|
|
1596
|
+
self.assertEqual(url, 'postgres://127.0.0.1:6432/shop')
|
|
1597
|
+
|
|
1598
|
+
|
|
1452
1599
|
if __name__ == '__main__':
|
|
1453
1600
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|