@memberjunction/sqlglot-ts 5.48.0 → 5.50.0

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.
@@ -52,6 +52,32 @@ def check(name, sql, must_contain=(), must_not_contain=(), expect_unhandled=0, e
52
52
  print(f"ok {name}")
53
53
 
54
54
 
55
+ def check_raw(name, sql, must_contain=(), must_not_contain=(), expect_unhandled=0):
56
+ """Whitespace-SENSITIVE variant of check(). Needed where the assertion turns on exact
57
+ spacing — e.g. verifying `/*` was broken to `/ *` inside a comment, which the
58
+ whitespace-insensitive check() would collapse back to the same string."""
59
+ global _failures
60
+ r = mj_transpile(sql)
61
+ joined = "\n".join(r["sql"])
62
+ errs = []
63
+ for s in must_contain:
64
+ if s not in joined:
65
+ errs.append(f"missing (raw) {s!r}")
66
+ for s in must_not_contain:
67
+ if s in joined:
68
+ errs.append(f"should not contain (raw) {s!r}")
69
+ if len(r["unhandled"]) != expect_unhandled:
70
+ errs.append(f"unhandled={len(r['unhandled'])} (expected {expect_unhandled}): {r['unhandled']}")
71
+ if errs:
72
+ _failures += 1
73
+ print(f"FAIL {name}")
74
+ for e in errs:
75
+ print(f" {e}")
76
+ print(f" output:\n{joined}\n")
77
+ else:
78
+ print(f"ok {name}")
79
+
80
+
55
81
  # --- AST type / function / boolean encoding ---------------------------------
56
82
  check("type mappings",
57
83
  "CREATE TABLE ${flyway:defaultSchema}.Foo (ID UNIQUEIDENTIFIER NOT NULL, Notes NVARCHAR(MAX) NULL, Name NVARCHAR(50));",
@@ -481,6 +507,19 @@ check("codegen-object extprop (vw*) in a mixed batch: skipped silently, not unha
481
507
  must_contain=['ADD COLUMN "Bar" INT'],
482
508
  must_not_contain=["COMMENT ON", "vwFooViews"])
483
509
 
510
+ # --- issue #3252 code review P2: align trg naming + account for empty extprop skips -------------
511
+ # The Python CodeGen-object convention (_CODEGEN_OBJECT_NAME) MUST match the TS classifier's
512
+ # CODEGEN_NAME, which dropped bare `trg` in RC2 (only trgUpdate/trgCreate/trgDelete are CodeGen).
513
+ # A HAND-written trigger (trgConversationDetail_AssignSequence) is NOT regenerated by CodeGen, so
514
+ # its extended property must NOT be skipped as a CodeGen object — it must emit a COMMENT ON, never
515
+ # vanish into no bucket the way a real CodeGen object's comment intentionally does.
516
+ check("hand-written trg* extended property emits COMMENT ON (bare trg is not a CodeGen convention)",
517
+ "EXEC sp_addextendedproperty @name=N'MS_Description', @value=N'assigns sequence per conversation', "
518
+ "@level0type=N'SCHEMA', @level0name=N'${flyway:defaultSchema}', "
519
+ "@level1type=N'TRIGGER', @level1name=N'trgConversationDetail_AssignSequence';",
520
+ must_contain=["COMMENT ON", "trgConversationDetail_AssignSequence"],
521
+ expect_unhandled=0)
522
+
484
523
  # --- MONEY/SMALLMONEY mapping ----------------------------------------------------
485
524
  check("MONEY/SMALLMONEY → DECIMAL(19,4)/DECIMAL(10,4)",
486
525
  "CREATE TABLE ${flyway:defaultSchema}.Invoice (Amount MONEY NOT NULL, Tip SMALLMONEY NULL);",
@@ -539,6 +578,53 @@ check("unrecognized sys.* guard → whole IF routed to unhandled, sys.* never em
539
578
  must_not_contain=["sys.", "DO $$", "DROP CONSTRAINT"],
540
579
  expect_unhandled=1)
541
580
 
581
+ # --- BLOCK-LESS IF guards (issue #3252 RC1): the v5.49 FK-index migration shape.
582
+ # `IF NOT EXISTS (...sys.indexes...) CREATE INDEX ...;` (no BEGIN/END) previously
583
+ # fell through to sqlglot, parsed as exp.IfBlock, and emitted a bare `;` with
584
+ # unhandled:[]. It must translate exactly like the BEGIN…END form. ------------------
585
+ check("block-less IF NOT EXISTS(sys.indexes) CREATE INDEX → pg_indexes DO block, no bare ;",
586
+ "IF NOT EXISTS (\n"
587
+ " SELECT 1 FROM sys.indexes\n"
588
+ " WHERE name = 'IDX_AUTO_MJ_FKEY_CompanyIntegrationRun_ScheduledJobRunID'\n"
589
+ " AND object_id = OBJECT_ID('${flyway:defaultSchema}.CompanyIntegrationRun'))\n"
590
+ " CREATE INDEX IDX_AUTO_MJ_FKEY_CompanyIntegrationRun_ScheduledJobRunID\n"
591
+ " ON ${flyway:defaultSchema}.CompanyIntegrationRun ([ScheduledJobRunID]);",
592
+ must_contain=["DO $$", "pg_indexes", "schemaname = '${flyway:defaultSchema}'",
593
+ "tablename = 'CompanyIntegrationRun'",
594
+ "indexname = 'IDX_AUTO_MJ_FKEY_CompanyIntegrationRun_ScheduledJobRunID'",
595
+ "CREATE INDEX", "END IF;"],
596
+ must_not_contain=["sys.", "OBJECT_ID"],
597
+ expect_unhandled=0)
598
+
599
+ # Inline named DEFAULT constraint (issue #3252 RC3): T-SQL allows a name on a column
600
+ # default (`CONSTRAINT [DF_x] DEFAULT (75)`); PG does NOT — it is a `syntax error at or
601
+ # near "CONSTRAINT"`. The name must be stripped, leaving a bare (unnamed) DEFAULT.
602
+ check("inline named column DEFAULT → name stripped (PG has no named defaults)",
603
+ "ALTER TABLE ${flyway:defaultSchema}.AIAgentType ADD "
604
+ "CompactionTriggerPercent INT NOT NULL CONSTRAINT DF_AIAgentType_CompactionTriggerPercent DEFAULT (75);",
605
+ must_contain=['ADD COLUMN "CompactionTriggerPercent" INT NOT NULL', "DEFAULT (75)"],
606
+ must_not_contain=['CONSTRAINT "DF_AIAgentType_CompactionTriggerPercent" DEFAULT',
607
+ "DF_AIAgentType_CompactionTriggerPercent"],
608
+ expect_unhandled=0)
609
+
610
+ # A named column CHECK constraint is valid PG and must NOT be stripped (only DEFAULT names go).
611
+ check("inline named CHECK constraint is preserved (only DEFAULT names are stripped)",
612
+ "ALTER TABLE ${flyway:defaultSchema}.AIAgentType ADD "
613
+ "Pct INT NOT NULL CONSTRAINT CK_AIAgentType_Pct CHECK (Pct BETWEEN 0 AND 100);",
614
+ must_contain=['CONSTRAINT "CK_AIAgentType_Pct" CHECK'],
615
+ must_not_contain=[],
616
+ expect_unhandled=0)
617
+
618
+ # A block-less IF/ELSE is not modeled by the envelope (RC1 1a bails on ELSE); it must be
619
+ # REPORTED by the plain path's If/IfBlock guard, never emitted as an empty `;`.
620
+ check("block-less IF … ELSE → reported (If/IfBlock guard), not silently dropped",
621
+ "IF NOT EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Widget WHERE ID = 'a')\n"
622
+ " CREATE TABLE ${flyway:defaultSchema}.Widget (ID UNIQUEIDENTIFIER NOT NULL)\n"
623
+ "ELSE\n"
624
+ " CREATE TABLE ${flyway:defaultSchema}.Other (ID UNIQUEIDENTIFIER NOT NULL);",
625
+ must_not_contain=["sys."],
626
+ expect_unhandled=1)
627
+
542
628
  # --- IF…BEGIN body scanner: CASE…END and in-string END don't truncate the block -----
543
629
  check("CASE…END (and 'END' in a literal) inside a guarded UPDATE; same-batch DDL after survives",
544
630
  "IF NOT EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Widget WHERE ID = 'a')\n"
@@ -567,6 +653,473 @@ check("bare CREATE FUNCTION → unhandled (T-SQL body is not transpilable)",
567
653
  must_not_contain=["CREATE FUNCTION"],
568
654
  expect_unhandled=1)
569
655
 
656
+
657
+ # --- Issue #3252 1d: drop accounting + SOFT reconciliation (never raises) --------------
658
+ # mj_transpile records every INTENTIONAL drop in result["dropped"] and self-checks
659
+ # parsed == emitted + unhandled + dropped, appending a soft ACCOUNTING-LEAK gap (never a
660
+ # raise) if a drop site was missed. These assert the result shape + the no-leak invariant.
661
+ def check_accounting(name, sql, expect_dropped_kinds=(), forbid_leak=True):
662
+ global _failures
663
+ r = mj_transpile(sql)
664
+ errs = []
665
+ if "dropped" not in r:
666
+ errs.append("result has no 'dropped' key")
667
+ else:
668
+ kinds = [d["kind"] for d in r["dropped"]]
669
+ for k in expect_dropped_kinds:
670
+ if k not in kinds:
671
+ errs.append(f"expected dropped kind {k!r}; got {kinds}")
672
+ if forbid_leak:
673
+ leaks = [u for u in r["unhandled"] if u["kind"] == "ACCOUNTING-LEAK"]
674
+ if leaks:
675
+ errs.append(f"unexpected ACCOUNTING-LEAK (a drop site is uninstrumented): {leaks}")
676
+ if errs:
677
+ _failures += 1
678
+ print(f"FAIL {name}")
679
+ for e in errs:
680
+ print(f" {e}")
681
+ else:
682
+ print(f"ok {name}")
683
+
684
+
685
+ check_accounting("SET NOCOUNT batch noise is recorded as a drop (not silent, no leak)",
686
+ "SET NOCOUNT ON;\nCREATE TABLE ${flyway:defaultSchema}.T (ID UNIQUEIDENTIFIER NOT NULL);",
687
+ expect_dropped_kinds=["SET-NOISE"])
688
+
689
+ # A GENUINE CodeGen-object (vw*/spCreate*/…) extended-property skip is intentional, but must be
690
+ # ACCOUNTED — recorded as a drop, not vanished into no bucket. Otherwise a real drop could hide
691
+ # behind the "intentional skip" path with zero trace (issue #3252 code review P2).
692
+ check_accounting("CodeGen-object (vw*) extprop skip is recorded as a drop, not silently vanished",
693
+ "EXEC sp_addextendedproperty @name=N'MS_Description', @value=N'a view', "
694
+ "@level0type=N'SCHEMA', @level0name=N'${flyway:defaultSchema}', "
695
+ "@level1type=N'VIEW', @level1name=N'vwCustomers';",
696
+ expect_dropped_kinds=["sp_addextendedproperty-codegen-skip"])
697
+
698
+ # The routine envelope (issue #3252 heavy smoke) absorbs a hand-routine WHOLE from raw text
699
+ # BEFORE parsing, so its body — including the closing `END` — is never split into dangling
700
+ # statements at all. A trailing poison statement in the same batch (forcing per-statement
701
+ # fallback for the GAP after the routine) must not disturb that containment: the routine is
702
+ # reported once, its body UPDATE never emits, the `END` never leaks as an `END;` COMMIT, and
703
+ # accounting still reconciles with zero drops and no leak. (Previously this input relied on a
704
+ # ROUTINE-END drop to stay balanced; the envelope makes that swallow path unnecessary here and
705
+ # — crucially — makes the outcome identical on pinned 27.18 and local 30.13.)
706
+ check("hand routine absorbed whole; trailing poison does not leak its body",
707
+ "CREATE PROCEDURE ${flyway:defaultSchema}.p AS BEGIN "
708
+ "UPDATE ${flyway:defaultSchema}.t SET a = 1; END;\nSELECT CAST(",
709
+ must_not_contain=["UPDATE", "SET a = 1", "END;"],
710
+ expect_unhandled=2) # the whole routine + the poison SELECT CAST(
711
+ check_accounting("hand routine + trailing poison reconciles with zero drops and no leak",
712
+ "CREATE PROCEDURE ${flyway:defaultSchema}.p AS BEGIN "
713
+ "UPDATE ${flyway:defaultSchema}.t SET a = 1; END;\nSELECT CAST(")
714
+
715
+ # The real hand-written trigger from the v5.49 ledger (issue #3252 RC2) must NOT produce a
716
+ # false ACCOUNTING-LEAK — the exact regression BLOCKER-2 guards against (a leak would raise
717
+ # under the original hard-assert design and lose all artifacts).
718
+ with open(str(Path(__file__).parents[4] / "migrations" / "v5"
719
+ / "V202607202110__v5.49.x__Fix_ConversationDetail_Sequence_Deadlock.sql")) as _f:
720
+ _trg_sql = _f.read()
721
+ check_accounting("real hand trigger (Fix_ConversationDetail) reconciles with NO leak, NO raise",
722
+ _trg_sql)
723
+
724
+ check_accounting("plain DDL reconciles with zero drops and no leak",
725
+ "ALTER TABLE ${flyway:defaultSchema}.APIKey ADD KeyPrefix NVARCHAR(20) NULL;")
726
+
727
+
728
+ # --- Swallowed-rider guard (issue #3252 heavy smoke): sqlglot 30.x's whole-batch parse can
729
+ # absorb the statement FOLLOWING a block-less non-EXISTS IF into the IfBlock node — the
730
+ # unconditional ALTER then neither emits nor appears (visibly) in the gap report. The raw
731
+ # text has no BEGIN, so per-piece splitting restores true statement boundaries: the IF is
732
+ # reported alone and the rider ALTER emits. (27.18 already parses them separately.)
733
+ check("statement after a block-less IF @var guard still EMITS (not swallowed into the gap)",
734
+ "DECLARE @C NVARCHAR(200);\n"
735
+ "SELECT @C = cc.name FROM sys.check_constraints cc "
736
+ "WHERE cc.parent_object_id = OBJECT_ID('${flyway:defaultSchema}.AIAgentNote');\n"
737
+ "IF @C IS NOT NULL\n"
738
+ " EXEC('ALTER TABLE ${flyway:defaultSchema}.AIAgentNote DROP CONSTRAINT [' + @C + ']');\n"
739
+ "ALTER TABLE ${flyway:defaultSchema}.AIAgentNote\n"
740
+ " ADD CONSTRAINT CK_AIAgentNote_Status CHECK (Status IN ('Active', 'Pending'));",
741
+ must_contain=['ADD CONSTRAINT "CK_AIAgentNote_Status" CHECK'],
742
+ expect_unhandled=3) # DECLARE + SELECT @C + the IF guard — each reported separately
743
+
744
+ # --- Routine-interior envelope guard (issue #3252 heavy smoke): a block-less
745
+ # `IF NOT EXISTS (...) RETURN;` INSIDE a trigger/proc body must not be captured by the
746
+ # IF-EXISTS envelope — cutting a DO $$ fragment out of a routine that is simultaneously
747
+ # reported needs-hand misleads the human porter into thinking that part is handled.
748
+ check("IF EXISTS guard inside a routine body is NOT enveloped into a stray DO block",
749
+ "CREATE OR ALTER TRIGGER [${flyway:defaultSchema}].[trgConvDetail_Assign]\n"
750
+ "ON [${flyway:defaultSchema}].[ConversationDetail]\n"
751
+ "AFTER INSERT AS\n"
752
+ "BEGIN\n"
753
+ " SET NOCOUNT ON;\n"
754
+ " IF NOT EXISTS (SELECT 1 FROM inserted)\n"
755
+ " RETURN;\n"
756
+ " UPDATE [${flyway:defaultSchema}].[ConversationDetail] SET [Sequence] = 1;\n"
757
+ "END",
758
+ must_not_contain=["DO $$"],
759
+ expect_unhandled=1) # the whole routine, reported once
760
+
761
+
762
+ # --- Never-emit-invalid-PG guards (issue #3252 heavy smoke): T-SQL constructs with no
763
+ # mechanical PG translation must be REPORTED (unhandled) rather than emitted as syntactically
764
+ # invalid PostgreSQL. Every case below was confirmed to emit invalid PG (pglast) on BOTH the
765
+ # committed pin (27.18) and local (30.13) before these guards landed.
766
+
767
+ # DROP PROC — the ONE real invalid-PG emission in the 201-file v5 ledger (V202605091143).
768
+ # sqlglot keeps the T-SQL `PROC` abbreviation, which PG rejects; must spell it PROCEDURE.
769
+ check("DROP PROC IF EXISTS is spelled out to DROP PROCEDURE (valid PG)",
770
+ 'DROP PROC IF EXISTS ${flyway:defaultSchema}."spUpdateExistingEntityFieldsFromSchema";',
771
+ must_contain=['DROP PROCEDURE IF EXISTS'],
772
+ must_not_contain=['DROP PROC IF'], # ws-strips to DROPPROCIF, absent from DROPPROCEDURE…
773
+ expect_unhandled=0)
774
+ check("bare DROP PROC is spelled out to DROP PROCEDURE",
775
+ 'DROP PROC ${flyway:defaultSchema}."spFoo";',
776
+ must_contain=['DROP PROCEDURE'],
777
+ must_not_contain=['DROP PROC "', 'DROP PROC $'],
778
+ expect_unhandled=0)
779
+
780
+ # DROP TRIGGER — PG requires `DROP TRIGGER name ON table`; sqlglot (30.x) emits it without the
781
+ # ON clause (invalid), while 27.x parses it as an opaque Command (already unhandled). The guard
782
+ # converges both on a reported gap and never emits the ON-less form.
783
+ check("bare DROP TRIGGER is reported (never emitted ON-less)",
784
+ 'DROP TRIGGER ${flyway:defaultSchema}.trg_x;',
785
+ must_not_contain=['DROP TRIGGER'],
786
+ expect_unhandled=1)
787
+ check("DROP TRIGGER IF EXISTS is reported (never emitted ON-less)",
788
+ 'DROP TRIGGER IF EXISTS ${flyway:defaultSchema}.trg_x;',
789
+ must_not_contain=['DROP TRIGGER'],
790
+ expect_unhandled=1)
791
+ check_accounting("DROP TRIGGER reconciles with no leak", 'DROP TRIGGER ${flyway:defaultSchema}.trg_x;')
792
+
793
+ # DELETE TOP (n) — misparses on both versions into a spurious multi-table DELETE
794
+ # (`DELETE "TOP" AS _t0(n) FROM t`); no mechanical PG rewrite. Report it.
795
+ check("DELETE TOP is reported (never emitted as the misparsed garbage)",
796
+ "DELETE TOP (10) FROM ${flyway:defaultSchema}.ErrorLog WHERE Severity = 'Info';",
797
+ must_not_contain=['AS _t0', '"TOP"'],
798
+ expect_unhandled=1)
799
+ check_accounting("DELETE TOP reconciles with no leak",
800
+ "DELETE TOP (10) FROM ${flyway:defaultSchema}.ErrorLog WHERE Severity = 'Info';")
801
+
802
+ # SET IDENTITY_INSERT — no PG equivalent (PG uses OVERRIDING SYSTEM VALUE per-INSERT). The
803
+ # `…OFF` form parses to exp.Set and would emit invalid `SET "IDENTITY_INSERT" = t AS "OFF"`;
804
+ # `…ON` already lands as an unhandled Command. Both must be reported.
805
+ check("SET IDENTITY_INSERT OFF is reported (never emitted as invalid SET assignment)",
806
+ "SET IDENTITY_INSERT ${flyway:defaultSchema}.Seq OFF;",
807
+ must_not_contain=['IDENTITY_INSERT'],
808
+ expect_unhandled=1)
809
+ check("SET IDENTITY_INSERT ON is reported",
810
+ "SET IDENTITY_INSERT ${flyway:defaultSchema}.Seq ON;",
811
+ must_not_contain=['IDENTITY_INSERT'],
812
+ expect_unhandled=1)
813
+
814
+ # Computed columns — T-SQL `col AS (expr) [PERSISTED]` emits `GENERATED ALWAYS AS (...) STORED`
815
+ # WITHOUT the required PG type (invalid), and a non-persisted computed column has no STORED
816
+ # equivalent anyway. Report the whole statement rather than emit the type-less generated column.
817
+ check("computed column in ALTER ADD is reported (never emits type-less GENERATED)",
818
+ "ALTER TABLE ${flyway:defaultSchema}.Invoice ADD Total AS (Qty * Price) PERSISTED;",
819
+ must_not_contain=['GENERATED ALWAYS'],
820
+ expect_unhandled=1)
821
+ check("computed column in CREATE TABLE is reported (never emits type-less GENERATED)",
822
+ "CREATE TABLE ${flyway:defaultSchema}.T (ID INT, Total AS (Qty * Price) PERSISTED);",
823
+ must_not_contain=['GENERATED ALWAYS'],
824
+ expect_unhandled=1)
825
+
826
+ # CREATE CLUSTERED INDEX — PG has no CLUSTERED/NONCLUSTERED qualifier on CREATE INDEX; sqlglot
827
+ # emits the keyword verbatim (invalid). Strip it to a plain CREATE INDEX. (The in-CREATE-TABLE
828
+ # and standalone ADD CONSTRAINT … CLUSTERED forms are already handled by sqlglot — see probe.)
829
+ check("CREATE CLUSTERED INDEX drops the CLUSTERED qualifier (valid PG)",
830
+ 'CREATE CLUSTERED INDEX IX_x ON ${flyway:defaultSchema}.T (c);',
831
+ must_contain=['CREATE INDEX'],
832
+ must_not_contain=['CLUSTERED'],
833
+ expect_unhandled=0)
834
+ check("CREATE NONCLUSTERED INDEX drops the NONCLUSTERED qualifier (valid PG)",
835
+ 'CREATE NONCLUSTERED INDEX IX_y ON ${flyway:defaultSchema}.T (c);',
836
+ must_contain=['CREATE INDEX'],
837
+ must_not_contain=['NONCLUSTERED'],
838
+ expect_unhandled=0)
839
+
840
+ # PK/UNIQUE with a CLUSTERED/NONCLUSTERED qualifier — PG has no such qualifier. sqlglot folds
841
+ # PK+CLUSTERED and UNIQUE+NONCLUSTERED cleanly, but the CROSS pairs (PK+NONCLUSTERED,
842
+ # UNIQUE+CLUSTERED) leak: PK+NONCLUSTERED emits the invalid `PRIMARY KEY, NONCLUSTERED (...)`
843
+ # (spurious comma) and UNIQUE+CLUSTERED keeps the CLUSTERED keyword. Both must fold to the plain
844
+ # constraint. (Synthetic-only — 0 real occurrences even in baselines — but must not emit bad PG.)
845
+ check("PK NONCLUSTERED in CREATE TABLE folds to plain PRIMARY KEY (valid PG)",
846
+ "CREATE TABLE ${flyway:defaultSchema}.T (ID UNIQUEIDENTIFIER NOT NULL, CONSTRAINT PK_T PRIMARY KEY NONCLUSTERED (ID));",
847
+ must_contain=['PRIMARY KEY ("ID")'],
848
+ must_not_contain=['NONCLUSTERED', 'PRIMARY KEY,'],
849
+ expect_unhandled=0)
850
+ check("PK NONCLUSTERED in ALTER ADD folds to plain PRIMARY KEY (valid PG)",
851
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT PK_T PRIMARY KEY NONCLUSTERED (ID);",
852
+ must_contain=['PRIMARY KEY ("ID")'],
853
+ must_not_contain=['NONCLUSTERED', 'PRIMARY KEY,'],
854
+ expect_unhandled=0)
855
+ check("UNIQUE CLUSTERED in CREATE TABLE folds to plain UNIQUE (valid PG)",
856
+ "CREATE TABLE ${flyway:defaultSchema}.T (ID UNIQUEIDENTIFIER NOT NULL, CONSTRAINT UQ_T UNIQUE CLUSTERED (ID));",
857
+ must_contain=['UNIQUE ("ID")'],
858
+ must_not_contain=['CLUSTERED'],
859
+ expect_unhandled=0)
860
+ check("UNIQUE CLUSTERED in ALTER ADD folds to plain UNIQUE (valid PG)",
861
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT UQ_T UNIQUE CLUSTERED (ID);",
862
+ must_contain=['UNIQUE ("ID")'],
863
+ must_not_contain=['CLUSTERED'],
864
+ expect_unhandled=0)
865
+
866
+ # WITH CHECK / WITH NOCHECK ADD CONSTRAINT — the SQL Server enforcement toggle has no PG form.
867
+ # `WITH CHECK` parses to an Alter that emits the invalid `… WITH CHECK ADD …`; `WITH NOCHECK`
868
+ # parses to an opaque Command (unhandled). The pre-parse strip unifies both to a plain,
869
+ # validating `ADD CONSTRAINT …` (safe: MJ CodeGen emits these only for fresh/consistent tables,
870
+ # e.g. the v5.39 Integration_Framework CHECK constraints on brand-new columns).
871
+ check("WITH CHECK ADD CONSTRAINT strips the toggle to a plain validating ADD (valid PG)",
872
+ "ALTER TABLE ${flyway:defaultSchema}.T WITH CHECK ADD CONSTRAINT CK_x CHECK (c IN ('a','b'));",
873
+ must_contain=['ADD CONSTRAINT "CK_x" CHECK'],
874
+ must_not_contain=['WITH CHECK', 'WITH NOCHECK'],
875
+ expect_unhandled=0)
876
+ check("WITH NOCHECK ADD CONSTRAINT strips the toggle to a plain validating ADD (valid PG)",
877
+ "ALTER TABLE ${flyway:defaultSchema}.T WITH NOCHECK ADD CONSTRAINT CK_y CHECK (c IS NULL OR c IN ('a'));",
878
+ must_contain=['ADD CONSTRAINT "CK_y" CHECK'],
879
+ must_not_contain=['WITH NOCHECK', 'WITH CHECK'],
880
+ expect_unhandled=0)
881
+ check_accounting("WITH NOCHECK ADD reconciles with no leak",
882
+ "ALTER TABLE ${flyway:defaultSchema}.T WITH NOCHECK ADD CONSTRAINT CK_y CHECK (c IN ('a'));")
883
+
884
+ # The pre-parse `WITH [NO]CHECK ADD` strip must be ATOM-AWARE: the phrase occurring INSIDE a string
885
+ # literal or a comment is data/prose, NOT the enforcement toggle — stripping it there silently
886
+ # rewrites emitted content (issue #3252: never silently alter output). Only a real ALTER statement's
887
+ # toggle is at a code position and gets rewritten.
888
+ check("WITH CHECK ADD inside a string DEFAULT literal is preserved verbatim (not stripped)",
889
+ "CREATE TABLE ${flyway:defaultSchema}.T (Note NVARCHAR(200) NOT NULL "
890
+ "DEFAULT N'run ALTER TABLE x WITH CHECK ADD CONSTRAINT c');",
891
+ must_contain=["WITH CHECK ADD CONSTRAINT c"],
892
+ expect_unhandled=0)
893
+ check("WITH CHECK ADD inside a comment is preserved (comment prose is not the toggle)",
894
+ "-- remember to run WITH CHECK ADD on the FK later\n"
895
+ "CREATE TABLE ${flyway:defaultSchema}.T2 (Id INT NOT NULL);",
896
+ must_contain=["WITH CHECK ADD on the FK"],
897
+ expect_unhandled=0)
898
+
899
+ # Multi-constraint `ALTER TABLE ... ADD` — T-SQL lets a single `ADD` govern a comma-separated
900
+ # constraint list; PG requires `ADD` before EACH action. sqlglot parses the list into ONE
901
+ # AddConstraint holding N constraints and emits one `ADD` + comma list (`ADD CONSTRAINT a ...,
902
+ # CONSTRAINT b ...`), which PG rejects with `syntax error at or near "CONSTRAINT"` — a SILENT
903
+ # invalid-emission (unhandled=0). Split into one ADD per constraint. (Real: v5.49 Compaction two
904
+ # CHECKs on AIAgentType; v5.24 KnowledgeHub CHECK + FK on Tag — both only exposed by whole-file
905
+ # validation. sqlglot already repeats ADD for multiple ADD COLUMN; only constraints leak.)
906
+ check("multi-CHECK ADD repeats ADD before each constraint (valid PG)",
907
+ "ALTER TABLE ${flyway:defaultSchema}.AIAgentType "
908
+ "ADD CONSTRAINT CK_A CHECK (TriggerPercent >= 1 AND TriggerPercent <= 100), "
909
+ "CONSTRAINT CK_B CHECK (TargetPercent >= 1 AND TargetPercent <= 100);",
910
+ must_contain=['ADD CONSTRAINT "CK_A" CHECK', 'ADD CONSTRAINT "CK_B" CHECK'],
911
+ must_not_contain=[', CONSTRAINT "CK_B"'],
912
+ expect_unhandled=0)
913
+ check("multi-constraint ADD mixing CHECK + FK repeats ADD (valid PG)",
914
+ "ALTER TABLE ${flyway:defaultSchema}.Tag "
915
+ "ADD CONSTRAINT CK_Tag_Status CHECK (Status IN ('Active','Merged')), "
916
+ "CONSTRAINT FK_Tag_MergedIntoTag FOREIGN KEY (MergedIntoTagID) "
917
+ "REFERENCES ${flyway:defaultSchema}.Tag(ID);",
918
+ must_contain=['ADD CONSTRAINT "CK_Tag_Status" CHECK',
919
+ 'ADD CONSTRAINT "FK_Tag_MergedIntoTag" FOREIGN KEY'],
920
+ must_not_contain=[', CONSTRAINT "FK_Tag_MergedIntoTag"'],
921
+ expect_unhandled=0)
922
+ check("single-constraint ADD is unaffected (still one ADD, valid PG)",
923
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT CK_only CHECK (x >= 1);",
924
+ must_contain=['ADD CONSTRAINT "CK_only" CHECK'],
925
+ expect_unhandled=0)
926
+ check_accounting("multi-constraint ADD reconciles with no leak",
927
+ "ALTER TABLE ${flyway:defaultSchema}.AIAgentType "
928
+ "ADD CONSTRAINT CK_A CHECK (a >= 1), CONSTRAINT CK_B CHECK (b >= 1);")
929
+
930
+ # Nested-comment sequences inside emitted block comments. sqlglot relocates `--` line comments
931
+ # into inline `/* ... */` block comments on AST nodes. PG block comments NEST, so a comment body
932
+ # containing `/*` (e.g. `image/*`) or `*/` opens/closes a nested comment → `unterminated /*
933
+ # comment`. sqlglot 30.x sanitizes this at emit (`image/ *`); the committed pin (27.18) emits it
934
+ # VERBATIM → invalid PG (version-skew!). Sanitize comment text so BOTH versions emit valid PG.
935
+ # (Real: v5.38 Backfill_Attachment_Artifacts, `image/*` in a wildcard-matching comment — pin-only,
936
+ # only surfaced by whole-file validation.) Raw (whitespace-sensitive) assertions: check() strips
937
+ # whitespace and would collapse `image/ *` back to `image/*`.
938
+ check_raw("nested /* in emitted block comment is broken to /space* (valid PG on pinned sqlglot)",
939
+ "SELECT 1 AS x -- Wildcard (e.g. image/* matches image/jpeg)\n;",
940
+ must_contain=["image/ *"],
941
+ must_not_contain=["image/*"],
942
+ expect_unhandled=0)
943
+ check_raw("nested */ in emitted block comment is broken to *space/ (valid PG on pinned sqlglot)",
944
+ "SELECT 1 AS x -- ends with a close seq */ here\n;",
945
+ must_not_contain=["*/ here"],
946
+ expect_unhandled=0)
947
+
948
+
949
+ # --- IF-EXISTS envelope must not match inside a string literal (issue #3252 review, RC1/RC2
950
+ # regression). The block-less IF-EXISTS finder scans raw text for `IF [NOT] EXISTS (SELECT ...)`
951
+ # heads; before this fix it also matched heads that fall INSIDE a T-SQL string literal — e.g. a
952
+ # migration seeding an AI-prompt/template body or an example SQL snippet that mentions the phrase.
953
+ # That shattered the surrounding INSERT into parse-error gaps AND, when the literal held a full
954
+ # `IF EXISTS (...) <DML>;`, FABRICATED a live DO-block running that DML (a phantom `DELETE FROM y`
955
+ # emitted to a discoverable .pg.sql). On origin/next the INSERT converted cleanly. The head-scan
956
+ # is now atom-aware (skips string/comment spans), so a literal that merely contains the phrase is
957
+ # inert. must_not_contain uses the DO-block markers (not "DELETE FROM", which legitimately appears
958
+ # INSIDE the preserved string literal).
959
+ check("IF-EXISTS inside a string literal does not fabricate a DO-block (INSERT stays intact)",
960
+ "INSERT INTO ${flyway:defaultSchema}.tmpl (Body) VALUES "
961
+ "('IF EXISTS (SELECT 1 FROM x) DELETE FROM y;');",
962
+ must_contain=['INSERT INTO ${flyway:defaultSchema}."tmpl"',
963
+ "IF EXISTS (SELECT 1 FROM x) DELETE FROM y;"],
964
+ must_not_contain=["DO $$", "END IF", "THEN"],
965
+ expect_unhandled=0)
966
+ check("IF-EXISTS phrase in prose template body leaves the INSERT intact (no gap shatter)",
967
+ "INSERT INTO ${flyway:defaultSchema}.AIPrompt (ID, Name, Body) VALUES "
968
+ "('a', 'P', 'Check IF EXISTS (SELECT 1 FROM Users WHERE X = 1) before running');",
969
+ must_contain=['INSERT INTO ${flyway:defaultSchema}."AIPrompt"'],
970
+ must_not_contain=["DO $$", "END IF"],
971
+ expect_unhandled=0)
972
+ check_accounting("IF-EXISTS-in-literal INSERT reconciles with no leak, no drop",
973
+ "INSERT INTO ${flyway:defaultSchema}.tmpl (Body) VALUES "
974
+ "('IF EXISTS (SELECT 1 FROM x) DELETE FROM y;');")
975
+
976
+
977
+ # --- ISJSON CHECK drop is by FUNCTION CALL and PER-CONSTRAINT (issue #3252 review). PG has no
978
+ # ISJSON(), so a CHECK using it is dropped — but (a) the OLD substring match `"ISJSON" in sql`
979
+ # also nuked a CHECK on a column merely NAMED `IsJsonEnabled` or comparing to the literal
980
+ # 'ISJSON', and (b) a multi-constraint `ADD CONSTRAINT pk PRIMARY KEY (...), CONSTRAINT ck CHECK
981
+ # (ISJSON(...))` dropped the WHOLE AddConstraint — silently losing the sibling PK/FK. Both are
982
+ # silent structural losses (#3252 invariant #1). The fix detects the ISJSON *call* (exp.Anonymous)
983
+ # and filters individual constraints, keeping the PK/FK.
984
+ check("multi-constraint ADD with an ISJSON CHECK keeps the sibling PRIMARY KEY",
985
+ "ALTER TABLE ${flyway:defaultSchema}.T "
986
+ "ADD CONSTRAINT PK_T PRIMARY KEY NONCLUSTERED (ID), CONSTRAINT CK_j CHECK (ISJSON(D) = 1);",
987
+ must_contain=['ADD CONSTRAINT "PK_T" PRIMARY KEY ("ID")'],
988
+ must_not_contain=["ISJSON"],
989
+ expect_unhandled=0)
990
+ check("multi-constraint ADD with an ISJSON CHECK keeps the sibling FOREIGN KEY",
991
+ "ALTER TABLE ${flyway:defaultSchema}.T "
992
+ "ADD CONSTRAINT FK_x FOREIGN KEY (PID) REFERENCES ${flyway:defaultSchema}.P(ID), "
993
+ "CONSTRAINT CK_j CHECK (ISJSON(D) = 1);",
994
+ must_contain=['ADD CONSTRAINT "FK_x" FOREIGN KEY ("PID") REFERENCES ${flyway:defaultSchema}."P" ("ID")'],
995
+ must_not_contain=["ISJSON"],
996
+ expect_unhandled=0)
997
+ check("CHECK on a column NAMED IsJsonEnabled is not an ISJSON call — preserved",
998
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT CK_Flag CHECK (IsJsonEnabled = 'yes');",
999
+ must_contain=['ADD CONSTRAINT "CK_Flag" CHECK'],
1000
+ expect_unhandled=0)
1001
+ check("CHECK comparing to the literal 'ISJSON' is not an ISJSON call — preserved",
1002
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT CK_s CHECK (Kind IN ('ISJSON', 'OTHER'));",
1003
+ must_contain=['ADD CONSTRAINT "CK_s" CHECK'],
1004
+ expect_unhandled=0)
1005
+ check_accounting("pure ISJSON CHECK is still dropped (ALTER left actionless), no leak",
1006
+ "ALTER TABLE ${flyway:defaultSchema}.T ADD CONSTRAINT CK_j CHECK (ISJSON(D) = 1);",
1007
+ expect_dropped_kinds=["ALTER-ACTIONLESS"])
1008
+
1009
+
1010
+ # --- RAISERROR guard fast path must not swallow sibling statements (issue #3252 review). An
1011
+ # IF-EXISTS guard whose body is ONLY RAISERROR (the common `BEGIN RAISERROR('conflict') END`
1012
+ # migration guard) still becomes a single RAISE EXCEPTION. But a body that pairs RAISERROR with
1013
+ # real statements (e.g. RAISERROR(...) then INSERT ...) cannot be modeled as one RAISE (which
1014
+ # aborts) without SILENTLY dropping the siblings — the old fast path emitted only the RAISE and
1015
+ # the INSERT vanished from every bucket. Report the whole guard as unhandled instead, so it is
1016
+ # hand-authored and never silently lost.
1017
+ check("RAISERROR-only guard still becomes a single RAISE EXCEPTION (fast path preserved)",
1018
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Conflict) "
1019
+ "BEGIN RAISERROR('conflict detected', 16, 1); END;",
1020
+ must_contain=["DO $$", "RAISE EXCEPTION 'conflict detected'"],
1021
+ expect_unhandled=0)
1022
+ check("RAISERROR + sibling INSERT is reported unhandled, not silently dropped",
1023
+ "IF NOT EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.cfg) "
1024
+ "BEGIN RAISERROR('missing cfg', 16, 1); INSERT INTO ${flyway:defaultSchema}.cfg (a) VALUES (1); END;",
1025
+ must_not_contain=["DO $$", "RAISE EXCEPTION"],
1026
+ expect_unhandled=1)
1027
+ check_accounting("RAISERROR + sibling INSERT reconciles as an unhandled guard, no leak",
1028
+ "IF NOT EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.cfg) "
1029
+ "BEGIN RAISERROR('missing cfg', 16, 1); INSERT INTO ${flyway:defaultSchema}.cfg (a) VALUES (1); END;")
1030
+ # T-SQL makes the statement-terminating `;` optional, so a guard body routinely pairs RAISERROR
1031
+ # with a semicolon-LESS sibling on the next line (`RAISERROR(...) \n UPDATE ...`). The sibling gate
1032
+ # must catch that shape too — splitting on `;` alone treats the whole `RAISERROR(...) UPDATE ...`
1033
+ # run as one RAISERROR-only piece and the UPDATE vanishes into no bucket (silent drop, #3252).
1034
+ check("RAISERROR + semicolon-LESS sibling UPDATE is reported unhandled, not silently dropped",
1035
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.foo WHERE id = 1) "
1036
+ "BEGIN RAISERROR('conflict', 16, 1) UPDATE ${flyway:defaultSchema}.foo SET bar = 1 WHERE id = 2 END;",
1037
+ must_not_contain=["DO $$", "RAISE EXCEPTION"],
1038
+ expect_unhandled=1)
1039
+ check_accounting("RAISERROR + semicolon-LESS sibling UPDATE reconciles, no leak",
1040
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.foo WHERE id = 1) "
1041
+ "BEGIN RAISERROR('conflict', 16, 1) UPDATE ${flyway:defaultSchema}.foo SET bar = 1 WHERE id = 2 END;")
1042
+ # A semicolon-less trailing RETURN is NOT a real sibling: RAISE EXCEPTION already aborts, so the
1043
+ # RETURN is moot. The RAISERROR-only fast path must survive a trailing RETURN (do not over-report).
1044
+ check("RAISERROR + trailing RETURN (no semicolon) still fast-paths to a single RAISE EXCEPTION",
1045
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Conflict) "
1046
+ "BEGIN RAISERROR('conflict detected', 16, 1) RETURN END;",
1047
+ must_contain=["DO $$", "RAISE EXCEPTION 'conflict detected'"],
1048
+ expect_unhandled=0)
1049
+ # The PRINT exemption must not become a HOLE (issue #3252 code review P1): T-SQL's optional `;`
1050
+ # lets a REAL statement ride glued after a benign PRINT with no separator. A `;`-split hands the
1051
+ # whole `RAISERROR(...) PRINT 'x' UPDATE ...` run in as ONE piece, and the old exemption skipped
1052
+ # it whole the moment it saw PRINT — the trailing UPDATE vanished into no bucket (silent drop).
1053
+ # The sibling gate must consume the PRINT and still SEE the UPDATE, reporting the guard unhandled.
1054
+ check("RAISERROR + PRINT + semicolon-LESS UPDATE: the UPDATE is not swallowed by the PRINT exemption",
1055
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.foo WHERE id = 1) "
1056
+ "BEGIN RAISERROR('conflict', 16, 1) PRINT 'continuing' "
1057
+ "UPDATE ${flyway:defaultSchema}.foo SET bar = 1 WHERE id = 2 END;",
1058
+ must_not_contain=["DO $$", "RAISE EXCEPTION"],
1059
+ expect_unhandled=1)
1060
+ check_accounting("RAISERROR + PRINT + semicolon-LESS UPDATE reconciles, no leak",
1061
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.foo WHERE id = 1) "
1062
+ "BEGIN RAISERROR('conflict', 16, 1) PRINT 'continuing' "
1063
+ "UPDATE ${flyway:defaultSchema}.foo SET bar = 1 WHERE id = 2 END;")
1064
+ # Guard against OVER-reporting the fix: a benign trailing PRINT with NO real sibling must still
1065
+ # fast-path to a single RAISE EXCEPTION (the exemption is narrowed, not removed).
1066
+ check("RAISERROR + trailing PRINT (no real sibling) still fast-paths to a single RAISE EXCEPTION",
1067
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Conflict) "
1068
+ "BEGIN RAISERROR('conflict detected', 16, 1) PRINT 'note' END;",
1069
+ must_contain=["DO $$", "RAISE EXCEPTION 'conflict detected'"],
1070
+ expect_unhandled=0)
1071
+ # Strict "never silently drop" contract (issue #3252 code review 2): a guard body with TWO
1072
+ # (semicolon-separated) RAISERRORs cannot collapse into a SINGLE RAISE EXCEPTION without dropping
1073
+ # the second unaccounted — the `;`-split makes each RAISERROR its own lone-raiserror piece, so the
1074
+ # old fast path emitted only the first and the second vanished into no bucket. A 2nd RAISERROR is a
1075
+ # sibling: report the whole guard unhandled instead. (Real migration guards never pair two, but the
1076
+ # fast path must still not silently lose one.)
1077
+ check("double-RAISERROR guard is reported unhandled, not collapsed to a single RAISE (2nd not dropped)",
1078
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Foo) "
1079
+ "BEGIN RAISERROR('a fail', 16, 1); RAISERROR('b fail', 16, 1) END;",
1080
+ must_not_contain=["DO $$", "RAISE EXCEPTION"],
1081
+ expect_unhandled=1)
1082
+ check_accounting("double-RAISERROR guard reconciles as one unhandled guard, no leak",
1083
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Foo) "
1084
+ "BEGIN RAISERROR('a fail', 16, 1); RAISERROR('b fail', 16, 1) END;")
1085
+ # Semicolon-LESS double RAISERROR is caught by the same contract (already, via _has_real_stmt_starter
1086
+ # seeing the 2nd RAISERROR after the first call) — pin it so it can't regress.
1087
+ check("double-RAISERROR guard (semicolon-LESS) is also reported unhandled, not collapsed",
1088
+ "IF EXISTS (SELECT 1 FROM ${flyway:defaultSchema}.Foo) "
1089
+ "BEGIN RAISERROR('a fail', 16, 1) RAISERROR('b fail', 16, 1) END;",
1090
+ must_not_contain=["DO $$", "RAISE EXCEPTION"],
1091
+ expect_unhandled=1)
1092
+
1093
+
1094
+ # --- Version-skew guard (issue #3252 review): every `exp.<Name>` the dialect references
1095
+ # must exist in the RUNNING sqlglot. A bare attribute access on a node type the installed
1096
+ # version lacks (e.g. exp.IfBlock, added in sqlglot 29.0.0, absent from the committed pin
1097
+ # sqlglot~=27.18.0) raises AttributeError at runtime and crashes every conversion. Run this
1098
+ # suite against `pip install -r requirements.txt` (CI does) and any such drift fails here
1099
+ # by name instead of exploding mid-conversion.
1100
+ def check_exp_attribute_references():
1101
+ global _failures
1102
+ import re as _re
1103
+ import sqlglot.expressions as _exp
1104
+ src = (Path(__file__).parent / "mj_postgres.py").read_text()
1105
+ # Strip `#` comments first: prose like "reaches here as exp.If/exp.IfBlock" must not be
1106
+ # treated as a runtime reference (exp.IfBlock is deliberately accessed ONLY via getattr
1107
+ # for version tolerance). Line-based strip is sufficient — real attribute accesses in
1108
+ # this module never share a line with a preceding '#'.
1109
+ code_only = "\n".join(line.split("#", 1)[0] for line in src.splitlines())
1110
+ referenced = sorted(set(_re.findall(r"\bexp\.([A-Z]\w*)", code_only)))
1111
+ missing = [name for name in referenced if not hasattr(_exp, name)]
1112
+ if missing:
1113
+ _failures += 1
1114
+ print(f"FAIL exp.* attribute references exist in the running sqlglot")
1115
+ print(f" missing from sqlglot {__import__('sqlglot').__version__}: {missing}")
1116
+ else:
1117
+ print(f"ok exp.* attribute references exist in the running sqlglot ({len(referenced)} names checked)")
1118
+
1119
+
1120
+ check_exp_attribute_references()
1121
+
1122
+
570
1123
  if _failures:
571
1124
  print(f"\n{_failures} test(s) FAILED")
572
1125
  sys.exit(1)