From 4c1f0ffb34209292efa96009b197a86c1caa6111 Mon Sep 17 00:00:00 2001 From: Yi-111-a <153097222+Yi-111-a@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:50:48 +0800 Subject: [PATCH] fix(sql): accept Oracle [NON]EDITIONABLE in routine and trigger recovery (#5167) --- graphify/extractors/sql.py | 9 +++- tests/test_sql_oracle_editionable.py | 80 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/test_sql_oracle_editionable.py diff --git a/graphify/extractors/sql.py b/graphify/extractors/sql.py index d7551dcdaa..b41ede11c0 100644 --- a/graphify/extractors/sql.py +++ b/graphify/extractors/sql.py @@ -32,8 +32,13 @@ # 'SELECT AUTOCREATE PROCEDURE x FROM t;' in an error-bearing file minted a # phantom routine x() (delimited identifiers are span-skipped at the scan # site, but a bare word has no span). +# Oracle's default DDL export (DBMS_METADATA.GET_DDL, most IDEs) writes +# CREATE OR REPLACE [NON]EDITIONABLE PROCEDURE|FUNCTION|TRIGGER, so the +# optional edition keyword is accepted here and in _TRIGGER_RECOVERY_RX; +# without it such files recovered no routine at all (#5167). _ROUTINE_RECOVERY_RX = re.compile( - r"\bCREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:FUNCTION|PROC(?:EDURE)?)\s+" + r"\bCREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:(?:NON)?EDITIONABLE\s+)?" + r"(?:FUNCTION|PROC(?:EDURE)?)\s+" r"(?:IF\s+NOT\s+EXISTS\s+)?" r"((?:\"(?:[^\"\n]|\"\")+\"|\[(?:[^\]\n]|\]\])+\]|[\w$]+)" r"(?:\s*\.\s*(?:\"(?:[^\"\n]|\"\")+\"|\[(?:[^\]\n]|\]\])+\]|[\w$]+))*)", @@ -52,7 +57,7 @@ r"(?:\s*\.\s*(?:\"(?:[^\"\n]|\"\")+\"|\[(?:[^\]\n]|\]\])+\]|[\w$]+))*" ) _TRIGGER_RECOVERY_RX = re.compile( - r"\bCREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?TRIGGER\s+" + r"\bCREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?(?:(?:NON)?EDITIONABLE\s+)?TRIGGER\s+" r"(?:IF\s+NOT\s+EXISTS\s+)?" r"(" + _TRIGGER_IDENT + r")" r".*?\bON\s+(" + _TRIGGER_IDENT + r")", diff --git a/tests/test_sql_oracle_editionable.py b/tests/test_sql_oracle_editionable.py new file mode 100644 index 0000000000..effc3194ef --- /dev/null +++ b/tests/test_sql_oracle_editionable.py @@ -0,0 +1,80 @@ +"""#5167: Oracle's [NON]EDITIONABLE keyword must not hide a routine or trigger.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("tree_sitter_sql") + +from graphify.extractors.sql import extract_sql # noqa: E402 + +PROCEDURE = ( + "CREATE OR REPLACE {kw}PROCEDURE hr.load_emp IS\n" + "BEGIN\n" + " INSERT INTO hr.emp_copy SELECT * FROM hr.emp;\n" + "END;\n/\n" +) +FUNCTION = ( + "CREATE OR REPLACE {kw}FUNCTION hr.f RETURN NUMBER IS\n" + " n NUMBER;\n" + "BEGIN\n" + " SELECT COUNT(*) INTO n FROM hr.emp;\n" + " RETURN n;\n" + "END;\n/\n" +) +TRIGGER = ( + "CREATE OR REPLACE {kw}TRIGGER hr.trg_emp BEFORE INSERT ON hr.emp FOR EACH ROW\n" + "BEGIN\n" + " INSERT INTO hr.audit_log (id) VALUES (:new.id);\n" + "END;\n/\n" +) + + +def _graph(tmp_path: Path, name: str, src: str): + path = tmp_path / name + path.write_text(src, encoding="utf-8") + result = extract_sql(path) + labels = sorted(n["label"] for n in result["nodes"] if n["label"] != name) + edges = sorted( + (e["relation"], e["target"]) + for e in result["edges"] + if e["relation"] != "contains" + ) + return labels, edges + + +@pytest.mark.parametrize( + "kw", ["EDITIONABLE ", "NONEDITIONABLE ", "editionable "], ids=["ed", "noned", "lower"] +) +@pytest.mark.parametrize( + "template, expected_label", + [ + (PROCEDURE, "hr.load_emp()"), + (FUNCTION, "hr.f()"), + (TRIGGER, "hr.trg_emp"), + ], + ids=["procedure", "function", "trigger"], +) +def test_edition_keyword_matches_plain_ddl(tmp_path, kw, template, expected_label): + plain = _graph(tmp_path, "plain.sql", template.format(kw="")) + edition = _graph(tmp_path, "edition.sql", template.format(kw=kw)) + + assert expected_label in edition[0] + assert edition == plain + + +def test_edition_trigger_keeps_its_subject_table(tmp_path): + _, edges = _graph(tmp_path, "trg.sql", TRIGGER.format(kw="EDITIONABLE ")) + assert ("triggers", "hr_emp") in edges + + +def test_edition_word_alone_is_not_a_routine(tmp_path): + # The keyword is only accepted between CREATE [OR REPLACE] and the object + # kind, so a table or column literally named editionable stays harmless. + labels, _ = _graph( + tmp_path, + "t.sql", + "CREATE TABLE editionable (id INT);\nSELECT editionable FROM editionable;\n", + ) + assert not any(label.endswith("()") for label in labels)