Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions graphify/extractors/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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$]+))*)",
Expand All @@ -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")",
Expand Down
80 changes: 80 additions & 0 deletions tests/test_sql_oracle_editionable.py
Original file line number Diff line number Diff line change
@@ -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)
Loading