Repository navigation
Conversation
cb803fc to
a58c3db
Compare
PR Reviewer Guide 🔍(Review updated until commit d2aa48f)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to d2aa48f Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 9ce89fd
Suggestions up to commit 00fa3f0
Suggestions up to commit 34340bb
Suggestions up to commit ce07556
Suggestions up to commit 99d3400
|
|
Persistent review updated to latest commit a58c3db |
flat_object Type Support
Codecov Report❌ Patch coverage is ❌ Your project check has failed because the head coverage (63.54%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #5817 +/- ##
============================================
+ Coverage 63.23% 63.54% +0.30%
- Complexity 8823 8997 +174
============================================
Files 938 954 +16
Lines 40236 40689 +453
Branches 4537 4639 +102
============================================
+ Hits 25445 25854 +409
+ Misses 13966 13934 -32
- Partials 825 901 +76
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
a58c3db to
7b300c1
Compare
|
Persistent review updated to latest commit 7b300c1 |
1450846 to
daac9c9
Compare
|
Persistent review updated to latest commit daac9c9 |
1 similar comment
|
Persistent review updated to latest commit daac9c9 |
daac9c9 to
a44116e
Compare
|
Persistent review updated to latest commit a44116e |
a44116e to
e24b7d6
Compare
|
Persistent review updated to latest commit e24b7d6 |
e24b7d6 to
abf3b4e
Compare
|
Persistent review updated to latest commit abf3b4e |
abf3b4e to
b7f0803
Compare
|
Persistent review updated to latest commit b7f0803 |
b7f0803 to
58074d2
Compare
|
Persistent review updated to latest commit 58074d2 |
penghuo
left a comment
There was a problem hiding this comment.
Two blocking issues, both reproduced against this branch. Details inline.
PredicateAnalyzer.flatObjectLeafPredicate'scase SEARCHnever appliesSarg.nullAs, soleaf != 'a' and leaf != 'b'returns records that never wrote the leaf.- In
OpenSearchExprValueFactory.parse()the newflat_objectbranch sits after|| content.isArray(), so an array-valuedflat_objectcolumn never reachesFlatObjectValues.flatten.
Also worth a look, same root cause as the second one — both spellings of a colliding path in one document silently drop a value, and which one survives depends on _source key order:
{"a": {"b": 1}, "a.b": 2} => {a.b: 2}
{"a.b": 2, "a": {"b": 1}} => {a.b: 1}
flattenInto does an unconditional out.put(key, ...), so the later entry wins. Two documents differing only in JSON key order give different query results, and since the index files both values under the one term a.b, a pushed-down attributes.a.b = 1 matches a row that then renders as 2 (and returns nothing at all with pushdown disabled). Representing a colliding path as a collection — which is what the index actually holds, and what this PR already does for array leaves — would keep projection and pushdown in step. constructFlatObject_bothSpellingsCollapseToDottedKey puts each spelling in a separate document, so this case is uncovered.
| } catch (ClassCastException e) { | ||
| return ExprNullValue.of(); | ||
| } | ||
| } else if (type.equals(OpenSearchFlatObjectType.of())) { |
There was a problem hiding this comment.
Blocking: this branch is unreachable for an array-valued flat_object column, because || content.isArray() above it wins first.
The else if (type.equals(...Nested)) || content.isArray()) branch at line ~221 is evaluated before this one, so a document whose flat_object column is an array goes to parseArray and never reaches FlatObjectValues.flatten.
Run against this branch:
{"flatV": [{"a":{"b":1}}]} => type=ARRAY, value=[{a:"{\"b\":1}"}]
{"flatV": ["a","b"]} => type=ARRAY
{"flatV": []} => NULL
Three consequences for the first case:
- the inner object is stringified as raw JSON text rather than flattened to the dotted key
a.b, soattributes.a.bresolves to nothing for that row; - the column's reported type is
ARRAY, not the declaredMAP<VARCHAR, VARIANT>, so that row's shape differs from every other row in the same column; FlatObjectValues.flatten's documented guard — "null if the value is not an object (a scalar or array ...)" — is dead code, sinceflattenis never called with an array.
OpenSearch accepts an array at the top level of a flat_object, and the guard in flatten suggests that was the intended handling, so this looks like branch ordering rather than a deliberate choice. Moving the flat_object check above the content.isArray() branch would restore the intent.
No test covers it — every existing flat_object test passes {"flatV": {...}}.
There was a problem hiding this comment.
Confirmed and fixed — branch moved above content.isArray() as you suggested
There was a problem hiding this comment.
And I didn't restore flatten's guard the way you suggested (array → null), because the index doesn't treat a top-level array as absent — it folds the elements into the same terms, so term flatV.a.b=1 matches [{"a":{"b":1}}] exactly as it matches {"a":{"b":1}}. Null would just swap the 500 for "index answers the filter, projection is null"— the inconsistency you flagged on {"a":{"b":1},"a.b":2}.
| * element with one declared element type while a flat_object array may hold a number next to a | ||
| * string; here each element is its own typed variant. | ||
| */ | ||
| public class OpenSearchVariantValue extends VariantSqlValue { |
There was a problem hiding this comment.
Why add this? VariantNonNull not enought?
There was a problem hiding this comment.
Two reasons:
castto text.VariantNonNull.castreturns null for a leaf that wasn't written as text. So for a document holding{"code": 503}— a number — the filterwhere code = '503'matched when pushed down (the index files the term"503") but matched nothing when evaluated on the coordinator with pushdown off. The carrier renders the value as the term the index holds, so both paths agree; there's a yaml case asserting that.Array. Calcite's composite variants convert every element with one declared element type, but a flat_object array can hold a number next to a string ([1, "x"]) — each element has to keep its own type.
MapValue is gone, since nothing produces a tuple leaf after the flattening fix. Both reasons are in the class javadoc now.
| return OpenSearchExprValueFactory.parseContent(value); | ||
| } | ||
|
|
||
| private static void flattenInto( |
There was a problem hiding this comment.
Does it support
{
"a": {"b": 1},
"a.b": 2
}
There was a problem hiding this comment.
Fixed, it now supported:
{"a": {"b": 1}, "a.b": 2} -> a.b: [1, 2]
Both values should be supported, because OpenSearch supports them: the same document on a plain mapped object field also indexes both, and term a.b=1 / term a.b=2 each match. Returning one was the bug.
Same fix covers a top-level array and an object inside an array. Tests added for all of them.
|
|
||
| A [flat_object field](https://opensearch.org/docs/latest/field-types/supported-field-types/flat-object/) is also presented as a struct, keyed by the dotted path of each leaf. A flat_object declares no sub-fields, so each leaf keeps the type it was written with in the document: `12.5` is a number and `"4"` is text. A nested object and a literal dotted key (`{"a": {"b": 1}}` and `{"a.b": 1}`) resolve to the same entry `a.b`, and an array leaf is an array whose elements keep their own types. | ||
|
|
||
| What a query can do with a flat_object leaf follows what the field's index can answer. OpenSearch indexes every leaf value as a keyword term with the path folded in and gives the leaf no mapping of its own, so it can look a leaf up but has nothing to compare, sort or aggregate it by. PPL supports every lookup the index answers, and pushes it down as the same query the DSL uses: |
There was a problem hiding this comment.
To be clear on limitations.
There was a problem hiding this comment.
re-wrote the part with to lists of what we support and what we not support. (https://github.com/RyanL1997/sql/blob/feat/flat-object/docs/user/ppl/general/datatypes.md#query-struct-data-types)
|
|
||
| In PPL, the Struct Data Types corresponding to the [Object field type in OpenSearch](https://opensearch.org/docs/latest/field-types/supported-field-types/object/). The "." is used as the path selector when access the inner attribute of the struct data. | ||
|
|
||
| A [flat_object field](https://opensearch.org/docs/latest/field-types/supported-field-types/flat-object/) is also presented as a struct, keyed by the dotted path of each leaf. A flat_object declares no sub-fields, so each leaf keeps the type it was written with in the document: `12.5` is a number and `"4"` is text. A nested object and a literal dotted key (`{"a": {"b": 1}}` and `{"a.b": 1}`) resolve to the same entry `a.b`, and an array leaf is an array whose elements keep their own types. |
There was a problem hiding this comment.
what is the value of "a.b"?
There was a problem hiding this comment.
if you are referring to the example of
{"a": {"b": 1}}and{"a.b": 1}
The value should be one - similar to the above comment I have re-wrote the above doc paragraph and please re-take a look.
| public RelNode analyze(UnresolvedPlan plan, CalcitePlanContext context) { | ||
| return getRelNodeVisitor().analyze(plan, context); | ||
| RelNode relNode = getRelNodeVisitor().analyze(plan, context); | ||
| FlatObjectScopeValidator.validate(relNode); |
There was a problem hiding this comment.
Can validate in analyze()?
There was a problem hiding this comment.
Done — moved into CalciteRelNodeVisitor.analyze.
| * are {@link VariantValue}s, the runtime representation of the {@code VARIANT} column type the | ||
| * field is declared as, so that a projected leaf carries its own type rather than text. | ||
| */ | ||
| public class OpenSearchExprFlatObjectValue extends ExprTupleValue { |
There was a problem hiding this comment.
It is not a flat_object, it is close to ExprTupleValue, right? the difference, it expect return VariantValue? The n we should define in core module instead in opensearch module.
There was a problem hiding this comment.
You're right. Moved and renamed:
OpenSearchExprFlatObjectValue→core/.../data/model/ExprVariantTupleValue— anExprTupleValuethat hands its values to Calcite as variants, which is all it doesOpenSearchVariantValue→core/.../data/model/ExprVariantValue
58074d2 to
c20db71
Compare
|
Persistent review updated to latest commit c20db71 |
c20db71 to
506496e
Compare
|
Persistent review updated to latest commit 506496e |
506496e to
935b958
Compare
|
Persistent review updated to latest commit 935b958 |
935b958 to
99d3400
Compare
|
Persistent review updated to latest commit 99d3400 |
99d3400 to
ce07556
Compare
|
Persistent review updated to latest commit ce07556 |
ce07556 to
34340bb
Compare
|
Persistent review updated to latest commit 34340bb |
34340bb to
00fa3f0
Compare
|
Persistent review updated to latest commit 00fa3f0 |
00fa3f0 to
9ce89fd
Compare
|
Persistent review updated to latest commit 9ce89fd |
A flat_object field was invisible to PPL: parseMapping skipped the type, so any reference to the field failed with "field not found" (opensearch-project#1604, opensearch-project#3067). The field is now registered as a struct keyed by the dotted path of each leaf, read from _source and flattened, so either spelling of a path ({"a": {"b": 1}} or {"a.b": 1}) resolves to the same entry. Each leaf keeps the type it was written with: the map's value type is Calcite's VARIANT, so 12.5 comes back as a number and "4" as text, and an array leaf as an array whose elements keep their own types. What a query may do with a leaf is what the field's index can answer, and nothing less. A flat_object indexes every leaf value as a keyword term with the path folded in and gives the leaf no mapping of its own, so OpenSearch can look a leaf up but has nothing to compare, sort or aggregate it by. PPL supports every lookup the index answers, pushed down as the query the same DSL request uses: - projecting a leaf, or the whole field; - filtering a leaf by value (= and !=, in (...)), by existence (isnull / isnotnull) or by a pattern (like, not like, any pattern), combined with and / or / not and with filters on other fields. These become term, terms, exists, prefix and wildcard queries on the dotted path. No script runs on a flat_object field at all: RexStandardizer and ScriptQueryExpression refuse to build one, so a plan that would need it falls back rather than reading every record. The queries are not built here. A leaf becomes a NamedFieldExpression named by its dotted path with no type -- which is how a keyword field behaves, and a keyword term per leaf is what the index holds -- so equals, notEquals, in, notIn, exists, notExists and like come from SimpleQueryExpression, the same ones a mapped field gets, and the nullAs switch is the one binary()'s SEARCH branch applies. That is what keeps `leaf != 'a' and leaf != 'b'` from returning records that never wrote the leaf: it folds into a complemented Sarg, and the negation needs the exists filter that notIn alone does not add. A lookup is a lookup of the term, so it does not distinguish a value from the text that spells it: `attributes.status_code = '503'` and `attributes.status_code = 503` both match a record that wrote either, which is the answer the same DSL query gives. A dotted path can receive more than one value, and the index holds every one of them as its own term: {"a": [1, 2]}, [{"a": 1}, {"a": 2}], {"nested": [{"k": 1}, {"k": 2}]} and {"a": {"b": 1}, "a.b": 2} all file two terms under one path. So flatten folds all four the same way, into a collection under that path, and a projected leaf shows what a lookup on it would match. Previously the first dropped a value, the second never reached flatten at all (parseArray won the branch above it, and reading the leaf threw a ClassCastException), and the third projected null while the index answered the filter. Everything else that touches a leaf is rejected while the plan is built, by FlatObjectScopeValidator: an aggregate, a group-by, a sort, a function or an expression over the leaf. A comparison is rejected too, and for a different reason -- the index would answer it, but the terms are text, so a range query does not order values numerically. Each is raised through ErrorReport, the way AnalyticsEngineFormatSupport and the alias check in OpenSearchDataType raise an unsupported operation: the cause message says only what the query did, and the rule and the link to the field type's documentation ride on the report's suggestion, so no message is assembled by hand. { "error": { "reason": "Cannot sort by attributes.duration_ms", "code": "UNSUPPORTED_OPERATION", "suggestion": "A flat_object field can only be projected, or looked up in the index -- by value, by existence or by a pattern. See https://opensearch.org/docs/latest/field-types/ supported-field-types/flat-object/", "context": {"stage": "analyzing", ...}, "location": ["while checking what a flat_object field can answer"], "type": "IllegalArgumentException" }, "status": 400 } Same shape for "Cannot compare attributes.duration_ms with >", "Cannot group by ...", "Cannot apply avg to a flat_object leaf", "Cannot evaluate an expression over ...", and "Cannot apply typeof to a flat_object leaf". A leaf reads as the text the index holds for it: the term for 12.5 is "12.5" and for true is "true", so OpenSearchVariantValue casts to VARCHAR that way rather than delegating to Calcite, whose variant cast answers null for a leaf not written as text. Without this a filter evaluated on the coordinator -- plugins.calcite.pushdown.enabled=false -- disagreed with the same filter pushed down as a term query: `status_code = '503'` matched the record that wrote the number 503 with pushdown on and nothing with it off. The yaml test asserts both paths agree. A cast of a leaf is rejected wherever it would be read -- casting a leaf that was not written as text yields null, so `cast(duration_ms as string)` would answer null for the records that wrote a number -- while a filter comparing a leaf with a value is unaffected, since the index holds one term either way. typeof is rejected for the same reason it would be useless: the static type of a leaf is no type at all. Cast stripping and literal unwrapping are shared: the validator and the pushdown have to agree on which node under a cast is the leaf, so both read it through PlanUtils.stripCast and PlanUtils.stripCastOfLiteral rather than each carrying its own loop. A character type is tested with SqlTypeUtil.isCharacter, as the rest of the calcite layer does. The two value classes live in core, not in the opensearch module: neither has an opensearch dependency, and neither is about flat_object. ExprVariantTupleValue is an ExprTupleValue that hands its values to Calcite as variants, and ExprVariantValue is one such value -- a column whose type varies from row to row, which flat_object is the field type that needs today. The validator is a pass over the logical plan, like withCheckedArithmetic, run from CalciteRelNodeVisitor.analyze so that the unified query pipeline, which calls the visitor directly rather than through QueryService, is held to the same rules. It recognizes the field and its leaves by the types they are registered with -- MAP<VARCHAR, VARIANT> and VARIANT -- read off each node's input row type, and walks the plan with Calcite's RelVisitor. Functions that receive a leaf they cannot take raise the same message from the function resolver rather than a type error. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
9ce89fd to
d2aa48f
Compare
|
Persistent review updated to latest commit d2aa48f |
penghuo
left a comment
There was a problem hiding this comment.
Review scoped to ExprVariantValue.Array only; comments inline.
Short version: the existence of a custom Array carrier looks justified to me — Calcite's own ARRAY variant is homogeneous by construction, so a heterogeneous flat_object array genuinely can't go through it. The concern is that cast() and item() return null unconditionally, which makes array-leaf behaviour depend on whether pushdown is enabled, and both failure modes are silent (no error, just missing rows).
|
|
||
| @Override | ||
| public @Nullable Object cast(RuntimeTypeInformation type) { | ||
| return null; |
There was a problem hiding this comment.
cast() returning null for every target type makes a filter on an array leaf answer differently depending on whether pushdown is on, which contradicts the invariant this PR states ("a filter evaluated on the coordinator returns the same rows as the same filter pushed down").
With plugins.calcite.pushdown.enabled=false, where attributes.tags = 'a' lowers to a comparison over cast(<variant> AS VARCHAR). For an Array that cast is null, so the row is dropped — while the pushdown-on path (asserted in 1604.yml) returns ["tags"]. The merged-path case the description advertises ({"a":{"b":1},"a.b":2} → [1,2]) has the same problem: where attributes.a.b = 1 matches with pushdown on and matches nothing with it off.
The "same answer with pushdown off" section of 1604.yml only exercises scalar leaves, so CI stays green. Adding an array leaf there should reproduce it.
Worth flagging that the fix is probably not "implement cast": a term query on an array field in OpenSearch has ANY semantics (matches if any element equals the term), and a scalar cast to a single VARCHAR can't express ANY — rendering the list as "[a, b]" would just introduce a third inconsistent answer. Two options that do hold:
- give array equality a real multi-value/ANY operator, so the coordinator-side and pushed-down paths agree; or
- have
FlatObjectScopeValidatorreject predicates on array leaves when pushdown is unavailable — an explicit error is better than silently returning fewer rows.
Either way, could the bare return null carry a comment stating the invariant? As written there's no way for the next reader to tell whether this is a deliberate decision or a stub.
|
|
||
| @Override | ||
| public @Nullable Object item(Object index) { | ||
| return null; |
There was a problem hiding this comment.
Same shape of concern as cast above: item() returning null unconditionally means attributes.tags[0] silently yields null rather than the element (or an error). If indexing into an array leaf isn't supported, it would be better to say so explicitly — either reject it in FlatObjectScopeValidator or document it here.
Stepping back on the class as a whole: Array extends VariantSqlValue but answers null from every variant operation, so it can't stand in for one (Effective Java Item 20). To be clear, having read the Calcite side I agree a custom carrier is necessary — VariantNonNull's ARRAY ctor applies a single getTypeArgument(0) to every element together with a per-tag assert value instanceof X, so a heterogeneous array (4 next to "n/a") can't be represented, and ARRAY<VARIANT> is degenerate because VARIANT-tagged elements fall through cast to null anyway. The open question is only whether this should participate in the variant type system while supporting none of its operations, or be an explicitly opaque carrier that the boundary unwraps — which is what unwrapAll() already does.
|
Closing due to #5830 - Instead of introducing flat object as a Why text rather than a type per leaf
|
Description
A
flat_objectfield is invisible to PPL today:parseMappingskips the type, sodescribedoes not list it and any reference to it fails with field not found.Scope
The field is presented as a struct keyed by the dotted path of each leaf, read from
_sourceand flattened, so either spelling of a path ({"a": {"b": 1}}or{"a.b": 1}) resolves to the same entry. Each leaf keeps the type it was written with — the map's value type is Calcite'sVARIANT— so12.5comes back as a number,"4"as text, and an array leaf as an array whose elements keep their own types.What a query may do with a leaf is bounded by the field's index, not by PPL: a
flat_objectindexes every leaf value as a keyword term with the path folded in and gives the leaf no mapping of its own (field type docs). Hence the rule — every filter the index answers is supported and pushed down as the same request a DSL user writes; nothing is answered by a script or by scanning_source.RexStandardizerandScriptQueryExpressionrefuse to build a script over aflat_objectat all, so a plan that would need one falls back rather than opening every record.Supported, because the DSL answers it from the index
fields attributes,fields attributes.namespace,eval ns = attributes.namespace_sourceincludewhere attributes.namespace = 'prod',where attributes.status_code = 503termwhere attributes.namespace != 'prod'exists+must_not termwhere attributes.namespace in ('prod', 'staging')termper value undershouldwhere isnotnull(attributes.k)/where isnull(attributes.k)exists/must_not existswhere like(attributes.namespace, 'ns-%'),where like(attributes.namespace, '%prod%')wildcardwhere not like(attributes.namespace, 'ns-%')exists+must_not wildcardand/or/not, and with filters on other fieldsboolTwo consequences, both the DSL's own behaviour: a lookup is a lookup of the term, so it does not distinguish a value from the text that spells it (
= '503'and= 503both match a record that wrote either;= 'true'matches one that wrote the boolean); andlikepushes down as the query a mappedkeywordfield gets, byte for byte —{"wildcard":{"<path>":{"wildcard":"ns-*","case_insensitive":true}}}.Not supported, because the DSL cannot answer it from the index
stats avg(attributes.duration_ms), and any other aggregatestats count() by attributes.ktermsaggregation over the folded terms returns bucket keys likejava.lang.Object@21c06f0sort attributes.kwhere attributes.duration_ms > 10, and<>=<=betweenrangequery does not match the leaf holding200for> 90The last row is the only place PPL is deliberately stricter than the DSL, and its error says why.
Error messages
Raised through
ErrorReport— the wayAnalyticsEngineFormatSupportand the alias check inOpenSearchDataTyperaise an unsupported operation — rather than as a message assembled by hand. The cause says only what the query did; the rule and the doc link ride on the report'ssuggestion:{ "error": { "reason": "Cannot sort by attributes.duration_ms", "code": "UNSUPPORTED_OPERATION", "suggestion": "A flat_object field can only be projected, or looked up in the index -- by value, by existence or by a pattern. See https://opensearch.org/docs/latest/field-types/supported-field-types/flat-object/", "context": { "stage": "analyzing", "stage_description": "Parsing and validating the query" }, "details": "Cannot sort by attributes.duration_ms", "location": [ "while preparing and validating the query plan", "while checking what a flat_object field can answer" ], "type": "IllegalArgumentException" }, "status": 400 }Same shape, same
suggestion, forCannot compare attributes.duration_ms with >,Cannot group by attributes.k,Cannot apply avg to a flat_object leaf,Cannot evaluate an expression over attributes.k, andCannot apply typeof to a flat_object leaf. Nothing in a message is invented — only the field name and the operator, both read off the query.expandover a leaf gets its own suggestion, since the fix is different: expand takes a field of the index, not a value computed from one. Expand the field itself, or project the value first.How
FlatObjectScopeValidatoris a pass over the logical plan, likewithCheckedArithmetic, run fromQueryService.analyze. It recognizes the field and its leaves by the types they are registered with —MAP<VARCHAR, VARIANT>andVARIANT— read off each node's input row type, and walks the plan with Calcite'sRelVisitor. Functions that receive a leaf they cannot take raise the same message from the function resolver rather than a type error. A leaf also renders as the text the index holds for it, so a filter evaluated on the coordinator (plugins.calcite.pushdown.enabled=false) returns the same rows as the same filter pushed down.One behaviour that follows the DSL and is documented as such: a path through an array of objects can be filtered (
where attributes.items.name = 'alpha'— the index holds one term per element) but projects as null, since in the document the value lives inside the array thatfields attributes.itemsreturns whole.Related Issues
Related to #1604, #3067
Check List
--signoffor-s.By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.