mcdview 0.34.2__tar.gz → 0.34.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcdview
3
- Version: 0.34.2
3
+ Version: 0.34.4
4
4
  Summary: Turn a SQL data model into one self-contained, interactive HTML page
5
5
  Project-URL: Homepage, https://github.com/Gheop/mcdview
6
6
  Project-URL: Demo, https://gheop.github.io/mcdview/
@@ -352,10 +352,57 @@ file's header.
352
352
 
353
353
  | Version | Date | Changes |
354
354
  |---------|------------|---------|
355
+ | 1.0.1 | 2026-11-04 | Add the v0.34.3 and v0.34.4 changelog entries |
355
356
  | 1.0.0 | 2026-09-01 | Initialize; add a CI section (local flags + hosted GitHub/GitLab integrations), document `--to-dico`/`--to-preview`/`--lint`/`--fail-on`, add the blog link, reconcile the tested-at-scale figure |
356
357
 
357
358
  ## Changelog
358
359
 
360
+ ### v0.34.4 — MySQL files without backticks keep all their tables (2026-11-04)
361
+
362
+ - A MySQL file without backticks could be read as PostgreSQL and lose its
363
+ lower-case tables: the PostgreSQL parser only matches upper-case
364
+ `CREATE TABLE`, so a file mixing `CREATE TABLE` and `create table` kept only
365
+ the upper-case ones (Dolibarr: 141 of 418 tables). When a file carries
366
+ `AUTO_INCREMENT` or `ENGINE=`, `--dialect auto` now also tries the MySQL
367
+ reading and keeps it when it finds more tables, or as many tables and no
368
+ fewer foreign keys. Across the test corpus, 612 files are now read as MySQL,
369
+ 25 regain 111 tables, and none loses a table or a foreign key.
370
+ - Those files also lose the phantom columns the PostgreSQL parser made from
371
+ MySQL lines (`KEY ...`, `CHECK` clauses, `ON DELETE ...`), and their types
372
+ drop the `AUTO_INCREMENT` / `NULL` noise (`INT AUTO_INCREMENT` → `int`).
373
+ - MySQL, SQLite and other non-PostgreSQL files now read columns added by
374
+ `ALTER TABLE ... ADD [COLUMN]`.
375
+ - A column declared with an explicit `NULL` in a MySQL, SQLite or SQL Server
376
+ file was shown as `NOT NULL`. 29,551 columns of the corpus were affected.
377
+ - Rails `t.check_constraint` and `t.exclusion_constraint` lines are no longer
378
+ shown as columns (348 phantom columns in the corpus).
379
+
380
+ ### v0.34.3 — `--lint` sees the indexes it used to miss (2026-09-27)
381
+
382
+ - `--lint` flagged foreign keys as `unindexed_fk` although an index covered
383
+ them, because some declared indexes were not read. They now are: Rails
384
+ `t.index` and `add_index`; Drizzle `index()`, `uniqueIndex()` and `unique()`
385
+ with `.on()` or `.onOnly()`, and column-level `.unique()`; SQL `UNIQUE` inside
386
+ `CREATE TABLE` (column or table level); MySQL `KEY` / `INDEX` inside
387
+ `CREATE TABLE`, which `mysqldump` writes for every foreign key. Across the test
388
+ corpus (13,630 files, 94,146 FKs), `unindexed_fk` drops from 64,806 to 44,172:
389
+ Rails 6,849 → 232, Drizzle 15,273 → 7,853, MySQL 8,005 → 2,531.
390
+ - phpMyAdmin exports, which add the primary key and indexes afterwards
391
+ (`ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (col)`), lost both: nearly
392
+ every table was reported as `missing_pk`. They are read now; across the
393
+ corpus `missing_pk` drops from 20,820 to 8,505.
394
+ - Drizzle composite primary keys (`primaryKey({ columns: [...] })` and the older
395
+ `primaryKey(t.a, t.b)`) are read.
396
+ - The panel lists these indexes, and a foreign key covered by a single-column
397
+ unique index is shown as 1:1.
398
+ - A Drizzle column declared without a name string took the first string found
399
+ further on as its name, for example `cascade` from `{ onDelete: "cascade" }`
400
+ or `number` from `{ mode: "number" }`. It now takes its field name
401
+ (2,613 columns fixed in the corpus).
402
+ - A crafted Drizzle file with unclosed `pgTable(` calls could take minutes to
403
+ parse (3 min for 130 KB). Parsing is now linear in the file size.
404
+ - Table and foreign-key counts are unchanged on every corpus file.
405
+
359
406
  ### v0.34.2 — Right SQL dialect on large MySQL and Prisma models (2026-09-23)
360
407
 
361
408
  - `--dialect auto` now chooses the dialect on the whole file. On files over
@@ -330,10 +330,57 @@ file's header.
330
330
 
331
331
  | Version | Date | Changes |
332
332
  |---------|------------|---------|
333
+ | 1.0.1 | 2026-11-04 | Add the v0.34.3 and v0.34.4 changelog entries |
333
334
  | 1.0.0 | 2026-09-01 | Initialize; add a CI section (local flags + hosted GitHub/GitLab integrations), document `--to-dico`/`--to-preview`/`--lint`/`--fail-on`, add the blog link, reconcile the tested-at-scale figure |
