diff --git a/superset/sql/parse.py b/superset/sql/parse.py index 1f13898be5c..37783e4a73a 100644 --- a/superset/sql/parse.py +++ b/superset/sql/parse.py @@ -2015,7 +2015,24 @@ def is_cte(source: exp.Table, scope: Scope) -> bool: WITH foo AS (SELECT * FROM target_table) SELECT * FROM foo + A CTE name is always a bare identifier: it can never carry a schema or + catalog qualifier. A schema/catalog-qualified reference therefore always + resolves to a physical table, even when its final name component happens to + match a CTE defined in scope. Such a reference must be reported as a real + table so it resolves to the correct object; otherwise + ``WITH orders AS (...) SELECT * FROM public.orders`` would treat the + qualified ``public.orders`` as the CTE and drop the physical table from the + extracted set. + + Note: an unqualified reference is always resolved relative to the caller's + own schema/catalog before any downstream use, so treating a bare name that + matches a CTE as a CTE stays correct and is intentionally left unchanged + here. """ + if source.db or source.catalog: + # Qualified references are always physical tables, never CTEs. + return False + parent_sources = scope.parent.sources if scope.parent else {} ctes_in_scope = { name diff --git a/tests/unit_tests/sql/parse_tests.py b/tests/unit_tests/sql/parse_tests.py index bc2151399c8..d7d940c9099 100644 --- a/tests/unit_tests/sql/parse_tests.py +++ b/tests/unit_tests/sql/parse_tests.py @@ -947,6 +947,49 @@ ORDER BY SalesPersonID, SalesYear; ) == {Table("SalesOrderHeader")} +def test_extract_tables_qualified_reference_matching_cte_name() -> None: + """ + Test that a schema/catalog-qualified reference is resolved as a physical + table even when its final name component matches a CTE defined in scope. + + A CTE name is always a bare identifier, so ``public.orders`` cannot be the + CTE ``orders`` and must be reported as the physical table it names. + """ + # schema-qualified reference shadowed by a same-named bare CTE + assert extract_tables_from_sql( + "WITH orders AS (SELECT 1) SELECT * FROM public.orders" + ) == {Table("orders", "public")} + + # the CTE itself still references its own physical source + assert extract_tables_from_sql( + "WITH orders AS (SELECT * FROM staging.orders) SELECT * FROM public.orders" + ) == {Table("orders", "staging"), Table("orders", "public")} + + # catalog-qualified reference shadowed by a same-named bare CTE + assert extract_tables_from_sql( + "WITH orders AS (SELECT 1) SELECT * FROM cat.public.orders" + ) == {Table("orders", "public", "cat")} + + +def test_extract_tables_bare_cte_still_excluded() -> None: + """ + Test that a genuine bare CTE reference is still not reported as a table. + """ + assert extract_tables_from_sql( + "WITH foo AS (SELECT * FROM target_table) SELECT * FROM foo" + ) == {Table("target_table")} + + +def test_extract_tables_unreferenced_cte_does_not_shadow_table() -> None: + """ + Test that a CTE that is defined but not used by the outer query does not + change extraction of a physical table sharing its name. + """ + assert extract_tables_from_sql( + "WITH orders AS (SELECT 1) SELECT * FROM orders_summary" + ) == {Table("orders_summary")} + + def test_extract_tables_identifier_list_with_keyword_as_alias() -> None: """ Test that aliases that are keywords are parsed correctly.