Skip to content

Commit 28ea101

Browse files
committed
fix: Evaluator now correctly ANDs multiple conditions in a rule
1 parent 075f139 commit 28ea101

7 files changed

Lines changed: 80 additions & 29 deletions

File tree

‎configdirector-openfeature-server-provider/CHANGELOG.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
66

77
## [Unreleased]
88

9-
## [1.2.0] - 2026-09-21
9+
## [1.1.0] - 2026-09-23
10+
11+
### Fixed
12+
13+
- Bump the dependency on the server SDK to 1.3.0 in order to pick up the conditional evaluation fix.
14+
15+
## [1.0.0] - 2026-09-21
1016

1117
### Added
1218

‎configdirector-openfeature-server-provider/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ classifiers = [
3939
]
4040

4141
dependencies = [
42-
"configdirector-server-sdk>=1.2,<2",
42+
"configdirector-server-sdk>=1.3,<2",
4343
"openfeature-sdk>=0.8.2,<1",
4444
]
4545

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.0.0"
1+
__version__ = "1.1.0"

‎configdirector-server-sdk/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
88

99
## [Unreleased]
1010

11+
## [1.3.0] - 2026-09-23
12+
13+
### Fixed
14+
15+
- The rules evaluator now ANDs multiple conditions in a conditional rule, rather than evaluating them as an OR.
16+
1117
## [1.2.0] - 2026-09-21
1218

1319
### Added

‎configdirector-server-sdk/src/configdirector/_evaluation/config_evaluator.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def _evaluate_conditional_rule(
117117
config: Config,
118118
context: EvaluationContext | None,
119119
) -> _RuleResult:
120-
if not any(evaluate_condition(condition, context) for condition in rule.conditions or []):
120+
if not all(evaluate_condition(condition, context) for condition in rule.conditions or []):
121121
return _NO_MATCH
122122
if rule.target == "value" and rule.value is not None:
123123
return _RuleResult(matched=True, value=to_json_string(rule.value), value_id=rule.value_id)

‎configdirector-server-sdk/src/configdirector/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44

5-
__version__ = "1.2.0"
5+
__version__ = "1.3.0"
66

77

88
@dataclass(frozen=True, slots=True)

‎configdirector-server-sdk/tests/evaluation/test_config_evaluator.py‎

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,39 @@ def identifier_is(value: str) -> Condition:
4242
)
4343

4444

45+
def plan_is(value: str) -> Condition:
46+
return Condition(
47+
id=uid(),
48+
attribute="traits",
49+
operator="=",
50+
trait="/plan",
51+
target_type="text",
52+
target_values=[value],
53+
)
54+
55+
56+
def config_requiring(*conditions: Condition) -> Config:
57+
return Config(
58+
id=CONFIG_ID,
59+
key="config-with-a-two-condition-rule",
60+
type="string",
61+
variations=[],
62+
target=TargetingRules(
63+
default_value="this-is-the-default",
64+
rules=[
65+
ConditionalRule(
66+
id=uid(),
67+
order=0,
68+
target="value",
69+
value="Rule A Value",
70+
percentages=[],
71+
conditions=list(conditions),
72+
)
73+
],
74+
),
75+
)
76+
77+
4578
def test_evaluates_to_the_default_value_when_there_are_no_targeting_rules() -> None:
4679
config = Config(
4780
id=CONFIG_ID,
@@ -251,7 +284,7 @@ def test_cycles_through_multiple_rules(self) -> None:
251284
assert evaluator.evaluate(config, ctx(id="15")).value == "Rule B Value"
252285
assert evaluator.evaluate(config, ctx(id="20")).value == "this-is-the-default"
253286

254-
def test_falls_back_to_the_default_when_conditions_array_is_empty(self) -> None:
287+
def test_a_rule_with_no_conditions_applies_to_every_context(self) -> None:
255288
config = Config(
256289
id=CONFIG_ID,
257290
key="config-without-rules",
@@ -272,32 +305,38 @@ def test_falls_back_to_the_default_when_conditions_array_is_empty(self) -> None:
272305
),
273306
)
274307

275-
assert evaluator.evaluate(config, ctx(id="10")).value == "this-is-the-default"
308+
assert evaluator.evaluate(config, ctx(id="10")).value == "Rule A Value"
309+
assert evaluator.evaluate(config).value == "Rule A Value"
276310

277-
def test_matches_on_the_first_true_condition(self) -> None:
278-
config = Config(
279-
id=CONFIG_ID,
280-
key="config-without-rules",
281-
type="string",
282-
variations=[],
283-
target=TargetingRules(
284-
default_value="this-is-the-default",
285-
rules=[
286-
ConditionalRule(
287-
id=uid(),
288-
order=0,
289-
target="value",
290-
value="Rule A Value",
291-
percentages=[],
292-
conditions=[identifier_is("10"), identifier_is("20")],
293-
)
294-
],
295-
),
311+
def test_a_rule_with_several_conditions_matches_when_every_condition_matches(self) -> None:
312+
config = config_requiring(identifier_is("10"), plan_is("pro"))
313+
314+
assert evaluator.evaluate(config, ctx(id="10", traits={"plan": "pro"})).value == "Rule A Value"
315+
316+
def test_a_rule_with_several_conditions_does_not_match_when_only_the_first_matches(self) -> None:
317+
config = config_requiring(identifier_is("10"), plan_is("pro"))
318+
319+
assert (
320+
evaluator.evaluate(config, ctx(id="10", traits={"plan": "free"})).value == "this-is-the-default"
296321
)
297322

298-
assert evaluator.evaluate(config, ctx(id="10")).value == "Rule A Value"
299-
assert evaluator.evaluate(config, ctx(id="20")).value == "Rule A Value"
300-
assert evaluator.evaluate(config, ctx(id="30")).value == "this-is-the-default"
323+
def test_a_rule_with_several_conditions_does_not_match_when_only_the_second_matches(self) -> None:
324+
config = config_requiring(identifier_is("10"), plan_is("pro"))
325+
326+
assert evaluator.evaluate(config, ctx(id="20", traits={"plan": "pro"})).value == "this-is-the-default"
327+
328+
def test_a_rule_with_several_conditions_does_not_match_when_no_condition_matches(self) -> None:
329+
config = config_requiring(identifier_is("10"), plan_is("pro"))
330+
331+
assert (
332+
evaluator.evaluate(config, ctx(id="20", traits={"plan": "free"})).value == "this-is-the-default"
333+
)
334+
335+
def test_a_rule_whose_conditions_cannot_all_hold_never_matches(self) -> None:
336+
config = config_requiring(identifier_is("10"), identifier_is("20"))
337+
338+
assert evaluator.evaluate(config, ctx(id="10")).value == "this-is-the-default"
339+
assert evaluator.evaluate(config, ctx(id="20")).value == "this-is-the-default"
301340

302341
def test_falls_back_to_the_default_when_the_condition_matches_but_the_value_is_none(
303342
self,

0 commit comments

Comments
 (0)