334
335
 
335
336
  ## Changelog
336
337
 
338
+ ### v0.34.4 — MySQL files without backticks keep all their tables (2026-11-04)
339
+
340
+ - A MySQL file without backticks could be read as PostgreSQL and lose its
341
+ lower-case tables: the PostgreSQL parser only matches upper-case
342
+ `CREATE TABLE`, so a file mixing `CREATE TABLE` and `create table` kept only
343
+ the upper-case ones (Dolibarr: 141 of 418 tables). When a file carries
344
+ `AUTO_INCREMENT` or `ENGINE=`, `--dialect auto` now also tries the MySQL
345
+ reading and keeps it when it finds more tables, or as many tables and no
346
+ fewer foreign keys. Across the test corpus, 612 files are now read as MySQL,
347
+ 25 regain 111 tables, and none loses a table or a foreign key.
348
+ - Those files also lose the phantom columns the PostgreSQL parser made from
349
+ MySQL lines (`KEY ...`, `CHECK` clauses, `ON DELETE ...`), and their types
350
+ drop the `AUTO_INCREMENT` / `NULL` noise (`INT AUTO_INCREMENT` → `int`).
351
+ - MySQL, SQLite and other non-PostgreSQL files now read columns added by
352
+ `ALTER TABLE ... ADD [COLUMN]`.
353
+ - A column declared with an explicit `NULL` in a MySQL, SQLite or SQL Server
354
+ file was shown as `NOT NULL`. 29,551 columns of the corpus were affected.
355
+ - Rails `t.check_constraint` and `t.exclusion_constraint` lines are no longer
356
+ shown as columns (348 phantom columns in the corpus).
357
+
358
+ ### v0.34.3 — `--lint` sees the indexes it used to miss (2026-09-27)
359
+
360
+ - `--lint` flagged foreign keys as `unindexed_fk` although an index covered
361
+ them, because some declared indexes were not read. They now are: Rails
362
+ `t.index` and `add_index`; Drizzle `index()`, `uniqueIndex()` and `unique()`
363
+ with `.on()` or `.onOnly()`, and column-level `.unique()`; SQL `UNIQUE` inside
364
+ `CREATE TABLE` (column or table level); MySQL `KEY` / `INDEX` inside
365
+ `CREATE TABLE`, which `mysqldump` writes for every foreign key. Across the test
366
+ corpus (13,630 files, 94,146 FKs), `unindexed_fk` drops from 64,806 to 44,172:
367
+ Rails 6,849 → 232, Drizzle 15,273 → 7,853, MySQL 8,005 → 2,531.
368
+ - phpMyAdmin exports, which add the primary key and indexes afterwards
369
+ (`ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (col)`), lost both: nearly
370
+ every table was reported as `missing_pk`. They are read now; across the
371
+ corpus `missing_pk` drops from 20,820 to 8,505.
372
+ - Drizzle composite primary keys (`primaryKey({ columns: [...] })` and the older
373
+ `primaryKey(t.a, t.b)`) are read.
374
+ - The panel lists these indexes, and a foreign key covered by a single-column
375
+ unique index is shown as 1:1.
376
+ - A Drizzle column declared without a name string took the first string found
377
+ further on as its name, for example `cascade` from `{ onDelete: "cascade" }`
378
+ or `number` from `{ mode: "number" }`. It now takes its field name
379
+ (2,613 columns fixed in the corpus).
380
+ - A crafted Drizzle file with unclosed `pgTable(` calls could take minutes to
381
+ parse (3 min for 130 KB). Parsing is now linear in the file size.
382
+ - Table and foreign-key counts are unchanged on every corpus file.
383
+
337
384
  ### v0.34.2 — Right SQL dialect on large MySQL and Prisma models (2026-09-23)
338
385
 
339
386
  - `--dialect auto` now chooses the dialect on the whole file. On files over
@@ -90,6 +90,9 @@ RE_FK_TABLE = re.compile(
90
90
  r'REFERENCES\s+(?:"?(\w+)"?\.)?"?(\w+)"?(?:\s*\(([^)]+)\))?', re.I)
91
91
  RE_FK_INLINE = re.compile(
92
92
  r'\bREFERENCES\s+(?:"?(\w+)"?\.)?"?(\w+)"?(?:\s*\(([^)]+)\))?', re.I)
93
+ RE_UNIQUE_TABLE = re.compile(
94
+ r'^\s*(?:CONSTRAINT\s+"?(\w+)"?\s+)?UNIQUE\b[^(]*\(([^)]+)\)', re.I)
95
+ RE_UNIQUE_MOT = re.compile(r'\bUNIQUE\b', re.I)
93
96
  # an entry that opens with a table constraint keyword is not a column definition
