When HAVING or ORDER BY names an aggregate whose argument is a transform — MAX(YEAR(x)), MAX(DATE_TRUNC(x, MINUTE)), MAX(ABS(x)) — the emitted aggregation is malformed. Two distinct symptoms, one root: an aggregate over a transform has no plain field to key a bucket path on.
Symptom 1 — HAVING: the transform is applied twice, inside a script that cannot run there
SELECT id, COUNT(x) AS c FROM t GROUP BY id HAVING MAX(YEAR(createdAt)) > 2020
"having_filter": {
"bucket_selector": {
"buckets_path": { "createdAt": "createdAt" },
"script": { "source": "def left = (doc['createdAt'].size() == 0 ? null : doc['createdAt'].value.toInstant().atZone(ZoneId.of('Z')).get(ChronoField.YEAR)).get(ChronoField.YEAR); left == null ? false : (left > 2020)" }
}
}
Two problems in one script:
.get(ChronoField.YEAR) appears twice — once inside the parenthesised doc[…] expression and once applied to its result.
- it reads
doc[…], which a bucket_selector script cannot do; that context exposes params only, populated from buckets_path. The declared buckets_path entry (createdAt) is never referenced by the script.
The correct form is the one the bare-column case already emits: "script": {"source": "params.salary > 2020"}.
Identical doubling with DATE_TRUNC (.truncatedTo(ChronoUnit.MINUTES) twice), so it is not specific to the extractors.
Symptom 2 — ORDER BY: the aggregation is named ""
SELECT id, COUNT(x) AS c FROM t GROUP BY id ORDER BY MAX(ABS(salary)) DESC
"terms": { "field": "id", "size": 65536, "min_doc_count": 1, "order": { "": "desc" } },
"aggs": { "c": { … }, "": { "max": { "script": { … Math.abs … } } } }
The sub-aggregation key and the order key are both the empty string. The name derives from the identifier's path, which is empty when the aggregate is script-backed rather than field-backed.
Controls
| SQL |
Result |
HAVING MAX(salary) > 2020 |
✅ "script": "params.salary > 2020" |
ORDER BY MAX(salary) DESC |
✅ "order": {"salary": "desc"}, agg named salary |
HAVING MAX(YEAR(createdAt)) > 2020 |
❌ double transform + doc[…] |
HAVING MAX(DATE_TRUNC(createdAt, MINUTE)) > 2020 |
❌ same |
ORDER BY MAX(ABS(salary)) DESC |
❌ agg named "" |
Pre-existing
Verified byte-identical before and after PR #221 by re-running the same probe against the pre-fix sources — not a regression from that change. Same family as #54 (buckets_path keying for un-aliased aggregates).
Found while reviewing #220 / PR #221: the parenthesis fix newly routes MAX(YEAR(x)) through this path, so the shape now looks supported where it previously failed to parse.
When
HAVINGorORDER BYnames an aggregate whose argument is a transform —MAX(YEAR(x)),MAX(DATE_TRUNC(x, MINUTE)),MAX(ABS(x))— the emitted aggregation is malformed. Two distinct symptoms, one root: an aggregate over a transform has no plain field to key a bucket path on.Symptom 1 —
HAVING: the transform is applied twice, inside a script that cannot run thereSELECT id, COUNT(x) AS c FROM t GROUP BY id HAVING MAX(YEAR(createdAt)) > 2020Two problems in one script:
.get(ChronoField.YEAR)appears twice — once inside the parenthesiseddoc[…]expression and once applied to its result.doc[…], which abucket_selectorscript cannot do; that context exposesparamsonly, populated frombuckets_path. The declaredbuckets_pathentry (createdAt) is never referenced by the script.The correct form is the one the bare-column case already emits:
"script": {"source": "params.salary > 2020"}.Identical doubling with
DATE_TRUNC(.truncatedTo(ChronoUnit.MINUTES)twice), so it is not specific to the extractors.Symptom 2 —
ORDER BY: the aggregation is named""SELECT id, COUNT(x) AS c FROM t GROUP BY id ORDER BY MAX(ABS(salary)) DESCThe sub-aggregation key and the
orderkey are both the empty string. The name derives from the identifier's path, which is empty when the aggregate is script-backed rather than field-backed.Controls
HAVING MAX(salary) > 2020"script": "params.salary > 2020"ORDER BY MAX(salary) DESC"order": {"salary": "desc"}, agg namedsalaryHAVING MAX(YEAR(createdAt)) > 2020doc[…]HAVING MAX(DATE_TRUNC(createdAt, MINUTE)) > 2020ORDER BY MAX(ABS(salary)) DESC""Pre-existing
Verified byte-identical before and after PR #221 by re-running the same probe against the pre-fix sources — not a regression from that change. Same family as #54 (
buckets_pathkeying for un-aliased aggregates).Found while reviewing #220 / PR #221: the parenthesis fix newly routes
MAX(YEAR(x))through this path, so the shape now looks supported where it previously failed to parse.