From 31d862d8bef874a5e84769c7aa85697b4d999ad9 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:34:32 -0700 Subject: [PATCH 1/4] Handle empty CTE body in with_queries WITH a AS () leaves cte.this as None; calling _body_sql on it raised AttributeError. Treat an empty body as an empty SQL string. --- sql_metadata/nested_resolver.py | 4 ++++ test/test_with_statements.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/sql_metadata/nested_resolver.py b/sql_metadata/nested_resolver.py index 91687c13..977f88a1 100644 --- a/sql_metadata/nested_resolver.py +++ b/sql_metadata/nested_resolver.py @@ -240,6 +240,10 @@ def extract_cte_bodies( for cte in self._cte_nodes(): alias = cte.alias original_name = cte_name_map.get(alias, alias) + # Empty CTE bodies (WITH a AS ()) leave cte.this as None. + if cte.this is None: + results[original_name] = "" + continue results[original_name] = self._body_sql(cte.this) return results diff --git a/test/test_with_statements.py b/test/test_with_statements.py index 67f0c0d0..3e139c97 100644 --- a/test/test_with_statements.py +++ b/test/test_with_statements.py @@ -715,6 +715,13 @@ def test_with_queries_empty_when_no_cte(): assert p.with_queries == {} +def test_with_queries_empty_cte_body(): + """Empty CTE body WITH a AS () must not AttributeError on with_queries.""" + p = Parser("WITH a AS () SELECT 1") + assert p.with_queries == {"a": ""} + assert p.columns == [] + + def test_cte_subquery_full_resolution(): """Subquery + CTE: CTE-qualified columns fully resolved.""" parser = Parser(""" From 13799787b9fec8e7f412ace05fff58cf85ec4317 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:00:15 -0700 Subject: [PATCH 2/4] refactor: handle None CTE bodies inside _body_sql Share empty-body handling between extract_cte_bodies and the subquery walker instead of a loop-local continue. --- sql_metadata/nested_resolver.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sql_metadata/nested_resolver.py b/sql_metadata/nested_resolver.py index 977f88a1..75dfae01 100644 --- a/sql_metadata/nested_resolver.py +++ b/sql_metadata/nested_resolver.py @@ -240,10 +240,6 @@ def extract_cte_bodies( for cte in self._cte_nodes(): alias = cte.alias original_name = cte_name_map.get(alias, alias) - # Empty CTE bodies (WITH a AS ()) leave cte.this as None. - if cte.this is None: - results[original_name] = "" - continue results[original_name] = self._body_sql(cte.this) return results @@ -671,7 +667,7 @@ def _cte_nodes(self) -> list[exp.CTE]: # ------------------------------------------------------------------- @staticmethod - def _body_sql(node: exp.Expression) -> str: + def _body_sql(node: exp.Expression | None) -> str: """Render an AST node to SQL, stripping identifier quoting. Example SQL:: @@ -679,7 +675,10 @@ def _body_sql(node: exp.Expression) -> str: WITH cte AS (SELECT "id" FROM "users") ... Renders the CTE body as ``SELECT id FROM users`` (quotes stripped). + Empty CTE bodies (``WITH a AS ()``) leave ``cte.this`` as None. """ + if node is None: + return "" body = node.copy() for ident in body.find_all(exp.Identifier): ident.set("quoted", False) From e67a25927bd854e43eb463d7001c05b2048af6b3 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:22:59 -0700 Subject: [PATCH 3/4] fix: skip empty nested bodies during column resolution Empty CTE bodies re-fed into Parser("") asserted in _require_ast when resolving columns. Skip blank definitions at the nested-parser boundary. --- sql_metadata/nested_resolver.py | 6 ++++++ test/test_with_statements.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sql_metadata/nested_resolver.py b/sql_metadata/nested_resolver.py index 75dfae01..faf8e740 100644 --- a/sql_metadata/nested_resolver.py +++ b/sql_metadata/nested_resolver.py @@ -466,6 +466,9 @@ def _lookup_alias_in_nested( """ for nested_name in names: nested_def = definitions[nested_name] + # Empty CTE/subquery bodies cannot be re-parsed (Parser("") asserts). + if not nested_def: + continue nested_parser = parser_cache.setdefault( nested_name, self._parser_factory(nested_def) ) @@ -515,6 +518,9 @@ def _resolve_nested_query( return [subquery_alias] sub_query, column_name = parts[0], parts[-1] sub_query_definition = nested_queries[sub_query] + # Empty bodies have no columns/aliases to resolve through. + if not sub_query_definition: + return [] if column_name == "*" else [subquery_alias] subparser = already_parsed.setdefault( sub_query, self._parser_factory(sub_query_definition) ) diff --git a/test/test_with_statements.py b/test/test_with_statements.py index 3e139c97..3d6ef10f 100644 --- a/test/test_with_statements.py +++ b/test/test_with_statements.py @@ -720,6 +720,19 @@ def test_with_queries_empty_cte_body(): p = Parser("WITH a AS () SELECT 1") assert p.with_queries == {"a": ""} assert p.columns == [] + assert p.with_names == ["a"] + + +def test_empty_cte_body_columns_do_not_crash(): + """Referencing an empty CTE must not assert via nested Parser('').""" + p = Parser("WITH a AS () SELECT * FROM a") + assert p.with_queries == {"a": ""} + assert p.columns == ["*"] + + # Sibling empty CTE must not poison resolution of a real CTE. + p2 = Parser("WITH a AS (), b AS (SELECT 1 AS x) SELECT x FROM b") + assert p2.with_queries == {"a": "", "b": "SELECT 1 AS x"} + assert p2.columns_dict["select"] == ["x"] def test_cte_subquery_full_resolution(): From afd0b0a8e14abc150244dce631aa17356b8b5972 Mon Sep 17 00:00:00 2001 From: santhreal <64453045+santhreal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:26:31 -0700 Subject: [PATCH 4/4] test: falsify empty CTE nested-parser guards Cover sibling star resolution and qualified a.*/a.x so either empty-body guard deletion turns the suite red. --- test/test_with_statements.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/test_with_statements.py b/test/test_with_statements.py index 3d6ef10f..d2e538a6 100644 --- a/test/test_with_statements.py +++ b/test/test_with_statements.py @@ -724,15 +724,23 @@ def test_with_queries_empty_cte_body(): def test_empty_cte_body_columns_do_not_crash(): - """Referencing an empty CTE must not assert via nested Parser('').""" + """Empty CTE bodies must not assert via nested Parser('').""" p = Parser("WITH a AS () SELECT * FROM a") assert p.with_queries == {"a": ""} assert p.columns == ["*"] - # Sibling empty CTE must not poison resolution of a real CTE. - p2 = Parser("WITH a AS (), b AS (SELECT 1 AS x) SELECT x FROM b") + # Sibling empty CTE must not poison star resolution of a real CTE. + p2 = Parser("WITH a AS (), b AS (SELECT 1 AS x) SELECT * FROM b") assert p2.with_queries == {"a": "", "b": "SELECT 1 AS x"} - assert p2.columns_dict["select"] == ["x"] + assert p2.columns_dict["select"] == ["*", "x"] + + # Qualified refs exercise _resolve_nested_query empty-body branch. + p_star = Parser("WITH a AS () SELECT a.* FROM a") + assert p_star.columns == [] + assert p_star.columns_dict.get("select") == [] + + p_col = Parser("WITH a AS () SELECT a.x FROM a") + assert p_col.columns == ["a.x"] def test_cte_subquery_full_resolution():