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.
Files changed (32) hide show
  1. {erdscope-0.7.1/erdscope.egg-info → erdscope-0.7.2}/PKG-INFO +3 -2
  2. {erdscope-0.7.1 → erdscope-0.7.2}/README.md +2 -1
  3. {erdscope-0.7.1 → erdscope-0.7.2}/erd.py +383 -12
  4. {erdscope-0.7.1 → erdscope-0.7.2/erdscope.egg-info}/PKG-INFO +3 -2
  5. {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/SOURCES.txt +2 -0
  6. {erdscope-0.7.1 → erdscope-0.7.2}/pyproject.toml +1 -1
  7. erdscope-0.7.2/tests/test_emit_dbml.py +540 -0
  8. erdscope-0.7.2/tests/test_excel_notes_groups.py +182 -0
  9. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_groups.py +21 -3
  10. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_notes.py +18 -2
  11. {erdscope-0.7.1 → erdscope-0.7.2}/LICENSE +0 -0
  12. {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/dependency_links.txt +0 -0
  13. {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/entry_points.txt +0 -0
  14. {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/requires.txt +0 -0
  15. {erdscope-0.7.1 → erdscope-0.7.2}/erdscope.egg-info/top_level.txt +0 -0
  16. {erdscope-0.7.1 → erdscope-0.7.2}/setup.cfg +0 -0
  17. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_build.py +0 -0
  18. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_characterization.py +0 -0
  19. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_config_validation.py +0 -0
  20. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_db_integration.py +0 -0
  21. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_demo.py +0 -0
  22. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_diff.py +0 -0
  23. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_e2e.py +0 -0
  24. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_config.py +0 -0
  25. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_digest.py +0 -0
  26. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_emit_json.py +0 -0
  27. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_erd.py +0 -0
  28. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_golden_format1.py +0 -0
  29. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_merge_ir.py +0 -0
  30. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_pipeline.py +0 -0
  31. {erdscope-0.7.1 → erdscope-0.7.2}/tests/test_provider_contract.py +0 -0
  32. {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.1
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` (notes Phase 1) and `groups` (groups Phase 1) are accepted but
3210
- UNUSED in this release wiring only, so the shared data form (tables +
3211
- notes + groups) is already in place for a future sheet without an
3212
- interface break. Not touching them here keeps every existing Excel test
3213
- byte-for-byte unchanged."""
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
- rows = [[(f'{title} table definitions', S_TITLE)], [],
3230
- [('#', S_HEADER), ('Table', S_HEADER), ('Comment', S_HEADER),
3231
- ('Columns', S_HEADER), ('Indexes', S_HEADER), ('Missing schema', S_HEADER)]]
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
- rows.append([(i, s), (n, s), (t.get('comment', ''), s), (len(t['columns']), s),
3238
- (len(t.get('indexes', [])), s),
3239
- ('yes' if t.get('schema_missing') else '', s)])
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=[5, 32, 50, 10, 10, 14], links=links)
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.1
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.1"
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"