erdscope 0.7.1__tar.gz → 0.7.2__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.7.1/erdscope.egg-info → erdscope-0.7.2}/PKG-INFO +3 -2
- {erdscope-0.7.1 → erdscope-0.7.2}/README.md +2 -1
- {erdscope-0.7.1 → erdscope-0.7.2}/erd.py +383 -12
- {erdscope-0.7.1 → erdscope-0.7.2/erdscope.egg-info}/PKG-INFO +3 -2
- {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/SOURCES.txt +2 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/pyproject.toml +1 -1
- erdscope-0.7.2/tests/test_emit_dbml.py +540 -0
- erdscope-0.7.2/tests/test_excel_notes_groups.py +182 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_groups.py +21 -3
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_notes.py +18 -2
- {erdscope-0.7.1 → erdscope-0.7.2}/LICENSE +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/dependency_links.txt +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/entry_points.txt +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/requires.txt +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/top_level.txt +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/setup.cfg +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_build.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_characterization.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_config_validation.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_db_integration.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_demo.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_diff.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_e2e.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_config.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_digest.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_json.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_erd.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_golden_format1.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_merge_ir.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_pipeline.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_provider_contract.py +0 -0
- {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_rails_schema.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: erdscope
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies
|
|
5
5
|
Author: tas6
|
|
6
6
|
License-Expression: MIT
|
|
@@ -192,11 +192,12 @@ explicit CLI argument wins.
|
|
|
192
192
|
|---|---|
|
|
193
193
|
| `--config PATH` | Load model sources, defaults, schema definitions or patches, notes, and groups |
|
|
194
194
|
| `--models PATH` | Override config `models` with Rails, Prisma, or Django input; repeatable |
|
|
195
|
-
| `--excel FILE.xlsx` | Also generate a table-definition workbook |
|
|
195
|
+
| `--excel FILE.xlsx` | Also generate a table-definition workbook (includes Notes/Groups sheets when configured) |
|
|
196
196
|
| `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
|
|
197
197
|
| `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
|
|
198
198
|
| `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
|
|
199
199
|
| `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
|
|
200
|
+
| `--emit-dbml FILE.dbml` | Also write a minimal DBML export of the schema — tables/columns/indexes/single-column-FK relations/table comments (`-` for stdout; no notes/groups/`TableGroup`) |
|
|
200
201
|
| `--only 'user*,order*'` | Generate only matching tables |
|
|
201
202
|
| `--exclude '*_logs'` | Leave matching tables out |
|
|
202
203
|
| `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
|
|
@@ -158,11 +158,12 @@ explicit CLI argument wins.
|
|
|
158
158
|
|---|---|
|
|
159
159
|
| `--config PATH` | Load model sources, defaults, schema definitions or patches, notes, and groups |
|
|
160
160
|
| `--models PATH` | Override config `models` with Rails, Prisma, or Django input; repeatable |
|
|
161
|
-
| `--excel FILE.xlsx` | Also generate a table-definition workbook |
|
|
161
|
+
| `--excel FILE.xlsx` | Also generate a table-definition workbook (includes Notes/Groups sheets when configured) |
|
|
162
162
|
| `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
|
|
163
163
|
| `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
|
|
164
164
|
| `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
|
|
165
165
|
| `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
|
|
166
|
+
| `--emit-dbml FILE.dbml` | Also write a minimal DBML export of the schema — tables/columns/indexes/single-column-FK relations/table comments (`-` for stdout; no notes/groups/`TableGroup`) |
|
|
166
167
|
| `--only 'user*,order*'` | Generate only matching tables |
|
|
167
168
|
| `--exclude '*_logs'` | Leave matching tables out |
|
|
168
169
|
| `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
|
|
@@ -3206,11 +3206,12 @@ def _build_stylesheet_parts(role_styles):
|
|
|
3206
3206
|
return fonts, fills, borders, cellxfs
|
|
3207
3207
|
|
|
3208
3208
|
def write_excel(tables, path, title, template_path=None, notes=None, groups=None):
|
|
3209
|
-
"""`notes` (
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3209
|
+
"""`notes`/`groups` (backlog #4, activating the Phase 1 wiring): a Notes
|
|
3210
|
+
sheet and a Groups sheet are appended when either is non-empty, and the
|
|
3211
|
+
overview sheet gains a trailing Group column when `groups` is non-empty.
|
|
3212
|
+
Both additions are fully omitted (not just left empty) when there's
|
|
3213
|
+
nothing to show, so a run with no notes/groups still produces
|
|
3214
|
+
byte-identical output to before this feature existed."""
|
|
3214
3215
|
import zipfile
|
|
3215
3216
|
used = set()
|
|
3216
3217
|
sheets = [] # (sheet_name, xml)
|
|
@@ -3226,19 +3227,34 @@ def write_excel(tables, path, title, template_path=None, notes=None, groups=None
|
|
|
3226
3227
|
used.add('tables')
|
|
3227
3228
|
names = sorted(tables)
|
|
3228
3229
|
sheet_of = {n: _sheet_name(n, used) for n in names}
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3230
|
+
# table -> its group's display label (title if set, else the group id).
|
|
3231
|
+
# Phase 1 groups have non-overlapping membership (resolve_and_validate_
|
|
3232
|
+
# groups), so a table maps to at most one label here.
|
|
3233
|
+
group_of = {}
|
|
3234
|
+
for g in (groups or []):
|
|
3235
|
+
label = g.get('title') or g['id']
|
|
3236
|
+
for tn in g.get('tables', []):
|
|
3237
|
+
group_of[tn] = label
|
|
3238
|
+
header = [('#', S_HEADER), ('Table', S_HEADER), ('Comment', S_HEADER),
|
|
3239
|
+
('Columns', S_HEADER), ('Indexes', S_HEADER), ('Missing schema', S_HEADER)]
|
|
3240
|
+
widths = [5, 32, 50, 10, 10, 14]
|
|
3241
|
+
if groups: # column omitted entirely (not left blank) when there are no groups
|
|
3242
|
+
header.append(('Group', S_HEADER))
|
|
3243
|
+
widths.append(20)
|
|
3244
|
+
rows = [[(f'{title} — table definitions', S_TITLE)], [], header]
|
|
3232
3245
|
links = []
|
|
3233
3246
|
for i, n in enumerate(names, 1):
|
|
3234
3247
|
t = tables[n]
|
|
3235
3248
|
r = len(rows) + 1
|
|
3236
3249
|
s = alt(i - 1)
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3250
|
+
row = [(i, s), (n, s), (t.get('comment', ''), s), (len(t['columns']), s),
|
|
3251
|
+
(len(t.get('indexes', [])), s),
|
|
3252
|
+
('yes' if t.get('schema_missing') else '', s)]
|
|
3253
|
+
if groups:
|
|
3254
|
+
row.append((group_of.get(n, ''), s))
|
|
3255
|
+
rows.append(row)
|
|
3240
3256
|
links.append((f'B{r}', f"'{sheet_of[n]}'", n))
|
|
3241
|
-
overview = _sheet_xml(rows, widths=
|
|
3257
|
+
overview = _sheet_xml(rows, widths=widths, links=links)
|
|
3242
3258
|
|
|
3243
3259
|
# ── per-table sheets ──
|
|
3244
3260
|
for n in names:
|
|
@@ -3276,6 +3292,37 @@ def write_excel(tables, path, title, template_path=None, notes=None, groups=None
|
|
|
3276
3292
|
rows.append([(a['type'], s), (a['name'], s), (a['target'], s), (via, s)])
|
|
3277
3293
|
sheets.append((sheet_of[n],
|
|
3278
3294
|
_sheet_xml(rows, widths=[12, 28, 24, 10, 18, 6, 16, 50])))
|
|
3295
|
+
|
|
3296
|
+
# ── notes sheet (backlog #4) — omitted entirely when there are no notes ──
|
|
3297
|
+
if notes:
|
|
3298
|
+
rows = [[(f'{title} — notes', S_TITLE)], [],
|
|
3299
|
+
[('#', S_HEADER), ('ID', S_HEADER), ('Scope', S_HEADER), ('Target', S_HEADER),
|
|
3300
|
+
('Title', S_HEADER), ('Text', S_HEADER), ('Links', S_HEADER)]]
|
|
3301
|
+
for i, n in enumerate(sorted(notes, key=lambda n: n['id']), 1):
|
|
3302
|
+
if n['scope'] == 'global':
|
|
3303
|
+
target = ''
|
|
3304
|
+
elif n['scope'] == 'table':
|
|
3305
|
+
target = n['table']
|
|
3306
|
+
else: # relation
|
|
3307
|
+
target = f"{n['source_table']} → {n['target']}"
|
|
3308
|
+
link_text = '; '.join((f"{l['label']} " if l.get('label') else '') + l['url']
|
|
3309
|
+
for l in n.get('links') or [])
|
|
3310
|
+
s = alt(i - 1)
|
|
3311
|
+
rows.append([(i, s), (n['id'], s), (n['scope'], s), (target, s),
|
|
3312
|
+
(n.get('title', ''), s), (n['text'], s), (link_text, s)])
|
|
3313
|
+
sheets.append(('Notes', _sheet_xml(rows, widths=[5, 12, 12, 30, 20, 60, 40])))
|
|
3314
|
+
|
|
3315
|
+
# ── groups sheet (backlog #4) — omitted entirely when there are no groups ──
|
|
3316
|
+
if groups:
|
|
3317
|
+
rows = [[(f'{title} — groups', S_TITLE)], [],
|
|
3318
|
+
[('#', S_HEADER), ('Group', S_HEADER), ('Title', S_HEADER),
|
|
3319
|
+
('Color', S_HEADER), ('Tables', S_HEADER)]]
|
|
3320
|
+
for i, g in enumerate(sorted(groups, key=lambda g: g['id']), 1):
|
|
3321
|
+
s = alt(i - 1)
|
|
3322
|
+
rows.append([(i, s), (g['id'], s), (g.get('title', ''), s), (g.get('color', ''), s),
|
|
3323
|
+
(', '.join(sorted(g.get('tables', []))), s)])
|
|
3324
|
+
sheets.append(('Groups', _sheet_xml(rows, widths=[5, 16, 24, 12, 60])))
|
|
3325
|
+
|
|
3279
3326
|
sheets.insert(0, ('Tables', overview))
|
|
3280
3327
|
|
|
3281
3328
|
# ── workbook plumbing ──
|
|
@@ -9150,6 +9197,308 @@ def emit_digest_document(tables, notes_data, groups_data, title=None, verbose=Fa
|
|
|
9150
9197
|
on why groups carry no schema meaning for a digest."""
|
|
9151
9198
|
schema = canonical_schema(tables, notes_data, groups_data)
|
|
9152
9199
|
return render_digest(schema, title=title, verbose=verbose)
|
|
9200
|
+
# ---------------------------------------------------------------------------
|
|
9201
|
+
# --emit-dbml — minimal DBML export of the schema (backlog #5). Reuses emit.py's
|
|
9202
|
+
# canonical_schema (same allowlist/pruning/deterministic order --emit-json/
|
|
9203
|
+
# --emit-config/--emit-digest already share) — render_dbml never re-derives
|
|
9204
|
+
# what survives or how it's ordered, only how it's RENDERED as DBML text.
|
|
9205
|
+
#
|
|
9206
|
+
# This is deliberately the EXPORT half only (DBML *input* — parsing a .dbml
|
|
9207
|
+
# file into the IR — is a separate, later piece of work; building export
|
|
9208
|
+
# first fixes the IR<->DBML mapping so a future importer can reuse it).
|
|
9209
|
+
# The MINIMAL version covers tables/columns/primary keys/indexes/single-
|
|
9210
|
+
# column-FK relations (`Ref:`)/table comments ONLY. Notes, groups, and
|
|
9211
|
+
# DBML's own `Project`/`TableGroup` blocks are explicitly OUT OF SCOPE this
|
|
9212
|
+
# round (a later "extension" phase) — `notes`/`groups` are present in the
|
|
9213
|
+
# canonical schema input but intentionally ignored/unused here, mirroring
|
|
9214
|
+
# how digest.py's emit_digest_document accepts-but-ignores `groups_data`.
|
|
9215
|
+
#
|
|
9216
|
+
# THE LOSSY CONTRACT — read this before touching Ref generation:
|
|
9217
|
+
#
|
|
9218
|
+
# Only `belongs_to` associations with a truthy `foreign_key` ever produce a
|
|
9219
|
+
# `Ref:` line. This is narrower than it looks, and deliberately so. Across
|
|
9220
|
+
# every provider in this codebase (db/base.py, frameworks/rails.py,
|
|
9221
|
+
# frameworks/prisma.py, frameworks/django.py), a `belongs_to`'s
|
|
9222
|
+
# `foreign_key` ALWAYS names a column on that association's own/declaring
|
|
9223
|
+
# table — every provider agrees on this, so it's always safe to render
|
|
9224
|
+
# `Ref: <this table>.<fk> > <target>.<target pk>`.
|
|
9225
|
+
#
|
|
9226
|
+
# `has_one`'s `foreign_key` is NOT safe the same way: it is AMBIGUOUS across
|
|
9227
|
+
# providers. db/base.py and frameworks/prisma.py both auto-label a
|
|
9228
|
+
# same-table unique FK column as `has_one` (for them, `foreign_key` is
|
|
9229
|
+
# still a column on the DECLARING table, exactly like belongs_to) — but
|
|
9230
|
+
# frameworks/rails.py's hand-written `has_one :profile, foreign_key: :xyz`
|
|
9231
|
+
# scan means the OPPOSITE: `xyz` names a column on the OTHER (target)
|
|
9232
|
+
# table, not this one. Once an association reaches canonical_schema there
|
|
9233
|
+
# is no remaining signal for which provider produced a given `has_one`, so
|
|
9234
|
+
# a `has_one`'s `foreign_key` cannot be safely resolved to a `Ref:` without
|
|
9235
|
+
# risking a Ref that points at a column that doesn't exist on the source
|
|
9236
|
+
# table (silently WRONG DBML, not just incomplete). So `has_one`/
|
|
9237
|
+
# `has_many`/`has_and_belongs_to_many` are excluded from Ref generation
|
|
9238
|
+
# ENTIRELY — only ever `belongs_to`. Do not "fix" this by adding `has_one`
|
|
9239
|
+
# back in; it would ship a wrong Ref for Rails schemas.
|
|
9240
|
+
#
|
|
9241
|
+
# A polymorphic `belongs_to` is skipped silently (no warning) — its
|
|
9242
|
+
# `target` is a synthetic, tableless placeholder (canonical_schema keeps it
|
|
9243
|
+
# in the association list per its own docstring so the details pane still
|
|
9244
|
+
# has something to show), and there is no real table to point a Ref at.
|
|
9245
|
+
#
|
|
9246
|
+
# The Ref's target column is resolved as the target table's own SOLE
|
|
9247
|
+
# primary-key column (scanned from the target's canonical `columns`, never
|
|
9248
|
+
# a `primary_key` field — canonical_schema's shape has no such field
|
|
9249
|
+
# anyway). A target with zero or 2+ primary columns (no PK, or a composite
|
|
9250
|
+
# PK) can't be expressed as a single-column Ref in this minimal version —
|
|
9251
|
+
# that relation is skipped, WITH a warning printed to stderr (never a hard
|
|
9252
|
+
# failure: a skip, not an error).
|
|
9253
|
+
#
|
|
9254
|
+
# Column `default:` values are always rendered as either a bare number or a
|
|
9255
|
+
# single-quoted string — there is no SQL-expression / backtick-expr
|
|
9256
|
+
# special case. This is a deliberate simplification, not an oversight: a
|
|
9257
|
+
# default like `CURRENT_TIMESTAMP` renders as `default: 'CURRENT_TIMESTAMP'`
|
|
9258
|
+
# (a quoted string), which is syntactically valid DBML even though it isn't
|
|
9259
|
+
# the most idiomatic rendering of a SQL expression default.
|
|
9260
|
+
#
|
|
9261
|
+
# Pure and non-destructive: render_dbml/emit_dbml_document never mutate their
|
|
9262
|
+
# input (canonical_schema already deep-copies; nothing here sorts in place).
|
|
9263
|
+
# render_dbml may print `Warning: --emit-dbml: ...` lines to stderr as a side
|
|
9264
|
+
# effect (the Ref-skip case above) — this matches how the rest of the
|
|
9265
|
+
# pipeline already prints informational lines to stderr (cli.py's
|
|
9266
|
+
# _run_pipeline/_finish).
|
|
9267
|
+
# ---------------------------------------------------------------------------
|
|
9268
|
+
|
|
9269
|
+
_IDENT_RE = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
|
|
9270
|
+
_NUMERIC_DEFAULT_RE = re.compile(r'-?\d+(\.\d+)?')
|
|
9271
|
+
|
|
9272
|
+
|
|
9273
|
+
def _ident(name):
|
|
9274
|
+
"""A table/column/index-column name -> its DBML token: bare when it's a
|
|
9275
|
+
simple identifier, else double-quoted with internal `"` escaped."""
|
|
9276
|
+
if _IDENT_RE.fullmatch(name):
|
|
9277
|
+
return name
|
|
9278
|
+
return '"' + name.replace('"', '\\"') + '"'
|
|
9279
|
+
|
|
9280
|
+
|
|
9281
|
+
def _sq(s):
|
|
9282
|
+
"""Single-quote a string for DBML, escaping internal `'` as `\\'`. Used
|
|
9283
|
+
for everything EXCEPT the table Note's triple-quote path, which needs no
|
|
9284
|
+
escaping at all (see _render_table_note)."""
|
|
9285
|
+
return "'" + s.replace("'", "\\'") + "'"
|
|
9286
|
+
|
|
9287
|
+
|
|
9288
|
+
def _dbml_default(val):
|
|
9289
|
+
"""A column's `default:` attribute value: bare/unquoted when the raw
|
|
9290
|
+
string is purely numeric (`^-?\\d+(\\.\\d+)?$`), else single-quoted with
|
|
9291
|
+
`'` escaped and any embedded newline collapsed to a space (defensive —
|
|
9292
|
+
reuse digest.py's _oneline). No SQL-expression detection — see the module
|
|
9293
|
+
docstring's lossy-contract note on `default:`."""
|
|
9294
|
+
s = str(val)
|
|
9295
|
+
if _NUMERIC_DEFAULT_RE.fullmatch(s):
|
|
9296
|
+
return s
|
|
9297
|
+
return _sq(_oneline(s))
|
|
9298
|
+
|
|
9299
|
+
|
|
9300
|
+
def _unique_single_columns(indexes):
|
|
9301
|
+
"""`{column_name, ...}` for every canonical index that is both unique and
|
|
9302
|
+
single-column — the set a column attr's `unique` marker (attr #4) checks
|
|
9303
|
+
against. Intentionally NOT deduped against the indexes block: a
|
|
9304
|
+
single-column unique index appears both inline on its column and as a
|
|
9305
|
+
block entry, by design (see the module docstring / DBML_REVIEW_NOTES)."""
|
|
9306
|
+
return {ix['columns'][0] for ix in indexes
|
|
9307
|
+
if ix.get('unique') and len(ix.get('columns', [])) == 1}
|
|
9308
|
+
|
|
9309
|
+
|
|
9310
|
+
def _column_attrs(c, is_sole_pk, unique_single_cols):
|
|
9311
|
+
"""One column's DBML attrs, comma-joined-ready list, in the fixed order:
|
|
9312
|
+
pk (sole single-column PK only) / increment / not null / unique /
|
|
9313
|
+
default. Composite PKs never contribute a per-column `pk` here — they go
|
|
9314
|
+
in the table's indexes block instead (_composite_pk_columns)."""
|
|
9315
|
+
attrs = []
|
|
9316
|
+
if is_sole_pk:
|
|
9317
|
+
attrs.append('pk')
|
|
9318
|
+
if 'auto_increment' in (c.get('extra') or '').lower():
|
|
9319
|
+
attrs.append('increment')
|
|
9320
|
+
if not c.get('nullable', False):
|
|
9321
|
+
attrs.append('not null')
|
|
9322
|
+
if c['name'] in unique_single_cols:
|
|
9323
|
+
attrs.append('unique')
|
|
9324
|
+
default = c.get('default')
|
|
9325
|
+
if default:
|
|
9326
|
+
attrs.append(f'default: {_dbml_default(default)}')
|
|
9327
|
+
return attrs
|
|
9328
|
+
|
|
9329
|
+
|
|
9330
|
+
def _render_dbml_column(c, sole_pk_name, unique_single_cols):
|
|
9331
|
+
"""One canonical column -> its DBML line: ` <ident> <type>[ [attrs]]`.
|
|
9332
|
+
`type` prefers `sql_type` (raw DB type, fidelity) and falls back to the
|
|
9333
|
+
coarse `type` shorthand — printed bare/unquoted even when it contains an
|
|
9334
|
+
internal space (e.g. `int unsigned`), since DBML column types are bare
|
|
9335
|
+
tokens, not string literals. Named _render_dbml_column (not
|
|
9336
|
+
_render_column) to avoid colliding with digest.py's own _render_column —
|
|
9337
|
+
both files are concatenated into one flat module by build_single_file.py,
|
|
9338
|
+
so a same-named function here would silently shadow digest.py's."""
|
|
9339
|
+
ctype = c.get('sql_type') or c.get('type', '')
|
|
9340
|
+
attrs = _column_attrs(c, c['name'] == sole_pk_name, unique_single_cols)
|
|
9341
|
+
line = f" {_ident(c['name'])} {ctype}"
|
|
9342
|
+
if attrs:
|
|
9343
|
+
line += ' [' + ', '.join(attrs) + ']'
|
|
9344
|
+
return line
|
|
9345
|
+
|
|
9346
|
+
|
|
9347
|
+
def _composite_pk_columns(columns):
|
|
9348
|
+
"""Column names individually flagged `primary: true`, in COLUMN ORDER —
|
|
9349
|
+
the SAME composite-PK detection rule emit.py's _config_table already
|
|
9350
|
+
established (P1/E-2): read from column flags, never from a `primary_key`
|
|
9351
|
+
field (canonical_schema's shape has no such field anyway). Returns the
|
|
9352
|
+
full list regardless of length; callers decide what 0/1/2+ means."""
|
|
9353
|
+
return [c['name'] for c in columns if c.get('primary')]
|
|
9354
|
+
|
|
9355
|
+
|
|
9356
|
+
def _render_indexes_block(primary_cols, indexes):
|
|
9357
|
+
"""The table's `indexes { ... }` sub-block as a list of lines (unindented
|
|
9358
|
+
at the `indexes {`/`}` level — the caller indents nothing further), or
|
|
9359
|
+
None when there's neither a composite PK (2+ primary columns) nor any
|
|
9360
|
+
index at all. The composite-PK line (if any) is always FIRST, then every
|
|
9361
|
+
entry of `indexes` in its already-canonical (not re-sorted) order."""
|
|
9362
|
+
if len(primary_cols) < 2 and not indexes:
|
|
9363
|
+
return None
|
|
9364
|
+
lines = [' indexes {']
|
|
9365
|
+
if len(primary_cols) >= 2:
|
|
9366
|
+
cols = ', '.join(_ident(c) for c in primary_cols)
|
|
9367
|
+
lines.append(f' ({cols}) [pk]')
|
|
9368
|
+
for ix in indexes:
|
|
9369
|
+
cols = ', '.join(_ident(c) for c in ix.get('columns', []))
|
|
9370
|
+
attrs = []
|
|
9371
|
+
if ix.get('unique'):
|
|
9372
|
+
attrs.append('unique')
|
|
9373
|
+
if ix.get('name'):
|
|
9374
|
+
attrs.append(f"name: {_sq(ix['name'])}")
|
|
9375
|
+
line = f' ({cols})'
|
|
9376
|
+
if attrs:
|
|
9377
|
+
line += ' [' + ', '.join(attrs) + ']'
|
|
9378
|
+
lines.append(line)
|
|
9379
|
+
lines.append(' }')
|
|
9380
|
+
return lines
|
|
9381
|
+
|
|
9382
|
+
|
|
9383
|
+
def _render_table_note(comment):
|
|
9384
|
+
"""The table's ` Note: ...` line, or None when there's no comment.
|
|
9385
|
+
A single-line comment is single-quoted with `'` escaped
|
|
9386
|
+
(` Note: 'text'`); a comment containing a newline instead uses DBML's
|
|
9387
|
+
triple-quote form (` Note: '''text'''`, unescaped) — same reasoning
|
|
9388
|
+
emit.py's config_yaml_text already applied to multi-line note/comment
|
|
9389
|
+
text (literal block style there, triple-quote here), for the same
|
|
9390
|
+
"don't mangle a paragraph of free text" reason."""
|
|
9391
|
+
if not comment:
|
|
9392
|
+
return None
|
|
9393
|
+
if '\n' in comment:
|
|
9394
|
+
return f" Note: '''{comment}'''"
|
|
9395
|
+
return f' Note: {_sq(comment)}'
|
|
9396
|
+
|
|
9397
|
+
|
|
9398
|
+
def _render_table(name, t):
|
|
9399
|
+
"""One canonical table -> its full `Table ... { ... }` block, as a list
|
|
9400
|
+
of lines. Blank-line separators appear only before a section that's
|
|
9401
|
+
actually present: columns, then (blank +) indexes block if any, then
|
|
9402
|
+
(blank +) Note if any."""
|
|
9403
|
+
columns = t.get('columns', [])
|
|
9404
|
+
primary_cols = _composite_pk_columns(columns)
|
|
9405
|
+
sole_pk_name = primary_cols[0] if len(primary_cols) == 1 else None
|
|
9406
|
+
indexes = t.get('indexes', [])
|
|
9407
|
+
unique_single_cols = _unique_single_columns(indexes)
|
|
9408
|
+
|
|
9409
|
+
lines = [f'Table {_ident(name)} {{']
|
|
9410
|
+
for c in columns:
|
|
9411
|
+
lines.append(_render_dbml_column(c, sole_pk_name, unique_single_cols))
|
|
9412
|
+
|
|
9413
|
+
idx_block = _render_indexes_block(primary_cols, indexes)
|
|
9414
|
+
if idx_block:
|
|
9415
|
+
lines.append('')
|
|
9416
|
+
lines.extend(idx_block)
|
|
9417
|
+
|
|
9418
|
+
note_line = _render_table_note(t.get('comment'))
|
|
9419
|
+
if note_line:
|
|
9420
|
+
lines.append('')
|
|
9421
|
+
lines.append(note_line)
|
|
9422
|
+
|
|
9423
|
+
lines.append('}')
|
|
9424
|
+
return lines
|
|
9425
|
+
|
|
9426
|
+
|
|
9427
|
+
def _collect_refs(tables):
|
|
9428
|
+
"""Every `Ref:` tuple `(source_table, foreign_key, target_table,
|
|
9429
|
+
target_pk_column)` this schema resolves to, sorted by that same tuple for
|
|
9430
|
+
deterministic output. See the module docstring for the full lossy-
|
|
9431
|
+
contract reasoning; in short: only `belongs_to` + truthy `foreign_key`,
|
|
9432
|
+
never `has_one`/`has_many`/`has_and_belongs_to_many`; a polymorphic
|
|
9433
|
+
belongs_to is skipped silently; a target with no single-column PK is
|
|
9434
|
+
skipped with a stderr warning. Dedups defensively on
|
|
9435
|
+
(source_table, foreign_key, target_table), keeping only the first
|
|
9436
|
+
occurrence, should a duplicate somehow occur."""
|
|
9437
|
+
seen = set()
|
|
9438
|
+
kept = []
|
|
9439
|
+
for name in sorted(tables):
|
|
9440
|
+
t = tables[name]
|
|
9441
|
+
for a in t.get('associations', []):
|
|
9442
|
+
if a.get('type') != 'belongs_to' or not a.get('foreign_key'):
|
|
9443
|
+
continue
|
|
9444
|
+
if a.get('polymorphic'):
|
|
9445
|
+
continue # synthetic tableless target — nothing real to reference
|
|
9446
|
+
target = a['target']
|
|
9447
|
+
fk = a['foreign_key']
|
|
9448
|
+
key = (name, fk, target)
|
|
9449
|
+
if key in seen:
|
|
9450
|
+
continue
|
|
9451
|
+
seen.add(key)
|
|
9452
|
+
target_table = tables[target] # guaranteed present: canonical_schema
|
|
9453
|
+
# already pruned any dangling non-poly target
|
|
9454
|
+
target_primary = _composite_pk_columns(target_table.get('columns', []))
|
|
9455
|
+
if len(target_primary) != 1:
|
|
9456
|
+
print(f'Warning: --emit-dbml: skipping Ref {name}.{fk} -> {target} '
|
|
9457
|
+
'(target has no single-column primary key)', file=sys.stderr)
|
|
9458
|
+
continue
|
|
9459
|
+
kept.append((name, fk, target, target_primary[0]))
|
|
9460
|
+
kept.sort()
|
|
9461
|
+
return kept
|
|
9462
|
+
|
|
9463
|
+
|
|
9464
|
+
def render_dbml(schema):
|
|
9465
|
+
"""Render a canonical `schema` (emit.py's canonical_schema shape:
|
|
9466
|
+
{tables, notes?, groups?}) to minimal DBML text. `notes`/`groups`, even
|
|
9467
|
+
when present, are never read — out of scope this round (see module
|
|
9468
|
+
docstring). Pure and deterministic: the same schema always renders the
|
|
9469
|
+
same text, since canonical_schema's tables/columns/indexes/associations
|
|
9470
|
+
are already canonically ordered and this function sorts tables by name
|
|
9471
|
+
itself (the one thing canonical_schema does not sort, since `tables` is a
|
|
9472
|
+
plain name-keyed dict) rather than relying on any caller-side ordering."""
|
|
9473
|
+
tables = schema.get('tables', {})
|
|
9474
|
+
names = sorted(tables)
|
|
9475
|
+
|
|
9476
|
+
lines = []
|
|
9477
|
+
for i, name in enumerate(names):
|
|
9478
|
+
if i:
|
|
9479
|
+
lines.append('')
|
|
9480
|
+
lines.extend(_render_table(name, tables[name]))
|
|
9481
|
+
|
|
9482
|
+
refs = _collect_refs(tables)
|
|
9483
|
+
if refs:
|
|
9484
|
+
lines.append('')
|
|
9485
|
+
for source, fk, target, target_pk in refs:
|
|
9486
|
+
lines.append(f'Ref: {_ident(source)}.{_ident(fk)} > {_ident(target)}.{_ident(target_pk)}')
|
|
9487
|
+
|
|
9488
|
+
return '\n'.join(lines) + '\n'
|
|
9489
|
+
|
|
9490
|
+
|
|
9491
|
+
def emit_dbml_document(tables, notes_data, groups_data):
|
|
9492
|
+
"""Build the --emit-dbml document: project the final merged IR through
|
|
9493
|
+
the SAME canonical_schema --emit-json/--emit-config/--emit-digest already
|
|
9494
|
+
share, then render it. `notes_data`/`groups_data` are accepted (mirroring
|
|
9495
|
+
emit_digest_document's signature — cli.py already has both in hand at the
|
|
9496
|
+
same call site, and keeping all four emit-family builders' signatures
|
|
9497
|
+
uniform is simpler than special-casing this one) but intentionally
|
|
9498
|
+
unused: this export's scope stops at tables/columns/indexes/single-
|
|
9499
|
+
column-FK Refs/table comments (see module docstring)."""
|
|
9500
|
+
schema = canonical_schema(tables, notes_data, groups_data)
|
|
9501
|
+
return render_dbml(schema)
|
|
9153
9502
|
def main():
|
|
9154
9503
|
p = argparse.ArgumentParser(
|
|
9155
9504
|
description='Generate an interactive ER diagram (and optional Excel table definitions) '
|
|
@@ -9203,6 +9552,11 @@ def main():
|
|
|
9203
9552
|
p.add_argument('--digest-verbose', action='store_true',
|
|
9204
9553
|
help='With --emit-digest, also include nullable/default/sql_type '
|
|
9205
9554
|
'per column (omitted by default to keep the digest small)')
|
|
9555
|
+
p.add_argument('--emit-dbml', metavar='FILE.dbml', default=argparse.SUPPRESS,
|
|
9556
|
+
help='Also write a minimal DBML export of the schema '
|
|
9557
|
+
'(tables/columns/indexes/single-column-FK relations/table '
|
|
9558
|
+
'comments) alongside the HTML; use - for stdout. Does not '
|
|
9559
|
+
'include notes/groups/TableGroup (deferred)')
|
|
9206
9560
|
p.add_argument('--excel-template', metavar='FILE.xlsx', default=argparse.SUPPRESS,
|
|
9207
9561
|
help="Override the workbook's colors/fonts/borders from a template "
|
|
9208
9562
|
'.xlsx — see excel-template.xlsx and its Styles sheet for the '
|
|
@@ -9592,6 +9946,7 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
|
|
|
9592
9946
|
for _flag, _val in (('--emit-json', getattr(args, 'emit_json', None)),
|
|
9593
9947
|
('--emit-config', getattr(args, 'emit_config', None)),
|
|
9594
9948
|
('--emit-digest', getattr(args, 'emit_digest', None)),
|
|
9949
|
+
('--emit-dbml', getattr(args, 'emit_dbml', None)),
|
|
9595
9950
|
('--excel', getattr(args, 'excel', None))):
|
|
9596
9951
|
if _val:
|
|
9597
9952
|
_diff_fail(f'--diff cannot be combined with {_flag} '
|
|
@@ -9646,6 +10001,7 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
|
|
|
9646
10001
|
('--emit-json', getattr(args, 'emit_json', None)),
|
|
9647
10002
|
('--emit-config', getattr(args, 'emit_config', None)),
|
|
9648
10003
|
('--emit-digest', getattr(args, 'emit_digest', None)),
|
|
10004
|
+
('--emit-dbml', getattr(args, 'emit_dbml', None)),
|
|
9649
10005
|
('--excel', getattr(args, 'excel', None))):
|
|
9650
10006
|
if not _val or _val == '-':
|
|
9651
10007
|
continue
|
|
@@ -9688,6 +10044,14 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
|
|
|
9688
10044
|
verbose=getattr(args, 'digest_verbose', False))
|
|
9689
10045
|
if emit_digest_val is not None else None)
|
|
9690
10046
|
|
|
10047
|
+
# --emit-dbml (backlog #5): same provenance-preserving-IR timing as the
|
|
10048
|
+
# other three emit-family builders above (built before serialize_for_viewer)
|
|
10049
|
+
# — render_dbml reads columns/indexes/associations straight off
|
|
10050
|
+
# canonical_schema, same as --emit-json/--emit-config/--emit-digest.
|
|
10051
|
+
emit_dbml_val = getattr(args, 'emit_dbml', None)
|
|
10052
|
+
emit_dbml_text = (emit_dbml_document(tables, notes_data, groups_data)
|
|
10053
|
+
if emit_dbml_val is not None else None)
|
|
10054
|
+
|
|
9691
10055
|
# §9.3 serialize boundary: convert the internal provenance/sources IR to
|
|
9692
10056
|
# today's legacy-flag shape (a no-op pass-through for the already-legacy demo
|
|
9693
10057
|
# IR), so BOTH the HTML DATA_JSON and the Excel export below see exactly the
|
|
@@ -9736,6 +10100,13 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
|
|
|
9736
10100
|
Path(emit_digest_val).write_text(emit_digest_text, encoding='utf-8')
|
|
9737
10101
|
print(f'Generated: {emit_digest_val}', file=sys.stderr)
|
|
9738
10102
|
|
|
10103
|
+
if emit_dbml_text is not None:
|
|
10104
|
+
if emit_dbml_val == '-':
|
|
10105
|
+
sys.stdout.write(emit_dbml_text)
|
|
10106
|
+
else:
|
|
10107
|
+
Path(emit_dbml_val).write_text(emit_dbml_text, encoding='utf-8')
|
|
10108
|
+
print(f'Generated: {emit_dbml_val}', file=sys.stderr)
|
|
10109
|
+
|
|
9739
10110
|
if getattr(args, 'excel', None):
|
|
9740
10111
|
write_excel(tables, Path(args.excel), title_name,
|
|
9741
10112
|
template_path=getattr(args, 'excel_template', None), notes=notes_data,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: erdscope
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies
|
|
5
5
|
Author: tas6
|
|
6
6
|
License-Expression: MIT
|
|
@@ -192,11 +192,12 @@ explicit CLI argument wins.
|
|
|
192
192
|
|---|---|
|
|
193
193
|
| `--config PATH` | Load model sources, defaults, schema definitions or patches, notes, and groups |
|
|
194
194
|
| `--models PATH` | Override config `models` with Rails, Prisma, or Django input; repeatable |
|
|
195
|
-
| `--excel FILE.xlsx` | Also generate a table-definition workbook |
|
|
195
|
+
| `--excel FILE.xlsx` | Also generate a table-definition workbook (includes Notes/Groups sheets when configured) |
|
|
196
196
|
| `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
|
|
197
197
|
| `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
|
|
198
198
|
| `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
|
|
199
199
|
| `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
|
|
200
|
+
| `--emit-dbml FILE.dbml` | Also write a minimal DBML export of the schema — tables/columns/indexes/single-column-FK relations/table comments (`-` for stdout; no notes/groups/`TableGroup`) |
|
|
200
201
|
| `--only 'user*,order*'` | Generate only matching tables |
|
|
201
202
|
| `--exclude '*_logs'` | Leave matching tables out |
|
|
202
203
|
| `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
|
|
@@ -16,9 +16,11 @@ tests/test_demo.py
|
|
|
16
16
|
tests/test_diff.py
|
|
17
17
|
tests/test_e2e.py
|
|
18
18
|
tests/test_emit_config.py
|
|
19
|
+
tests/test_emit_dbml.py
|
|
19
20
|
tests/test_emit_digest.py
|
|
20
21
|
tests/test_emit_json.py
|
|
21
22
|
tests/test_erd.py
|
|
23
|
+
tests/test_excel_notes_groups.py
|
|
22
24
|
tests/test_golden_format1.py
|
|
23
25
|
tests/test_groups.py
|
|
24
26
|
tests/test_merge_ir.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "erdscope"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|