RFC 0001: Native Pipeline DSL Increment¶
- Status: Implemented (predicates, sorting, projection aliases, distinct, aggregation)
- Tracking issue: #46
- Scope: predicates, sorting, projection aliases, distinct, and aggregation
The current user reference is the pipeline DSL guide. Server-side search now reuses the predicate grammar through structured search, with the documented server-side subset.
Summary¶
This RFC defines the next compatible increment of the native JSON pipeline language. It adds an explicit typed predicate grammar, stable multi-key sorting, strict casts including IPv4 and IPv6, projection aliases, stable distinct, and global aggregation. The existing shorthand remains valid.
The native language is for operations where the CLI can preserve semantic shape, column metadata, completion, renderer independence, and redirect safety. JQ remains the escape hatch for arbitrary JSON construction. Joins, unions, window functions, and unbounded collection are outside this increment.
Goals¶
- Make boolean filtering and typed comparisons explicit and predictable.
- Keep redirection unambiguous even when comparisons contain a spaced
>. - Make multi-key ordering stable and reject invalid casts with useful context.
- Preserve JSON values and column metadata through common transformations.
- Add only aggregation operations supported by current CLI workflows.
- Represent parsed syntax with validated types rather than string rewriting.
- Deliver the work in reviewable slices that each include help, completion, documentation, and tests.
Compatibility Boundary¶
Existing syntax keeps its current meaning:
| contact
| F contact
| F age>=3
| F age equals 3
| reject retired
| S name
| S name desc AS str
| P Name os_version
| G os_version | A count AS Hosts | Z
In particular, existing F comparisons retain scalar-text compatibility. The
new typed predicate grammar begins only after the WHERE marker:
This boundary prevents a typed literal such as 3 from changing whether the
legacy expression F age=3 matches a JSON string, a JSON number, or both.
reject WHERE predicate is the negation of the complete typed predicate. Bare
search, legacy F, and legacy reject continue to use their current parser.
A current one-key sort is a one-element multi-key sort. Unaliased projection,
grouped aggregation, output names, selectors, and stage aliases remain valid.
D is available for distinct because unknown single-letter stages already
fail; accepting it does not reinterpret a previously valid pipeline.
Lexical Rules¶
The typed grammar needs a dedicated lexer over the original stage source.
Using shell-style word splitting would discard the distinction between 3 and
"3" before typed literal parsing.
- Whitespace separates tokens except inside quotes.
- Keywords in the new grammar are ASCII case-insensitive. Stage aliases retain their documented spelling.
- Double-quoted strings accept JSON escapes, including Unicode escapes.
- Single-quoted strings accept
\\,\', and otherwise preserve their text. - A selector in operand position may be bare or quoted. Quoting is necessary for a visible alias containing whitespace.
- A comma separates sort keys, aliased projection terms, aggregate expressions, and membership values. A trailing comma is an error.
- Parentheses group predicates. Brackets delimit an
INlist and retain their existing selector meaning when attached to a selector component. - Empty expressions, unterminated quotes, unexpected delimiters, and extra tokens fail during pipeline parsing with a byte position.
POSIX shell quoting is a separate outer layer. The examples in this document are REPL or script syntax; a one-shot shell command must still quote or escape its pipe and redirect characters.
Predicate Grammar¶
The proposed grammar is:
typed-filter = ("F" | "grep"), "WHERE", predicate ;
typed-reject = "reject", "WHERE", predicate ;
predicate = or-expression ;
or-expression = and-expression, { "OR", and-expression } ;
and-expression = not-expression, { "AND", not-expression } ;
not-expression = { "NOT" }, primary ;
primary = "(", predicate, ")" | test ;
test = operand, [ "AS", cast ], test-tail ;
test-tail = comparator, literal
| ("~" | "!~" | "MATCHES"), string
| [ "NOT" ], "IN", "[", literal-list, "]"
| "IS", [ "NOT" ], ("NULL" | "MISSING") ;
operand = selector ;
comparator = "=" | "==" | "!=" | "<" | "<=" | ">" | ">=" ;
literal-list = [ literal, { ",", literal } ] ;
literal = string | number | "true" | "false" | "null" ;
cast = "str" | "num" | "bool" | "ip" | "datetime"
| "version" | "natural" ;
Precedence from highest to lowest is parentheses, NOT, a field test, AND,
then OR. Operators at the same level associate left to right. Comparisons do
not chain: 1 < age < 10 is an error and must be written as two tests joined by
AND.
Examples:
| F WHERE state IN ["ready", "running"] AND retries AS num < 3
| F WHERE NOT (owner IS MISSING OR owner IS NULL)
| F WHERE "OS Version" AS natural >= "9.4"
| F WHERE address AS ip >= "2001:db8::1"
| reject WHERE status == "retired" OR disabled == true
Typed Values¶
null, booleans, and numbers use their JSON types. Quoted values are strings.
Without AS, equality requires the same JSON type; 3 and "3" differ.
Ordered comparison without a cast requires two values of the same orderable
type. Arrays and objects are not implicitly stringified.
Casts apply to selected non-null values and to the comparison literal:
straccepts JSON strings, numbers, and booleans and uses their canonical scalar text.numaccepts a JSON number or a string containing one complete finite JSON number. Prefixes, suffixes, NaN, and infinity are invalid.boolaccepts JSON booleans or the stringstrueandfalse, ignoring ASCII case.ipaccepts only values parsed bystd::net::IpAddr.datetimeaccepts RFC 3339 with an explicit offset and compares instants after normalization to UTC.versionaccepts Semantic Versioning 2.0 values.naturalaccepts strings and compares alternating digit and non-digit runs; digit runs compare by numeric magnitude and text runs compare by Unicode scalar value. Equal numeric runs use fewer leading zeroes first as a stable tie-break.
An invalid literal is a parse error. A selected non-null value that cannot be cast aborts evaluation and reports the stage, selector, cast, row index, and offending JSON value. Missing and null values are not cast failures. Boolean evaluation short-circuits left to right, so an unevaluated branch cannot cause a cast error.
Selector Cardinality And Missing Values¶
Positive comparisons, regex matches, and IN are existential for fanout
selectors: any selected value may satisfy the test. !=, !~, and NOT IN
require at least one selected value and require every selected value to satisfy
the negative test. This preserves the current safe behavior where a missing
field does not satisfy field != value.
IS NULL tests for an actual selected JSON null. IS MISSING tests whether the
selector produced no values. Their IS NOT forms are exact logical opposites.
The general NOT operator negates the complete nested predicate, including its
missing-value result.
Redirect Disambiguation¶
Redirects remain standalone > or >> tokens followed by exactly one sink.
Compact comparison tokens such as age>3 and age>=3 can never be redirects.
For spaced typed comparisons, redirect discovery scans candidates from right to left. A token is a redirect only when:
- the suffix is one valid file or
each:sink; - the prefix is a complete command and complete pipeline under the typed predicate grammar; and
- consuming the token as a predicate operator is not required to complete the
active
WHEREexpression.
Thus the first > below is a comparison and the second is a redirect:
An incomplete comparison such as F WHERE age > is a predicate error, not a
redirect to an empty or missing sink. Redirect parsing must consume the same
typed pipeline AST used by execution; it must not duplicate a partial grammar.
Ordered Multi-key Sorting¶
Sorting extends the current stage without changing its one-key form:
sort-stage = ("S" | "sort"), sort-key, { ",", sort-key } ;
sort-key = [ "!" ], selector,
[ "asc" | "desc" ],
[ "AS", cast ],
[ "USING", ("first" | "min" | "max") ],
[ "NULLS", ("FIRST" | "LAST") ] ;
Examples:
| S state asc, updated_at desc AS datetime, Name asc AS natural
| S data.network.interfaces[].ipv4 asc AS ip USING min, Name
Keys compare lexicographically in declaration order. The sort is stable, so
rows equal on every key retain their input order. !field remains the short
form of field desc.
A selector producing no values supplies a null key. Nulls default to last for
both ascending and descending order; NULLS FIRST or NULLS LAST overrides
that per key. A fanout selector defaults to USING first for backward
compatibility. USING min and USING max cast every selected value and reduce
them under that key's ordering. An invalid selected cast aborts the stage using
the diagnostic policy defined above.
AS ip uses validated IPv4 and IPv6 addresses. IPv4 sorts by its numeric
32-bit value and IPv6 by its numeric 128-bit value. IPv4 sorts before IPv6 in
ascending order; descending reverses the family and address order. IPv4-mapped
IPv6 remains IPv6. Invalid addresses never become zero and never silently sort
as null.
Projection Aliases¶
Projection gains an optional output name:
projection-stage = ("P" | "columns"), projection-term,
{ [ "," ], projection-term } ;
projection-term = [ "!" ], selector, [ "AS", output-name ] ;
When any term uses AS, commas are required between terms. This avoids guessing
where an alias ends and the next selector begins.
An alias creates one top-level output field with the selected JSON value. One
match remains scalar, multiple matches become an array, and no match becomes
null, matching current projection behavior. Unaliased terms retain their
current output name. Drop terms cannot use AS.
Aliases use the validated output-name type. Empty names and duplicate final names fail during parsing. An alias may not overwrite another projected field, group key, or aggregate field. Projection of grouped output continues to act on visible summaries and keeps member rows attached for later aggregation.
Native computed expressions are deferred. P selector AS name only renames a
selected value; JQ remains the explicit mechanism for constructing new values.
Stable Distinct¶
D and distinct remove duplicates while retaining the first occurrence:
distinct-stage = ("D" | "distinct"),
[ distinct-key, { ",", distinct-key } ] ;
distinct-key = selector, [ "AS", cast ] ;
Without keys, equality uses the complete visible JSON value. Object field order does not affect equality. With keys, equality uses the ordered tuple of selected results. A fanout result participates as its complete ordered sequence; it is not reduced to the first value. Missing is a distinct sentinel and is not equal to JSON null. Cast failures use the common strict diagnostic policy.
Distinct accepts Empty, Lines, Rows, Values, and Groups, retains the
input shape, order, and columns, and rejects Detail and Message. For Groups,
keys and whole-value equality see only the visible summary and duplicates are
removed as whole groups; hidden members are never merged.
Grouped And Global Aggregation¶
Current grouped aggregation remains unchanged:
Global aggregation is explicit and does not synthesize a fake group key:
global-aggregate = "A", "GLOBAL", aggregate-list ;
aggregate-list = aggregate-term, { ",", aggregate-term } ;
aggregate-term = aggregate-expression, [ "AS", output-name ] ;
A GLOBAL accepts Empty, Rows, and Values and returns exactly one Rows
record containing the aggregate aliases. It consumes its input collection.
Applying it to Groups, Detail, Message, or Lines is an actionable shape
error. Use Z before a global aggregate of visible group summaries.
The following additions have concrete repository workflows:
count(selector)counts non-null selector matches. Fanout selectors count each selected value. This answers inventory completeness questions such as how many returned hosts report an owner or an interface address.count_distinct(selector)counts distinct non-null selected values using the same equality rules asD. This answers inventory cardinality questions for OS versions, owners, collections, and addresses.first(selector)andlast(selector)return the first or last non-null match in current row and selector order. Sorting beforeGmakes task or audit event boundaries explicit and reproducible.
Examples:
object list --class Hosts \
| A GLOBAL count AS Hosts, count(data.owner) AS Owned, \
count_distinct(os_version) AS Versions
task events <task-id> \
| S created_at asc AS datetime \
| G kind \
| A first(created_at) AS First \
| A last(created_at) AS Last \
| Z
count on an empty input is zero. count(selector) and
count_distinct(selector) are also zero. Numeric, first, and last
aggregates return null when they have no contributing values. Existing default
aggregate aliases remain; all final names must be unique.
collect, string joining, percentile, median, and arbitrary reducers are
deferred. Collection needs an explicit item/byte bound, while percentile needs
an agreed interpolation method and a demonstrated command-level workflow.
Adding them speculatively would enlarge the language without a stable contract.
Typed Model And Library Boundary¶
The parser should produce small validated types with private fields:
Predicate,PredicateExpr,Comparison,TypedLiteral, andValueCast;SortSpec,SortKey,SortDirection,FanoutReduction, andNullOrder;ProjectionSpecandProjectionTermwith an optionalOutputName;DistinctSpecandDistinctKey;AggregateRequest,AggregateSpec, and validated aggregate functions.
Evaluation accepts those types and serde_json::Value; it must not depend on
Hubuum commands, global configuration, terminal rendering, or app errors. Cast
and selector behavior belongs in the reusable pipeline crate. The CLI owns
command tokenization, final rendering, help presentation, completion sources,
and redirect sinks.
Every new stage or variant must extend the stage/shape contract metadata. The same metadata should constrain REPL stage completion after a statically known transition where possible.
Delivery Plan¶
Each item below is independently deliverable and should be tracked by its own
follow-up issue and PR against the then-current main:
- #55: typed
F WHEREandreject WHEREpredicate lexer, AST, evaluation, and parser-aware redirect disambiguation. - #56: stable ordered multi-key sorting, strict common cast policy, fanout reduction, and null placement.
- #57: strict
IpAddrsorting for IPv4 and IPv6, separated so address-ordering tests and diagnostics stay focused. - #58: projection aliases using validated output names and collision checks.
- #59: stable
D/distinctfor lines and semantic collections. - #60:
A GLOBALpluscount(selector)andcount_distinct(selector). - #61: ordered
first(selector)andlast(selector)aggregates, with task and audit workflow examples.
Dependencies are limited: item 3 builds on item 2's cast interface, and items 6
and 7 share the aggregate model. The other items can land independently after
rebasing onto updated main.
Every item includes:
- parser, model, evaluator, and stage/shape contract tests;
- legacy syntax and redirect regression tests;
- text, JSON, JSONL, CSV, and TSV renderer coverage where output changes;
each:redirect coverage when names or cardinality change;help pipe, REPL completion, anddocs/DSL.mdupdates; and- workspace tests, clippy with warnings denied, rustfmt, and Markdown lint.
Deferred Features¶
- Native computed projection expressions remain JQ territory.
- Joins, unions, and window functions remain JQ territory until a concrete Hubuum workflow demonstrates a metadata-preserving native design.
- Locale-sensitive collation is not part of
strornaturalordering. - Implicit coercion is not added to the typed grammar.
- Invalid casts do not gain a silent skip, zero, or null mode in this increment.
These choices keep the native layer small, deterministic, and suitable for a standalone JSON pipeline crate.