feat: support JSON subpaths in programmatic term-query constructors - #716
feat: support JSON subpaths in programmatic term-query constructors#716stumpylog wants to merge 2 commits into
Conversation
Installed tantivy-py's Query.term_query resolves fields by exact name, so it cannot address a JSON subpath (notes.user) even when notes is a JSON field. Add a cached per-emitter probe (_json_paths_supported) that tries term_query against the registry's first JSON field/subpath; when it succeeds, JSON terms are built the same way as TEXT/KEYWORD terms (reusing _text_term_query's multitoken handling). When it fails, fall back to index.parse_query with quote/backslash-escaped text -- the only route currently able to reach a JSON subpath. The carve-out self-retires once quickwit-oss/tantivy-py#716 ships and the probe starts succeeding. Adds a 5th fixture doc (emitter/conftest.py) to exercise a JSON value containing both a quote and a backslash, proving the parse_query fallback's escaping round-trips. Updates the handful of existing assertions over the whole doc set (test_every, test_not_padded, test_boolean_exists_false, test_every_unfielded, test_every_field_fast) that now genuinely include doc 5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
How does the upstream tantivy crate deal with this kind of query? I'm trying to figure out whether this feature belongs in tantivy-py or tantivy. |
|
@cjrh To answer your question directly, since you asked the same thing on #434 — I went and read the tantivy 0.26 source we pin. Upstream has no public API for this. What it has are the primitives, all
The logic that composes those into "user-supplied value → correct term" is So: expressible in Rust, but only by reassembling the parser's private logic out of public parts — which is exactly what this PR does. It isn't duplicating an upstream API; there is no upstream API to use. The detail that decides the design question. Look at That splits the PR cleanly, and I think the two halves have different answers:
My suggestion: land it, because there's a concrete need and no upstream API to wait for, but open an issue upstream asking for a public helper that returns the term set ( Findings, all minor — I found no correctness bugs: **1. This is about locking the behavior in, not about bugs:
2. 3. "parity with the parser" overstates it, in the summary rather than the code. Two ways it isn't parity: the union thing above, and the parser tokenizes via the field's tokenizer ( 4. "Closes #434" may be a stretch. That issue asks about fuzzy matching over an entire JSON doc the way you would a text field; this delivers fuzzy on a specific subpath. Worth confirming with the reporter rather than auto-closing. The |
I know that upstream doesn't have it yet, nor do we. My question is: should this new functionality go into upstream or us? Why us? I would expect this functionality would be more widely applicable than just the python wrapper. Why must it go into the python wrapper specifically? |
I am unsure about whether to use the existing |
|
Honestly I made the code and pr here because we had a need for it and this repo had been much more responsive. I didn't really consider it some code would be better in the main crate But in happy to try and help align with the main project however I can. Is there a process? Create a discussion thread or issue? |
|
Their discord might be most direct way https://discord.gg/MT27AG5EVE |
|
Just for posterity: https://discord.com/channels/908281611840282624/1536788223316205708 It doesn't seem like the most active community sadly. |
|
No worries, I added a comment and tagged people. They're usually pretty responsive. |
|
@stumpylog there is a response on the discord |
|
Thanks, a little afk, but I'll have a look soon. |
|
Coming back to this after a bit, sorry for the gap. Went back through the review, agreed on all four points. I will:
Also found one more while going back over this: the Separately, re: whether any of this belongs in tantivy itself — I raised There's a small, real overlap with a different private tantivy function though: |
Query.term_query, term_set_query, phrase_query, phrase_prefix_query,
fuzzy_term_query, and Searcher.doc_freq now accept a JSON subpath as
field_name (e.g. "notes.user"), matching what the string query parser
(index.parse_query("notes.user:alice")) already resolved.
make_term now resolves field_name via Schema::find_field instead of an
exact-name-only lookup: a field literally named "notes.user" still takes
precedence over the subpath interpretation. For a JSON subpath, the value
is typed dynamically the way tantivy's own query parser types a JSON leaf:
a string is tried as a fast numeric/bool/date value before falling back to
text, floats and out-of-i64-range ints are normalized/widened to match
what the JSON indexer actually stores, and dates are truncated to search
precision. Phrase words on a JSON subpath are always treated as text,
never as typed fast values, since tantivy's parser never types phrase
tokens either.
Also exposes JsonObjectOptions::set_expand_dots_enabled() on
SchemaBuilder.add_json_field (previously unavailable from Python at all),
needed to test the expand_dots-enabled path end to end.
A JSON string leaf whose text looks numeric/bool/date (e.g. "5") remains
unreachable via term_query, since a single Term can't represent the union
the query parser builds internally for that case; this is documented on
term_query and in the tutorial, with index.parse_query() as the workaround.
Rename make_term_for_phrase_word to make_term_text_only since it's also used by fuzzy_term_query, not just phrase queries. Fix a doc comment on make_json_path_term that mis-cited the query parser's private generate_literals_for_json_object as the mirrored function; it actually mirrors the JSON indexer's index_json_value. Guard the Value::F64 branch against NaN/inf, since the JSON indexer never writes a term for a non-finite float leaf and building one here would produce a term that can never match anything indexed. Add test coverage for term_set_query and phrase_prefix_query on JSON subpaths, the F64 normalize path (including whole-number folding), and expand_dots_enabled exercised through term_query directly.
871edd2 to
a926826
Compare
Summary
Related to #434 — that issue asks about fuzzy matching over an entire JSON document the way you would a text field; this PR delivers fuzzy (and other term-based) matching on a specific, named JSON subpath, which is narrower.
Query.term_query(schema, "notes.user", "alice")currently raisesValueError: Field 'notes.user' is not defined in the schema.even whennotesis a JSON field containing auserkey — the only way to query a JSON subpath today is the string query parser (index.parse_query("notes.user:alice")). This PR brings the programmaticQueryconstructors closer to what the parser can already do for this case, though it isn't full parity — see the two gaps called out below.Query.term_query,term_set_query,phrase_query,phrase_prefix_query,fuzzy_term_query, andSearcher.doc_freqnow accept a JSON subpath asfield_name(e.g."attrs.user").make_termresolvesfield_nameviaSchema::find_fieldinstead of an exact-name-only lookup. A field literally named"attrs.user"still takes precedence over the subpath interpretation (this isfind_field's own resolution order).i64-range ints are normalized/widened to match what the JSON indexer actually stores, and dates are truncated to search precision. Phrase words and fuzzy-query values on a JSON subpath are always treated as text, never as typed fast values, matching how the parser only ever tokenizes phrase content.JsonObjectOptions::set_expand_dots_enabled()onSchemaBuilder.add_json_field(not previously reachable from Python at all) — needed to exercise theexpand_dots-enabled path in tests."5") is not reachable viaterm_query. The query parser builds an OR of the fast-value term and the text term for this case; a singleTermcan't represent that union. Documented onterm_query's docstring and in the tutorial, withindex.parse_query()given as the workaround.term_querydoes not tokenize its input the way the parser does — it takes the value as-is, consistent withterm_query's existing "not tokenized, pre-tokenize yourself" contract for non-JSON fields. This is intentional, not a bug, but it means aterm_queryvalue and aparse_querystring aren't always interchangeable even when both resolve to the same field.Motivation:
paperless-ngxbuilds tantivy queries programmatically, constructing an AST ofQueryobjects rather than a query string — this keeps parity with how its search layer worked under the older Whoosh library, which it's migrating off of. Whoosh's query builder gave fine-grained, predictable control over how each search term was constructed, and reproducing that as closely as possible minimizes the risk of subtly changing what users' existing searches match as part of the migration — a difference in how a query string gets parsed is a much easier way to accidentally break someone's saved search than an explicitly constructed term. JSON-subpath terms were the one leaf type that still forced a fallback to the string parser, reintroducing exactly that risk.AI disclosure
Per CONTRIBUTING.md's AI policy: this PR was built with AI assistance (Claude, via Claude Code), including a multi-pass review step that caught and fixed a few real bugs (date-truncation precision, phrase queries mistyping numeric-looking words, a missing NaN/inf guard on the float-normalization path) before and during review. I've read through the diff and understand the changes — happy to discuss any part of it.
Test plan
cargo fmt --checkpassesFull pytest suite passes
New tests cover: basic and nested JSON subpath term queries, typed values (int/str-fallback/bool/date/u64-range-int/whole-number float folding/fractional float/non-finite float rejection), phrase queries (including a numeric-looking phrase word), phrase-prefix queries, term-set queries, exact-name-over-subpath precedence, unknown-root and non-JSON-field-path error cases, the documented numeric-looking-string-leaf limitation (0 hits via
term_queryvs. 1 viaparse_query), parity againstindex.parse_query()for equivalent queries,Searcher.doc_freq,fuzzy_term_queryon a JSON subpath (including a numeric-looking value), andexpand_dots_enabledexercised through bothparse_queryandterm_querydirectlyManually verified the previously-broken scenario (
Query.term_query(schema, "notes.user", "alice")on a JSON field) now works as expectedI have read the contributing guidelines which also contains information about our AI policy.