94
97
  RE_DEBUT_CONTRAINTE = re.compile(
95
98
  r'^\s*(?:CONSTRAINT\s+"?\w+"?\s+)?'
@@ -413,15 +416,21 @@ def analyser_sql(chemin, src=None):
413
416
  vues.add((de, scol, vers, dcol))
414
417
  fks.append(nouvelle_fk(de, scol, vers, dcol, nom, od, ou))
415
418
 
419
+ def ajouter_unique(de, nom, cols):
420
+ if cols and not any(ix['cols'] == cols and ix['unique'] for ix in tables[de]['index']):
421
+ tables[de]['index'].append({'nom': nom, 'cols': cols, 'unique': True})
422
+
416
423
  for cle, corps in corps_par_cle.items():
417
424
  de = resoudre(cle)
418
425
  if de not in tables:
419
426
  continue
420
427
  # this pass only ever yields FKs from a REFERENCES token (RE_FK_TABLE /
421
- # RE_FK_INLINE both require it). A body without one — every table in a
422
- # pg_dump, where FKs live in ALTER TABLE — produces nothing, so skip the
423
- # per-character body scan entirely (it was ~35 % of the parse time)
424
- if 'REFERENCES' not in corps.upper():
428
+ # RE_FK_INLINE both require it) and indexes from a UNIQUE one. A body
429
+ # with neither — every table in a pg_dump, where constraints live in
430
+ # ALTER TABLE — produces nothing, so skip the per-character body scan
431
+ # entirely (it was ~35 % of the parse time)
432
+ haut = corps.upper()
433
+ if 'REFERENCES' not in haut and 'UNIQUE' not in haut:
425
434
  continue
426
435
  # terminate the body so decouper_corps emits the final entry too (it only
427
436
  # flushes the buffer at a depth-0 ')' or comma; a multi-line body has
@@ -430,6 +439,16 @@ def analyser_sql(chemin, src=None):
430
439
  entrees, _ = decouper_corps(corps + '\n)')
431
440
  for e in entrees:
432
441
  e = RE_COMMENTAIRE.sub('', RE_LITTERAL.sub('', e)).strip()
442
+ # UNIQUE in the body is an index too: table-level `[CONSTRAINT n]
443
+ # UNIQUE (a, b)` or column-level `col type UNIQUE` (pg_dump writes
444
+ # them as ALTER TABLE ... ADD CONSTRAINT, read above)
445
+ mu = RE_UNIQUE_TABLE.match(e)
446
+ if mu:
447
+ ajouter_unique(de, mu.group(1) or '', identifiants(mu.group(2)))
448
+ continue
449
+ cm = RE_COLONNE.match(e)
450
+ if cm and not RE_DEBUT_CONTRAINTE.match(e) and RE_UNIQUE_MOT.search(cm.group(2)):
451
+ ajouter_unique(de, '', [cm.group(1)])
433
452
  mt = RE_FK_TABLE.match(e)
434
453
  if mt:
435
454
  scols, dsch, dtab, dcols = mt.groups()
@@ -489,6 +508,10 @@ RE_CREATE_TABLE = re.compile(r'\bCREATE\b[^;(]{0,80}?\bTABLE\b', re.I)
489
508
  SEUIL_ECHANTILLON = 25000
490
509
 
491
510
 
511
+ # MySQL table options a PostgreSQL file never carries
512
+ RE_MARQUE_MYSQL = re.compile(r'\bAUTO_INCREMENT\b|\)\s*ENGINE\s*=', re.I)
513
+
514
+
492
515
  def ressemble_postgres(chemin, src=None):
493
516
  """False when the DDL is clearly another dialect the regex parser would
494
517
  mangle: backticks (never valid PostgreSQL), AUTOINCREMENT (SQLite) or
@@ -531,55 +554,129 @@ def analyser(chemin, dialecte='auto'):
531
554
  apercu = src[:300000]
532
555
  # PostgreSQL regex parser first, but only trust it when the file isn't
533
556
  # obviously another dialect it would mis-parse (MySQL backtick PKs...)
557
+ pg = None
534
558
  if ressemble_postgres(chemin, apercu):
535
559
  tables, fks = analyser_sql(chemin, src)
536
560
  if tables:
537
- return (*normaliser_casse(tables, fks), 'postgresql')
538
- # otherwise try the sqlglot dialects, keeping the first one with the most
539
- # tables; a dialect that finds every table the text declares cannot be
540
- # beaten, so the trial stops there.
541
- candidats = list(dict.fromkeys([flairer_dialecte(chemin, apercu)] + DIALECTES_ESSAI))
542
- attendu = len(RE_CREATE_TABLE.findall(src))
543
-
544
- def essayer(d, texte):
545
- # defense in depth: an unforeseen sqlglot AST shape must not crash
546
- # the whole tool — fall back to no tables for that dialect
547
- try:
548
- return analyser_sqlglot(chemin, d, False, texte)
549
- except Exception:
550
- return {}, []
551
-
552
- if len(src) <= SEUIL_ECHANTILLON:
553
- # small file: whole-file parse per dialect, exact
554
- meilleur = ({}, [], candidats[0])
555
- for d in candidats:
556
- t, f = essayer(d, src)
557
- if len(t) > len(meilleur[0]):
558
- meilleur = (t, f, d)
559
- if attendu and len(t) >= attendu:
560
- break
561
- return (*normaliser_casse(meilleur[0], meilleur[1]), meilleur[2])
562
- # big file: parse the sniffed dialect in full; if it misses declared
563
- # tables, rank the dialects on a prefix cut at a statement end, and parse
564
- # the winner in full too, keeping the better of the two. (The prefix
565
- # alone used to decide: cut mid-statement, it chose the wrong dialect on
566
- # 99 of 775 big corpus files, losing tables or rewriting column types.)
567
- t, f = essayer(candidats[0], src)
568
- if attendu and len(t) >= attendu:
569
- return (*normaliser_casse(t, f), candidats[0])
570
- ech = src[:SEUIL_ECHANTILLON]
571
- fin = ech.rfind(';')
572
- ech = ech[:fin + 1] if fin > 0 else ech
573
- gagnant = max(candidats, key=lambda d: len(essayer(d, ech)[0])) # ties: earliest
574
- if gagnant != candidats[0]:
575
- t2, f2 = essayer(gagnant, src)
576
- if len(t2) > len(t):
577
- return (*normaliser_casse(t2, f2), gagnant)
578
- return (*normaliser_casse(t, f), candidats[0])
561
+ pg = (*normaliser_casse(tables, fks), 'postgresql')
562
+ # MySQL without backticks also reaches here, and the regex parser
563
+ # only matches upper-case CREATE TABLE: a file mixing cases lost
564
+ # every lower-case table. With a MySQL marker, sqlglot is tried
565
+ # too and wins with more tables, or on a tie when it reads the
566
+ # file as MySQL with no fewer FKs: the regex parser then takes
567
+ # MySQL's KEY lines for columns and keeps AUTO_INCREMENT / NULL
568
+ # in the types. Not on a tie for other dialects:
569
+ # ClickHouse's ENGINE = also matches, and sqlite would rewrite
570
+ # its types (String -> text)
571
+ if not RE_MARQUE_MYSQL.search(apercu):
572
+ return pg
573
+ autre = analyser_autres(chemin, src, apercu)
574
+ if pg is None:
575
+ return autre
576
+ # only tables with columns count: sqlglot also reports a CREATE
577
+ # TEMPORARY TABLE ... AS SELECT, empty
578
+ na, np_ = tables_pleines(autre[0]), tables_pleines(pg[0])
579
+ if na > np_ or (na == np_ and autre[2] in ('mysql', 'mariadb')
580
+ and len(autre[1]) >= len(pg[1])):
581
+ return autre
582
+ return pg
579
583
  tables, fks = analyser_sqlglot(chemin, dialecte)
580
584
  return (*normaliser_casse(tables, fks), dialecte)
581
585
 
582
586
 
587
+ def tables_pleines(tables):
588
+ return sum(1 for t in tables.values() if t['cols'])
589
+
590
+
591
+ def analyser_autres(chemin, src, apercu):
592
+ """The sqlglot side of analyser(..., 'auto'): returns (tables, fks, dialect)."""
593
+ # try the sqlglot dialects, keeping the first one with the most
594
+ # tables; a dialect that finds every table the text declares cannot be
595
+ # beaten, so the trial stops there.
596
+ candidats = list(dict.fromkeys([flairer_dialecte(chemin, apercu)] + DIALECTES_ESSAI))
597
+ attendu = len(RE_CREATE_TABLE.findall(src))
598
+
599
+ def essayer(d, texte):
600
+ # defense in depth: an unforeseen sqlglot AST shape must not crash
601
+ # the whole tool — fall back to no tables for that dialect
602
+ try:
603
+ return analyser_sqlglot(chemin, d, False, texte)
604
+ except Exception:
605
+ return {}, []
606
+
607
+ if len(src) <= SEUIL_ECHANTILLON:
608
+ # small file: whole-file parse per dialect, exact
609
+ meilleur = ({}, [], candidats[0])
610
+ for d in candidats:
611
+ t, f = essayer(d, src)
612
+ if len(t) > len(meilleur[0]):
613
+ meilleur = (t, f, d)
614
+ if attendu and len(t) >= attendu:
615
+ break
616
+ return (*normaliser_casse(meilleur[0], meilleur[1]), meilleur[2])
617
+ # big file: parse the sniffed dialect in full; if it misses declared
618
+ # tables, rank the dialects on a prefix cut at a statement end, and parse
619
+ # the winner in full too, keeping the better of the two. (The prefix
620
+ # alone used to decide: cut mid-statement, it chose the wrong dialect on
621
+ # 99 of 775 big corpus files, losing tables or rewriting column types.)
622
+ t, f = essayer(candidats[0], src)
623
+ if attendu and len(t) >= attendu:
624
+ return (*normaliser_casse(t, f), candidats[0])
625
+ ech = src[:SEUIL_ECHANTILLON]
626
+ fin = ech.rfind(';')
627
+ ech = ech[:fin + 1] if fin > 0 else ech
628
+ gagnant = max(candidats, key=lambda d: len(essayer(d, ech)[0])) # ties: earliest
629
+ if gagnant != candidats[0]:
630
+ t2, f2 = essayer(gagnant, src)
631
+ if len(t2) > len(t):
632
+ return (*normaliser_casse(t2, f2), gagnant)
633
+ return (*normaliser_casse(t, f), candidats[0])
634
+
635
+
636
+ RE_ALTER_TETE = re.compile(
637
+ r'ALTER\s+TABLE\s+(?:ONLY\s+)?(?:[`"\[]?(\w+)[`"\]]?\.)?[`"\[]?(\w+)[`"\]]?\s+ADD\b', re.I)
638
+ RE_ALTER_CLE = re.compile(
639
+ r'\bADD\s+(?:CONSTRAINT\s+[`"\[]?\w+[`"\]]?\s+)?'
640
+ r'(PRIMARY\s+KEY|UNIQUE(?:\s+(?:KEY|INDEX))?|KEY|INDEX)\s*'
641
+ r'(?:[`"\[]?(\w+)[`"\]]?\s*)?(?:USING\s+\w+\s*)?\(((?:[^()]|\([^()]*\))*)\)', re.I)
642
+
643
+
644
+ def ajouter_cles_alter(src, tables):
645
+ """Primary keys and indexes added by ALTER TABLE, as phpMyAdmin exports them:
646
+ `ALTER TABLE t ADD PRIMARY KEY (id), ADD KEY k (a), ADD UNIQUE KEY u (b);`.
647
+ sqlglot cannot parse several ADD KEY in one statement (it keeps a raw
648
+ Command), so these are read here. A PK only fills a table that has none;
649
+ an index already present is not added twice."""
650
+ # a statement ends at the next ';' or, when that is missing, at the next
651
+ # ALTER: bounding it (and caching the ';' position) keeps many unterminated
652
+ # ALTERs linear instead of each one rescanning the rest of the file
653
+ tetes = list(RE_ALTER_TETE.finditer(src))
654
+ fin = -1
655
+ for n, m in enumerate(tetes):
656
+ cle = f"{m.group(1) or 'public'}.{m.group(2)}"
657
+ if cle not in tables:
658
+ continue
659
+ if fin < m.end():
660
+ fin = src.find(';', m.end())
661
+ if fin < 0:
662
+ fin = len(src)
663
+ borne = min(fin, tetes[n + 1].start() if n + 1 < len(tetes) else len(src))
664
+ t = tables[cle]
665
+ for c in RE_ALTER_CLE.finditer(src, m.end() - 3, borne):
666
+ genre = c.group(1).upper()
667
+ cols = [x for x in identifiants(re.sub(r'\(\d+\)|\s+(?:ASC|DESC)\b', '', c.group(3), flags=re.I))
668
+ if x]
669
+ if not cols:
670
+ continue
671
+ if genre.startswith('PRIMARY'):
672
+ if not t['pk']:
673
+ t['pk'] = cols
674
+ continue
675
+ unique = genre.startswith('UNIQUE')
676
+ if not any(ix['cols'] == cols and ix['unique'] == unique for ix in t['index']):
677
+ t['index'].append({'nom': c.group(2) or '', 'cols': cols, 'unique': unique})
678
+
679
+
583
680
  def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
584
681
  try:
585
682
  import logging
@@ -624,6 +721,26 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
624
721
  except Exception:
625
722
  return ''
626
723
 
724
+ def colonne_sqlglot(d):
725
+ """A ColumnDef as a column record, plus its constraint kinds."""
726
+ # a constraint is usually a ColumnConstraint wrapping a .kind;
727
+ # some sqlglot nodes (InOutColumnConstraint…) sit in the list
728
+ # directly and have no .kind — keep the node itself then
729
+ kinds = [getattr(c, 'kind', c) for c in d.constraints]
730
+ typ = d.args.get('kind')
731
+ defc = next((c.this for c in kinds
732
+ if isinstance(c, exp.DefaultColumnConstraint)), None)
733
+ brut = rendre_sql(typ) if typ else ''
734
+ # lowercase to match the PostgreSQL parser's output, but keep
735
+ # string literals intact (ENUM('Active') must not become 'active')
736
+ type_txt = brut if "'" in brut else brut.lower()
737
+ # an explicit `NULL` is a NotNullColumnConstraint too, with allow_null
738
+ return nouvelle_colonne(
739
+ d.name, type_txt,
740
+ any(isinstance(c, exp.NotNullColumnConstraint) and not c.args.get('allow_null')
741
+ for c in kinds),
742
+ rendre_sql(defc) if defc is not None else ''), kinds
743
+
627
744
  def ajouter_fk(de, cols, ref, nom):
628
745
  cible = ref.find(exp.Table)
629
746
  if not cible:
@@ -650,30 +767,17 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
650
767
  k = cle(tbl)
651
768
  cols, pk, colcomments = [], [], {}
652
769
  for d in stmt.find_all(exp.ColumnDef):
653
- # a constraint is usually a ColumnConstraint wrapping a .kind;
654
- # some sqlglot nodes (InOutColumnConstraint…) sit in the list
655
- # directly and have no .kind — keep the node itself then
656
- kinds = [getattr(c, 'kind', c) for c in d.constraints]
657
- typ = d.args.get('kind')
658
- defc = next((c.this for c in kinds
659
- if isinstance(c, exp.DefaultColumnConstraint)), None)
660
- comc = next((c.this for c in kinds
661
- if isinstance(c, exp.CommentColumnConstraint)), None)
770
+ col, kinds = colonne_sqlglot(d)
662
771
  if any(isinstance(c, exp.PrimaryKeyColumnConstraint) for c in kinds):
663
772
  pk.append(d.name)
664
773
  for c in kinds:
665
774
  if isinstance(c, exp.Reference):
666
775
  ajouter_fk(k, [d.name], c, '')
776
+ comc = next((c.this for c in kinds
777
+ if isinstance(c, exp.CommentColumnConstraint)), None)
667
778
  if comc is not None:
668
779
  colcomments[d.name] = comc.name
669
- brut = rendre_sql(typ) if typ else ''
670
- # lowercase to match the PostgreSQL parser's output, but keep
671
- # string literals intact (ENUM('Active') must not become 'active')
672
- type_txt = brut if "'" in brut else brut.lower()
673
- cols.append(nouvelle_colonne(
674
- d.name, type_txt,
675
- any(isinstance(c, exp.NotNullColumnConstraint) for c in kinds),
676
- rendre_sql(defc) if defc is not None else ''))
780
+ cols.append(col)
677
781
  for p in stmt.find_all(exp.PrimaryKey):
678
782
  noms = [c.name for c in p.expressions]
679
783
  if noms:
@@ -689,12 +793,25 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
689
793
  ref = fk.args.get('reference')
690
794
  if ref:
691
795
  ajouter_fk(k, [c.name for c in fk.expressions], ref, nom_fk(fk))
692
- # inline UNIQUE constraints become indexes
796
+ # inline UNIQUE constraints become indexes: table-level `UNIQUE (a, b)`
797
+ # carries its columns, column-level `col INT UNIQUE` has none (its
798
+ # column is the enclosing definition)
693
799
  for u in stmt.find_all(exp.UniqueColumnConstraint):
694
- sch = u.this if u.this is not None else None
695
- noms = [e.name for e in getattr(sch, 'expressions', [])] if sch else []
800
+ if u.this is not None:
801
+ noms = [e.name for e in getattr(u.this, 'expressions', [])]
802
+ else:
803
+ cd = u.find_ancestor(exp.ColumnDef)
804
+ noms = [cd.name] if cd else []
696
805
  if noms:
697
806
  tables[k]['index'].append({'nom': '', 'cols': noms, 'unique': True})
807
+ # MySQL declares plain indexes in the body: KEY / INDEX name (cols);
808
+ # mysqldump writes one for every foreign key
809
+ for ix in stmt.find_all(exp.IndexColumnConstraint):
810
+ noms = [e.name for e in ix.expressions if e.name]
811
+ if noms:
812
+ tables[k]['index'].append(
813
+ {'nom': ix.this.name if ix.this is not None else '',
814
+ 'cols': noms, 'unique': False})
698
815
  elif isinstance(stmt, exp.Create) and stmt.kind == 'INDEX':
699
816
  idx = stmt.this
700
817
  tbl = idx.args.get('table') if idx is not None else None
@@ -708,6 +825,13 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
708
825
  src_tbl = stmt.find(exp.Table)
709
826
  if src_tbl:
710
827
  k = cle(src_tbl)
828
+ # columns added afterwards: ALTER TABLE t ADD [COLUMN] c type
829
+ if k in tables:
830
+ noms = {c['nom'] for c in tables[k]['cols']}
831
+ for d in stmt.args.get('actions') or []:
832
+ if isinstance(d, exp.ColumnDef) and d.name and d.name not in noms:
833
+ tables[k]['cols'].append(colonne_sqlglot(d)[0])
834
+ noms.add(d.name)
711
835
  for fk in stmt.find_all(exp.ForeignKey):
712
836
  ref = fk.args.get('reference')
713
837
  if ref:
@@ -722,6 +846,8 @@ def analyser_sqlglot(chemin, dialecte, strict=True, src=None):
722
846
  tables[k]['index'].append(
723
847
  {'nom': cont.name if cont else '', 'cols': noms, 'unique': True})
724
848
 
849
+ ajouter_cles_alter(src, tables)
850
+
725
851
  # a reference without a column list points at the target's primary key
726
852
  for f in fks:
727
853
  if not f['colcible']:
@@ -1026,6 +1152,30 @@ def singulariser(mot):
1026
1152
  return mot
1027
1153
 
1028
1154
 
1155
+ def index_rails(args):
1156
+ """One Rails index declaration from what follows `t.index` / `add_index "t",`:
1157
+ the columns (["a", "b"], %w[a b], "a" or :a) then the options (name:,
1158
+ unique:, also the old :name => / :unique => form). None if unreadable."""
1159
+ args = args.strip()
1160
+ if args.startswith('%w'):
1161
+ mw = re.match(r'%w[\[(]([^\])]*)[\])]', args)
1162
+ cols, reste = (mw.group(1).split(), args[mw.end():]) if mw else ([], '')
1163
+ elif args.startswith('['):
1164
+ fin = args.find(']')
1165
+ if fin < 0:
1166
+ return None
1167
+ cols = [a or b for a, b in re.findall(r'["\']([^"\']+)["\']|:(\w+)', args[:fin])]
1168
+ reste = args[fin + 1:]
1169
+ else:
1170
+ mc = re.match(r'["\']([^"\']+)["\']|:(\w+)', args)
1171
+ cols, reste = ([mc.group(1) or mc.group(2)], args[mc.end():]) if mc else ([], '')
1172
+ if not cols:
1173
+ return None
1174
+ mn = re.search(r'name:\s*["\']([^"\']+)["\']|:name\s*=>\s*["\']([^"\']+)["\']', reste)
1175
+ return {'nom': (mn.group(1) or mn.group(2)) if mn else '', 'cols': cols,
1176
+ 'unique': bool(re.search(r'unique:\s*true|:unique\s*=>\s*true', reste))}
1177
+
1178
+
1029
1179
  def analyser_schema_rb(chemin):
1030
1180
  """Parse a Rails db/schema.rb natively (the create_table / add_foreign_key
1031
1181
  DSL is regular). The implicit `id` primary key is added unless `id: false`;
@@ -1057,7 +1207,9 @@ def analyser_schema_rb(chemin):
1057
1207
  for cm in re.finditer(r'\b' + re.escape(var) + r'\.(\w+)\s+["\'"]([^"\'"]+)["\'"]([^\n]*)', corps):
1058
1208
  typ, cn, reste = cm.groups()
1059
1209
  if typ == 'index':
1060
- continue
1210
+ continue # read below with the array forms
1211
+ if typ.endswith('_constraint'):
1212
+ continue # check_constraint / exclusion_constraint: no column
1061
1213
  defc = re.search(r'default:\s*("[^"]*"|\'[^\']*\'|[^,\n]+)', reste)
1062
1214
  defaut = defc.group(1).strip('\'"') if defc else ''
1063
1215
  if typ in ('references', 'belongs_to'):
@@ -1066,7 +1218,14 @@ def analyser_schema_rb(chemin):
1066
1218
  cols.append(nouvelle_colonne(cn + '_type', 'string'))
1067
1219
  else:
1068
1220
  cols.append(nouvelle_colonne(cn, typ, 'null: false' in reste, defaut))
1069
- tables[cle] = nouvelle_table('public', nom, cols, pk)
1221
+ index = [ix for im in re.finditer(r'\b' + re.escape(var) + r'\.index\b\(?\s*([^\n]*)', corps)
1222
+ if (ix := index_rails(im.group(1)))]
1223
+ tables[cle] = nouvelle_table('public', nom, cols, pk, index=index)
1224
+ # pre-Rails-5 dumps declare indexes after the tables: add_index "t", [...]
1225
+ for m in re.finditer(r'add_index\s*\(?\s*["\':]([\w.]+)["\']?\s*,\s*([^\n]*)', src):
1226
+ ix = index_rails(m.group(2))
1227
+ if ix and f'public.{m.group(1)}' in tables:
1228
+ tables[f'public.{m.group(1)}']['index'].append(ix)
1070
1229
  for m in re.finditer(r'add_foreign_key\s+["\']([^"\']+)["\'],\s*["\']([^"\']+)["\']([^\n]*)', src):
1071
1230
  det, verst, reste = m.groups()
1072
1231
  mp = re.search(r'primary_key:\s*["\']([^"\']+)["\']', reste)
@@ -1192,6 +1351,12 @@ def analyser_mermaid(chemin):
1192
1351
  return tables, fks
1193
1352
 
1194
1353
 
1354
+ RE_ACCOLADE = re.compile(r'[{}]')
1355
+ RE_DELIM = re.compile(r'[()\[\]{}]')
1356
+ RE_DZ_INDEX = re.compile(r'\b(uniqueIndex|index|unique)\s*\(\s*(?:["\'`]([^"\'`]*)["\'`])?\s*\)'
1357
+ r'\s*\.on(?:Only)?\(')
1358
+
1359
+
1195
1360
  def analyser_drizzle(chemin):
1196
1361
  """Parse a Drizzle ORM schema.ts natively (regular enough): each
1197
1362
  `pgTable("name", { field: type("col").notNull().primaryKey()
@@ -1229,35 +1394,73 @@ def analyser_drizzle(chemin):
1229
1394
  fabriques += re.findall(
1230
1395
  r'(?:const|let|var)\s+(\w+)\s*=\s*(?:pg|mysql|sqlite)TableCreator\s*\(', src)
1231
1396
  motif_table = '|'.join(re.escape(f) for f in dict.fromkeys(fabriques))
1232
- for m in re.finditer(
1233
- r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:' + motif_table + r')\s*\(\s*'
1234
- r'["\'`]([^"\'`]+)["\'`]\s*,\s*\{', src):
1397
+ appels = list(re.finditer(
1398
+ r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:' + motif_table + r')\s*\(\s*'
1399
+ r'["\'`]([^"\'`]+)["\'`]\s*,\s*\{', src))
1400
+ for n, m in enumerate(appels):
1235
1401
  var, nom = m.group(1), m.group(2)
1236
- i, prof = m.end() - 1, 0 # scan balanced braces for the body
1237
- while i < len(src):
1238
- prof += (src[i] == '{') - (src[i] == '}')
1402
+ # a table's scans stop where the next table starts: on unclosed braces
1403
+ # (crafted upload) each one ran to the end of file, O(n²) (3 min / 130 KiB)
1404
+ borne = appels[n + 1].start() if n + 1 < len(appels) else len(src)
1405
+ i, prof = borne, 0 # scan balanced braces for the body
1406
+ for d in RE_ACCOLADE.finditer(src, m.end() - 1, borne):
1407
+ prof += 1 if d.group() == '{' else -1
1239
1408
  if prof == 0:
1409
+ i = d.start()
1240
1410
  break
1241
- i += 1
1242
1411
  cle = f'public.{nom}'
1243
1412
  var_table[var] = cle
1244
1413
  var_cols[var] = {}
1245
- cols, pk = [], []
1414
+ cols, pk, index = [], [], []
1246
1415
  for entree in champs(src[m.end():i]):
1247
1416
  cm = re.match(r'\s*(\w+)\s*:\s*(\w+)\s*\(', entree)
1248
1417
  if not cm:
1249
1418
  continue
1250
1419
  champ, typ = cm.groups()
1251
- sm = re.search(r'''["'`]([^"'`]+)["'`]''', entree)
1420
+ # the column name is the type call's first argument when it is a
1421
+ # string (`text("user_id")`); otherwise the field name. Not the first
1422
+ # string anywhere: `uuid().references(..., { onDelete: "cascade" })`
1423
+ sm = re.match(r'''\s*["'`]([^"'`]+)["'`]''', entree[cm.end():])
1252
1424
  col = sm.group(1) if sm else champ
1253
1425
  var_cols[var][champ] = col
1254
1426
  cols.append(nouvelle_colonne(col, typ, '.notNull(' in entree))
1255
1427
  if '.primaryKey(' in entree:
1256
1428
  pk.append(col)
1429
+ if re.search(r'\.unique\(\s*(["\'`][^"\'`]*["\'`])?\s*\)', entree):
1430
+ index.append({'nom': '', 'cols': [col], 'unique': True})
1257
1431
  rm = re.search(r'\.references\(\s*\(\s*\)\s*=>\s*(\w+)\.(\w+)', entree)
1258
1432
  if rm:
1259
1433
  refs.append((cle, col, rm.group(1), rm.group(2)))
1260
- tables[cle] = nouvelle_table('public', nom, cols, pk)
1434
+ # the optional third argument, `(table) => [...]` or `=> ({...})`, holds
1435
+ # the indexes, unique constraints and composite primary key: read up to
1436
+ # the pgTable call's closing parenthesis
1437
+ j, prof = borne, 0
1438
+ for d in RE_DELIM.finditer(src, i + 1, borne):
1439
+ if prof == 0 and d.group() == ')':
1440
+ j = d.start()
1441
+ break
1442
+ prof += 1 if d.group() in '([{' else -1
1443
+ extra, champ_col = src[i + 1:j], var_cols[var]
1444
+ # the arguments of .on(...) end at the next ')'; that position is cached
1445
+ # so that many unclosed .on( do not each rescan to the end (linear)
1446
+ ferme = -1
1447
+ for im in RE_DZ_INDEX.finditer(extra):
1448
+ if ferme < im.end():
1449
+ ferme = extra.find(')', im.end())
1450
+ if ferme < 0:
1451
+ break
1452
+ ixcols = [champ_col.get(f, f) for f in re.findall(r'\w+\.(\w+)', extra[im.end():ferme])]
1453
+ if ixcols:
1454
+ index.append({'nom': im.group(2) or '', 'cols': ixcols,
1455
+ 'unique': im.group(1) != 'index'})
1456
+ mp = None if pk else re.search(r'\bprimaryKey\s*\(', extra)
1457
+ if mp:
1458
+ # primaryKey({ columns: [t.a, t.b] }), or the older primaryKey(t.a, t.b):
1459
+ # the columns are the t.x up to the closing ']' or ')' (first match only)
1460
+ fin = min((k for k in (extra.find(']', mp.end()), extra.find(')', mp.end())) if k >= 0),
1461
+ default=len(extra))
1462
+ pk = [champ_col.get(f, f) for f in re.findall(r'\w+\.(\w+)', extra[mp.end():fin])]
1463
+ tables[cle] = nouvelle_table('public', nom, cols, pk, index=index)
1261
1464
 
1262
1465
  for de, col, tvar, tfield in refs:
1263
1466
  vers = var_table.get(tvar)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcdview"
7
- version = "0.34.2"
7
+ version = "0.34.4"
8
8
  description = "Turn a SQL data model into one self-contained, interactive HTML page"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes