Skip to content

feat(langgraph-oracledb): containment matching for dict and list checkpoint metadata filters - #293

Open
fede-kamel wants to merge 2 commits into
oracle:mainfrom
fede-kamel:feat/checkpoint-nested-filter-containment-290
Open

feat(langgraph-oracledb): containment matching for dict and list checkpoint metadata filters#293
fede-kamel wants to merge 2 commits into
oracle:mainfrom
fede-kamel:feat/checkpoint-nested-filter-containment-290

Conversation

@fede-kamel

@fede-kamel fede-kamel commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 exact JSON_EQUAL matches: 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 from PostgresSaver'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:

JSON_VALUE(metadata, '$.nested.b.c') = 'true'
  • recurses through arbitrarily deep dicts; every nested key is validated with the same JSON-path-injection check as top-level keys
  • leaf typing preserved: RETURNING NUMBER for numerics, literal 'true'/'false' for booleans, IS NULL for nulls, bind params for strings
  • {} asserts path existence via JSON_EXISTS
  • sync (OracleSaver) and async (AsyncOracleSaver) share the implementation

List 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 PostgresSaver already use. The stored value must be an array (scalars never match), and elements compare by JSON type:

JSON_EXISTS(metadata, '$.tags?(@.type() == "array")')
JSON_EXISTS(metadata, '$.tags[*]?(@.type() == "string" && @ == $FILTER_KEY_0)'
            PASSING :filter_key_0 AS "FILTER_KEY_0")
  • string/number elements are bound through the PASSING clause; only our own literals (true, false, null, "") reach the path text
  • dict elements match by recursive containment, array elements by nested containment; nested member names go through the same injection validation
  • [] matches any stored array at the path
  • verified live against the JS saver on shared tables: for metadata {"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/false discrimination, numbers, absent nested arrays) agrees exactly

Backward 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 rejection
  • test_search_where_key_combinations.py / test_search_where_parameter_combinations.py updated to the containment contract; previously-disabled complex list cases ([1, 2, "three"], [{"item": 1}, {"item": 2}, [3, 4]]) re-enabled
  • Full package suite verified against a local Oracle 26ai Free container (per-file, see note), all green

Note: 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 with ORA-12516.

… 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
@fede-kamel fede-kamel changed the title feat(langgraph-oracledb): nested-path containment matching for dict checkpoint metadata filters feat(langgraph-oracledb): containment matching for dict and list checkpoint metadata filters Aug 12, 2026
@fileames

Copy link
Copy Markdown
Member

Thanks a lot for working on this.
Went through this against the JS saver since that's what it's aligning to. The containment approach looks right, and _element_predicate lines up well with how the JS filter compiler does it (type-checked scalars, values bound through PASSING, recursive dicts and arrays).

Two things I want to consider.

_expand_filter builds unquoted paths ('$.{path}') while _element_predicate quotes member names ({subject}."{sub_key}"). So a filter key containing a dot is a nested path at the top level but a literal key inside a list element. Before this PR only top-level keys got interpolated into a path (dicts went through JSON_EQUAL on the whole subdocument), so the flattening spreads that ambiguity to every depth. JS quotes everywhere and reads {"a.b": 1} as a literal key. Can we quote the flattened segments too, or is dotted-as-nested intentional?

Smaller one: _element_predicate rejects non-finite floats, but the numeric branch in _add_leaf_condition doesn't, so {"score": float("nan")} still gets bound.

One more thing we can fix, though it is not directly same as the main issue. The boolean leaf compares JSON_VALUE(...) = 'true', so it also matches the string "true"; IS NULL matches an absent path as well as an explicit JSON null; and {"a": {}} only checks existence, so it matches {"a": 5}. In each of those JS agrees with what metadata @> filter does in Postgres, so I'd say Python is the odd one out and should follow.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

OCA Verified All contributors have signed the Oracle Contributor Agreement.

Projects

None yet

2 participants