mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
The two SpendLogs index migrations shipped in v1.103.0 each break one table shape: the plain CREATE INDEX holds a SHARE lock on a large unpartitioned table and the CONCURRENTLY one fails with 0A000 on a partitioned parent. Both files are now inert and the indexes are built by a table-driven, shape-aware step after migrate deploy: CONCURRENTLY on a plain table, ON ONLY the parent plus per-partition CONCURRENTLY and ATTACH PARTITION on a partitioned one. The migration job builds them synchronously and exits non-zero on failure; a serving proxy that ran migrate deploy itself builds them in the background off the readiness path. A valid index of the same definition under another name is renamed and reused, an invalid one is rebuilt, and extra copies are reported with their DROP INDEX statement instead of being dropped. The migration checker rejects any CREATE INDEX on LiteLLM_SpendLogs or LiteLLM_ErrorLogs in future migrations Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1913 lines
84 KiB
Python
1913 lines
84 KiB
Python
"""Tests for tests/code_coverage_tests/check_migrations_no_data_rewrites.py.
|
|
|
|
The checker reads migration.sql as SQL rather than as text, so the cases that matter
|
|
are the ones a grep would get wrong: the referential actions in a foreign key, of which
|
|
the shipped migrations carry 60, an `UPDATE` inside a string literal or a comment, and
|
|
an `UPDATE` hidden in the `DO $$ ... $$` block this repo uses for conditional DDL.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_CHECKER_PATH = Path(__file__).resolve().parents[1] / "code_coverage_tests" / "check_migrations_no_data_rewrites.py"
|
|
_SPEC = importlib.util.spec_from_file_location("check_migrations_no_data_rewrites", _CHECKER_PATH)
|
|
assert _SPEC is not None and _SPEC.loader is not None
|
|
checker = importlib.util.module_from_spec(_SPEC)
|
|
sys.modules[_SPEC.name] = checker
|
|
_SPEC.loader.exec_module(checker)
|
|
|
|
|
|
def _scan(tmp_path: Path, sql: str) -> tuple:
|
|
directory = tmp_path / "20260101000000_fixture"
|
|
directory.mkdir(exist_ok=True)
|
|
(directory / "migration.sql").write_text(sql, encoding="utf-8")
|
|
return checker.scan_migration(directory)
|
|
|
|
|
|
def _keywords(tmp_path: Path, sql: str) -> tuple:
|
|
return tuple(violation.keyword for violation in _scan(tmp_path, sql))
|
|
|
|
|
|
class TestRowRewritesAreFlagged:
|
|
def test_update_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'UPDATE "Foo" SET "a" = 1;') == ("UPDATE",)
|
|
|
|
def test_delete_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'DELETE FROM "Foo" WHERE "a" IS NULL;') == ("DELETE",)
|
|
|
|
def test_update_without_trailing_semicolon_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'UPDATE "Foo" SET "a" = 1') == ("UPDATE",)
|
|
|
|
def test_lowercase_update_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'update "Foo" set "a" = 1;') == ("UPDATE",)
|
|
|
|
def test_merge_is_flagged(self, tmp_path):
|
|
sql = 'MERGE INTO "Foo" t USING "Bar" s ON t."id" = s."id" WHEN MATCHED THEN UPDATE SET "a" = s."a";'
|
|
assert _keywords(tmp_path, sql) == ("MERGE",)
|
|
|
|
def test_every_offending_statement_is_reported(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1;\nALTER TABLE "Foo" ADD COLUMN "b" TEXT;\nDELETE FROM "Bar";'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE", "DELETE")
|
|
|
|
def test_the_incident_migration_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'UPDATE "LiteLLM_SpendLogs"\n'
|
|
' SET "created_at" = "endTime",\n'
|
|
' "updated_at" = "endTime"\n'
|
|
' WHERE "created_at" > "endTime" + interval \'1 hour\';\n'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
|
|
class TestSchemaStatementsPass:
|
|
def test_on_delete_cascade_is_not_a_data_rewrite(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "A" ADD CONSTRAINT "A_b_fkey" FOREIGN KEY ("b") '
|
|
'REFERENCES "B"("id") ON DELETE CASCADE ON UPDATE CASCADE;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_on_delete_set_null_is_not_a_data_rewrite(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "A" ADD CONSTRAINT "A_b_fkey" FOREIGN KEY ("b") '
|
|
'REFERENCES "B"("id") ON DELETE SET NULL ON UPDATE CASCADE;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_add_column_with_default_passes(self, tmp_path):
|
|
sql = 'ALTER TABLE "Foo" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_drop_table_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'DROP TABLE IF EXISTS "Foo";') == ()
|
|
|
|
def test_empty_file_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, "") == ()
|
|
|
|
def test_only_comments_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, "-- nothing to do here\n") == ()
|
|
|
|
|
|
SPEND_LOGS_DEFAULT = 'ADD COLUMN ... DEFAULT on "LiteLLM_SpendLogs"'
|
|
|
|
|
|
class TestDefaultedColumnsOnRequestLogTables:
|
|
def test_the_shipped_timestamp_migration_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "LiteLLM_SpendLogs"\n'
|
|
'ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n'
|
|
'ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n'
|
|
)
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_nullable_column_with_a_default_is_flagged(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "proxy_server_request" JSONB DEFAULT \'{}\';'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_error_logs_is_a_request_log_table(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_ErrorLogs" ADD COLUMN "status" TEXT DEFAULT \'failure\';'
|
|
assert _keywords(tmp_path, sql) == ('ADD COLUMN ... DEFAULT on "LiteLLM_ErrorLogs"',)
|
|
|
|
def test_a_column_without_a_default_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "status" TEXT;') == ()
|
|
|
|
def test_set_default_on_an_existing_column_passes(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ALTER COLUMN "status" SET DEFAULT \'success\';'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_adding_a_column_and_defaulting_another_passes(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT, ALTER COLUMN "b" SET DEFAULT 1;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_referential_set_default_on_the_new_column_passes(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "team_id" TEXT '
|
|
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_column_default_beside_a_referential_set_default_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "team_id" TEXT DEFAULT \'t\' '
|
|
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_block_comment_before_the_table_name_is_flagged(self, tmp_path):
|
|
sql = 'ALTER TABLE /* audit */ "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT DEFAULT \'x\';'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_line_comment_before_the_table_name_is_flagged(self, tmp_path):
|
|
sql = 'ALTER TABLE IF EXISTS -- audit\n"LiteLLM_SpendLogs" ADD COLUMN "a" TEXT DEFAULT \'x\';'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_defaulted_column_among_other_actions_is_flagged(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT, ADD COLUMN "b" INTEGER DEFAULT 0;'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_comma_inside_the_type_does_not_split_the_action(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" NUMERIC(10, 2) DEFAULT 0;'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_a_foreign_key_set_default_action_passes(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "LiteLLM_SpendLogs" ADD CONSTRAINT "fk" FOREIGN KEY ("team_id") '
|
|
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_default_inside_a_check_constraint_passes(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD CONSTRAINT "c" CHECK ("status" IS DISTINCT FROM DEFAULT);'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_other_tables_pass(self, tmp_path):
|
|
sql = 'ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN "a" INTEGER NOT NULL DEFAULT 0;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_schema_qualified_and_if_exists_forms_are_flagged(self, tmp_path):
|
|
sql = (
|
|
'ALTER TABLE "public"."LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\n'
|
|
'ALTER TABLE IF EXISTS ONLY "LiteLLM_SpendLogs" ADD COLUMN "b" INTEGER DEFAULT 0;\n'
|
|
)
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT, SPEND_LOGS_DEFAULT)
|
|
|
|
def test_inside_a_do_block_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nBEGIN\n"
|
|
" IF NOT EXISTS (SELECT 1 FROM information_schema.columns\n"
|
|
" WHERE table_name = 'LiteLLM_SpendLogs' AND column_name = 'a') THEN\n"
|
|
' ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\n'
|
|
" END IF;\nEND $$;\n"
|
|
)
|
|
violations = _scan(tmp_path, sql)
|
|
assert [(violation.line, violation.keyword) for violation in violations] == [(5, SPEND_LOGS_DEFAULT)]
|
|
|
|
def test_handed_to_execute_is_flagged(self, tmp_path):
|
|
sql = 'DO $$ BEGIN EXECUTE \'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0\'; END $$;'
|
|
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
|
|
|
|
def test_in_a_comment_passes(self, tmp_path):
|
|
sql = '-- ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\nSELECT 1;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_it(self, tmp_path):
|
|
sql = (
|
|
"-- data-migration-ok: table is created empty two statements up\n"
|
|
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_report_names_the_table(self, tmp_path):
|
|
sql = '\nALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;'
|
|
rendered = _scan(tmp_path, sql)[0].render()
|
|
assert "20260101000000_fixture/migration.sql:2" in rendered
|
|
assert 'ADD COLUMN ... DEFAULT on "LiteLLM_SpendLogs" rewrites existing rows at boot' in rendered
|
|
|
|
|
|
class TestIndexesOnLogTables:
|
|
"""Every CREATE INDEX on a request-log table is rejected: a plain one blocks writes for
|
|
the whole build and a concurrent one fails on a partitioned parent, so the migration job
|
|
(litellm_proxy_extras/request_log_indexes.py) builds those instead."""
|
|
|
|
def test_the_original_spend_log_index_statement_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"-- CreateIndex\n"
|
|
'CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" '
|
|
'ON "LiteLLM_SpendLogs"("api_key", "startTime");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_the_original_concurrent_call_id_index_statement_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" '
|
|
'ON "LiteLLM_SpendLogs"("litellm_call_id");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_unique_index_with_if_not_exists_on_error_logs_is_flagged(self, tmp_path):
|
|
sql = 'CREATE UNIQUE INDEX IF NOT EXISTS "ix" ON "LiteLLM_ErrorLogs" ("request_id");'
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_ErrorLogs"',)
|
|
|
|
def test_a_unique_concurrent_index_on_error_logs_is_flagged(self, tmp_path):
|
|
sql = 'CREATE UNIQUE INDEX CONCURRENTLY "ix" ON "LiteLLM_ErrorLogs" ("request_id");'
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_ErrorLogs"',)
|
|
|
|
def test_lowercase_schema_qualified_and_only_forms_are_flagged(self, tmp_path):
|
|
sql = (
|
|
'create index on "public"."LiteLLM_SpendLogs" ("api_key");\n'
|
|
'CREATE INDEX "ix" ON ONLY "LiteLLM_SpendLogs" ("api_key");\n'
|
|
'CREATE INDEX CONCURRENTLY "iy" ON "public"."LiteLLM_SpendLogs" ("api_key");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',) * 3
|
|
|
|
def test_a_comment_between_on_and_the_table_is_flagged(self, tmp_path):
|
|
sql = 'CREATE INDEX CONCURRENTLY "ix" ON /* table */ "LiteLLM_SpendLogs" ("api_key");'
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_a_concurrent_index_with_comments_and_line_breaks_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"-- CreateIndex\n"
|
|
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "ix"\n'
|
|
' ON "LiteLLM_SpendLogs" /* partitioned in some deployments */\n'
|
|
' ("api_key", "startTime");\n'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_indexes_on_a_non_log_table_pass_concurrent_or_not(self, tmp_path):
|
|
sql = (
|
|
'CREATE INDEX "ix" ON "LiteLLM_VerificationToken" ("token");\n'
|
|
'CREATE INDEX CONCURRENTLY "iy" ON "LiteLLM_VerificationToken" ("token");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_index_run_by_execute_is_flagged(self, tmp_path):
|
|
sql = 'DO $$ BEGIN EXECUTE \'CREATE INDEX "ix" ON "LiteLLM_SpendLogs" ("api_key")\'; END $$;'
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_a_marker_does_not_exempt_the_index(self, tmp_path):
|
|
sql = (
|
|
'-- data-migration-ok: table is empty at this point\nCREATE INDEX "ix" ON "LiteLLM_SpendLogs" ("api_key");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_a_marker_on_a_rewrite_still_leaves_the_index_below_it_flagged(self, tmp_path):
|
|
sql = (
|
|
"-- data-migration-ok: one row\n"
|
|
'UPDATE "LiteLLM_SpendLogs" SET "api_key" = \'k\' WHERE "request_id" = \'r\';\n'
|
|
'CREATE INDEX CONCURRENTLY "ix" ON "LiteLLM_SpendLogs" ("api_key");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ('CREATE INDEX on "LiteLLM_SpendLogs"',)
|
|
|
|
def test_render_points_at_the_migration_job_index_list(self, tmp_path):
|
|
sql = 'CREATE INDEX CONCURRENTLY "ix" ON "public"."LiteLLM_SpendLogs" ("api_key");'
|
|
rendered = _scan(tmp_path, sql)[0].render()
|
|
assert "20260101000000_fixture/migration.sql:1" in rendered
|
|
assert "blocks writes until the build finishes, or fails on a partitioned table" in rendered
|
|
assert "REQUEST_LOG_INDEXES in litellm_proxy_extras/request_log_indexes.py" in rendered
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
("20260823000000_add_spend_logs_api_key_starttime_index", "20260831120001_spend_logs_litellm_call_id_index"),
|
|
)
|
|
def test_the_inert_index_migrations_scan_clean_without_a_grandfather(self, name):
|
|
assert checker.scan_migration(checker.MIGRATIONS_DIR / name) == ()
|
|
assert name not in checker.GRANDFATHERED
|
|
|
|
|
|
class TestInsert:
|
|
def test_insert_values_is_bounded_and_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, "INSERT INTO \"Foo\" (\"id\") VALUES ('a'), ('b');") == ()
|
|
|
|
def test_insert_select_scans_and_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'INSERT INTO "Foo" ("id") SELECT "id" FROM "Bar";') == ("INSERT ... SELECT",)
|
|
|
|
def test_insert_values_with_a_scalar_subquery_passes(self, tmp_path):
|
|
sql = 'INSERT INTO "Config" ("k", "v") VALUES (\'rev\', (SELECT max("id")::text FROM "Bar"));'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_insert_values_with_a_scalar_subquery_per_row_passes(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Config" ("k", "v") VALUES\n'
|
|
" ('a', (SELECT \"id\" FROM \"Bar\" WHERE \"n\" = 'a')),\n"
|
|
" ('b', (SELECT \"id\" FROM \"Bar\" WHERE \"n\" = 'b'));"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_values_inside_a_subquery_does_not_bound_an_insert_select(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") SELECT "id" FROM (VALUES (1), (2)) AS "v"("id");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_values_after_a_set_operation_does_not_bound_an_insert_select(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") SELECT "id" FROM "Bar" UNION ALL VALUES (1);'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_values_after_an_except_does_not_bound_an_insert_select(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") SELECT "id" FROM "Bar" EXCEPT VALUES (1);'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_select_term_after_a_values_list_is_still_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") VALUES (1), (2) UNION ALL SELECT "id" FROM "Bar";'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_parenthesised_select_row_source_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (SELECT "id" FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_parenthesised_select_row_source_without_a_column_list_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" (SELECT "id" FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_parenthesised_select_row_source_spanning_lines_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id")\n(\n SELECT "id" FROM "Bar"\n);'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_parenthesised_select_over_a_values_list_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (SELECT * FROM (VALUES (1), (2)) AS "v"("id"));'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_set_operation_over_parenthesised_selects_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (SELECT 1) UNION (SELECT 2);'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_values_list_joined_to_a_parenthesised_select_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") VALUES (1) UNION ALL (SELECT "id" FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_values_list_excepting_a_parenthesised_select_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") VALUES (1) EXCEPT (SELECT "id" FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_values_list_joined_to_a_parenthesised_table_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") VALUES (1) UNION ALL (TABLE "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... TABLE",)
|
|
|
|
def test_a_set_operation_inside_a_values_list_does_not_flag_it(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") VALUES ((SELECT 1 UNION SELECT 2 LIMIT 1));'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_scalar_subquery_in_a_set_operated_values_list_stays_bounded(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") VALUES ((SELECT max("id") FROM "Bar"))'
|
|
" UNION ALL VALUES (2);"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_scalar_subquery_in_a_parenthesised_values_list_stays_bounded(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES ((SELECT max("id") FROM "Bar")));'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_scalar_subquery_in_set_operated_parenthesised_values_lists_stays_bounded(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") (VALUES ((SELECT max("id") FROM "Bar")))'
|
|
" UNION ALL (VALUES (2));"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_set_operation_inside_a_values_list_does_not_split_the_terms(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") VALUES ((SELECT max("id") FROM "Bar"'
|
|
' UNION SELECT max("id") FROM "Bar")) UNION ALL VALUES (2);'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_conflict_target_after_a_parenthesised_row_source_does_not_hide_it(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") (SELECT "id" FROM "Bar")'
|
|
' ON CONFLICT ("id") DO NOTHING;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_returning_list_after_a_parenthesised_row_source_does_not_hide_it(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (SELECT "id" FROM "Bar") RETURNING ("id");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_conflict_target_beside_a_bounded_values_list_stays_bounded(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") (VALUES ((SELECT max("id") FROM "Bar")))'
|
|
' ON CONFLICT ("id") DO NOTHING;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_query_term_written_before_a_values_term_is_still_the_row_source(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (SELECT "id" FROM "Bar") UNION ALL (VALUES (2));'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_table_term_beside_parenthesised_values_is_still_the_row_source(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1)) UNION ALL (TABLE "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... TABLE",)
|
|
|
|
def test_a_table_row_source_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'INSERT INTO "Foo" TABLE "Bar";') == ("INSERT ... TABLE",)
|
|
|
|
def test_a_table_named_in_the_insert_target_does_not_flag_it(self, tmp_path):
|
|
assert _keywords(tmp_path, 'INSERT INTO "audit table" ("id") VALUES (1);') == ()
|
|
|
|
def test_a_returning_subquery_after_a_wrapped_values_list_is_not_the_row_source(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1)) RETURNING (SELECT count(*) FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_conflict_update_after_a_wrapped_values_list_stays_bounded(self, tmp_path):
|
|
sql = (
|
|
'INSERT INTO "Foo" ("id") (VALUES (1))'
|
|
' ON CONFLICT ("id") DO UPDATE SET "id" = (SELECT max("id") FROM "Bar");'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_wrapped_values_list_of_several_rows_stays_bounded(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1), (2)) RETURNING (SELECT count(*) FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_row_source_names_its_own_keyword_not_a_later_subquery(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (TABLE "Bar") RETURNING (SELECT count(*) FROM "Baz");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... TABLE",)
|
|
|
|
def test_a_select_term_wrapped_beside_a_values_term_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1) UNION ALL SELECT "id" FROM "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_a_table_term_wrapped_beside_a_values_term_is_flagged(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1) UNION ALL TABLE "Bar");'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... TABLE",)
|
|
|
|
def test_a_wrapped_set_operation_of_values_lists_stays_bounded(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("id") (VALUES (1) UNION ALL VALUES (2));'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
|
|
class TestCommonTableExpressions:
|
|
def test_cte_led_update_is_flagged(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "Foo" LIMIT 100) UPDATE "Foo" SET "a" = 1 FROM batch;'
|
|
assert _keywords(tmp_path, sql) == ("WITH ... UPDATE",)
|
|
|
|
def test_cte_led_delete_is_flagged(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "Foo" LIMIT 100) DELETE FROM "Foo" USING batch;'
|
|
assert _keywords(tmp_path, sql) == ("WITH ... DELETE",)
|
|
|
|
def test_cte_led_insert_select_is_flagged(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "Bar") INSERT INTO "Foo" ("id") SELECT "id" FROM batch;'
|
|
assert _keywords(tmp_path, sql) == ("WITH ... INSERT ... SELECT",)
|
|
|
|
def test_cte_led_insert_from_a_parenthesised_select_is_flagged(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "Bar") INSERT INTO "Foo" ("id") (SELECT "id" FROM batch);'
|
|
assert _keywords(tmp_path, sql) == ("WITH ... INSERT ... SELECT",)
|
|
|
|
def test_cte_led_insert_into_a_values_list_passes(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT max("id") FROM "Bar") INSERT INTO "Foo" ("id") VALUES (1);'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_read_only_cte_passes(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "Foo") SELECT count(*) FROM batch;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_cte_led_insert_values_is_bounded_and_passes(self, tmp_path):
|
|
sql = 'WITH latest AS (SELECT max("id") AS "id" FROM "Bar")\nINSERT INTO "Config" ("k", "v") VALUES (\'rev\', (SELECT "id"::text FROM latest));'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_writable_cte_bounded_by_values_passes(self, tmp_path):
|
|
sql = 'WITH added AS (INSERT INTO "Foo" ("id") VALUES (1) RETURNING "id") SELECT * FROM added;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_writable_cte_copying_a_query_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'WITH added AS (INSERT INTO "Foo" ("id") SELECT "id" FROM "Bar" RETURNING "id")'
|
|
" SELECT * FROM added;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("WITH ... INSERT ... SELECT",)
|
|
|
|
def test_a_bounded_writable_cte_does_not_hide_a_copying_one_beside_it(self, tmp_path):
|
|
sql = (
|
|
'WITH added AS (INSERT INTO "Foo" ("id") VALUES (1) RETURNING "id"),'
|
|
' copied AS (INSERT INTO "Baz" ("id") SELECT "id" FROM "Bar" RETURNING "id")'
|
|
" SELECT * FROM added, copied;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("WITH ... INSERT ... SELECT",)
|
|
|
|
def test_a_writable_cte_wrapping_its_row_source_is_flagged(self, tmp_path):
|
|
sql = (
|
|
'WITH added AS (INSERT INTO "Foo" ("id") (SELECT "id" FROM "Bar") RETURNING "id")'
|
|
" SELECT * FROM added;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("WITH ... INSERT ... SELECT",)
|
|
|
|
|
|
class TestDollarQuotedBlocks:
|
|
def test_update_inside_do_block_is_flagged(self, tmp_path):
|
|
sql = 'DO $$\nBEGIN\n UPDATE "Foo" SET "a" = 1;\nEND $$;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_conditional_ddl_do_block_passes(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'x') THEN\n"
|
|
' ALTER TABLE "Foo" DROP CONSTRAINT "x";\n'
|
|
" END IF;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_guarded_update_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
' IF EXISTS (SELECT 1 FROM "Foo") THEN\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END IF;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_guard_with_a_nested_call_still_flags_the_update(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
' IF EXISTS (SELECT 1 FROM "Foo" WHERE lower("a") = \'x\' UNION SELECT 1) THEN\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END IF;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_tagged_dollar_quote_is_scanned(self, tmp_path):
|
|
sql = 'DO $body$\nBEGIN\n DELETE FROM "Foo";\nEND $body$;'
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_tagged_dollar_quote_holds_an_apostrophe(self, tmp_path):
|
|
sql = 'INSERT INTO "Foo" ("t") VALUES ($body$don\'t$body$);\nUPDATE "Bar" SET "b" = 1;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_semicolons_inside_do_block_do_not_split_outer_statements(self, tmp_path):
|
|
sql = 'DO $$ BEGIN PERFORM 1; END $$;\nALTER TABLE "Foo" ADD COLUMN "b" TEXT;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_line_number_inside_a_do_block_counts_from_the_top_of_the_file(self, tmp_path):
|
|
sql = (
|
|
"-- AlterTable\n"
|
|
'ALTER TABLE "Foo" ADD COLUMN "b" INT;\n'
|
|
"\n"
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
' UPDATE "Foo" SET "b" = 1;\n'
|
|
"END $$;"
|
|
)
|
|
assert _scan(tmp_path, sql)[0].line == 6
|
|
|
|
def test_line_number_inside_a_nested_body_counts_from_the_top_of_the_file(self, tmp_path):
|
|
sql = (
|
|
"-- CreateIndex\n"
|
|
'CREATE INDEX "i" ON "Foo"("a");\n'
|
|
"\n"
|
|
"DO $outer$\n"
|
|
"BEGIN\n"
|
|
" EXECUTE $inner$\n"
|
|
' UPDATE "Foo" SET "a" = 1\n'
|
|
" $inner$;\n"
|
|
"END $outer$;"
|
|
)
|
|
assert _scan(tmp_path, sql)[0].line == 7
|
|
|
|
|
|
class TestStoredRoutines:
|
|
DEFINITION = (
|
|
"CREATE FUNCTION backfill() RETURNS void AS $$\n"
|
|
"BEGIN\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
)
|
|
PROCEDURE = (
|
|
"CREATE OR REPLACE PROCEDURE sweep() AS $$\n"
|
|
"BEGIN\n"
|
|
' DELETE FROM "Foo";\n'
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
)
|
|
|
|
def test_a_function_body_nothing_calls_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, self.DEFINITION) == ()
|
|
|
|
def test_a_procedure_body_nothing_calls_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, self.PROCEDURE) == ()
|
|
|
|
def test_a_function_the_migration_calls_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, self.DEFINITION + "SELECT backfill();\n") == ("UPDATE",)
|
|
|
|
def test_a_procedure_the_migration_calls_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, self.PROCEDURE + "CALL sweep();\n") == ("DELETE",)
|
|
|
|
def test_a_call_written_above_the_definition_still_counts(self, tmp_path):
|
|
assert _keywords(tmp_path, "SELECT backfill();\n" + self.DEFINITION) == ("UPDATE",)
|
|
|
|
def test_a_call_from_inside_a_do_block_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN PERFORM backfill(); END; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_through_a_quoted_identifier_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT "backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_an_unrelated_quoted_identifier_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT "other"();\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_quoted_column_sharing_the_routine_name_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'ALTER TABLE "Foo" ADD COLUMN "backfill" int;\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_quoted_index_target_sharing_the_routine_name_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "Foo" ("backfill");\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_quoted_call_with_space_before_its_parenthesis_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT "backfill" ();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_quoted_call_with_a_block_comment_before_its_parenthesis_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT "backfill" /* reason */ ();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_quoted_call_with_a_line_comment_before_its_parenthesis_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT "backfill" -- run it\n();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_quoted_column_followed_by_a_comment_is_still_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'ALTER TABLE "Foo" ADD COLUMN "backfill" /* note */ int;\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_like_named_table_with_a_column_list_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE TABLE "backfill" (id int);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_insert_into_a_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'INSERT INTO "backfill" ("id") VALUES (1);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_insert_into_a_like_named_table_with_a_commented_column_list_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'INSERT INTO "backfill" /* cols */ ("id") VALUES (1);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_foreign_key_referencing_a_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE TABLE "Bar" (id int REFERENCES "backfill" ("id"));\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_index_on_a_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "backfill" ("id");\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_if_not_exists_table_named_after_the_routine_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE TABLE IF NOT EXISTS "backfill" (id int);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_copy_into_a_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'COPY "backfill" ("id") FROM stdin;\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_set_returning_call_in_from_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT * FROM "backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_in_a_join_condition_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT 1 FROM "Bar" b JOIN "Baz" z ON "backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_in_an_index_predicate_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "Foo" ("a") WHERE "backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_join_condition_call_after_an_earlier_index_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "Foo" ("a");\nSELECT 1 FROM "Bar" b JOIN "Baz" z ON "backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_an_insert_into_a_schema_qualified_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'INSERT INTO public."backfill" ("id") VALUES (1);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_schema_qualified_index_on_a_like_named_table_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON public."backfill" ("id");\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_schema_qualified_quoted_call_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT public."backfill"();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_spaced_schema_qualifier_on_an_insert_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'INSERT INTO public . "backfill" ("id") VALUES (1);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_spaced_schema_qualifier_on_an_index_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON public . "backfill" ("id");\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_quoted_schema_with_spaces_round_the_dot_stays_a_relation(self, tmp_path):
|
|
sql = self.DEFINITION + 'INSERT INTO "public" . "backfill" ("id") VALUES (1);\n'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_spaced_schema_qualified_call_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'SELECT public . "backfill" ();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_schema_qualified_call_in_an_index_expression_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "Bar" (public."backfill"("a"));\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_spaced_schema_qualified_call_in_an_index_expression_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE INDEX ON "Bar" (public . "backfill" ("a"));\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_trigger_wiring_the_function_up_counts_as_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + 'CREATE TRIGGER t AFTER INSERT ON "Foo" EXECUTE FUNCTION backfill();\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_schema_qualified_definition_nothing_calls_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, self.DEFINITION.replace("backfill()", "public.backfill()")) == ()
|
|
|
|
def test_a_schema_qualified_function_the_migration_calls_is_flagged(self, tmp_path):
|
|
sql = self.DEFINITION.replace("backfill()", "public.backfill()") + "SELECT public.backfill();\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_the_name_written_only_in_a_comment_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "-- backfill() is run by hand after the deploy\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_only_in_a_do_body_comment_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN\n-- backfill() is run by hand after the deploy\nPERFORM 1;\nEND; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_only_in_a_do_body_block_comment_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN /* backfill() runs later */ PERFORM 1; END; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_only_in_a_nested_body_comment_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN EXECUTE $q$SELECT 1 -- backfill() runs later\n$q$; END; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_in_an_executed_literal_counts_as_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN EXECUTE 'SELECT backfill()'; END; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_after_a_literal_holding_comment_dashes_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO $$ BEGIN RAISE NOTICE '--'; PERFORM backfill(); END; $$;\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_from_inside_a_single_quoted_do_block_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN PERFORM backfill(); END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_an_executed_literal_inside_a_single_quoted_do_counts_as_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN EXECUTE ''SELECT backfill()''; END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_variable_run_by_execute_in_a_single_quoted_do_counts_as_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'DECLARE q text; BEGIN q := ''SELECT backfill()''; EXECUTE q; END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_after_a_single_quoted_literal_holding_comment_dashes_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN RAISE NOTICE ''--''; PERFORM backfill(); END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_call_after_a_single_quoted_literal_opening_a_block_comment_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN RAISE NOTICE ''/*''; PERFORM backfill(); END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_an_executed_literal_after_a_single_quoted_comment_dash_string_still_counts(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN RAISE NOTICE ''--''; EXECUTE ''SELECT backfill()''; END';\n"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_long_run_of_escaped_quotes_before_an_uncalled_definition_is_not_a_call(self, tmp_path):
|
|
escaped_quotes = "'" * 84
|
|
sql = f"DO 'BEGIN RAISE NOTICE ''{escaped_quotes}''; PERFORM 1; END';\n" + self.DEFINITION
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_only_in_a_single_quoted_do_comment_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "DO 'BEGIN\n-- backfill() runs later\nPERFORM 1; END';\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_the_name_written_only_in_a_non_runnable_string_is_not_a_call(self, tmp_path):
|
|
sql = self.DEFINITION + "SELECT 'backfill() runs after the deploy';\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_recursive_call_does_not_count_as_the_migration_calling_it(self, tmp_path):
|
|
sql = (
|
|
"CREATE FUNCTION backfill(n int) RETURNS void AS $$\n"
|
|
"BEGIN\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" PERFORM backfill(n - 1);\n"
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_quoted_routine_name_is_read_rather_than_trusted(self, tmp_path):
|
|
sql = self.DEFINITION.replace("backfill()", '"back fill"()')
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_do_block_is_not_a_routine_definition(self, tmp_path):
|
|
sql = 'DO $$ BEGIN UPDATE "Foo" SET "a" = 1; END; $$;\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_definition_written_after_another_statement_is_still_recognised(self, tmp_path):
|
|
sql = 'ALTER TABLE "Foo" ADD COLUMN "a" INT;\n' + self.DEFINITION
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_a_rewrite_in_a_routine_the_migration_calls(self, tmp_path):
|
|
sql = (
|
|
"CREATE FUNCTION backfill() RETURNS void AS $$\n"
|
|
"BEGIN\n"
|
|
' UPDATE "Foo" SET "a" = 1; -- data-migration-ok: single config row\n'
|
|
"END;\n"
|
|
"$$ LANGUAGE plpgsql;\n"
|
|
"SELECT backfill();\n"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_called_routine_reports_the_line_inside_its_body(self, tmp_path):
|
|
assert _scan(tmp_path, self.DEFINITION + "SELECT backfill();\n")[0].line == 3
|
|
|
|
|
|
class TestLoopBodies:
|
|
def test_a_rewrite_in_a_query_driven_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_delete_in_a_query_driven_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
' DELETE FROM "Foo" WHERE "id" = r."id";\n'
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_join_using_in_the_loop_query_does_not_hide_the_body(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT a."id" FROM "A" a JOIN "B" b USING ("id") LOOP\n'
|
|
" EXECUTE 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_executed_in_a_query_driven_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
" EXECUTE 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_nested_under_a_guard_inside_a_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
' IF r."id" > 0 THEN\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END IF;\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_inside_a_nested_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE a record;\n"
|
|
"DECLARE b record;\n"
|
|
"BEGIN\n"
|
|
' FOR a IN SELECT "id" FROM "A" LOOP FOR b IN SELECT "id" FROM "B" LOOP\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END LOOP; END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_supplying_a_nested_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE a record;\n"
|
|
"DECLARE b record;\n"
|
|
"BEGIN\n"
|
|
' FOR a IN SELECT "id" FROM "A" LOOP\n'
|
|
' FOR b IN UPDATE "Foo" SET "x" = 1 RETURNING "id" LOOP\n'
|
|
" NULL;\n"
|
|
" END LOOP; END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 6
|
|
|
|
def test_a_loop_running_only_ddl_passes(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
' CREATE INDEX "i" ON "Foo"("a");\n'
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_loop_over_a_rewrite_returning_rows_is_flagged_once(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
' FOR r IN UPDATE "Foo" SET "a" = 1 RETURNING "id" LOOP\n'
|
|
" NULL;\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_marker_on_a_loop_exempts_the_rewrite_it_repeats(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE r record;\n"
|
|
"BEGIN\n"
|
|
" -- data-migration-ok: one row\n"
|
|
' FOR r IN SELECT "id" FROM "Bar" LOOP\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_select_for_update_lock_is_not_read_as_a_loop(self, tmp_path):
|
|
sql = 'DO $$\nBEGIN\n PERFORM 1 FROM "Foo" FOR UPDATE;\nEND $$;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
|
|
class TestQuotingAndComments:
|
|
def test_update_inside_string_literal_passes(self, tmp_path):
|
|
sql = 'ALTER TABLE "Foo" ADD COLUMN "note" TEXT NOT NULL DEFAULT \'UPDATE nothing\';'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_escaped_quote_inside_string_does_not_leak(self, tmp_path):
|
|
sql = "ALTER TABLE \"Foo\" ADD COLUMN \"note\" TEXT NOT NULL DEFAULT 'it''s fine';\n"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_update_inside_line_comment_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, '-- UPDATE "Foo" SET "a" = 1;\nDROP TABLE "Bar";') == ()
|
|
|
|
def test_update_inside_block_comment_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, '/* UPDATE "Foo" SET "a" = 1; */\nDROP TABLE "Bar";') == ()
|
|
|
|
def test_nested_block_comment_passes(self, tmp_path):
|
|
sql = '/* outer /* UPDATE "Foo" SET "a" = 1; */ still comment */\nDROP TABLE "Bar";'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_nested_block_comment_masks_past_the_inner_close(self, tmp_path):
|
|
sql = '/* outer /* inner */ UPDATE "Foo" SET "a" = 1; */\nDROP TABLE "Bar";'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_update_inside_quoted_identifier_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'ALTER TABLE "UPDATE Foo" ADD COLUMN "b" TEXT;') == ()
|
|
|
|
def test_select_in_a_quoted_identifier_does_not_make_an_insert_a_rewrite(self, tmp_path):
|
|
assert _keywords(tmp_path, 'INSERT INTO "SELECT Foo" ("id") VALUES (\'a\');') == ()
|
|
|
|
def test_update_in_a_quoted_identifier_does_not_make_a_cte_a_rewrite(self, tmp_path):
|
|
sql = 'WITH batch AS (SELECT "id" FROM "UPDATE Foo") SELECT count(*) FROM batch;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_positional_parameter_is_not_a_dollar_quote(self, tmp_path):
|
|
sql = 'ALTER TABLE "Foo" ADD COLUMN "b" TEXT;\nUPDATE "Foo" SET "b" = $1;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
|
|
class TestEscapeHatch:
|
|
def test_marker_with_reason_exempts_the_statement(self, tmp_path):
|
|
sql = '-- data-migration-ok: one row per tenant, at most a few hundred\nUPDATE "Foo" SET "a" = 1;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_marker_without_reason_does_not_exempt(self, tmp_path):
|
|
assert _keywords(tmp_path, '-- data-migration-ok:\nUPDATE "Foo" SET "a" = 1;') == ("UPDATE",)
|
|
|
|
def test_marker_exempts_only_its_own_statement(self, tmp_path):
|
|
sql = '-- data-migration-ok: bounded to in-flight jobs\nUPDATE "Foo" SET "a" = 1;\nUPDATE "Bar" SET "b" = 2;\n'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_marker_works_inside_a_do_block(self, tmp_path):
|
|
sql = 'DO $$\nBEGIN\n -- data-migration-ok: single row\n UPDATE "Foo" SET "a" = 1;\nEND $$;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_marker_below_the_statement_does_not_exempt_the_next_one(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1;\n-- data-migration-ok: bounded\nALTER TABLE "Bar" ADD COLUMN "b" TEXT;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_marker_written_below_its_statement_leaves_that_statement_flagged(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1;\n-- data-migration-ok: bounded\nUPDATE "Bar" SET "b" = 2;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 1
|
|
|
|
def test_marker_inside_a_do_block_below_the_first_line_exempts(self, tmp_path):
|
|
sql = (
|
|
"-- AlterTable\n"
|
|
'ALTER TABLE "Foo" ADD COLUMN "b" INT;\n'
|
|
"\n"
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" -- data-migration-ok: one config row\n"
|
|
' UPDATE "Foo" SET "b" = 1;\n'
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_trailing_a_statement_exempts_that_statement(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1; -- data-migration-ok: one row\nALTER TABLE "Bar" ADD COLUMN "b" TEXT;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_trailing_a_statement_does_not_exempt_the_next_one(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1; -- data-migration-ok: one row\nUPDATE "Bar" SET "b" = 2;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 2
|
|
|
|
def test_a_marker_trailing_a_multiline_statement_does_not_exempt_the_next_one(self, tmp_path):
|
|
sql = (
|
|
'UPDATE "Foo"\n'
|
|
' SET "a" = 1; -- data-migration-ok: one row\n'
|
|
'UPDATE "Bar" SET "b" = 2;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_a_marker_alone_between_two_statements_belongs_to_the_one_below_it(self, tmp_path):
|
|
sql = 'UPDATE "Foo" SET "a" = 1;\n-- data-migration-ok: one row\nUPDATE "Bar" SET "b" = 2;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 1
|
|
|
|
def test_a_marker_a_blank_line_above_a_statement_does_not_exempt_it(self, tmp_path):
|
|
sql = '-- data-migration-ok: one row\n\nUPDATE "Foo" SET "a" = 1;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_a_trailing_marker_exempts_only_the_statement_it_follows(self, tmp_path):
|
|
sql = 'DELETE FROM "Foo" WHERE "a" = 1; UPDATE "Bar" SET "b" = 2; -- data-migration-ok: one row'
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_marker_above_a_shared_line_exempts_only_the_first_statement_on_it(self, tmp_path):
|
|
sql = '-- data-migration-ok: one row\nUPDATE "Foo" SET "a" = 1; DELETE FROM "Bar" WHERE "b" = 2;'
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_marker_on_the_opening_line_of_a_statement_exempts_that_statement(self, tmp_path):
|
|
sql = 'UPDATE "Foo" -- data-migration-ok: one row\n SET "a" = 1;\nDELETE FROM "Bar";'
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_marker_above_a_do_block_does_not_exempt_a_rewrite_inside_it(self, tmp_path):
|
|
sql = (
|
|
"-- data-migration-ok: bounded, this belongs to the insert below\n"
|
|
"INSERT INTO \"Config\" (\"k\") VALUES ('x');\n"
|
|
"\n"
|
|
'DO $$ BEGIN UPDATE "Foo" SET "b" = 1; END $$;'
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_marker_directly_above_a_do_block_does_not_exempt_its_body(self, tmp_path):
|
|
sql = (
|
|
"-- data-migration-ok: seeding two default rows\n"
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
' INSERT INTO "Foo" ("a") VALUES (1);\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_marker_directly_above_a_one_line_do_block_does_not_exempt_its_body(self, tmp_path):
|
|
sql = '-- data-migration-ok: seeding one default row\nDO $$ BEGIN UPDATE "Foo" SET "a" = 1; END $$;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 2
|
|
|
|
def test_marker_on_the_do_line_does_not_exempt_its_body(self, tmp_path):
|
|
sql = (
|
|
"DO $$ -- data-migration-ok: bounded to one row\n"
|
|
"BEGIN\n"
|
|
' IF EXISTS (SELECT 1 FROM "Foo") THEN\n'
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" END IF;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_a_marked_rewrite_does_not_exempt_a_later_one_in_the_same_block(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" -- data-migration-ok: bounded to one row\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
' UPDATE "Bar" SET "b" = 2;\n'
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
|
|
class TestDynamicSql:
|
|
def test_execute_of_a_quoted_update_is_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_execute_of_a_quoted_delete_is_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'DELETE FROM \"Foo\"';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_execute_of_a_formatted_update_is_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE format('UPDATE %I SET \"a\" = 1', 'Foo');\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_execute_of_a_dollar_quoted_update_is_flagged(self, tmp_path):
|
|
sql = 'DO $outer$\nBEGIN\n EXECUTE $q$UPDATE "Foo" SET "a" = 1$q$;\nEND $outer$;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_doubled_quote_inside_executed_sql_does_not_hide_the_rewrite(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'UPDATE \"Foo\" SET \"a\" = date_trunc(''day'', \"t\")';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_quoted_as_data_inside_executed_sql_is_not_run(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT ''UPDATE \"Foo\" SET \"a\" = 1''';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_doubled_quote_does_not_split_the_literal_it_sits_in(self, tmp_path):
|
|
sql = "INSERT INTO \"Foo\" (\"note\") VALUES ('a''UPDATE \"Bar\" SET \"a\" = 1''b');"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_following_a_doubled_quote_in_the_same_payload_is_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT ''x''; UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_comment_dash_inside_a_doubled_quote_does_not_hide_a_later_rewrite(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT ''--''; UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_a_block_comment_open_inside_a_doubled_quote_does_not_hide_a_later_rewrite(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT ''/*''; DELETE FROM \"Foo\"';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_rewrite_genuinely_commented_out_inside_executed_sql_is_not_run(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT 1 -- UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_below_escaped_quotes_in_a_multiline_payload_reports_its_own_line(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nBEGIN\n EXECUTE '\n"
|
|
"SELECT ''a'', ''b'', ''c'', ''d'', ''e''\n"
|
|
"; UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_in_a_later_command_before_bind_values_is_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT 1; DELETE FROM \"Foo\" WHERE \"a\" = $1' USING 1;\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_bind_value_naming_a_rewrite_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nBEGIN\n EXECUTE 'INSERT INTO \"Audit\" (\"note\") VALUES ($1)'"
|
|
" USING 'DELETE FROM \"Foo\"';\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_executed_with_bind_values_is_still_flagged(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'DELETE FROM \"Foo\" WHERE \"a\" = $1' USING 1;\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_using_written_inside_the_command_does_not_end_it(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nBEGIN\n EXECUTE 'DELETE FROM \"Foo\" USING \"Bar\""
|
|
" WHERE \"Foo\".\"a\" = \"Bar\".\"a\"';\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_join_using_in_a_subquery_building_the_command_does_not_end_it(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nDECLARE v text;\nBEGIN\n EXECUTE (SELECT v FROM \"A\" x JOIN \"A\" y"
|
|
" USING (\"id\")) || 'UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_join_using_does_not_take_the_place_of_the_real_bind_values(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nDECLARE v text;\nBEGIN\n EXECUTE (SELECT v FROM \"A\" x JOIN \"A\" y"
|
|
" USING (\"id\")) || 'UPDATE \"Foo\" SET \"a\" = $1' USING 2;\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_bind_value_naming_a_rewrite_after_a_subquery_join_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\nDECLARE v text;\nBEGIN\n EXECUTE (SELECT v FROM \"A\" x JOIN \"A\" y"
|
|
" USING (\"id\")) USING 'UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_execute_of_ddl_passes(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_execute_of_a_read_only_query_passes(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'SELECT 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_an_executed_rewrite(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n -- data-migration-ok: one row\n EXECUTE 'UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_literal_that_is_not_executed_is_still_inert(self, tmp_path):
|
|
sql = "INSERT INTO \"Foo\" (\"note\") VALUES ('UPDATE \"Bar\" SET \"a\" = 1');"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_declared_into_a_variable_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text := 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_a_rewrite_assigned_in_the_body_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'DELETE FROM \"Foo\" WHERE \"a\" = 1';\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_selected_into_a_variable_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" SELECT 'UPDATE \"Foo\" SET \"a\" = 1' INTO stmt;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_selected_into_a_strict_target_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" SELECT 'DELETE FROM \"Foo\"' INTO STRICT stmt;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_rewrite_assigned_with_a_bare_equals_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" stmt = 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_assigned_with_a_bare_equals_after_then_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" IF true THEN stmt = 'DELETE FROM \"Foo\"'; END IF;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_declared_with_a_bare_equals_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text = 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_an_insert_target_table_is_not_read_as_an_assignment(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" audit text := 'ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT';\n"
|
|
"BEGIN\n"
|
|
" INSERT INTO audit (note) VALUES ('DELETE FROM \"Foo\" is left to the app');\n"
|
|
" EXECUTE audit;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_returned_into_a_variable_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" INSERT INTO \"Log\" (\"sql\") VALUES ('UPDATE \"Foo\" SET \"a\" = 1')\n"
|
|
" RETURNING \"sql\" INTO stmt;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_assigned_past_an_earlier_comparison_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int := 1;\n"
|
|
"BEGIN\n"
|
|
" IF total = 1 THEN stmt = 'DELETE FROM \"Foo\" WHERE true'; END IF;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 6
|
|
|
|
def test_a_rewrite_assigned_past_a_loop_comparison_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int := 3;\n"
|
|
"BEGIN\n"
|
|
" WHILE total >= 1 LOOP stmt = 'UPDATE \"Foo\" SET \"a\" = 1'; END LOOP;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_selected_into_a_target_a_line_down_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" SELECT 'UPDATE \"Foo\" SET \"a\" = 1'\n"
|
|
" INTO\n"
|
|
" stmt;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_selected_into_a_strict_target_a_line_down_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" SELECT 'DELETE FROM \"Foo\"' INTO STRICT\n"
|
|
" stmt;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_rewrite_executed_by_a_name_a_line_down_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text := 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE\n"
|
|
" stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_rewrite_compared_against_is_not_an_assignment(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text := 'ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT';\n"
|
|
"BEGIN\n"
|
|
" IF stmt = 'UPDATE \"Foo\" SET \"a\" = 1' THEN\n"
|
|
" RAISE NOTICE 'the application owns that one';\n"
|
|
" END IF;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_passed_to_execute_as_a_parameter_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text := 'UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE 'INSERT INTO \"Log\" (\"sql\") VALUES ($1)' USING stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_compared_beside_an_assignment_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int := 1;\n"
|
|
" ok boolean;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'CREATE INDEX IF NOT EXISTS \"ix_a\" ON \"Foo\" (\"a\")';\n"
|
|
" ok := total = 1 AND stmt = 'DELETE FROM \"Foo\"';\n"
|
|
" RAISE NOTICE 'purge script? %', ok;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_named_as_an_argument_beside_an_assignment_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" ok boolean;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'CREATE INDEX IF NOT EXISTS \"ix_a\" ON \"Foo\" (\"a\")';\n"
|
|
" ok := probe_match(subject => stmt, wanted => 'DELETE FROM \"Foo\"');\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_compared_after_a_wider_comparison_is_not_run(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int := 1;\n"
|
|
" ok boolean;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'CREATE INDEX IF NOT EXISTS \"ix_a\" ON \"Foo\" (\"a\")';\n"
|
|
" ok := total >= 1 AND stmt = 'DELETE FROM \"Foo\"';\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_assigned_through_a_case_expression_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int := 1;\n"
|
|
"BEGIN\n"
|
|
" stmt := CASE WHEN total = 1 THEN 'DELETE FROM \"Foo\"' ELSE 'SELECT 1' END;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_query_reaching_into_past_an_execute_is_not_a_rewrite(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'CREATE INDEX IF NOT EXISTS \"ix_a\" ON \"Foo\" (\"a\")';\n"
|
|
" EXECUTE 'SELECT count(*) FROM \"Foo\"'\n"
|
|
" INTO total;\n"
|
|
" SELECT (CASE WHEN total > 0 THEN 1 ELSE 2 END) INTO total\n"
|
|
" FROM \"Foo\"\n"
|
|
" WHERE \"a\" = 'DELETE FROM \"Foo\"';\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_query_reaching_using_past_an_execute_is_not_a_rewrite(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
" total int;\n"
|
|
"BEGIN\n"
|
|
" stmt := 'CREATE INDEX IF NOT EXISTS \"ix_a\" ON \"Foo\" (\"a\")';\n"
|
|
" EXECUTE 'SELECT count(*) FROM \"Foo\" WHERE \"a\" = $1'\n"
|
|
" USING 'k1';\n"
|
|
" SELECT (CASE WHEN true THEN 1 ELSE 2 END) INTO total\n"
|
|
" FROM \"Foo\" x JOIN \"Foo\" y USING (\"a\")\n"
|
|
" WHERE x.\"a\" = 'DELETE FROM \"Foo\"';\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_rewrite_walked_by_a_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" FOR stmt IN SELECT 'DELETE FROM \"Foo\"' LOOP\n"
|
|
" EXECUTE stmt;\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 5
|
|
|
|
def test_a_rewrite_walked_by_a_foreach_loop_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text;\n"
|
|
"BEGIN\n"
|
|
" FOREACH stmt IN ARRAY ARRAY['UPDATE \"Foo\" SET \"a\" = 1'] LOOP\n"
|
|
" EXECUTE stmt;\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_a_loop_over_a_query_running_nothing_is_inert(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" rec record;\n"
|
|
"BEGIN\n"
|
|
" FOR rec IN SELECT \"a\" FROM \"Foo\" LOOP\n"
|
|
" RAISE NOTICE 'the DELETE FROM \"Foo\" path is the application''s: %', rec;\n"
|
|
" END LOOP;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_literal_selected_into_a_variable_nothing_runs_is_inert(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" msg text;\n"
|
|
"BEGIN\n"
|
|
" SELECT 'UPDATE of legacy rows is skipped' INTO msg;\n"
|
|
" RAISE NOTICE '%', msg;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_comparing_an_executed_variable_does_not_flag_the_comparison(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" stmt text := 'ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT';\n"
|
|
"BEGIN\n"
|
|
" IF stmt = 'DELETE FROM \"Foo\"' THEN RAISE NOTICE 'never'; END IF;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_ddl_assigned_to_a_variable_passes(self, tmp_path):
|
|
sql = "DO $$\nDECLARE\n stmt text := 'ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT';\nBEGIN\n EXECUTE stmt;\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_a_rewrite_held_in_a_variable(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" -- data-migration-ok: one config row, keyed by its primary key\n"
|
|
" stmt text := 'UPDATE \"Config\" SET \"v\" = 1 WHERE \"k\" = ''rev''';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_an_execute_whose_sql_starts_on_a_later_line(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" -- data-migration-ok: one config row, keyed by its primary key\n"
|
|
" EXECUTE '\n"
|
|
" UPDATE \"Config\" SET \"v\" = 1 WHERE \"k\" = ''rev''';\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_an_assignment_whose_sql_starts_on_a_later_line(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" -- data-migration-ok: one config row, keyed by its primary key\n"
|
|
" stmt text := '\n"
|
|
" UPDATE \"Config\" SET \"v\" = 1 WHERE \"k\" = ''rev''';\n"
|
|
"BEGIN\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_unmarked_execute_whose_sql_starts_on_a_later_line_is_flagged(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" EXECUTE '\n"
|
|
" UPDATE \"Foo\" SET \"a\" = 1';\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_a_message_assigned_but_never_executed_passes(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" msg text := 'UPDATE of legacy rows skipped, the application backfills them';\n"
|
|
"BEGIN\n"
|
|
" RAISE NOTICE '%', msg;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_notice_naming_a_delete_it_never_runs_passes(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" note text := 'DELETE FROM legacy rows is handled by the application';\n"
|
|
"BEGIN\n"
|
|
" RAISE NOTICE '%', note;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_do_body_in_single_quotes_is_scanned(self, tmp_path):
|
|
sql = "DO 'BEGIN UPDATE \"Foo\" SET \"a\" = 1; END';"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 1
|
|
|
|
def test_a_quoted_do_body_with_a_language_clause_is_scanned(self, tmp_path):
|
|
sql = "DO LANGUAGE plpgsql 'BEGIN DELETE FROM \"Foo\"; END';"
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
|
|
def test_a_quoted_do_body_holding_only_ddl_passes(self, tmp_path):
|
|
sql = "DO 'BEGIN ALTER TABLE \"Foo\" ADD COLUMN \"b\" TEXT; END';"
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_exempts_a_quoted_do_body(self, tmp_path):
|
|
sql = (
|
|
"-- data-migration-ok: one config row, keyed by its primary key\n"
|
|
"DO 'BEGIN UPDATE \"Config\" SET \"v\" = 1 WHERE \"k\" = ''rev''; END';"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_concatenated_sql_is_flagged_when_the_keyword_leads_a_fragment(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'UPDATE ' || quote_ident('Foo') || ' SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
def test_concatenated_sql_is_flagged_when_the_keyword_leads_a_later_fragment(self, tmp_path):
|
|
sql = "DO $$\nBEGIN\n EXECUTE 'WITH x AS (SELECT 1) ' || 'UPDATE \"Foo\" SET \"a\" = 1';\nEND $$;"
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
|
|
def test_only_the_variable_that_is_executed_is_read_as_sql(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"DECLARE\n"
|
|
" msg text := 'UPDATE of legacy rows skipped';\n"
|
|
" stmt text := 'DELETE FROM \"Foo\" WHERE \"a\" = 1';\n"
|
|
"BEGIN\n"
|
|
" RAISE NOTICE '%', msg;\n"
|
|
" EXECUTE stmt;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("DELETE",)
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_a_marker_on_an_execute_covers_its_single_quoted_payload(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" EXECUTE ' -- data-migration-ok: bounded to one row\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" ';\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_a_marker_on_an_execute_does_not_reach_into_a_dollar_quoted_payload(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" EXECUTE $x$ -- data-migration-ok: bounded to one row\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" $x$;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_a_marker_inside_a_dollar_quoted_payload_exempts_its_rewrite(self, tmp_path):
|
|
sql = (
|
|
"DO $$\n"
|
|
"BEGIN\n"
|
|
" EXECUTE $x$\n"
|
|
" -- data-migration-ok: bounded to one row\n"
|
|
' UPDATE "Foo" SET "a" = 1;\n'
|
|
" $x$;\n"
|
|
"END $$;"
|
|
)
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
|
|
class TestExplain:
|
|
def test_explain_analyze_over_an_update_is_flagged(self, tmp_path):
|
|
sql = 'EXPLAIN ANALYZE UPDATE "Foo" SET "a" = 1;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 1
|
|
|
|
def test_explain_analyze_verbose_over_a_delete_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'EXPLAIN ANALYZE VERBOSE DELETE FROM "Foo";') == ("DELETE",)
|
|
|
|
def test_explain_with_a_parenthesised_analyze_is_flagged(self, tmp_path):
|
|
assert _keywords(tmp_path, 'EXPLAIN (ANALYZE, BUFFERS) UPDATE "Foo" SET "a" = 1;') == ("UPDATE",)
|
|
|
|
def test_explain_analyze_over_an_insert_select_is_flagged(self, tmp_path):
|
|
sql = 'EXPLAIN ANALYZE INSERT INTO "Foo" SELECT "a" FROM "Bar";'
|
|
assert _keywords(tmp_path, sql) == ("INSERT ... SELECT",)
|
|
|
|
def test_explain_analyze_over_a_select_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'EXPLAIN ANALYZE SELECT * FROM "Foo";') == ()
|
|
|
|
def test_a_marker_exempts_an_explained_rewrite(self, tmp_path):
|
|
sql = '-- data-migration-ok: one config row\nEXPLAIN ANALYZE UPDATE "Foo" SET "a" = 1;'
|
|
assert _keywords(tmp_path, sql) == ()
|
|
|
|
def test_an_analyze_of_its_own_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'ANALYZE "Foo";') == ()
|
|
|
|
def test_a_vacuum_analyze_passes(self, tmp_path):
|
|
assert _keywords(tmp_path, 'VACUUM ANALYZE "Foo";') == ()
|
|
|
|
def test_an_explained_rewrite_inside_a_block_reports_its_line(self, tmp_path):
|
|
sql = 'DO $$\nBEGIN\n EXPLAIN ANALYZE UPDATE "Foo" SET "a" = 1;\nEND $$;'
|
|
assert _keywords(tmp_path, sql) == ("UPDATE",)
|
|
assert _scan(tmp_path, sql)[0].line == 3
|
|
|
|
|
|
class TestReporting:
|
|
def test_line_number_points_at_the_statement_keyword(self, tmp_path):
|
|
sql = '-- CreateIndex\nCREATE INDEX "i" ON "Foo"("a");\n\nUPDATE "Foo" SET "a" = 1;'
|
|
assert _scan(tmp_path, sql)[0].line == 4
|
|
|
|
def test_render_names_the_migration_and_line(self, tmp_path):
|
|
violation = _scan(tmp_path, '\n\nDELETE FROM "Foo";')[0]
|
|
rendered = violation.render()
|
|
assert "20260101000000_fixture/migration.sql:3" in rendered
|
|
assert "DELETE" in rendered
|
|
|
|
|
|
class TestGrandfathering:
|
|
def test_every_grandfathered_migration_still_violates(self):
|
|
for name in sorted(checker.GRANDFATHERED):
|
|
directory = checker.MIGRATIONS_DIR / name
|
|
assert directory.is_dir(), f"{name} no longer exists; drop it from GRANDFATHERED"
|
|
assert checker.scan_migration(directory), f"{name} is clean; drop it from GRANDFATHERED"
|
|
|
|
def test_stale_entry_is_reported_when_a_migration_stops_violating(self):
|
|
found = {name: () for name in checker.GRANDFATHERED}
|
|
assert checker.stale_grandfathers(found) == tuple(sorted(checker.GRANDFATHERED))
|
|
|
|
def test_missing_entry_is_reported(self):
|
|
assert checker.stale_grandfathers({}) == tuple(sorted(checker.GRANDFATHERED))
|
|
|
|
def test_no_stale_entries_against_the_real_tree(self):
|
|
found = {
|
|
path.name: checker.scan_migration(path)
|
|
for path in checker.MIGRATIONS_DIR.iterdir()
|
|
if (path / "migration.sql").is_file()
|
|
}
|
|
assert checker.stale_grandfathers(found) == ()
|
|
|
|
|
|
class TestShippedMigrations:
|
|
def test_the_repo_is_clean(self):
|
|
assert checker.main() == 0
|
|
|
|
|
|
CLEAN = 'ALTER TABLE "Foo" ADD COLUMN "a" INT;'
|
|
DIRTY = 'UPDATE "Foo" SET "a" = 1;'
|
|
FIXTURE = "20260101000000_fixture"
|
|
|
|
|
|
def _tree(monkeypatch, tmp_path: Path, sql: str, grandfathered: frozenset = frozenset()) -> None:
|
|
"""Stand a migrations directory holding one fixture migration in for the repo's own. The
|
|
root moves with it, since a rendered violation names the migration relative to the root and
|
|
the two are read off the same checkout everywhere but here."""
|
|
directory = tmp_path / "migrations" / FIXTURE
|
|
directory.mkdir(parents=True)
|
|
(directory / "migration.sql").write_text(sql, encoding="utf-8")
|
|
monkeypatch.setattr(checker, "REPO_ROOT", tmp_path)
|
|
monkeypatch.setattr(checker, "MIGRATIONS_DIR", tmp_path / "migrations")
|
|
monkeypatch.setattr(checker, "GRANDFATHERED", grandfathered)
|
|
|
|
|
|
class TestExitCode:
|
|
def test_a_clean_tree_passes(self, tmp_path, monkeypatch):
|
|
_tree(monkeypatch, tmp_path, CLEAN)
|
|
assert checker.main() == 0
|
|
|
|
def test_a_violation_fails_the_check(self, tmp_path, monkeypatch):
|
|
_tree(monkeypatch, tmp_path, DIRTY)
|
|
assert checker.main() == 1
|
|
|
|
def test_a_stale_grandfather_alone_fails_the_check(self, tmp_path, monkeypatch):
|
|
_tree(monkeypatch, tmp_path, CLEAN, frozenset({FIXTURE}))
|
|
assert checker.main() == 1
|
|
|
|
def test_a_grandfathered_violation_passes(self, tmp_path, monkeypatch):
|
|
_tree(monkeypatch, tmp_path, DIRTY, frozenset({FIXTURE}))
|
|
assert checker.main() == 0
|
|
|
|
def test_a_missing_migrations_directory_is_an_error(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(checker, "MIGRATIONS_DIR", tmp_path / "absent")
|
|
assert checker.main() == 2
|
|
|
|
def test_the_failure_names_the_migration_the_line_and_the_keyword(
|
|
self, tmp_path, monkeypatch, capsys
|
|
):
|
|
_tree(monkeypatch, tmp_path, DIRTY)
|
|
checker.main()
|
|
printed = capsys.readouterr().out
|
|
assert f"migrations/{FIXTURE}/migration.sql:1" in printed
|
|
assert "UPDATE rewrites existing rows at boot" in printed
|
|
assert checker.GUIDANCE in printed
|
|
|
|
def test_a_stale_grandfather_is_named(self, tmp_path, monkeypatch, capsys):
|
|
_tree(monkeypatch, tmp_path, CLEAN, frozenset({FIXTURE}))
|
|
checker.main()
|
|
assert f"{FIXTURE}: listed in GRANDFATHERED" in capsys.readouterr().out
|
|
|
|
def test_a_missing_directory_is_reported_on_stderr(self, tmp_path, monkeypatch, capsys):
|
|
monkeypatch.setattr(checker, "MIGRATIONS_DIR", tmp_path / "absent")
|
|
checker.main()
|
|
assert "migrations directory not found" in capsys.readouterr().err
|