Skip to content

Commit 1c28b61

Browse files
Warn when selector contains unaccompanied graph operator
Detect bare graph operators (+, @, 1+1) at parse time and emit a NoNodesForSelectionCriteria warning instead of silently accepting them. Fixes #10388
1 parent b62928f commit 1c28b61

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Under the Hood
2+
body: Warn when a graph operator (+, @) is used without an accompanying model name in a selector (e.g. "+", "1+1", "@").
3+
time: 2026-04-05T04:06:26.000000Z
4+
custom:
5+
Author: vinicius
6+
Issue: "10388"

core/dbt/graph/selector_spec.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from dataclasses import dataclass
55
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union
66

7+
from dbt.events.types import NoNodesForSelectionCriteria
78
from dbt.exceptions import InvalidSelectorError
89
from dbt.flags import get_flags
910
from dbt_common.dataclass_schema import StrEnum, dbtClassMixin
11+
from dbt_common.events.functions import warn_or_error
1012
from dbt_common.exceptions import DbtRuntimeError
1113

1214
from .graph import UniqueId
@@ -23,6 +25,35 @@
2325
SELECTOR_METHOD_SEPARATOR = "."
2426

2527

28+
def _has_graph_operator(groupdict: Dict[str, Any]) -> bool:
29+
"""Return True if the parsed selector groupdict contains any graph operator."""
30+
return bool(
31+
groupdict.get("childrens_parents")
32+
or groupdict.get("parents")
33+
or groupdict.get("children")
34+
)
35+
36+
37+
def _is_unaccompanied_graph_operator(groupdict: Dict[str, Any]) -> bool:
38+
"""Return True when a graph operator is present but has no accompanying model name.
39+
40+
A valid selector must pair a graph operator with a "stringy" value (a model name, tag,
41+
path, etc.). When the value is absent or purely numeric the operator is unaccompanied:
42+
43+
- ``+`` parents flag set, value is empty string
44+
- ``1+`` parents flag set with depth, value is empty string
45+
- ``@`` childrens_parents flag set, value is empty string
46+
- ``1+1`` parents flag set, value is ``"1"`` (numeric only — looks like a depth)
47+
- ``+2`` parents flag set, value is ``"2"`` (numeric only)
48+
"""
49+
if not _has_graph_operator(groupdict):
50+
return False
51+
value = groupdict.get("value") or ""
52+
# An empty value or a value that is purely numeric (i.e. looks like a depth modifier
53+
# rather than a real model name) means the operator has no accompanying target.
54+
return not value or value.isdigit()
55+
56+
2657
class IndirectSelection(StrEnum):
2758
Eager = "eager"
2859
Cautious = "cautious"
@@ -164,7 +195,11 @@ def from_single_spec(cls, raw: str) -> "SelectionCriteria":
164195
# bad spec!
165196
raise DbtRuntimeError(f'Invalid selector spec "{raw}"')
166197

167-
return cls.selection_criteria_from_dict(raw, result.groupdict())
198+
groupdict = result.groupdict()
199+
if _is_unaccompanied_graph_operator(groupdict):
200+
warn_or_error(NoNodesForSelectionCriteria(spec_raw=raw))
201+
202+
return cls.selection_criteria_from_dict(raw, groupdict)
168203

169204

170205
class BaseSelectionGroup(dbtClassMixin, Iterable[SelectionSpec], metaclass=ABCMeta):

tests/unit/graph/test_selector_spec.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
SelectionDifference,
1212
SelectionIntersection,
1313
SelectionUnion,
14+
_has_graph_operator,
15+
_is_unaccompanied_graph_operator,
1416
)
1517

1618

