opensearch-project / opensearch-project/sql
[RFC] PPL multikv command — extract rows from a field
@noCharger is already working on this.
Since Jul 21, 2026.
- Dominant language
- Java
- Stars
- 176
- Forks
- 229
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 43
Description
[RFC] PPL multikv command — extract rows from a field
Problem
Operational and log data frequently arrives with a table packed inside a single field, or with
records nested inside a document, and PPL has no terse way to turn either into rows:
- Table-formatted text. Command output such as
ps,top,netstat, ordfis captured as
one text blob (for example amessagefield). It has a header row and one record per line, but
PPL sees it as a single opaque string. Extracting the columns today means hand-rollingrex/
parseregexes per tool, which is brittle against variable whitespace. - Structured records. An array of objects (
procs = [{pid,cpu}, ...]) or a single object must
be exploded and projected with amvexpand+eval field.sub+fieldschain, which is verbose
and easy to get wrong.
Splunk solves the first case with multikv. There is no PPL equivalent, and no single command that
covers both the text and the structured shapes with one interface.
Proposal
Add a streaming (mid-pipeline) command multikv that reads an input field and emits one row per
source record — a one-to-many, row-multiplying command. It runs on the coordinating node and
requires the Calcite (v3) engine.
multikv [field=<name>] [fields <col>[:<type>]...] [forceheader=<int>] [noheader=<bool>]
field=<name>selects the input field. Defaults to_raw. OpenSearch has no implicit_raw
field, so callers either passfield=<name>or place text in_rawfirst witheval _raw=<f>.fields <col>[:<type>]...declares the output columns by name; an optional:<type>
(string|boolean|int|integer|long|float|double) types the column at plan time (see Column
typing). This is the only form that yields named columns, and it is what makes v1 fixed-schema
(see Scope).forceheader=<int>uses the given 1-based line as the header, skipping banner lines above it.noheader=<bool>performs row explosion only (no named columns), for example to count data lines.
Input modes (dispatch on the input field's type)
multikv inspects the plan-time type of field=<name> and routes to one of three paths. This is
the key design point: the same field= ... fields ... interface works whether the field is raw
text or already structured.
| Input field type | Calcite type | Behavior |
|---|---|---|
| Text | VARCHAR |
Parse the table text: a header row names the columns, each following line becomes a row. Extracted values are string. |
| Array of objects | ARRAY<ANY> |
Explode into one row per element; read each declared column from the element. Values surface as ANY (see Column typing). |
| Single object | MAP<VARCHAR,ANY> |
Read each declared column from the object; emits one row. Values surface as ANY (see Column typing). |
For the structured modes, OpenSearchTypeFactory collapses object to MAP<VARCHAR,ANY> and
nested/array-of-objects to ARRAY<ANY>, so neither the sub-field names nor their mapped types
survive into the Calcite type; the container is a bag of ANY. The declared fields clause is
therefore what names the output columns (exactly as in the text mode), and reading a column via
ITEM/INTERNAL_ITEM yields ANY, not the mapped scalar type. Nested container values are
returned as-is (serialized JSON string), matching the merged makeresults convention
(isObject()||isArray()); extract deeper fields downstream with spath or another
multikv field=<subfield>.
Column typing
- Text mode: every extracted column is
string, because values come from splitting text and
there is no mapping to infer from. The typed engine coerces per operation, sostats avg(pctIdle)
andwhere pctIdle > 100work numerically; pin a hard type withcastdownstream. - Structured modes: extracted columns surface as
ANY, not the mapped scalar type. A mapped
sub-field does have a known type (for exampleprocs.pidBIGINT,procs.cpuDOUBLE in the
flattened mapping), but that type never reaches the command:OpenSearchTypeFactorycollapses
object/nestedtoMAP<VARCHAR,ANY>/ARRAY<ANY>at the type layer, andmvexpandthen drops
the typed dotted fields (tryToRemoveNestedFields, andUncollectofARRAY<ANY>produces
ANY), so even amvexpand field | fields field.subchain returnsANY. Pin a type withcast
downstream. Nested containers serialize to a JSON string. - Inline typing (
fields <col>:<type>). Declaring a type in thefieldsclause types the
column at plan time instead of leaving the default, in both modes. It is lowered as a plan-time
cast: text mode casts thestringextraction to the declared type; structured mode casts the
ANYextraction to it. This is exactly... | eval col = cast(col as <type>)hoisted into the
command, so it composes with both shapes and gives structured columns an explicit typed form
without the core type-layer change (see Scope).:<type>reuses the sharedmakeresultsscalar
vocabulary (string|boolean|int|integer|long|float|double); the UDT types
(date|time|timestamp|ip|json) are rejected with "use string and cast". The cast is a safe cast,
so an unparseable value yields null rather than an error. Note: because the case-insensitive PPL
lexer foldscol:into the cross-cluster prefix token, a typed column name must start with a
letter or*; for other names (for example_raw) declare it untyped andcastdownstream.
Validation
- A bare
multikv(nofields, nonoheader=true) cannot resolve output column names at plan time
and is rejected with actionable guidance ("add an explicitfieldsclause"). This covers the
overwhelming majority of real usage, which is explicit-schema. - The structured modes never feed a non-string value into the text-split path, so no runtime type
error is possible from pointingfield=at an object or array.
Scope
In scope (v1): the text-parsing mode, the array-of-objects mode, and the single-object mode
above, selected by field=<name> with an explicit fields clause (or noheader=true), on the
Calcite (v3) engine. forceheader and noheader are supported.
Out of scope / future:
- Automatic (implicit) types for structured modes. With no
:<type>, structured columns still
surface asANY; recovering the mapped scalar type automatically (for exampleprocs.pidas
BIGINT without an explicit:long) requiresOpenSearchTypeFactoryto emit preciseROW/element
types forobject/nestedfields (a standing TODO in the type layer), a core, cross-command
change well beyondmultikv. Until then, declare the type inline (fields pid:long) orcast
downstream. - Runtime auto-header (bare
multikv). Detecting column names from the table's header row at
runtime and materializing them dynamically, so nofieldsclause is needed. Deferred to a later
version that reuses the schema-on-read / field-resolution substrate (_MAPcatch-all +ITEM
access), with no new API. v1 rejects the bare form with guidance instead. - Aligned-offset (fixed-width) column parsing. v1 splits text on whitespace; detecting column
boundaries from header character offsets (to handle values containing spaces and right-aligned
numerics) is a later parsing-fidelity improvement. filter,rmorig,multitable,copyattrs,confoptions. Wired in the AST/UDF where
applicable but not exposed in grammar; deferred pending demand.parse-to-MAPperformance optimization. v1 text mode re-scans the serialized record once per
declared column (O(cols × record length)). A follow-up can parse each record once into a
MAPand switch column access to nativeITEM(O(1)per column). This shares the per-rowMAP
substrate with the v2 auto-header work above, so the two are natural to do together.- The v2 (pre-Calcite) engine.
multikvrequiresplugins.calcite.enabled=true; v2 returns the
standard "supported only when Calcite is enabled" error, matching the mergedmakeresults
convention.
Note on row limits: the makeresults compile-time inline caps (row/cell/char budgets from the
JVM 64KB per-method bytecode limit) do not apply here. multikv explodes at runtime via
mvexpand/Uncollect, with no compile-time inlined literals, so those bytecode limits are not hit.
Alternatives considered
rex/parseregex per tool (text) +mvexpand+evalchain (structured). The current
workarounds. They work but are verbose and tool-specific;multikvis the terse, unified command
that lowers to the same primitives (mvexpand, native field access) — it is syntax sugar with a
single interface over both the text and structured shapes.- Two separate commands (one for text, one for structured). Rejected: the
field=<name>type
dispatch lets one command serve both, and the declared-fieldscontract is identical, so a split
would duplicate surface area for no user benefit.
Implementation sketch
- Grammar in the
ppl/copy only: a multikv-localmultikvFieldrule adds the optionalcol:type
(matched via theCLUSTERtoken, since the case-insensitive lexer foldscol:into it; the
column name is that token minus its trailing colon). NewMultikvAST node carriesinField
(default_raw), the declaredfieldswith a parallel per-columnfieldTypes(null when
untyped),forceHeader,noHeader,rmOrig. The:typename resolves through a shared
PplInlineTypeResolverthatmakeresultsalso uses (one type vocabulary, one rejection policy). CalciteRelNodeVisitor.visitMultikvbuilds the child once, inspects thefield=column type, and
dispatches:- Text →
MULTIKV_SPLITUDF (record array) →mvexpand→MULTIKV_EXTRACT(record,'col')per
declared column. - Array of objects →
buildExpandRelNode(mvexpand) →INTERNAL_ITEM(field,'col')per column,
named viaproject(nodes, names). - Single object (MAP) → skip
mvexpand;INTERNAL_ITEM(field,'col')per column → one row. - In every mode, a column declared with
:<type>wraps its extraction in a plan-time safe cast to
the declared type (viaOpenSearchTypeFactory); undeclared columns are unchanged.
- Text →
- Bare-form rejection lives at the semantic layer (field resolution), not in grammar, so enabling v2
auto-header later is "relax the rejection + wire_MAP" with no grammar change.
Reference implementation: PR on opensearch-project/sql (linked below). Unit + integration tests
cover field=, text, array-of-objects, and single-object modes.
Related
- Splunk
multikvcommand — behavioral reference for the text-parsing mode. makeresults(merged) — shares the inlinename:typescalar vocabulary (via the common
PplInlineTypeResolver) and the nested-container-to-JSON-string convention, plus the
"Calcite-only, v2 returns unsupported" convention.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.