title: Tellaro Query Language (TQL) - Rust
class: repo-spec
audience: in-repo
status: current
owner: tql-team
last_verified: '2026-09-04'
verification_note: 'CONTRADICTED on SEMANTICS again, and this page is the one that matters most
for it: crates.io is what the Tellaro agent''s detection engine ships from, so a Rust-only
defect changes the answer a saved rule gives on an endpoint while the backend query API stays
correct and silent. The 2026-09-04 pass re-ran 102 literals through the Rust CLI and found 0
failures -- a PARSING result, which is exactly why it did not catch three Rust-only semantics
changes that leave every one of those literals parsing.
Added here, none of which had a doc surface anywhere in the repo (grep for
"transpos|bareword|reversed" across every .md returned nothing): (12) Rust used to read
f in [true] as a REVERSED comparison -- true eq ''f'', emitting
{"match_phrase": {"true": "f"}}, zero hits and NO ERROR -- for every bracketed list whose
elements all lex as barewords, including the multi-element [true, false]; and (13) Rust
parsed value mutators and then never applied them, so f eq ''%41'' | urldecode matched
{"f":"%41"} here and {"f":"A"} in Python. Also corrected: in/in_cs going element-wise was
filed under the heading "Also user-visible, NOT BREAKING" -- it is breaking in Rust in the
direction that ADDS records (f in [5] now matches a stored "05") and is now section 8; the
matches/regexp non-string-operand half of the scalar-operand fix was absent -- now section
9; and the operator-less field | <predicate> form, where Rust was the MORE broken engine
(all five IP predicates matched every record with the field, vs two of five in Python) -- now
section 10. The intro said "six operator families ... and refuses a seventh" above SEVEN
sections; it now defines the count as the section count, and the counts in this file,
README.md, CHANGELOG.md and docs/TROUBLESHOOTING.md are derived from the headings by
tests/unit/test_breaking_change_counts.py rather than typed. NO COUNT AND NO SECTION NUMBER
IS RESTATED IN THIS NOTE, deliberately: this note twice carried a typed total that the next
section addition falsified, and a section number typed here does not move when the sections
are renumbered. A further false claim was fixed: the Type Hints parity warning said Rust and
Python write a hint and a mutator chain in opposite orders and neither accepts the other''s --
closed by cea6024 on this branch.
Executed against the Rust CLI built from this tree AND the Python engine at HEAD, not read:
f in [true], f in [g] and f in [true,false] (forward reading, both engines agree);
the in/eq matrix over [{f:5},{f:"5"},{f:"05"},{f:5.0}]; f eq ''%41'' | urldecode, its
not contains twin, and the elementwise list form f eq [''%41''] | urldecode (matches
{"f":["A"]} and NOT {"f":"A"}, in both); the value-mutator failure disposition including the
zero-record case, which returns empty rather than raising; ip | is_loopback /
ip | is_private against the ip | lowercase projection control; both hint/mutator orders on
three shapes; and ANY tags = ''premium'' (still Rust-only, still correctly flagged).
All 107 TQL-shaped literals in fenced blocks on this page and RUST_CLI_GUIDE.md were re-run
through the Rust CLI: 107 parse, 0 fail. Three GeoIP examples were changed from geo() /
geoip_lookup() to the bare names, which parse in BOTH runtimes -- the parenthesised
spellings run only here, and they were the page''s only non-portable examples that carried no
warning.
Round 3 of the doc audit added the non-list in operand refusal: compare_in/compare_in_ci
answered a constant false for a non-list operand and not_in is its negation, so
f not in ''x'' matched EVERY record including the one holding exactly "x" -- a Rust-only
fail-OPEN reachable from ordinary query text, and the only refusal of its class with no
section. Both states were measured by running this crate''s CLI over [{f:"y"},{f:"x"}] with
comparator.rs checked out at ffb52a7^ and again at HEAD, not read off the diff;
f between ''x'' was run for the message this refusal is modelled on.
Round 4 added a SHARED section for the top/bottom count Python refused inconsistently
(ef65d17). This crate was already correct and is unchanged by it, but the section is shared
rather than Rust-only because the release makes both runtimes refuse the same counts, so the
whole Rust-only tail renumbered up by one -- which is why no section number is typed in this
note. Rust''s side was re-verified by EXECUTION at HEAD, not read: this crate''s CLI refuses
both stats count() by g top -1 and stats count() by g top 99999999999999999999 at parse,
and accepts top 0, which is what makes "both engines agree on top 0" checkable.
Not checked here: the stats/aggregation surface, the cache examples, and the performance
figures.'
verified_by: tql-docs-parity-campaign@2026-09-04
source_refs:
- tellaro-query-language:tql/Cargo.toml
- tellaro-query-language:tql/src
- tellaro-query-language:tql/src/lib.rs
- tellaro-query-language:tql/src/error.rs
- tellaro-query-language:tql/src/stats_evaluator.rs
- tellaro-query-language:tql/src/parser/grammar.pest
- tellaro-query-language:tql/src/opensearch/mod.rs
- tellaro-query-language:tql/src/field_accessor.rs
- tellaro-query-language:tql/src/opensearch/query_builder.rs
- tellaro-query-language:tql/src/opensearch/field_mappings.rs
Tellaro Query Language (TQL) - Rust
A blazing-fast, human-friendly query language for searching and filtering structured data in Rust.
TQL provides an intuitive SQL-like syntax for querying JSON, JSONL, CSV files, and OpenSearch indices with:
- 300x faster than Python for large file processing
- First-class file support with CLI and programmatic API
- OpenSearch integration with automatic DSL translation
- 25+ field mutators for data transformation (string, encoding, DNS, GeoIP, network)
- Statistical aggregations for data analysis
use Tql;
use json;
let tql = new;
let records = vec!;
// Simple query
let results = tql.query.unwrap;
assert_eq!;
// With field mutators
let results = tql.query.unwrap;
🚀 Quick Start
Installation
Add this to your Cargo.toml:
[]
= "1.3"
= "1.0"
# Optional: Enable OpenSearch backend
# tellaro-query-language = { version = "1.3", features = ["opensearch"] }
CLI Installation
Install the high-performance command-line tool:
# Query files directly
# Statistical aggregations
⚠️ Upgrading: breaking changes in this release
This release makes eighteen breaking changes to what a query means in the Rust runtime: eleven applied identically in the Rust, Python and OpenSearch backends, and seven where Rust alone was wrong and converges on Python. Each is deliberate, and each is motivated by the same failure: a query that returned zero hits with no error, which in a detection rule is indistinguishable from "nothing happened".
Read this before upgrading a saved query, a detection rule, or anything that builds TQL from a template. This crate is what the Tellaro agent's detection engine runs, so sections 12–18 change the answer a saved rule gives on the endpoint even though the backend query API never had those defects.
The count is the number of numbered sections below — nothing else, and it is now asserted rather than asked for. It said "six operator families" while seven sections followed it; the recount that fixed that added this very paragraph as a guard, and the next commit added
### 14.without recounting anyway. Prose does not hold this.tests/unit/test_breaking_change_counts.pyderives every number on this page — and inREADME.md,CHANGELOG.mdanddocs/TROUBLESHOOTING.md— from the headings themselves, so adding a section without recounting is now a test failure rather than a reading error.
1. is null and not exists now mean HAS NO VALUE
Both now mean absent or explicitly null, making them the exact complement
of is not null / exists. The in-memory evaluator previously implemented a
three-state model in which is null meant only "present and null" and
not exists meant only "absent key" — so a record whose field was absent
satisfied neither f is null nor f is not null.
No backend could ever have honoured that. OpenSearch does not index JSON nulls,
so must_not: {exists: {field}} — which both DSL backends already emitted for
both spellings — cannot tell an absent field from a null one.
What to check: saved queries using is null, is not null or not exists
over a field that is sometimes absent. exists is unchanged. If you specifically
wanted "present and explicitly null", write f eq null. Full matrix under
Null and existence semantics.
2. matches / regexp searches unless you anchor it
f matches 'abc' and f matches '^abc$' emitted identical DSL, and both
meant "the whole field value is exactly abc" — while the in-memory evaluator
used a search and matched abc anywhere. The same query meant two things
depending on where it ran. An unanchored pattern is now wrapped in .*; ^ and
$ are still translated rather than wrapped.
Of 188 regex patterns in the shipped detection corpus, 5 were explicitly anchored and 151 were bare search patterns living on command-line, URL and path fields — where a full-value match is never what was meant.
What to check: OpenSearch queries with an unanchored pattern; they now
match strictly more documents. If you relied on the implicit anchoring, write
^…$.
3. A LIST operand to any / none is refused
tags any ['a','b'] and every spelling of it now raise TqlError::TypeError —
on translation and in the in-memory evaluator. The scalar form is
unchanged, and a one-element list still unwraps to it. The clause previously
produced either an HTTP 400 that failed the whole search, or a silent
match_none — which made tags none [...] an exclusion clause that excluded
nothing.
The evaluator was raised to the translator in a second step, after the two were
measured selecting different record sets rather than different kinds of the
same answer: a translator refusal is not local to the clause, it fails the whole
query, so code eq 1 OR tags any ['a','b'] returned rows in memory and nothing
through OpenSearch, and tags none ['a','b'] — an exclusion — returned every
record in memory and nothing through OpenSearch.
What to write instead: tags in ['a','b']. The error names it. all /
not_all are exempt in both layers.
4. Case-sensitive operators refuse a field that cannot answer them
contains_cs, startswith_cs, endswith_cs, in_cs and their not_ forms
now raise TqlError::TypeError against an analyzed text field with no
case-preserving subfield, instead of silently matching nothing. The analyzer
lowercased every token it indexed, so the case being asked about is not in the
index at all.
Measured on OpenSearch 2.19.4, analyzed field holding "A MiXeD Value":
wildcard *MiXeD* -> 0 hits
must_not(wildcard *MiXeD*) -> EVERY document
wildcard *mixed* (control) -> EVERY document
The negated direction is the dangerous one: an exclusion that excludes nothing.
What to write instead: add a .keyword subfield to the mapping, or use the
case-insensitive operator. The error names both. A .keyword or wildcard
subfield, a keyword base field, non-string types (long, ip, date,
boolean) and an unmapped field all still translate.
5. A type hint that cannot read a value skips the record, not the query
(Migration note reproduced verbatim from the commit that made the change.)
A query that previously ERRORED on a heterogeneous field now returns results. Nothing that previously returned results changes -- with one exception, and it is the one to check: a query using a NEGATED comparator with a type hint over a field whose values do not all read as the hinted type. Those records used to abort the query; they now answer false rather than being swept in. If any saved query relied on the abort as a data-quality alarm, that alarm is gone.
An author who wanted the strict behaviour should assert the shape explicitly rather than leaning on the hint's failure mode --
f::int exists and f::int > 75selects records readable as an int, and a separate rule over the un-hinted field finds the ones that are not. There is no flag to restore the raise, deliberately: two evaluators with two modes is how the engines drifted before.
In Rust this lands on top of a larger change: type hints were inert entirely before this release. See Type Hints for the boundaries and for the four hint names Rust gained.
6. ::string renders a boolean lowercase
flag::string eq 'true' over {"flag": true} previously matched nothing,
because the hint rendered the boolean as True. It now matches;
flag::string eq 'True' no longer does. JSON, TQL's own boolean literals and
OpenSearch all write true/false; the capitalised spelling was the only one
in the stack. Scope: a top-level boolean under string/str only.
7. The string operators render a boolean lowercase too
The string comparison operators do the same. f contains_cs true,
startswith_cs, endswith_cs, matches / regexp and their not_ forms
render a boolean as true / false on both sides — the operand written in the
query and the value read out of the record. The evaluator and the OpenSearch
translator had been selecting opposite documents for the same query: over
[{"f": "x true y"}, {"f": "x True y"}] the evaluator returned the True
record and wildcard *true* returned the true one, with no error on either
side. OpenSearch settles it — term on a boolean field rejects "True"
(only [true] or [false] are allowed). The case-insensitive forms lowercase
both sides and are unaffected.
8. in / in_cs are element-wise eq_ci / eq
f in [a, b] is f eq_ci a or f eq_ci b, and f in_cs is the eq twin.
Both engines re-implemented equality inside the membership arm instead of
delegating to it, and drifted from it in opposite directions. They now
delegate. in / not_in remain case-insensitive and in_cs / not_in_cs
case-sensitive; that part is unchanged.
Measured over [{"f": 5}, {"f": "5"}, {"f": "05"}, {"f": 5.0}]:
| Query | Before | Now (both engines) |
|---|---|---|
f in ['5'] |
Python also matched "05" |
5, "5", 5.0 |
f in [5] |
Rust did not match "05" |
5, "5", "05", 5.0 |
The Rust half is the second row: compare_in_ci pre-lowercased every element
with ast_value_to_string and compared a string field against that text, so a
non-string element was answered as a string. f in [5] was false on the stored
"05" while f eq_ci 5 — the same question with one element — was true, and
f in_cs [5] was true as well. Rust disagreed with itself twice.
What to check: any membership query whose list holds a numeric-looking value,
quoted or unquoted, over a field that stores numbers as text. Quoting is the
author's type declaration; write [5] for the numeric reading and ['5'] for
the text one.
The OpenSearch half is inert. A terms clause is resolved by the field's
mapping, not the literal's JSON type — on OpenSearch 2.19.4 a long field
holding 5 is selected by ["5"], [5], ["05"] and ["5.0"] alike. Only the
in-memory evaluators, which have no mapping to consult, could select different
records.
9. matches / regexp translate a non-string operand into a pattern
f matches true and f regexp 5 passed the operand straight through as raw
JSON — {"regexp": {"f": true}} — Rust with the stated reasoning that
"OpenSearch would report the type error". It does not. Measured on OpenSearch
2.19.4:
{"regexp": {"f": true}} -> 0 hits, NO ERROR
{"regexp": {"f": ".*true.*"}} -> 1 hit
The boolean is coerced to "true" and Lucene's regexp engine anchors
implicitly, so the pattern must equal the whole value — a silent zero.
{"regexp": {"f": null}} is worse: a hard HTTP 400 (value cannot be null)
that fails the entire search, including every unrelated clause.
Both engines now render a non-string operand as a translated pattern.
What to check: any matches / regexp clause with an unquoted true,
false or number as the operand. This is one visible symptom of a wider fix
spanning contains, startswith, endswith, all three _cs twins, matches,
regexp, cidr, any and all, in both the positive and the negated
spelling. The combination count this sentence used to give ("35 of 216" over an
"18-operator × 6-literal matrix") was removed in round 4 because it reproduces
on no axis — 18 × 6 is 108, the operators named here are eleven, and the matrix
the suite pins in tests/unit/test_scalar_operand_parity.py is 8 × 6. Read it
off that test rather than from prose.
10. field | <predicate> with no operator is a FILTER, not exists
When the last mutator in a chain returns a boolean — is_private, is_global,
is_loopback, is_multicast, is_link_local — the operator-less form now means
eq true. It used to parse to exists, so ip | is_loopback matched every
record that merely had an ip field while reading as a filter. An
unconditional-true clause dressed as a predicate cannot fail loudly; it quietly
stops filtering and keeps returning plausible results.
Rust was the more broken of the two engines here, not the less:
parse_field_only_expression enumerated nothing and emitted exists for every
mutator, so all five predicates matched everything. (Python listed two of the
five in three hand-written places, so two filtered and three did not.) Rust now
derives the set from a Mutator::returns_boolean method rather than naming any
predicate in the parser, and a test pins its membership equal to Python's.
Reverting tql/src/parser/mod.rs makes ip | is_loopback return
["127.0.0.1", "8.8.8.8", "10.0.0.1", "224.0.0.1", "169.254.1.1"] again — every
record with an ip field.
A transforming mutator is unchanged. ip | lowercase is a projection —
keep every record that has the field, apply the mutator on the way out — and
still parses to exists.
What to check: any saved rule using one of the five IP predicates with no
comparison operator. The explicit spellings (ip | is_loopback = true) are
unchanged.
11. A top / bottom count the engine cannot honour is refused at every surface
This crate was already right, and is unchanged by it — the fix is Python's,
and it is listed here because it closes the divergence section 16 opens from the
other side. Rust refuses a count outside 0..=usize::MAX at parse; Python now
refuses the same counts at validate(), evaluate(), to_opensearch() and
query() alike, so the two runtimes finally answer one saved query the same way.
Python refused overflow nowhere: stats count() by g top 99999999999999999999
passed validate() and emitted
{"terms": {"field": "g", "size": 99999999999999999999}} — a size OpenSearch
parses into a 32-bit int and rejects — while this crate refused the identical
query when it was written. And Python refused a negative count only on the
surfaces that CHECK a query: query() routes through neither validate() nor
to_opensearch(), so stats count() by g top -1 over five groups returned
four buckets with health_status: "green" and no error, results[:-1]
silently dropping the last one.
top 0 is unaffected in both runtimes — it is representable, so this parser
has nothing to observe about it, and Python still refuses it only in the
translator.
What to check: nothing in a Rust consumer. If the same saved query also runs against the backend query API, it now raises there instead of answering one bucket short.
The next seven affect the Rust runtime only. Python and the TypeScript editor already behaved this way, so a query written against the backend query API never saw them — but the agent's detection engine runs this crate.
12. An empty list literal is refused at parse
f in [] parsed here and was refused by the other two engines — Python raised
TQLSyntaxError and the TypeScript editor reported Empty value list. Python is
what the backend query API runs and the editor is what autocompletes queries, so
this was a query that was green while you typed it, refused when you saved it,
and valid in the agent's detection engine.
The rule is shared by every list-taking operator, so in, not_in, in_cs,
not_in_cs, any, all, none and between all refuse an empty literal now.
A non-empty list is untouched, and f in [''] is still a list containing one
empty string.
The original reason to refuse — both translators rendered an empty membership
list as {"bool":{"should":[],"minimum_should_match":1}}, which OpenSearch reads
as match_all, so f in [] matched every document and f not_in [] matched
none — no longer applies: that shape was converged onto {"terms":{"f":[]}},
which does not invert. What settles it is that an empty membership list cannot
express an intent. It is what a rule template emits when its value list came from
a variable that happened to be empty.
13. f in [bareword] no longer transposes the field and the value
This one has no loud failure mode at all, and it is the reason to read this
page before upgrading a detection rule. In the Rust parser, in_fields_list
is a list of bare field_names and value_with_mutators accepts a bare
identifier, so every list whose elements all lex as bare identifiers was a
well-formed reversed-in expression. in_fields_comparison was listed ahead
of binary_comparison in the comparison alternation, and PEG's ordered choice
took that reading and never reached the forward one:
f in [true] parsed as {field: "true", operator: "eq", value: "f"}
That is a comparison against a field literally named true, and the DSL it
emitted was {"match_phrase": {"true": "f"}} — zero hits and no error,
indistinguishable from "nothing matched".
The affected spellings are every bracketed list whose elements are all
barewords: f in [true], f in [false], f in [null], f in [g],
f in [true, false]. It was not limited to single-element lists —
f in [true, false] became true eq 'f' OR false eq 'f'. f in [true, 'a']
parsed correctly only by accident: one quoted element breaks the field-list
reading, and that accident is what made the defect look narrower than it is.
Python has never had this — field_in_values is listed ahead of
value_in_field_list there, so the forward reading always won. The semantics
adopted are Python's, verbatim: the forward reading wins for a bracketed
list, and the reversed reading is reachable only when the left operand cannot be
read as a field name (a quoted string or a number). No query the backend query
API accepts today changes meaning.
The reversed spellings that must survive, and do:
'val' in [f1, f2] // bracketed: LOSES to the forward reading
'val' in arrayfield // unbracketed: still means "arrayfield contains 'val'"
Those sit on opposite sides of binary_comparison, which is why the grammar
gained a separate in_field_comparison rule instead of the one-line alternation
reorder that would silently have flipped the second form.
What to check: every saved rule with a bracketed in list whose elements are
unquoted words. It was returning nothing and reporting success.
14. Value mutators are applied, not parsed and ignored
f eq '%41' | urldecode matched {"f": "%41"} in Rust and {"f": "A"} in
Python — same query, two answers, no error.
A value mutator transforms the literal the author typed, before comparison.
The parser built ComparisonNode::value_mutators correctly all along; the
evaluator never read the field, so the comparator always saw the raw literal.
Three semantics, established by running Python rather than reading it:
- A list operand mutates elementwise.
f eq ['%41'] | urldecodebecomes["A"], not the mutated text of the whole list — it matches{"f": ["A"]}and not{"f": "A"}. - Mutators chain left to right, as on the field side.
- A failure is a QUERY error, not a data error.
f eq '%FF' | urldecoderaisesTqlError::ValidationError(Python'sTQLValidationError) rather than answeringfalse, because the operand comes from the query text and so fails identically for every record. One measured wrinkle: the raise happens on the evaluation path, so a query over zero records returns empty and does not raise, in both engines. The field-mutator direction keeps its leniency — an unreadable record value skips that record and the scan carries on.
This also fixed every not <op> spelling in Rust. f not contains '%41' | urldecode returned a hit for {"f": "A"} and none for {"f": "%41"} — an
exclusion clause excluding exactly the wrong record. It is now the other way
round. Rust's parser was never wrong here: it already produced
operator: "not_contains", value: "%41", value_mutators: [urldecode], so
one evaluator fix closed all of them.
Still open, on the Python side only. Python's parser folds the mutator into the value for the negated spellings, so
f not contains '%41' | urldecodestill drops the mutator there and matches both records. Its strict xfail is deliberately left in place and flips green when Python's parser is fixed. If you need that spelling today, Rust is the engine that answers it correctly.
15. A boolean predicate no longer overwrites the field it filtered on
ip | is_loopback returned {"ip": true} in Rust and {"ip": "127.0.0.1"} in
Python — same query, same MATCH SET, two different payloads.
A predicate answers a boolean about a value; it is not a request to replace
the value with that boolean. filter_and_enrich wrote every mutator's output
back over its field, so the address an analyst filtered for was destroyed by
the filter that selected it. All five IP predicates were affected
(is_private, is_global, is_loopback, is_multicast, is_link_local), in
the bare, exists and eq true spellings, and the negated form projected
false the same way. A nested field was worse: source.ip | is_loopback
returned the record with its nested address intact and a flat "source.ip": true key grafted on beside it.
The counts always agreed, which is why nothing caught it for so long: every
predicate matched exactly the right records in both engines, and the shared
cross-language fixture format has no key for an expected returned record —
only expect_match, a boolean per record. A count assertion is structurally
incapable of seeing this defect.
Python fixed the identical shape on its own side earlier
(TYPE_CHANGING_MUTATORS in src/tql/mutator_classification.py), where
source.ip | is_loopback exists returned {"source": {"ip": true}} while
is_private three lines away preserved the address. Rust converges onto that
behaviour for every chain that ends in the predicate — the bare
ip | is_loopback, the exists and eq true spellings, the negated form, the
nested field, and ip | lowercase | is_loopback all now return the same payload
in both engines. Measured 2026-09-04 by running both, not inferred.
One spelling has NOT converged, and it is this section's own defect wearing a different chain order.
ip | is_loopback | lowercase— a transform written after the predicate — still returns{"ip": true}/{"ip": false}in Rust, while Python returns the address unchanged. Both engines select the same records (every record with the field: the last mutator is not a predicate, so the clause reads asexists), so the counts agree and only the payload differs — the exact shape that hid the original bug for so long.The two engines ask different questions. Rust suppresses write-back when the last mutator in the chain returns a boolean, so any transform after a predicate re-enables projection. Python's post-processor never applies the predicate to the value it returns at all, so the address survives whatever follows it in the chain.
Which rule is correct is a semantics decision, not a documentation fix, so nothing here changes it. It is recorded under Known gaps in
CHANGELOG.mdand pinned in both suites (tql/tests/predicate_does_not_project.rs,tests/unit/test_predicate_does_not_project.py) so today's answer cannot change silently in either engine. Measured 2026-09-04 onip | is_loopback | lowercaseandip | is_loopback | uppercase.
A projecting mutator is unchanged and must stay that way. name | lowercase
replacing name with its lowercase form is the entire purpose of a projection,
so the fix distinguishes the two classes rather than disabling write-back: it
asks Mutator::returns_boolean — the same declaration the parser already uses
to decide that field | <predicate> is a filter — about the last mutator in
the chain. A sixth predicate overrides that one method and needs no edit here.
ip | lowercase | is_loopback therefore preserves and name | lowercase
projects, both verified. Asking only the last mutator is also why the
reverse order, ip | is_loopback | lowercase, still projects here and is the
residual divergence noted above.
What to check: any Rust consumer of Tql::query_enriched,
Evaluator::filter_and_enrich, the file-query path, or the OpenSearch
post-processor that reads a field an IP predicate was applied to. It was
receiving true/false where it expected the address. Tql::query is
unaffected — it returns borrowed records and never projected.
16. A top / bottom count this runtime cannot represent is refused, not replaced with 10
| stats sum(salary) top -1 by department parsed here as top 10. So did
| stats count() by department top -1, on the group-by side.
The grammar's integer is "-"? ~ ASCII_DIGIT+, so a negative count is
well-formed source. The parser then read it into an Option<usize> with
.parse().unwrap_or(10) — a fallback written to supply a default for a
missing count, firing instead for a malformed one. Absent and
unrepresentable got the same answer. Measured 2026-09-04 before the fix, by
running both engines:
| Query | Rust parsed to | Same query through Python |
|---|---|---|
stats sum(salary) top -1 by department |
modifier: Some("top"), limit: Some(10) |
refused: "asks for a non-positive number of buckets" |
stats count() by department top -1 |
bucket_size: Some(10) |
refused: "asks for a negative number of buckets" |
So one saved query got ten buckets from the agent's detection engine and an
error from the backend. The two answers were not merely different: one of them
was invented. A live cluster rejects the DSL the Rust reading would have
produced ([size] must be greater than 0), so there was no runtime at which the
substituted 10 was the author's request.
A count larger than usize::MAX took the same path and produced the same
top 10.
The message deliberately does not match Python's. Python's parser carries a
signed integer all the way to its translator, so opensearch_stats.py can see a
negative limit and call it non-positive. Option<usize> cannot hold a
negative at all, so what this runtime observes is narrower — a leading -, or a
count past usize::MAX. Borrowing Python's phrasing would describe a state this
type excludes.
An absent count still defaults, and top 0 still parses. A modifier written
with no number produces no integer pair, so limit / bucket_size stay
None and every downstream default is unchanged. 0 is representable, so the
parser has nothing to observe about it; it is refused later by the OpenSearch
translator, exactly as before. Only a count that cannot be represented is
refused here.
What to check: any saved query, detection rule or template that computes a
top N where N can reach zero-or-below by arithmetic — top <count - 1>
against an empty count, say. Under the agent it was silently answering with ten
buckets; it now refuses at parse. A positive or absent count is unaffected.
17. A non-list operand to in is refused, not answered false
f not in 'x' matched every record, including the one holding exactly
"x" — an exclusion that excluded nothing and reported success.
compare_in and compare_in_ci ended in _ => Ok(false) for an operand that
was not a list, and compare computes not_in as !compare_in_ci(..)?. A
constant false inverts to a constant true. Measured 2026-09-04 over
[{"f":"y"},{"f":"x"}], by running this crate's CLI at the commit before the
fix and at the commit after it:
| Query | Before | After |
|---|---|---|
f in 'x' |
0 of 2 matched | Operator error: IN operator requires a list of values |
f not in 'x' |
2 of 2 matched — y and x |
the same refusal |
f in_cs 'x' |
0 of 2 matched | the same refusal |
f not in_cs 'x' |
2 of 2 matched | the same refusal |
The positive and negative spellings were not complements, and the negative one
was the dangerous direction: a rule reading "exclude these values" admitted
every record it was written to drop. grammar.pest's in_list admits the bare
scalar, so this was reachable from ordinary query text rather than only from a
hand-built AST.
OperatorError, and not ValueError, matching compare_between — the
neighbouring operator that already refused its own non-list operand, and the
reason this shape was findable at all. f between 'x' answers
Operator error: BETWEEN operator requires a list of exactly 2 values.
This is the in-memory evaluator only. The change is confined to
comparator.rs; the OpenSearch query builder has no arm for a scalar in
operand and is untouched, so no translated DSL moves.
What to check: any saved rule or template that writes in, not in,
in_cs or not in_cs against a single unbracketed value — most likely a
template that renders one item into f not in '{{value}}'. Under this runtime
the two negative spellings were matching everything; they now refuse at
evaluation. The list form is unaffected and has always been correct:
f in ['x'] selects {"f":"x"} and f not in ['x'] selects {"f":"y"}, in
both runtimes.
Python is wrong differently here, and is deliberately not changed — it still
answers a scalar operand, and answers inconsistently with itself. The measured
matrix is in Known gaps in CHANGELOG.md; write the list
and the divergence cannot reach you.
18. A presence predicate carrying a mutator returned the exact complement
f | md5 not_exists translated to {"exists": {"field": "f"}} — precisely
the documents that do not match, returned as hits.
build_query_clause consulted has_post_processing_mutators at the very top,
before the not_exists arm and before build_comparison could reach its is /
is_not / eq null / ne null arms, and answered a bare exists clause for
any comparison carrying one of the 24 non-collection mutators. For a real
comparison that rewrite is the intended over-broad phase-1 net: fetch everything
that has the field, let post-processing narrow it. A presence predicate is not
approximate — its translation is exact — so the same rewrite does not
over-broaden it, it inverts it.
| Query | Before | After (and Python, always) |
|---|---|---|
f not_exists |
{"bool":{"must_not":{"exists":{"field":"f"}}}} |
unchanged |
f | md5 not_exists |
{"exists":{"field":"f"}} |
{"bool":{"must_not":{"exists":{"field":"f"}}}} |
Five of the ten presence spellings were affected, not one: not_exists,
not exists, is null, eq null and = null. The other five — exists,
is not null, is_not null, ne null, != null — were unharmed only by the
coincidence that exists is already their correct answer, not because anything
protected them. Measured across all 10 spellings × every entry of
mutators::MUTATOR_NAMES × 2 mapping shapes: every combination naming a mutator
that blocks pushdown inverted, and no other. The collection mutators — any,
all, avg, average, sum, min, max — do not block pushdown, so the
rewrite never fired for them and they were never affected. No count is written
here on purpose: it is the product of two derived sets, and a figure typed into
prose is what this release has already had go wrong.
The gate is now has_operand, a property of the node rather than a list of
operator names. Absence needs two spellings here where Python needs one: a
valueless operator (exists, not_exists) parses to value: None, while the
null literal (is null, eq null) parses to Some(AstValue::Null). Matching
only None fixes the valueless spellings — not exists and not_exists — and
leaves is null / eq null / = null inverted, reporting itself green: which is
why this is a named function and not an is_some() at the call site, and why the
partial fix is pinned by mutation rather than by reasoning.
Python's polarity was correct throughout — it never emitted the complement —
so the clause ordering in this crate was the entire divergence on that axis. It
was documented in two Python docstrings as a known Rust defect deliberately not
copied, and asserted in no Rust test at all. Python's field resolution was a
separate defect and not correct: eq null and = null resolved against
.keyword rather than the base field until a4f482d, which the Breaking —
Python runtime and the OpenSearch backend only section of
CHANGELOG.md covers.
What to check: any detection rule that asks whether a field is absent
while also naming a mutator on it — user.name | lowercase not_exists,
hash | md5 is null. Under this runtime it was selecting every record that
had the field. Rules using the positive spellings (exists, is not null)
were returning the right records throughout and need no review. The bare
predicate with no mutator was never affected in either runtime.
Also user-visible, not breaking
cidr/not_cidrtranslate to OpenSearch. The Rust query builder had no arm for them, so both fell through to "Unsupported operator" while the parser and the in-memory evaluator accepted them — a shipped bundled rule usingcidrcould never execute on an agent. Both evaluators now also iterate array-valued fields:related.ip cidr '10.0.0.0/8'matches["10.1.2.3", "8.8.8.8"].cidris refused on akeyword-mapped field, wheretermcompares the literal string"10.0.0.0/8"; map the field asip.- Word operators have boundary guards.
f nonexists— the obvious typo forf not exists— parsed asnonewith the value'xists'and returned a match. It is now a parse error, as aref containszq,f eqzq,f isnullandf::intzq. - Four mutator names are rejected at parse:
count,unique,first,last.
📁 Query Files (First-Class Support)
CLI Usage
TQL treats files as first-class data sources:
# Query JSON/JSONL files
# Query CSV files (auto-detects headers)
# Statistical aggregations
# Process folders recursively
# Stream data from stdin
|
# Output formats
Performance: Process 50MB files in ~200ms with streaming (no memory overhead).
Programmatic File Queries
use Tql;
use File;
use BufReader;
use Value;
let tql = new;
// Read and query JSON file
let file = open?;
let reader = new;
let records: = from_reader?;
let results = tql.query?;
// Stream JSONL for large files
let file = open?;
let reader = new;
for line in reader.lines
🗄️ OpenSearch Integration
TQL seamlessly integrates with OpenSearch/Elasticsearch:
Automatic DSL Translation
use ;
// Configure OpenSearch (reads from environment)
set_var;
set_var;
set_var;
// Create client
let config = from_env?;
let client = new?;
// Parse TQL query
let tql = new;
let ast = tql.parse?;
// Build OpenSearch DSL
let builder = new;
let opensearch_query = builder.build_query?;
// Execute search
let response = client.client
.search
.body
.send
.await?;
TQL → OpenSearch Query DSL
TQL automatically translates to optimized OpenSearch queries:
| TQL Operator | OpenSearch Query | Example |
|---|---|---|
eq, = |
term or match_phrase |
status = "active" |
ne, != |
bool + must_not |
status != "deleted" |
gt, gte, lt, lte |
range |
age > 25 |
contains |
wildcard + case_insensitive |
email contains "@example.com" |
startswith |
wildcard + case_insensitive |
name startswith "John" |
endswith |
wildcard + case_insensitive |
filename endswith ".pdf" |
contains_cs, startswith_cs, endswith_cs |
wildcard, no case_insensitive |
name startswith_cs "John" |
matches (regexp) |
regexp, unanchored patterns wrapped in .* |
email matches "^\\w+@\\w+" |
in |
bool + should of term + case_insensitive |
status in ["active", "pending"] |
in_cs |
terms (case-sensitive) |
status in_cs ["Active"] |
between |
range with gte + lte |
age between [18, 65] |
cidr |
term on an ip-mapped field |
ip cidr "192.168.0.0/16" |
not_cidr |
bool + must_not + term |
ip not cidr "10.0.0.0/8" |
exists, is not null |
exists on the base field |
field exists |
not exists, is null |
bool + must_not + exists |
field is null |
AND |
bool + must |
age > 25 AND city = "NYC" |
OR |
bool + should |
city = "NYC" OR city = "LA" |
NOT |
bool + must_not |
NOT status = "deleted" |
Field Mapping Intelligence
use FieldMappings;
// Get mappings from OpenSearch
let mappings_response = client.client
.indices
.get_mapping
.index
.send
.await?;
let mappings = from_opensearch_response?;
// Use mappings for intelligent query generation
let builder = new;
let query = builder.build_query?;
// Automatically selects .keyword for exact matches on text fields
📖 Syntax Guide
Comparison Operators
// Equality
"status = 'active'" // Exact match (alias: eq)
"status != 'inactive'" // Not equal (alias: ne)
// Numeric comparisons
"age > 25" // Greater than
"age >= 18" // Greater or equal
"age < 65" // Less than
"age <= 100" // Less or equal
// String operations (case-INSENSITIVE)
"email contains '@example.com'" // Substring match
"name startswith 'John'" // Prefix match
"filename endswith '.pdf'" // Suffix match
// Case-SENSITIVE twins, and their negations
"name contains_cs 'John'" // also startswith_cs, endswith_cs
"name not_contains_cs 'John'" // also not_startswith_cs, not_endswith_cs
// Pattern matching. An UNANCHORED pattern SEARCHES; ^...$ means the whole value.
"email matches 'example\\.com'" // matches anywhere in the value
"email matches '^\\w+@\\w+\\.\\w+$'" // matches the whole value
// Range and membership (in / not in are case-INSENSITIVE)
"age between [18, 65]" // Inclusive range
"status in ['active', 'pending']" // Value in list
"status not in ['deleted', 'archived']" // Value not in list
"status in_cs ['Active']" // case-sensitive; also not_in_cs
// IP operations
"ip cidr '192.168.0.0/16'" // IP in CIDR range
// Existence checks. `exists` and `is not null` are the SAME question,
// and so are `not exists` and `is null`.
"field exists" // Field is present AND non-null
"field is not null" // identical to `field exists`
"field not exists" // Field is absent OR present-and-null
"field is null" // identical to `field not exists`
Null and existence semantics
The two pairs are exact complements — every record satisfies exactly one side:
| document | is null |
not exists |
is not null |
exists |
|---|---|---|---|---|
{} |
match | match | -- | -- |
{"f": null} |
match | match | -- | -- |
{"f": "x"} |
-- | -- | match | match |
{"f": []} |
-- | -- | match | match |
An empty array is a present value: the producer deliberately wrote a field
with zero elements, so [] satisfies exists and is not null. [null] and
["a", null] follow the same rule.
Residual divergence, stated rather than left to be found. OpenSearch indexes neither a JSON null nor an empty array, so against a cluster
{"f": []}and{"f": [null]}DO matchnot exists/is null. The in-memory evaluator reads the record itself and answers per the table above. This is the one document shape where the two execution paths differ.
Regex semantics
matches and regexp are the same operator. An unanchored pattern searches
— f matches 'abc' matches any value containing abc. Against OpenSearch the
pattern is wrapped in .*, because Lucene's regexp query anchors implicitly;
a pattern carrying ^ or $ is translated unwrapped, so an author who anchored
deliberately keeps full-match semantics.
Logical Operators
// AND (all conditions must be true)
"age > 25 AND city = 'NYC'"
"status = 'active' AND role in ['admin', 'moderator']"
// OR (either condition must be true)
"city = 'NYC' OR city = 'LA'"
"status = 'admin' OR role = 'superuser'"
// NOT (negates condition)
"NOT (age < 18)"
"NOT status = 'deleted'"
// Complex expressions with parentheses
"(age > 25 AND city = 'NYC') OR (status = 'vip' AND score > 90)"
Collection Operators
// ANY - at least one array element matches
"ANY tags = 'premium'"
"ANY user.roles = 'admin'"
// ALL - every array element matches
"ALL scores >= 80"
"ALL status = 'active'"
// NONE - no array elements match
"NONE flags = 'spam'"
"NONE violations.severity = 'critical'"
Parity warning. These operator-first forms are Rust-only. The Python grammar accepts only the field-first, operator-less form (
tags any 'premium') and raisesTQLSyntaxErroronANY tags = 'premium'. Use the field-first form for queries that must run under both.Stats top-N is now portable in both spellings. Both engines accept the modifier inside the parens (
| stats sum(x, top 10) by y) and after them (| stats sum(x) top 10 by y), and both build the samemodifier+limit. Until 2026-09-04 each engine accepted only its own spelling and rejected the other's, so there was no portable spelling at all -- and the warning that stood here stated only the Rust half, which implied an alternative that did not exist.Both engines now also MEAN the same thing by it. Until 2026-09-04 the gap after the grammars were reconciled was behavioural and it was in Rust:
tql/src/stats_evaluator.rsconsumed neither modifier, so| stats count() by role top 3answered 9 buckets where Python answers 3, and| stats sum(salary) top 3 by departmentanswered 13 where Python answers 3 -- the same 13 the un-modified query returns. A limit that is parsed and then dropped answers with more rows than were asked for, which reads as a complete result rather than as an error.The two modifiers are not one modifier, and this is the part worth remembering:
spelling AST field ranks buckets by sum(x) top 3 by yAggregation.modifier+.limitthe aggregate value sum(x, top 3) by yAggregation.modifier+.limitthe aggregate value ... by y top 3GroupBy.bucket_sizedoc_countA query carrying both applies the aggregation modifier first and the bucket limit second, so the survivors are re-ranked; swapping the passes yields a different set, not merely a different order. Bucket emission order is first-appearance (record order) in both engines, and both top-N passes sort stably, so emission order is what breaks ties.
Two Python quirks are reproduced in Rust deliberately rather than corrected, because two engines disagreeing is worse than one odd answer:
top 0on a single group-by field is a complete no-op while on a multi-level grouping it empties the result, and only the first aggregation carrying a modifier is applied. One divergence is deliberate: a bucket whose aggregate isnullsorts as zero in Rust, where Python raisesTypeError-- a divergence from an exception, not from a semantics.Against a CLUSTER, the answer is the same -- with three stated exceptions. Until 2026-09-04 the translators pushed neither modifier down: the aggregation limit was computed and discarded (
# noqa: F841inopensearch_stats.py, never read at all instats_translator.rs) and an absent bucket size was defaulted to five, so| stats sum(x) top 3 by yasked the cluster for five buckets and| stats sum(x) by yasked for five where memory returns every one. Both now emitsizeandorderper modifier, with abucket_sortsub-aggregation as the second pass when a query carries both.Three things a
termsaggregation cannot be made to do, measured against OpenSearch 2.19.4 and pinned bytql/tests/stats_top_n_pushdown_live.rsandtests/integration/test_stats_top_n_pushdown_opensearch.py:
- Tie-breaks.
termsbreaks a tie on its order key with_keyascending; both in-memory engines break it in first-appearance order, which notermsorder expresses.| stats sum(salary) by department top 5over the shared corpus cuts inside a four-waydoc_count = 2tie, so the fifth bucket isFinancein memory andData Sciencefrom a cluster.- Un-modified bucket order. The set agrees;
termsreturns_countdescending where memory returns first-appearance order.- Multi-level bucket limits. Nested
termspicks each level's top N by that level's owndoc_count; the in-memory pass sorts the flattened key combinations and reserves a slot per parent key. Different set and different count, not merely a different order.Four spellings are refused at translation time rather than answered differently, because the DSL that would express them is one OpenSearch rejects: an aggregation modifier alongside more than one group-by field,
top 0on an aggregation, an aggregation modifier ranking by a multi-value metric (median,percentile,std,percentile_rank) or by the listing family (values/unique/distinct), andtop 0on a group-by field in a multi-level grouping. All four remain answerable in memory; the refusal is about this backend, not about the language.
The operand of any / none is a scalar. tags any ['a','b'] does not ask
"is any element of tags one of these two values" — it asks whether a single
element equals the two-element list, which no scalar element can. Both the
OpenSearch translator and the in-memory evaluator raise TqlError::TypeError on
it and name in:
"tags any ['a', 'b']" // TqlError::TypeError -- ill-typed operand
"tags in ['a', 'b']" // what that query meant
"tags any 'a'" // fine; a one-element list unwraps to this
all / not_all are the deliberate exception: they are answered by a Painless
script that compares each element to the operand, where a list is a valid
(never-matching) value, in both layers.
The refusal is decided from the query alone, never from the data: and / or
short-circuit, so a check made only where the clause is reached would refuse
code eq 1 OR tags any ['a','b'] for a corpus containing a code != 1 record
and quietly answer it for one that does not. A query that is well-typed or not
depending on the records is the silent shape this refusal exists to remove.
Nested Field Access
// Dot notation for nested objects
"user.profile.email contains '@example.com'"
"metadata.tags.priority = 'high'"
// Array indexing uses dot notation -- bracket syntax is NOT accepted
"tags.0 = 'urgent'"
"history.5.status = 'completed'"
🔄 Field Mutators (25+)
Transform field values inline before comparison:
String Mutators
// Case conversion
"email | lowercase contains '@example.com'"
"name | uppercase = 'JOHN DOE'"
// Whitespace handling
"message | trim = 'hello'"
// String manipulation
"text | length > 100"
"path | split('/') | length = 3"
"text | replace('old', 'new') contains 'new'"
Encoding Mutators
// Base64
"data | b64encode = 'aGVsbG8='"
"encoded | b64decode contains 'secret'"
// URL encoding
"param | urldecode = 'hello world'"
// Hexadecimal encoding
"data | hexencode = '68656c6c6f'"
"encoded | hexdecode = 'hello'"
// Cryptographic hashing
"password | md5 = '5f4dcc3b5aa765d61d8327deb882cf99'"
"data | sha256 = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'"
Network/Security Mutators
// Defang/Refang URLs (security analysis)
"url | defang contains 'hxxp://example[.]com'"
"indicator | refang = 'http://malicious.com'"
// IP address classification
"source_ip | is_private = true" // Check if IP is RFC 1918 private
"dest_ip | is_global = true" // Check if IP is globally routable
// Use cases
"source_ip | is_private = true and port = 22" // Internal SSH connections
"dest_ip | is_global = false" // Non-routable destinations
DNS Mutators
// DNS lookups with caching
"hostname | nslookup contains '8.8.8.8'"
"domain | nslookup = '1.1.1.1'"
Performance: DNS results are cached in memory to avoid repeated lookups.
GeoIP Mutators
// GeoIP enrichment (MaxMind and DB-IP support).
// The mutator enriches the record; you then filter on the enriched geo.* fields.
// Mutator names cannot contain dots -- `ip | geoip.country_name` does NOT parse.
"ip | geoip_lookup exists"
"ip | geo exists" // `geo` and `geoip` are aliases
"geo.country_name = 'United States'" // filter the enriched field
"geo.city_name = 'New York'"
"geo.continent_code = 'NA'"
// Configure with environment variables
// TQL_GEOIP_DB_PATH=/path/to/GeoLite2-City.mmdb
// TQL_GEOIP_MMDB_PATH=/usr/share/GeoIP/
Supported fields:
geo.country_name,geo.country_iso_codegeo.city_namegeo.location(lat/lon)geo.continent_code,geo.continent_namegeo.region_name,geo.region_iso_codegeo.postal_code,geo.timezone
Performance: Uses memory-mapped I/O for efficient database access (200,000+ lookups/sec).
List Mutators
// Boolean aggregations
"tags | any = true" // Check if any element is truthy
"flags | all = true" // Check if all elements are truthy
// Numeric aggregations
"scores | avg > 80" // Calculate average
"values | sum > 1000" // Calculate sum
"prices | min >= 10" // Find minimum value
"ratings | max <= 5" // Find maximum value
// Example with nested arrays
"users.scores | avg > 75" // Average of nested array
"metrics.values | sum between [100, 500]" // Sum within range
Chaining Mutators
// Multiple transformations in sequence
"email | trim | lowercase contains '@example.com'"
"data | b64decode | lowercase = 'secret'"
"geo.country_name | lowercase = 'united states'" // after `ip | geo()` enrichment
A mutator name TQL does not know is rejected at parse time. count,
unique, first and last used to parse and then quietly return nothing; they
are now parse errors in every runtime.
🏷️ Type Hints
A ::hint suffix tells TQL how to read a field's value before the comparison
runs. It is applied after any field mutators and before the operator:
"value::number > 75" // read as a number, then compare
"flag::string eq 'true'" // read as a string, then compare
"f | trim::number > 75" // after the mutator chain
These hints were inert until this release. type_hint was parsed, stored on
the AST node, and read by no arm of the evaluator, so f::int eq 'Hello'
answered identically to the un-hinted query. Every name now reaches a decision,
and Rust gained the four names Python already had — date, geo, object and
ip — so both runtimes accept the same sixteen:
| Hint | Behaviour |
|---|---|
string, str |
render the value as a string |
integer, int, number, decimal, float, double |
numeric conversion |
boolean, bool |
boolean conversion |
ip |
validate as an IP address (and as a CIDR under the cidr operator) |
array, list, date, geo, object |
assert the field's shape; no value conversion |
Three rules govern what happens when a hint meets a value it cannot read:
- The record is skipped; the query is not aborted.
value::number > 75over[{v: 80}, {v: "abc"}, {v: 90}]matches 80 and 90. - A skipped record matches nothing, positively or negatively.
f::number != 75over"abc"is false, not true — likewisenot_contains,not_startswith,not_endswith,not_in,not_cidr, and all four existence operators.f::int exists,f::int not exists,f::int is nullandf::int is not nullare all false over{"f": "Hello"}:fdoes not exist as an int, which is what the hint asked. An absent or null field is not a read failure, sof::int not existsover{}is unchanged. - A query-level
not (...)does still invert a skip, the same way it already does for an absent field and the same waymust_notanswers on the cluster path.
An unrecognised hint name is a hard error, not a skip.
::string renders a boolean as true / false, not True / False —
matching JSON, TQL's own boolean literals, and what OpenSearch stores. Scope is
narrow on purpose: only a top-level boolean, and only under string/str.
A boolean nested in a list or object still renders True (['a', True]), and
error text still renders the offending value with the capitalised spelling.
A hint and a mutator chain may be written in EITHER order.
f | trim::number > 75andf::number | trim > 75both parse, in both runtimes and in the TypeScript editor, and select the same records. This was a parity divergence — each grammar rejected the other's spelling — and it was closed in this release. If you find a page still describing one order as runtime-specific, that page is stale.
Type hints are honoured by the in-memory evaluator. The OpenSearch execution path ignores them: the cluster reads the value as the mapping says it is stored.
📊 Statistical Aggregations
TQL includes powerful stats functions for data analysis:
Available Functions
use ;
use HashMap;
let evaluator = new;
let records = vec!;
// Count records
let query = StatsQuery ;
let result = evaluator.evaluate_stats?;
// result["value"] = 3
// Sum with grouping
let query = StatsQuery ;
let result = evaluator.evaluate_stats?;
// Groups by city: {"NYC": {"total_sales": 300}, "LA": {"total_sales": 150}}
CLI Stats Queries
# Simple aggregations
# Grouped analysis
# Top N analysis
# Combined filtering and stats
Aggregation Functions
- count: Count records (
count(*)orcount(field)) - sum: Sum numeric values
- avg/average/mean: Calculate mean
- min/max: Find minimum/maximum values
- median/med: Calculate median
- std / standard_deviation: Calculate standard deviation
- percentile / p / pct: Calculate percentiles
- distinct / unique / values: Return unique values
The full list is the agg_func_name rule in tql/src/parser/grammar.pest, but
six names in that rule are unreachable and will not parse: stddev,
percentiles, percentile_rank, percentile_ranks, pct_rank and
pct_ranks. agg_func_name is a PEG ordered choice that lists the shorter
alternative first, so std consumes the front of stddev, percentile
consumes percentiles and percentile_rank(s), and pct consumes
pct_rank(s). Use the names listed above. (There is also no unique_count in
Rust, though the Python implementation does accept it.)
🎯 API Reference
Basic Usage
use Tql;
use json;
// Create TQL instance
let tql = new;
// Or with custom depth limits
let tql = with_max_depth;
// Query records
let records = vec!;
// Execute query
let results = tql.query.unwrap;
println!;
// Count matches
let count = tql.count.unwrap;
println!;
// Check single record
let user = json!;
if tql.matches.unwrap
Query Pre-compilation
For queries executed multiple times, parse once and reuse the AST:
use ;
let tql = new;
// Parse query once
let ast = tql.parse.unwrap;
// Reuse AST for multiple datasets
let evaluator = new;
let results1 = evaluator.filter.unwrap;
let results2 = evaluator.filter.unwrap;
Error Handling
use ;
let tql = new;
match tql.query
⚡ Performance
Benchmarks
Unsubstantiated. The figures below are carried over from an earlier revision and could not be reproduced: this crate ships no benchmark harness (no
benches/directory and no[[bench]]target), socargo benchruns nothing. Treat them as rough historical claims, not measurements. Removing them, or landing a Criterion benchmark that produces them, is tracked work.
Rust Implementation:
- In-memory queries: ~3,000,000 records/sec
- File parsing (JSON): ~150MB/sec
- GeoIP lookups: ~200,000 lookups/sec (memory-mapped)
- DNS lookups: ~10,000 lookups/sec (with caching)
- Large file streaming: Process 50MB in ~200ms
vs Python Implementation:
- 300x faster for file processing
- 500x faster for GeoIP lookups (memory-mapped vs Python)
- 100x faster for DNS lookups (async + caching)
Performance Features
- Zero-copy deserialization where possible
- Memory-mapped I/O for GeoIP databases
- In-memory caching for DNS and GeoIP results
- Streaming file processing with no memory overhead
- Parallel query evaluation (planned)
Optimization Tips
// Pre-compile queries for reuse
let ast = tql.parse.unwrap;
let results1 = evaluator.filter.unwrap;
let results2 = evaluator.filter.unwrap;
// Use streaming for large files
let file = open?;
let reader = new;
for line in reader.lines
🗺️ Roadmap
✅ Implemented Features
- ✅ Core query engine with all operators
- ✅ 25+ field mutators (string, encoding, network, DNS, GeoIP, list)
- ✅ Statistical aggregations with grouping
- ✅ File support (JSON, JSONL, CSV) with CLI
- ✅ OpenSearch backend with automatic DSL translation
- ✅ Memory-mapped GeoIP lookups
- ✅ DNS resolution with caching
- ✅ High-performance streaming
🚧 In Progress
- 🚧 OpenSearch stats aggregation translation
- 🚧 Post-processing for complex mutator chains
- 🚧 Additional hash functions (SHA1, SHA512)
📋 Planned Features
- 📋 Parallel record evaluation
- 📋 Query optimization engine
- 📋 JSON parsing mutator
- 📋 Timestamp conversion mutators
- 📋 PostgreSQL/MySQL backends
- 📋 Custom mutator plugins via traits
🔮 Future Considerations
- 🔮 Distributed query execution
- 🔮 Query result caching
- 🔮 Real-time data streaming
- 🔮 WASM compilation for browser usage
🔧 Development
Setup
# Clone repository
# Build
# Run tests
# Build release
# Build with OpenSearch feature
Testing
# Run all tests
# Run with output
# Run integration tests (requires OpenSearch)
Code Quality
# Format code
# Linting
# Check compilation
🤝 Contributing
Contributions are welcome! See docs/developer/contributing.md in the repository root for guidelines.
📄 License
Tellaro Query Language (TQL) is source-available software with specific usage terms:
✅ Permitted Uses:
- Personal use (individual, non-commercial)
- Organizational use (within your company/organization)
- Integration into your applications and services
- Internal tools and automation
❌ Restricted Uses:
- Creating derivative query language products
- Commercial redistribution or resale
- Offering TQL-based commercial services to third parties
- Using source code to build competing products
For commercial licensing inquiries, contact: support@tellaro.io
See LICENSE for complete terms and conditions.
🔗 Related Projects
- TQL Python - Python implementation with rich ecosystem
- Tellaro Platform - Security operations platform using TQL
💬 Support
- Issues: GitHub Issues
- Documentation: docs.rs/tellaro-query-language
- Email: support@tellaro.io
🌟 Advanced Examples
Security Log Analysis
use Tql;
use json;
let tql = new;
let logs = vec!;
// Find high-severity events with malicious indicators
let query = r#"
severity in ['high', 'critical'] AND
source_ip | is_private = true AND
(ANY tags = 'malware' OR url | refang contains 'malicious')
"#;
let results = tql.query.unwrap;
assert_eq!;
E-commerce Product Search
let products = vec!;
// Find in-stock electronics with good ratings under $1500
let query = r#"
in_stock = true AND
price < 1500 AND
rating.average >= 4.0 AND
ANY tags = 'electronics'
"#;
let results = tql.query.unwrap;
assert_eq!;
GeoIP Enrichment Pipeline
// Set GeoIP database path
set_var;
let tql = new;
let events = vec!;
// Query with GeoIP enrichment
// Enrich, then filter on the enriched field
let query = "ip | geo exists AND geo.country_name = 'United States'";
let results = tql.query.unwrap;
// Results include enriched geo data
println!;
// {
// "ip": "8.8.8.8",
// "event": "login",
// "geo": {
// "country_name": "United States",
// "country_iso_code": "US",
// "city_name": "Mountain View",
// "location": {"lat": 37.386, "lon": -122.0838}
// }
// }
Made with ❤️ by the Tellaro Team