@memberjunction/sqlglot-ts 5.49.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)
|