@@ -219,3 +221,81 @@ def test_union():
219221
[{"model_a", "model_b"}, {"model_b", "model_c"}, {"model_d"}]
220222
)
221223
assert combined == {"model_a", "model_b", "model_c", "model_d"}
224+
225+
226+
# ── Unaccompanied graph operator detection ────────────────────────────────────
227+
228+
229+
@pytest.mark.parametrize(
230+
"groupdict,expected",
231+
[
232+
# No graph operator at all — not unaccompanied
233+
({"childrens_parents": None, "parents": None, "children": None, "value": ""}, False),
234+
({"childrens_parents": None, "parents": None, "children": None, "value": "mymodel"}, False),
235+
# Has operator, empty value — unaccompanied
236+
({"childrens_parents": None, "parents": "+", "children": None, "value": ""}, True),
237+
({"childrens_parents": "@", "parents": None, "children": None, "value": ""}, True),
238+
({"childrens_parents": None, "parents": "1+", "children": None, "value": ""}, True),
239+
# Has operator, numeric-only value — unaccompanied (looks like a depth modifier)
240+
({"childrens_parents": None, "parents": "1+", "children": None, "value": "1"}, True),
241+
({"childrens_parents": None, "parents": "+", "children": None, "value": "2"}, True),
242+
# Has operator, real string value — NOT unaccompanied
243+
({"childrens_parents": None, "parents": "+", "children": None, "value": "mymodel"}, False),
244+
({"childrens_parents": "@", "parents": None, "children": None, "value": "mymodel"}, False),
245+
({"childrens_parents": None, "parents": "2+", "children": "+3", "value": "mymodel"}, False),
246+
# Children-only with real value — not unaccompanied
247+
({"childrens_parents": None, "parents": None, "children": "+", "value": "mymodel"}, False),
248+
# Children-only with numeric value — unaccompanied
249+
({"childrens_parents": None, "parents": None, "children": "+1", "value": "1"}, True),
250+
# Alphanumeric value with operator — NOT unaccompanied (could be a model named "1abc")
251+
({"childrens_parents": None, "parents": "+", "children": None, "value": "1abc"}, False),
252+
],
253+
)
254+
def test_is_unaccompanied_graph_operator(groupdict, expected):
255+
assert _is_unaccompanied_graph_operator(groupdict) == expected
256+
257+
258+
@pytest.mark.parametrize(
259+
"raw_spec",
260+
[
261+
"+",
262+
"@",
263+
"1+",
264+
"1+1",
265+
"+2",
266+
"2+3",
267+
],
268+
)
269+
def test_from_single_spec_warns_on_unaccompanied_operator(raw_spec):
270+
"""from_single_spec emits a NoNodesForSelectionCriteria warning for bare operators."""
271+
with patch("dbt.graph.selector_spec.warn_or_error") as mock_warn:
272+
SelectionCriteria.from_single_spec(raw_spec)
273+
mock_warn.assert_called_once()
274+
event = mock_warn.call_args[0][0]
275+
assert type(event).__name__ == "NoNodesForSelectionCriteria"
276+
277+
278+
@pytest.mark.parametrize(
279+
"raw_spec",
280+
[
281+
"mymodel",
282+
"+mymodel",
283+
"mymodel+",
284+
"@mymodel",
285+
"2+mymodel+3",
286+
"tag:my_tag",
287+
"+tag:my_tag",
288+
"path/to/models",
289+
"+path/to/models",
290+
# model names that start with digits are valid if they contain letters too
291+
"1abc",
292+
"+1abc",
293+
],
294+
)
295+
def test_from_single_spec_no_warning_for_valid_selectors(raw_spec):
296+
"""from_single_spec does not warn when a graph operator accompanies a real model name."""
297+
with patch("dbt.graph.selector_spec.warn_or_error") as mock_warn:
298+
with patch("dbt.graph.selector_spec.get_flags") as patched_flags:
299+
patched_flags.return_value.INDIRECT_SELECTION = IndirectSelection.Eager
300+
SelectionCriteria.from_single_spec(raw_spec)
301+
mock_warn.assert_not_called()

0 commit comments

Comments
 (0)