feat(langgraph-oracledb): containment matching for dict and list checkpoint metadata filters - #293
Conversation
… metadata filters Closes oracle#290. Dict values in list()/alist() metadata filters previously required an exact JSON_EQUAL match of the whole nested object, so {"nested": {"b": {"c": true}}} failed to match metadata {"nested": {"a": 1, "b": {"c": true}}} — diverging from the LangGraph.js Oracle saver and PostgresSaver (metadata @> filter). _search_where() now flattens dict filter values into per-leaf JSON-path predicates (the same nested-path approach oracle#280 applied to OracleVS): JSON_VALUE(metadata, '$.nested.b.c') = 'true', recursing through nested dicts and validating every nested key against JSON path injection. Leaf typing is preserved (RETURNING NUMBER for numerics, literal true/false for booleans, IS NULL for nulls). Lists keep JSON_EQUAL exact-match (positional) semantics, and {} asserts path existence via JSON_EXISTS. Sync and async savers share the implementation. Exact-match dict filters keep working (a filter equal to the metadata is trivially contained in it), so existing behavior only widens. Tests: new SQL-generation + live sync/async containment tests (tests/test_search_where_nested_containment.py); the key/parameter combination suites updated to the containment contract with deeper nested cases enabled. Verified against a local Oracle 26ai Free container: all checkpoint/search suites pass.
…tadata filters Align list filter values in _search_where with the LangGraph.js Oracle saver and PostgresSaver (metadata @> filter): the stored value must be an array containing every filter element, regardless of order or duplicates. Scalar elements compare by JSON type and value through PASSING binds; dict and array elements match by recursive containment, with member names going through the same JSON-path-injection validation as top-level keys. Previously lists compiled to positional JSON_EQUAL(JSON_QUERY(...)) equality, so the same filter returned different rows in each language on shared tables. Fixes oracle#296
|
Thanks a lot for working on this. Two things I want to consider.
Smaller one: One more thing we can fix, though it is not directly same as the main issue. The boolean leaf compares The catch is that unlike containment these would narrow matching rather than widen it, so they'd change results for filters people are relying on today ({"x": None} currently finds rows that don't have the key at all). Do you think we should make the change or a new PR with clear release note? |
Summary
Closes #290 — the follow-up @fileames raised on #280.
Closes #296 — list-filter divergence from the LangGraph.js Oracle saver, found while reviewing langchain-ai/langgraphjs#2660.
Metadata filters in
list()/alist()previously required exactJSON_EQUALmatches: dict filters didn't match metadata containing extra keys, and list filters required exact positional equality. Both diverged from the LangGraph.js Oracle saver and fromPostgresSaver's containment semantics (metadata @> filter).Changes
Dict containment (#290)
_search_where()flattens dict filter values into per-leaf JSON-path predicates — the same nested-path approach #280 applied to OracleVS:RETURNING NUMBERfor numerics, literal'true'/'false'for booleans,IS NULLfor nulls, bind params for strings{}asserts path existence viaJSON_EXISTSOracleSaver) and async (AsyncOracleSaver) share the implementationList containment (#296)
List filter values now match any stored array that contains every filter element, regardless of order or duplicates — the semantics the JS saver and
PostgresSaveralready use. The stored value must be an array (scalars never match), and elements compare by JSON type:PASSINGclause; only our own literals (true,false,null,"") reach the path text[]matches any stored array at the path{"tags": ["a","b"]}, the filters["a","b"]/["b","a"]/["a"]now return identical results from both languages (previously Python returned 1/0/0 vs JS 1/1/1), and a seven-case typed-element matrix (dict containment, dict miss,null,true/falsediscrimination, numbers, absent nested arrays) agrees exactlyBackward compatibility: an exact-match filter is trivially contained in equal metadata, so every previously-matching filter still matches — the change only widens matching, it never narrows it.
Tests
tests/test_search_where_nested_containment.py: SQL-generation assertions (no DB) + live sync/async containment tests — dict scenarios from feat(langgraph-oracledb): nested-path containment matching for checkpoint metadata filters in _search_where #290 plus list containment (order-insensitivity, subsets, non-matching elements, empty-list, typed elements incl. dict-in-list), injection checks for nested dict keys and dict-in-list member names, non-finite number rejectiontest_search_where_key_combinations.py/test_search_where_parameter_combinations.pyupdated to the containment contract; previously-disabled complex list cases ([1, 2, "three"],[{"item": 1}, {"item": 2}, [3, 4]]) re-enabledNote: this package has no repo CI job, so the verification above was run locally. On a resource-constrained container (default
processes=200) the suite must be run file-by-file or with pauses — running all files back-to-back exhausts DB processes and fails spuriously withORA-12516.