Repository navigation
Conversation
A flat_object field is invisible to PPL: parseMapping skips the type, so describe does not list it and any reference to it fails with "field not found" (opensearch-project#1604, opensearch-project#3067). The field is now registered and presented as a struct keyed by the dotted path of each leaf, read from _source. A flat_object gives its leaves no mapping of their own -- the index files every leaf value as a keyword term with the path folded in -- so a leaf has no type either: every value reads as text, and a path holding more than one value reads as their JSON. That is the rule spath already applies to a JSON string, and typeof on a leaf reports string. {"a": {"b": 1}} -> a.b: "1" {"a.b": 1} -> a.b: "1" {"a": [1, 2]} -> a: "[\"1\",\"2\"]" [{"a": 1}, {"a": 2}] -> a: "[\"1\",\"2\"]" {"nested": [{"k": 1}, {"k": 2}]} -> nested.k: "[\"1\",\"2\"]" {"a": {"b": 1}, "a.b": 2} -> a.b: "[\"1\",\"2\"]" Every way of writing a path reaches the same leaf, because the index files one term either way, and a path written more than once holds every value rather than the last -- the index answers a lookup for any of them, so dropping one would make a projection disagree with a filter. What a query may do with a leaf is read it, and cast it: source=logs | fields attributes source=logs | fields attributes.duration_ms source=logs | eval ms = cast(attributes.duration_ms as double) A cast is reading: a leaf is text, so converting it is the parse a caller would do anyway and the value comes from _source either way. It is how a query asks for a leaf as a number, which is what the field type leaves to the caller. Everything else that touches a leaf is rejected while the plan is built, by FlatObjectScopeValidator, with a message that names the field as the query writes it and a suggestion linking the field type's documentation: filtering by it, aggregating it, grouping or sorting by it, computing a value from it, expanding it. None of this is a PPL restriction -- the limitations are the field type's own, and answering any of it would mean opening every record, the cost this type exists to avoid. Cannot sort by attributes.duration_ms code UNSUPPORTED_OPERATION suggestion A flat_object field can only be read. Its leaves have no mapping of their own, so nothing else can be answered without opening every record. See https://docs.opensearch.org/latest/mappings/ supported-field-types/flat-object/ Filtering a leaf is the next step and is left to a follow-up, which adds the index lookups the field type does support and relaxes this check to admit them. The validator runs from CalciteRelNodeVisitor.analyze, so the unified query pipeline -- which calls the visitor directly rather than through QueryService -- is held to the same rules. It finds the field by its mapping type rather than by the type it takes in the plan, because a map of text to text is also what spath produces for an extracted document; and it tracks a column a projection derived from a leaf, so a command that sorts or groups by a leaf, projecting it into a column of its own first, is caught too. Two smaller changes come with it. expand over anything that is not a column of the index now raises the error mvexpand already raises, rather than the ClassCastException the cast threw. And RexStandardizer refuses to build a script over a flat_object field: it has no usable doc values, so a script could only read _source, and the field type does not support Painless for retrieving subfield values either. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
The scope check walked the finished plan for a table to ask which columns were flat_object. Record them instead as each relation is bound, which is where the mapping is already read, and hand the names to the check. This also covers every relation a query scans rather than the first one found, so the right side of a join is no longer missed. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
Reading a leaf as a number is allowed, so the check strips a cast before
deciding that. Two other places ask the same question -- which column a
read went into, and what to call it -- and neither stripped the cast. A
cast leaf therefore reached pushdown untracked, where a script over a
flat_object cannot be built, and the failure surfaced as
HTTP 500, code UNKNOWN
java.sql.SQLException: ... A flat_object field cannot be read by a
pushed-down script: attributes
for a query such as
source=otel | eval d = cast(attributes.duration_ms as double)
| sort d | fields service
which now reports "Cannot sort by attributes.duration_ms" like the rest.
The three callers share one stripCasts so they cannot drift again.
Signed-off-by: Jialiang Liang <jiallian@amazon.com>
A reviewer's first question is why this is not thrown from where a leaf reference is built, the way an unsupported command is rejected from its visit method. Field resolution is not a safe place to reject from: MapPathPreMaterializer, which every plan node passes through, wraps it in catch (RuntimeException | AssertionError) and logs at debug, so the rejection would be dropped for rename, fillnull, replace, rare, top, lookup and join and the query would fail in pushdown instead. Noted on the class, and the class is package-private now -- only the visitor and its own test use it. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
RyanL1997
force-pushed
the
feat/flat-object-string
branch
from
September 30, 2026 20:31
038575f to
99e5316
Compare
The comment claimed the field type does not support Painless for retrieving subfield values. That is a limitation of doc-value access; this engine's script reads _source, and it does retrieve flat_object leaves correctly -- a sort over one returns the right order. The reason to refuse is the cost of that read, not an inability, and the types above are read from _source on purpose, so the comment now says which of the two this is. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
… of allowed uses The check walked the finished plan and rejected every node that was not a read. Nothing else in this package works that way -- MapPathPreMaterializer, SchemaUnifier, ForeachPlanner and QualifiedNameResolver are all helpers the visitor calls while building -- and a plan pass pays for it: it has to trace a column back through a projection to find the leaf it came from, and rebuild "attributes.duration_ms" out of a generated $f2, because Calcite renames columns and rebuilds expressions. The rule is now a list of the uses a flat_object allows, checked where a field reference resolves. Every strategy in QualifiedNameResolver converges on one point, so a command not on the list is refused without an entry of its own -- including one added later -- and the name in the message is the one the query wrote. Reading a field or a leaf, optionally cast, and typeof are the list; a column a read went into is remembered so a later command using it is refused too, named by the leaf rather than by the alias. Two things had to be arranged around this. The materializer resolves fields inside catch (RuntimeException | AssertionError), so it runs with the read permission open rather than having a refusal swallowed there and the field quietly ignored. And expand already refuses anything that is not a plain column, in its own words, so its resolution is a read and that check decides. Behaviour is unchanged over 22 queries covering every allowed and refused form, with one message corrected: dedup said "Cannot filter by", the name of what it lowers to, and now says "Cannot dedup by". Signed-off-by: Jialiang Liang <jiallian@amazon.com>
The class javadoc repeated in three paragraphs what one says, several method comments restated their signature, and isFlatObjectField carried a javadoc describing parameters it does not have -- left from an earlier version of the check. It and isBareRead had no callers, and rootedInFlatObject folds into isFlatObject. 230 lines to 172; behaviour unchanged over the 22 queries. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
A leaf was readable and nothing more: a check in the analysis stage refused every other use, and RexStandardizer refused to build a script over one. Both are gone. PPL already has a field of exactly this shape -- one spath extracts from a JSON string -- and puts no restriction on it: the value is text, a path holding several values is their JSON, and filter, sort, stats and arithmetic all work. Measured on a spath field, `stats avg` over "12.5" and "4" gives 8.25 and `where tags = 'x'` over ["x","y"] matches nothing. A flat_object leaf now gives the same answers for the same shapes, so there is one rule to learn rather than two, and no list of refused commands to maintain. What the refusal was protecting is not specific to this field type either. A predicate on any multi-valued field answers differently depending on whether it reached the index -- `where k = 'a'` on a plain keyword field holding ["a","b"] returns nothing once a `head` blocks pushdown -- so it belongs to the comparison, not here. Kept: the expand check, which reports a leaf the way the mvexpand check does rather than as the ClassCastException the cast used to throw. Signed-off-by: Jialiang Liang <jiallian@amazon.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Self-review PR against my own fork — not for upstream. This is the first half of the flat_object work, split out so it can be read on its own: reading the field. Filtering it is the follow-up.
Why this exists
A flat_object field is invisible to PPL today:
parseMappingskips the type, sodescribedoes not list it and any reference fails with field not found (opensearch-project#1604, opensearch-project#3067).The representation: text, like
spathA flat_object gives its leaves no mapping of their own — the index files every leaf value as a keyword term with the path folded in — so a leaf has no type either. Every value reads as text, and a path holding more than one value reads as their JSON. This is the rule
spathalready applies to a JSON string, andtypeofon a leaf reportsstring.{"a": {"b": 1}}a.b: "1"{"a.b": 1}a.b: "1"{"a": [1, 2]}a: "[\"1\",\"2\"]"[{"a": 1}, {"a": 2}]a: "[\"1\",\"2\"]"{"nested": [{"k": 1}, {"k": 2}]}nested.k: "[\"1\",\"2\"]"{"a": {"b": 1}, "a.b": 2}a.b: "[\"1\",\"2\"]"Every way of writing a path reaches the same leaf, because the index files one term either way. A path written more than once holds every value rather than the last: the index answers a lookup for any of them, so dropping one would make a projection disagree with a filter.
Supported
A cast counts as reading: a leaf is text, so converting it is the parse a caller would do anyway and the value comes from
_sourceeither way. It is how a query asks for a leaf as a number — which the field type leaves to the caller.Rejected, while the plan is built
Filtering by a leaf, aggregating it, grouping or sorting by it, computing a value from it, expanding it. None of this is a PPL restriction: the limitations are the field type's own, and answering any of it would mean opening every record — the cost this type exists to avoid.
How the check works
FlatObjectScopeValidatorruns fromCalciteRelNodeVisitor.analyze, so the unified query pipeline — which calls the visitor directly rather than throughQueryService— is held to the same rules.It finds the field by its mapping type, not by the type it takes in the plan: a map of text to text is also what
spathproduces for an extracted document, so recognising by plan type brokeCalcitePPLSpathTestand the opensearch-project#5718 yaml tests. It also tracks a column a projection derived from a leaf, so a command that sorts or groups by a leaf — projecting it into a column of its own first, under a generated name — is caught and reported by the leaf's name rather than$f2.Two smaller changes riding along
expandover anything that is not a column of the index now raises the errormvexpandalready raises, instead of theClassCastExceptionthe cast threw.RexStandardizerrefuses to build a script over a flat_object field: it has no usable doc values, so a script could only read_source, and the field type does not support Painless for retrieving subfield values either.Why text rather than a typed representation
An earlier attempt kept each leaf's written type with Calcite's
VARIANT. Measured on a cluster, it buys only how a projection displays — every numeric operation is rejected in this scope either way — and it costs a second set of semantics that has to be kept in sync with the index by hand: the index compares text and ANY-matches multi-values, Calcite compares typed scalars. Two divergence bugs came out of that gap. It also had to rejectcast, because Calcite's variant cast answers null for a leaf not written as text — so a user could not get a number at all. Text has one semantics, and the cast works.Verified
Full
core,opensearch,pplmodule tests and the entire yamlRestTest suite pass, and the behaviour above was exercised end to end against a running cluster.