Skip to content

Support null-safe variable references in expressions#181

Merged
amc-corey-cox merged 3 commits intomainfrom
null-safe-expressions-180
Mar 31, 2026
Merged

Support null-safe variable references in expressions#181
amc-corey-cox merged 3 commits intomainfrom
null-safe-expressions-180

Conversation

@amc-corey-cox
Copy link
Copy Markdown
Contributor

Summary

Closes #180

Replaces the abort-on-null behavior of {x} with SQL-style NULL propagation. Both {x} and bare x are now equivalent — None flows through arithmetic and function calls (returning None) while comparisons evaluate naturally, enabling case() branching on null values.

Before

# {companion} is null → entire expression aborts → field is None
# case() fallback branch never reached
expr: "case(({primary} == '1' and {companion} == '1', 'PRESENT'), (True, 'FALLBACK'))"

After

# {companion} is null → None == '1' is False → falls through → 'FALLBACK'
expr: "case(({primary} == '1' and {companion} == '1', 'PRESENT'), (True, 'FALLBACK'))"

What changed

  • _eval_set: {x} returns None instead of raising UnsetValueError
  • Arithmetic operators (add, sub, mul, div, etc.): wrapped to return None if either operand is None
  • Unary operators (neg, pos, invert): wrapped to return None if operand is None
  • Scalar functions (float, int, str, etc.): return None if any argument is None
  • Comparisons and boolean logic: unchanged — Python handles None == "1"False natively
  • Removed: UnsetValueError class and its try/except in eval_expr_with_mapping

Behavioral change

Expressions with {x} where x is None now propagate None through operations instead of aborting the entire expression. The end result for simple arithmetic ({x} * 0.453) is the same (field gets None). The difference is that case() fallback branches now fire instead of being silently skipped.

Test plan

  • 129 eval_utils tests pass (including new null propagation tests)
  • 348 tests pass across transformer, utils, and compliance suites
  • Doctests updated to document new behavior
  • continue-on-error tests updated to use genuinely failing expressions

🤖 Generated with Claude Code

Replace the abort-on-null behavior of {x} with SQL-style NULL propagation:
None flows through arithmetic and function calls (returning None) while
comparisons evaluate naturally, enabling case() branching on null values.
{x} and bare x are now equivalent.

Closes #180

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 31, 2026 14:46
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the LinkML expression evaluator to use SQL-style NULL propagation so that None values flow through arithmetic and function calls instead of aborting evaluation, enabling case() fallback branches to execute as expected.

Changes:

  • Removed the {x} “abort-on-null” behavior and made {x} equivalent to bare x variable resolution.
  • Added NULL-propagating wrappers for arithmetic and unary operators, and for scalar functions.
  • Updated evaluator and transformer tests to reflect the new semantics and to use a reliably failing expression for continue-on-error coverage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/linkml_map/utils/eval_utils.py Implements NULL propagation in operators/functions and removes the UnsetValueError abort path.
tests/test_utils/test_eval_utils.py Updates/extends tests to cover NULL propagation in arithmetic, function calls, and case() branching.
tests/test_transformer/test_continue_on_error.py Adjusts error-handling tests to use division-by-zero now that undefined-variable arithmetic no longer fails.

Comment thread src/linkml_map/utils/eval_utils.py Outdated
Comment thread src/linkml_map/utils/eval_utils.py
Wrap list functions (len, min, max) with null-safe guards, fix None
handling in distributed function calls over lists, and fix CLI
continue-on-error tests to use genuinely failing expressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@amc-corey-cox amc-corey-cox requested a review from csiege March 31, 2026 15:23
Copy link
Copy Markdown
Collaborator

@csiege csiege left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Approve — with one gap to address


What this changes (and why it matters)

The old {x} behavior was raise-on-None: if any {var} resolved to None, the entire expression threw UnsetValueError and eval_expr_with_mapping caught it and returned None. That sounds safe, but it silently swallowed case() fallback branches — even (True, FALLBACK) never fired when a preceding {var} was null. This PR replaces that with SQL-style NULL propagation: None flows through arithmetic, functions return None when given None, and comparisons work naturally (None == "1" is False), so case() fallback branches execute as intended.

Impact on our production patterns

Three distinct patterns in our YAML files:

  1. == "value" comparisons with (True, None) fallback — no behavioral change. {phv} == "1" with phv=None → False → case falls through to (True, None) → None. Same result as the old abort path.

  2. == "value" comparisons with (True, MEANINGFUL_VALUE) fallback (e.g., cig_smok.yaml (True, "OMOP:45885135")) — behavior changes. Old: phv=None → abort → None. New: phv=None → None == "Yes" → False → falls through → "Unknown if ever smoked". This is the correct intended behavior — the (True, ...) fallback now actually fires. This is a win.

  3. Numeric guard comparisons (bdy_hgt.yaml: case(({phv} <= 0, None), ...)) — this is the gap. Arithmetic operators (+, *, etc.) are now null-propagating, but <, <=, >, >= are not. _coercing wraps them for numeric string coercion but doesn't guard against None. So {phv} <= 0 with phv=None → None is returned by {phv}, then operator.le(None, 0) raises TypeError. Old behavior: graceful None. New behavior: uncaught TypeError.

The <= 0 guard pattern (case(({phv} <= 0, None), (True, {phv} * ...))) is a common defensive idiom across height, weight, and similar files. With this PR, a null input that previously returned None cleanly will now throw. Depending on how dm-bip handles the TypeError (catch vs. propagate), this is either a logged null or a failed row.

Recommendation

_null_propagating should also be applied to ast.Lt, ast.LtE, ast.Gt, ast.GtE — not just arithmetic. Eq/NotEq are fine since Python handles None == x natively. That would close the gap and make the null propagation complete and consistent.

Approving because the core change is correct, the test suite is solid (348 tests, 7/7 CI), and the arithmetic + case() semantics are exactly what we need. The <= gap needs a follow-up fix before the numeric guard pattern is fully safe. If the dm-bip error handler treats TypeError the same as prior None (nullifying the slot), this is low-risk in practice — but the inconsistency should be addressed.

@amc-corey-cox amc-corey-cox merged commit 042d24f into main Mar 31, 2026
11 checks passed
@amc-corey-cox amc-corey-cox deleted the null-safe-expressions-180 branch March 31, 2026 18:13
@amc-corey-cox amc-corey-cox restored the null-safe-expressions-180 branch March 31, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support null-safe variable references in expressions

3 participants