Skip to content

Read a flat_object field in PPL - #12

Closed
RyanL1997 wants to merge 8 commits into
mainfrom
feat/flat-object-string
Closed

RyanL1997 wants to merge 8 commits into
mainfrom
feat/flat-object-string

Conversation

@RyanL1997

Copy link
Copy Markdown
Owner

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: parseMapping skips the type, so describe does not list it and any reference fails with field not found (opensearch-project#1604, opensearch-project#3067).

The representation: text, like spath

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. This is the rule spath already applies to a JSON string, and typeof on a leaf reports string.

document reads as
{"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

source=logs | fields attributes
source=logs | fields attributes.duration_ms
source=logs | eval ms = cast(attributes.duration_ms as double)

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 _source either 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.

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/

How the check works

FlatObjectScopeValidator 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, not by the type it takes in the plan: a map of text to text is also what spath produces for an extracted document, so recognising by plan type broke CalcitePPLSpathTest and 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

  • expand over anything that is not a column of the index now raises the error mvexpand already raises, instead of the ClassCastException the cast threw.
  • 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.

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 reject cast, 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, ppl module tests and the entire yamlRestTest suite pass, and the behaviour above was exercised end to end against a running cluster.

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
RyanL1997 force-pushed the feat/flat-object-string branch from 038575f to 99e5316 Compare September 30, 2026 20:31
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>
@RyanL1997 RyanL1997 closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant