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.
- {mcdview-0.34.2 → mcdview-0.34.4}/PKG-INFO +48 -1
- {mcdview-0.34.2 → mcdview-0.34.4}/README.md +47 -0
- {mcdview-0.34.2 → mcdview-0.34.4}/mcdview.py +281 -78
- {mcdview-0.34.2 → mcdview-0.34.4}/pyproject.toml +1 -1
- {mcdview-0.34.2 → mcdview-0.34.4}/.gitignore +0 -0
- {mcdview-0.34.2 → mcdview-0.34.4}/LICENSE +0 -0
- {mcdview-0.34.2 → mcdview-0.34.4}/logo.svg +0 -0
- {mcdview-0.34.2 → mcdview-0.34.4}/templates/explorateur.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcdview
|
|
3
|
-
Version: 0.34.
|
|
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)
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|