Repository navigation
fix(sql): date arithmetic follows the SQL engines; type checks wait for the column types - #412
Merged
Merged
Conversation
…or the column types DATE - DATE is calendar days (BIGINT). TIMESTAMP - TIMESTAMP, or a DATE and a TIMESTAMP, is fractional days (DOUBLE). DATE ± n moves the date by n days (a DATE, or a TIMESTAMP for a fractional n), and TIMESTAMP ± n moves it by n days. date + date, a date under * / %, and n - date are refused by name, as PostgreSQL, DuckDB, Oracle, Snowflake, Trino and SQL Server refuse them. The rules hold in every venue: row SELECT, WHERE, per group, HAVING, SCRIPT AS computed columns, CREATE TABLE ... AS SELECT and the view calculation channel. Type rules move from parse time to validateResolved(), after the mapping is read. A statement is never refused on a guessed column type, and with no type to read nothing is refused. One comparison rule covers =, <, >, BETWEEN, IN, simple CASE, NULLIF and CASE conditions: a quoted literal that spells a number or a date is read as one, and DATE / TIMESTAMP compare on their declared types. It was checked against PostgreSQL on the statements the move first refused. A date field elasticsql did not declare is a TIMESTAMP everywhere, DESCRIBE included. A column declared DATE stays DATE. Also: - casts over arithmetic apply, and keep NULL; - GREATEST / LEAST compare dates; - index patterns and comma lists merge their mappings field by field, subqueries included, and DDL refreshes the cached types; - the clock and COUNT(d) run per group; - DATE_TRUNC(ts, DAY) compared with a date runs; - ES 6.8 compares a date column with a computed date. The normalisation is rendered for 6.8 only, through a per-major PainlessTarget. Breaking changes (release note): - date differences are days, not milliseconds; - DESCRIBE reports undeclared date fields as TIMESTAMP; - statements that answered wrong are now refused: MAX(d) / 1000, MAX(d) + MIN(d), n + s, WHERE id = n, and CASE conditions over incompatible types; - PainlessContext's arity, the bridge implicits and the ArithmeticExpression companion change: downstream projects must rebuild. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an integer must be whole The comparison rule re-read every string literal on each type question and during request generation, and tried each one as a date through java.time parsers, whose refusal is an exception (about 2 µs each). Each literal is now read once per node. A cheap shape check runs before any date parse, so '1' or 'abc' never reaches the parser. Request generation is back to its earlier cost (WHERE-heavy 60.7 → 45.8 µs, against 41.5 before the comparison rule). The requests are byte-identical to the previous commit. A quoted number beside an integer type must be a whole number that the type can hold, as in PostgreSQL. COALESCE(n, 0) = '1.5', n + '1.5', NULLIF(n, '1.0') and CASE n WHEN '1.5' ... are refused by name. Beside DOUBLE, REAL or DECIMAL, any number is accepted. The docs no longer attribute d + '1' = the next day to PostgreSQL. It is elasticsql's own rule: PostgreSQL 16 refuses d + '1' and d - '1', and reads ts + '1' as one second. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…constants and NULL-safe conditions - NULLIF with a date literal of the other shape (a DATE operand with a date-time literal, or a TIMESTAMP operand with a date-only literal, in either order) no longer binds a per-document parse of the literal. It renders the literal's reading, in queries and in computed columns. It failed in queries and stored NULL in computed columns; main refused it by name. Its values now equal PostgreSQL's. - CASE <boolean> WHEN '1' / 'true' / TRUE renders the constant (1L / 0L) instead of (true ? 1L : 0L), which Elasticsearch 6.8 refuses to compile. This also fixes CASE b WHEN TRUE on 6.8, which is broken on main. - CASE WHEN <boolean column> takes the ELSE branch on a document with no value for the column, in queries and in computed columns. - Docs: the whole-number rule beside integer types in operators.md; the BETWEEN wording; an accurate test comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each client module (es6 rest / jest, es7 rest, es8 / es9 java) now generates a BuildInfo object (elasticVersion, elasticMajor) from its own elasticSearchVersion. The search-body serializers read ElasticsearchMajor from it, and every painlessTarget uses the serializer's major. No client source writes a major by hand any more; the ES 8 and ES 9 modules hard-coded 8 and 9. The ES 8 and ES 9 serializers stay byte-identical. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…S and a simple CASE answer PostgreSQL's types
- A CASE or a NULLIF used as an operand is one value. The operator applies
to every branch: CASE WHEN n > 0 THEN n ELSE 0 END + 1 added the 1 to the
ELSE branch only. A NULLIF that nulls gives NULL instead of failing the
query: x / NULLIF(n, 0), NULLIF(n, 0) + 1, a % NULLIF(b, 0), in queries and
computed columns (a computed column was silently absent).
- GREATEST / LEAST answer their arguments' common type on every row. Whole
numbers stay whole: GREATEST(n, 2) answered 3.0. With a DOUBLE argument
every row is a double, where an integer argument that won, or survived a
NULL, came back as an integer.
- ABS is typed by its argument, as abs(integer) is an integer. ABS(n) + 1
runs (it failed to compile), and a quoted fraction beside ABS(n) is refused
by name again.
- A simple CASE answers its THEN / ELSE values in their own type. It answered
them in the operand's type ("0" over a KEYWORD).
- NULLIF with a quoted literal first is typed by the literal's reading:
NULLIF('2024-01-31', ts) is a TIMESTAMP, and CREATE TABLE ... AS SELECT
declares it.
- A number cast to DATE is refused by name, as PostgreSQL refuses it. A number
cast to TIMESTAMP keeps its epoch-millisecond conversion.
- Docs: the NULLIF guard works for / and %; the result types of GREATEST /
LEAST, ABS and NULLIF; (a / b)::INTEGER is a truncated quotient, since casts
over arithmetic apply.
Values, and a refusal by name where PostgreSQL refuses, match PostgreSQL 16 on
Elasticsearch 8.18.3 and 6.8.23.
Closes #380
Closes #392
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Oct 6, 2026
fupelaqu
marked this pull request as ready for review
October 6, 2026 14:21
This was referenced Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Date arithmetic with
+and-follows the SQL engines (PostgreSQL, DuckDB, Oracle, Snowflake, BigQuery). It behaves the same in core and in every place a script runs: rowSELECT,WHERE, per-group calculations,HAVING, computed columns (SCRIPT AS),CREATE TABLE … AS SELECT, and the materialized-view calculation channel. Type errors are checked once the column types are known, never at parse.DATE - DATEBIGINTTIMESTAMP - TIMESTAMP, or aDATEand aTIMESTAMP1.25= a day and 6 hours)DOUBLEDATE ± n,na whole numberndays later / earlierDATEDATE ± n,nfractional /TIMESTAMP ± nndays later / earlier, the fraction as a time of dayTIMESTAMPdate + date, a date under*/%,n - dateThe engine evidence:
Also in this PR:
validateResolved(), after the mapping is read. With no type to read, nothing is refused and Elasticsearch answers.=,<,>,BETWEEN,IN, simpleCASE,NULLIFandCASEconditions, wherever they appear.DATEandTIMESTAMPcompare on their declared types.WHERE CURRENT_DATE - d > 30,WHERE d + 7 > CURRENT_DATE,WHERE COALESCE(n, 0) = '3'.datefield elasticsql did not declare is aTIMESTAMPeverywhere,DESCRIBEincluded. This covers indices created by a bulk load, Logstash or an application, judged per field. The schema is derived from the Elasticsearch mapping, whosedateholds an instant, as Elasticsearch SQL also reports.DATEthrough elasticsql staysDATE.ALTER TABLEkeeps an undeclared date aTIMESTAMP.(n + x)::BIGINTanswered 3.25; it is now 3. A NULL operand gives NULL.CURRENT_DATE - MAX(d));COUNT(d) - 1per group;DATE_TRUNC(ts, DAY) = '2024-01-31';NULLIFwith a date literal of either shape (NULLIF(d, '2025-01-01 10:00:00')), in queries and computed columns;CASE b WHEN '1'/WHEN TRUEover a boolean on ES 6.8, andCASE WHEN <boolean column>on a document without the boolean (theELSEbranch).PainlessTargetin each client module, so the ES 7 / 8 / 9 requests for those shapes are byte-identical to before.CASEor aNULLIFused as an operand is one value.CASE WHEN n > 0 THEN n ELSE 0 END + 1adds 1 on every branch. It added 1 to theELSEbranch only.x / NULLIF(n, 0),NULLIF(n, 0) + 1anda % NULLIF(b, 0)give NULL where theNULLIFnulls, in a query and in a computed column. The query failed, and the computed column was silently absent.GREATEST/LEASTanswer their arguments' common type on every row.GREATEST(n, 2)over anINTanswers3, where it answered3.0. With aDOUBLEargument, every row is a double; main answered an integer on the rows where theDOUBLEwas missing.ABSis typed by its argument, asabs(integer)→integer.ABS(n) + 1runs; it failed to compile.ABS(n) = '1.5'is refused by name, as on main.CASEanswers itsTHEN/ELSEvalues in their own type:CASE s WHEN 'a' THEN 1 ELSE 0 ENDgives the integers1/0, where it gave the strings"1"/"0".NULLIF('2024-01-31', ts)is typedTIMESTAMP, the literal's reading, andCREATE TABLE … AS SELECTdeclares it.DATE, such as(d - d2)::DATE, is refused by name, as PostgreSQL refuses it. A number cast toTIMESTAMPkeeps its epoch-millisecond conversion.CREATE TABLE … AS SELECTwhoseSELECTis refused leaves no table. Main created the empty table first.Measured
DateArithmeticSpec, with runners for es6 rest/jest, es7, es8 and es9, plus GroupByCompleteness, DateFunctionExecution, GatewayApi, ReplGateway and PredicateFunctionResultDateArithmeticSpec+ TemporalLiteral, PredicateFunctionResult, DateFunctionExecution (REST);DateArithmeticSpec(Jest)esMajor >= 7cancels, unchangedGREATEST(n, 2)was3.0), and 1 that readsCURRENT_DATE(PostgreSQL's fixture was recorded the day before). Where PostgreSQL refuses the statement: 480 new refusals, and 8 changed answers (d + '1', and a textCASEconcatenated on every branch). All from the rulings aboveCASE/NULLIFoperands,GREATEST/LEAST,ABS, simpleCASE,NULLIFliteral first, number →DATE), ES 8.18.3 and 6.8.23GREATEST/LEASTalso check the Java class on every row. They fail on the previous head. No column mixes number classes over 3,592 answered statements (main mixesIntegerandDoublein 5GREATESTshapes)sql1821,core1149, bridges es6 260 / es7 267 / es8 267 / es9 267,macrosTests30;+ compile2.12.20 / 2.13.16; lint cleanCost: 5 interleaved rounds against main, with a new committed probe,
ResolutionCostProbe(parse → schema attach →validateResolved→ emission).Up to the second fix round:
The last fix round reads each comparison literal once, and checks a literal's shape before any date parse. Request generation is back to its cost before the comparison rule (
WHERE heavy41.5 → 45.8 µs, after 60.7 µs at the first commit). The remaining type-check cost, up to +1.8 µs on about 20 statements, is the rule doing its work once. The population total is unchanged (566 vs 571 ms). The requests are byte-identical to the first commit.The scalar-expression fixes: population total ×0.96 to ×1.02 over two 5-round runs, every range overlapping; no statement slower beyond noise.
Not run locally, left to CI: ES 7.17 and 9.0 legs, and the Jest leg since the scalar-expression tests.
DATE - DATEis days, not milliseconds.MAX(d) - MIN(d)answered2592000000for 30 days and now answers30. Remove a division by86400000; write aHAVINGthreshold in days. Per group, a number added to a date is days (MAX(d) + 1.5).DESCRIBEof an index elasticsql did not create reports itsdatefields asTIMESTAMP, notDATE. So doesCREATE TABLE … AS SELECTfrom it. Their arithmetic keeps the time of day:ts2 - ts=0.948, and per groupMAX(ts2) - MIN(ts)=1.385days (it was1.197E8ms). Measured on such an index,WHERE ts = '2024-01-31',>andBETWEENselect the same rows as before.New refusals of statements that ran, all answering wrong or meaningless before:
MAX(d) / 1000,MAX(d) + MIN(d),*,/,%(epoch-millisecond arithmetic);d + 'x'(string concatenation);n + s(INT+KEYWORD, concatenation3a);WHERE id = n(KEYWORD=INT, no rows);COALESCE(n, 0) = '1.5',n + '1.5',NULLIF(n, '1.0'), as PostgreSQL refuses them;CASEconditions comparing incompatible types (PostgreSQL refuses them all; on main 9 answered 0 for every row and 17 failed in Elasticsearch).Remedies:
CONCAT(CAST(n AS VARCHAR), s),WHERE id = CAST(n AS VARCHAR).Values that change because they were wrong:
CASEapplies to every branch (CASE WHEN n > 0 THEN n ELSE 0 END + 1forn = 3: 3 → 4);GREATEST/LEASTover whole numbers answer whole numbers (3.0→3), and over aDOUBLE, a double on every row;CASEanswers itsTHEN/ELSEvalues in their own type ("0"→0);(n + x)::BIGINT3.25 → 3);d + '1'is the next day (it concatenated). This is elasticsql's own rule: PostgreSQL 16 refusesd + '1';AVG(n) + 1per group is 1.5 (it was truncated to 1.0);CAST(n AS TIMESTAMP)over a numeric column is an instant (it returned the number);DATEholding a date-time string stores a value (it stored NULL);API (binary-incompatible, downstream rebuild):
PainlessContextgainstarget: PainlessTarget, and the bridge conversions take an implicitPainlessTarget;RestClientBuildInfo,JestClientBuildInfo,JavaClientBuildInfo:elasticVersion,elasticMajor) from its build's Elasticsearch version; the serializers'ElasticsearchMajor(new onJavaClientSearchBodySerializer) and everypainlessTargetread it, so no client source writes a major by hand;object ArithmeticExpressionno longer extendsAbstractFunction4;sql.ComparisonRule,SingleSearch.typeErrors,Validation.typeError,convert.typeErrorsOfandConversion.typeError;Case.baseTypeis the type of theTHEN/ELSEvalues (it included theCASE <expr>operand);ABS'sreportedTypeis its argument's type;NullIf.reportedTypereads a literal first argument;validate()checks syntax and structure only: a caller that executes a parsed statement outsideSearchApimust callvalidateResolved()after attaching the schema.Follow-ups (not in this PR)
Measured on main and on this PR's head, against PostgreSQL 16. Each is filed:
validateResolved()after attaching the schema, and store a DATE / TIMESTAMP per-group calculation. A transform writes decimals, which adatecolumn refuses.GROUP BY(A calculation over aggregates withoutGROUP BYfails in Elasticsearch:SELECT MAX(n) - MIN(n) FROM t#413;SELECT MAX(n) - MIN(n) FROM t) fails in Elasticsearch on main for every shape. Its own PR, before the release.0.0forMIN/MAX/AVGof a column a whole group lacks,CREATE TABLE … AS SELECT … GROUP BYstores one empty document per source document, and values come back in the class Elasticsearch's JSON gives them (a date per group as epoch milliseconds) #414):MIN/MAX/AVGof a column absent from every document of a group answer0.0;CREATE TABLE … AS SELECT … GROUP BYstores one empty document per source document;CREATE TABLE … AS SELECT DATEDIFF(…)fails at index creation (scaling_factor);MIN(d),MAX(d) + 1) as a number of epoch milliseconds, and aBIGINTthat fits 32 bits as anInteger.BETWEENand text/booleanGREATESTare refused, text+text concatenates #415):WHERE n = '1.5'over a bare integer column, and itsIN/BETWEEN/>forms, go to Elasticsearch as on main, where PostgreSQL refuses them; the integer ranges per type are not checked either ('99999999999'beside anINT);WHERE, a bareDATEcolumn compared with a timestamp literal compares the instant, where PostgreSQL compares the date part;DATE/TIMESTAMPcolumn, aBETWEENwith mixed date and timestamp bounds is refused, where PostgreSQL accepts it;GREATEST/LEASTover text or booleans are refused, where PostgreSQL accepts them;UPPER(s) + 'a';+ NULLgives the text'null'), where PostgreSQL refuses it;ABS(n)asNUMERICin arithmetic and asINTin a comparison;BIGINT, where PostgreSQL types itintegerwhen it fits.ROUND(x, 1)returns an integer, function results fail to compile under an operator or inGREATEST, a simpleCASEmatches NULL or fails over an expression, andORDER BY GREATEST(…)fails #416):ROUND(x, 1)returns a whole number, even in a plainSELECT;ROUND(x, 1) - 1,ABS(x) + 1over aDOUBLE,GREATEST(ABS(n), 1)(also overSQRTor aCASE) andGREATEST(n, NULL)fail to compile on every document;BIGINTliteral past theINTrange fails to compile (n + 3000000000), except inGREATEST/LEAST;ORDER BY GREATEST(…)sorts by the alias as a field and fails;CASE YEAR(d) WHEN 2024 …andCASE CAST(n AS VARCHAR) WHEN '3' …fail in Elasticsearch;CASE x WHEN xmatches a NULL operand;CASEor a function over an aggregate is refused per group;DATE_TRUNC(d, HOUR)over aDATEfails when compared with aDATE.CAST(TRUE AS INT)renders a constant conditional that 6.8 refuses to compile;DATE_FORMAT/DATETIME_FORMATstill fail.INwithCASTelements,INTERVALover an aggregate,CAST(n + x AS DOUBLE).🤖 Generated with Claude Code