1010// instant was already refused `INVALID_FILTER` / 400. The door that calls this
1111// predicate (`@objectstack/objectql`, `temporal-comparand-door.ts`) now refuses
1212// the number and the `Date` too; its own suite pins the envelope.
13+ //
14+ // [#20264] The range is 0001..9999 (triage's ruling on that card), on `date`
15+ // AND `datetime`: year 0 joins the refused years — PostgreSQL's `DATE` and
16+ // `timestamptz` have no year 0 — and a `datetime` number, `Date` or string
17+ // outside the range is refused as the `date` one is. `time` has no year.
1318
1419import { describe , it , expect } from 'vitest' ;
1520import { isUninterpretableTemporalComparand } from './temporal-comparand.js' ;
@@ -22,6 +27,8 @@ const OUT_OF_RANGE: ReadonlyArray<readonly [string, number]> = [
2227 [ 'the first day of year 10000' , 253402300800000 ] ,
2328 [ 'year -1 (the card\'s number)' , - 62198755200000 ] ,
2429 [ 'the last millisecond of year -1' , at ( '-000001-12-31T23:59:59.999Z' ) ] ,
30+ [ '[#20264] the first millisecond of year 0' , at ( '0000-01-01T00:00:00.000Z' ) ] ,
31+ [ '[#20264] the last millisecond of year 0' , at ( '0000-12-31T23:59:59.999Z' ) ] ,
2532 [ 'the Date range maximum' , 8.64e15 ] ,
2633 [ 'the Date range minimum' , - 8.64e15 ] ,
2734] ;
@@ -36,7 +43,7 @@ const PAST_THE_DATE_RANGE: ReadonlyArray<readonly [string, number]> = [
3643
3744/** Finite numbers whose UTC calendar day has a four-digit year — read as before. */
3845const IN_RANGE : ReadonlyArray < readonly [ string , number ] > = [
39- [ 'the first millisecond of year 0 ' , at ( '0000 -01-01T00:00:00.000Z' ) ] ,
46+ [ '[#20264] the first millisecond of year 1 ' , at ( '0001 -01-01T00:00:00.000Z' ) ] ,
4047 [ '0999-06-15' , - 30627504000000 ] ,
4148 [ '1000-01-01' , at ( '1000-01-01T00:00:00.000Z' ) ] ,
4249 [ '2026-02-01T10:00Z' , 1769940000000 ] ,
@@ -45,7 +52,7 @@ const IN_RANGE: ReadonlyArray<readonly [string, number]> = [
4552] ;
4653
4754describe ( '[#20240] isUninterpretableTemporalComparand — a date column\'s number or Date outside the four-digit years' , ( ) => {
48- it ( 'refuses a number and the Date of the same value, for a year below 0 or above 9999' , ( ) => {
55+ it ( 'refuses a number and the Date of the same value, for a year below 0001 or above 9999' , ( ) => {
4956 for ( const [ name , ms ] of OUT_OF_RANGE ) {
5057 expect ( isUninterpretableTemporalComparand ( 'date' , ms ) , `number, ${ name } ` ) . toBe ( true ) ;
5158 expect ( isUninterpretableTemporalComparand ( 'date' , new Date ( ms ) ) , `Date, ${ name } ` ) . toBe ( true ) ;
@@ -80,20 +87,37 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
8087 expect ( isUninterpretableTemporalComparand ( 'date' , '-000001-01-01T00:00:00.000Z' ) ) . toBe ( true ) ;
8188 expect ( isUninterpretableTemporalComparand ( 'date' , '0999-06-15T00:00:00.000Z' ) ) . toBe ( false ) ;
8289 expect ( isUninterpretableTemporalComparand ( 'date' , '0999-06-15' ) ) . toBe ( false ) ;
90+ // [#20264] Year 0 in its string spellings, beside the first supported day.
91+ expect ( isUninterpretableTemporalComparand ( 'date' , '0000-06-15' ) ) . toBe ( true ) ;
92+ expect ( isUninterpretableTemporalComparand ( 'date' , '0000-06-15T00:00:00.000Z' ) ) . toBe ( true ) ;
93+ expect ( isUninterpretableTemporalComparand ( 'date' , '0001-01-01' ) ) . toBe ( false ) ;
8394 } ) ;
8495
85- it ( 'leaves the datetime and time rules alone — they read an instant ' , ( ) => {
86- for ( const kind of [ 'datetime' , 'time' ] as const ) {
87- for ( const [ name , ms ] of [ ... OUT_OF_RANGE , ... PAST_THE_DATE_RANGE , ... IN_RANGE ] ) {
88- expect ( isUninterpretableTemporalComparand ( kind , ms ) , ` ${ kind } , number, ${ name } ` ) . toBe ( false ) ;
89- expect ( isUninterpretableTemporalComparand ( kind , new Date ( ms ) ) , `${ kind } , Date, ${ name } ` ) . toBe ( false ) ;
96+ it ( '[#20264] judges a datetime number or Date by the same years — the rule reads it, but its year is outside ' , ( ) => {
97+ for ( const [ name , ms ] of [ ... OUT_OF_RANGE , ... PAST_THE_DATE_RANGE ] ) {
98+ expect ( isUninterpretableTemporalComparand ( 'datetime' , ms ) , `number, ${ name } ` ) . toBe ( true ) ;
99+ if ( Number . isFinite ( new Date ( ms ) . getTime ( ) ) ) {
100+ expect ( isUninterpretableTemporalComparand ( 'datetime' , new Date ( ms ) ) , `Date, ${ name } ` ) . toBe ( true ) ;
90101 }
91102 }
103+ for ( const [ name , ms ] of IN_RANGE ) {
104+ expect ( isUninterpretableTemporalComparand ( 'datetime' , ms ) , `number, ${ name } ` ) . toBe ( false ) ;
105+ expect ( isUninterpretableTemporalComparand ( 'datetime' , new Date ( ms ) ) , `Date, ${ name } ` ) . toBe ( false ) ;
106+ }
107+ } ) ;
108+
109+ it ( 'leaves the time rule alone — a wall clock has no year' , ( ) => {
110+ for ( const [ name , ms ] of [ ...OUT_OF_RANGE , ...PAST_THE_DATE_RANGE , ...IN_RANGE ] ) {
111+ expect ( isUninterpretableTemporalComparand ( 'time' , ms ) , `number, ${ name } ` ) . toBe ( false ) ;
112+ expect ( isUninterpretableTemporalComparand ( 'time' , new Date ( ms ) ) , `Date, ${ name } ` ) . toBe ( false ) ;
113+ }
92114 } ) ;
93115
94116 it ( 'does not judge NaN, ±Infinity or an Invalid Date — they name no instant and no year' , ( ) => {
95- for ( const value of [ Number . NaN , Number . POSITIVE_INFINITY , Number . NEGATIVE_INFINITY , new Date ( Number . NaN ) ] ) {
96- expect ( isUninterpretableTemporalComparand ( 'date' , value ) , String ( value ) ) . toBe ( false ) ;
117+ for ( const kind of [ 'date' , 'datetime' ] as const ) {
118+ for ( const value of [ Number . NaN , Number . POSITIVE_INFINITY , Number . NEGATIVE_INFINITY , new Date ( Number . NaN ) ] ) {
119+ expect ( isUninterpretableTemporalComparand ( kind , value ) , `${ kind } ${ String ( value ) } ` ) . toBe ( false ) ;
120+ }
97121 }
98122 } ) ;
99123
@@ -103,3 +127,51 @@ describe('[#20240] isUninterpretableTemporalComparand — a date column\'s numbe
103127 }
104128 } ) ;
105129} ) ;
130+
131+ // [#20264] The `datetime` rule reads an extended-year ISO string, a negative
132+ // year and year 0 because `Date.parse` does, and spells them `+010000-…`,
133+ // `-000001-…` and `0000-…`. The first two sort below every four-digit year as
134+ // text, so a `where` on a `datetime` field counted `$gt` / `$lt` / `$eq`
135+ // 7 / 0 / 0 on driver-memory and SQLite for a bound in year 10000 (the right
136+ // answer is 0 / 7 / 0), and PostgreSQL answered 500 (`22009`, `22007`) for
137+ // them and for year 0 (`22008`). Each is now uninterpretable, beside the same
138+ // instant a millisecond inside the range.
139+ describe ( '[#20264] isUninterpretableTemporalComparand — a datetime string outside the years 0001 to 9999' , ( ) => {
140+ const REFUSED : ReadonlyArray < readonly [ string , string ] > = [
141+ [ 'the extended ISO spelling of year 10000' , '+010000-01-01T00:00:00.000Z' ] ,
142+ [ 'a bare extended day' , '10000-01-01' ] ,
143+ [ 'the extended ISO spelling of year -1' , '-000001-01-01T00:00:00.000Z' ] ,
144+ [ 'year 0, ISO' , '0000-06-15T00:00:00.000Z' ] ,
145+ [ 'year 0, a bare day (read as midnight UTC)' , '0000-06-15' ] ,
146+ [ 'year 0, zone-naive (read as UTC)' , '0000-06-15 10:00' ] ,
147+ [ 'year 9999 in its zone, year 10000 in UTC' , '9999-12-31T23:59:59-01:00' ] ,
148+ [ 'year 1 in its zone, year 0 in UTC' , '0001-01-01T00:00:00+08:00' ] ,
149+ [ 'epoch milliseconds for year 10000, as a string' , '253402300800000' ] ,
150+ ] ;
151+ const READ : ReadonlyArray < readonly [ string , string ] > = [
152+ [ 'the first instant of year 1' , '0001-01-01T00:00:00.000Z' ] ,
153+ [ 'the last instant of year 9999' , '9999-12-31T23:59:59.999Z' ] ,
154+ [ 'year 1, a bare day' , '0001-01-01' ] ,
155+ [ 'year 0099 — inside the range' , '0099-03-04T10:00:00.000Z' ] ,
156+ [ 'a 2026 instant (the control)' , '2026-02-01T10:00:00.000Z' ] ,
157+ [ 'a 2026 zone-naive timestamp (the control)' , '2026-02-01 10:00' ] ,
158+ [ 'epoch milliseconds for 2026, as a string (the control)' , '1769940000000' ] ,
159+ ] ;
160+ it ( 'refuses each, on datetime and on date' , ( ) => {
161+ for ( const [ name , value ] of REFUSED ) {
162+ expect ( isUninterpretableTemporalComparand ( 'datetime' , value ) , name ) . toBe ( true ) ;
163+ }
164+ for ( const value of [ '+010000-01-01T00:00:00.000Z' , '-000001-01-01T00:00:00.000Z' , '0000-06-15' ] ) {
165+ expect ( isUninterpretableTemporalComparand ( 'date' , value ) , value ) . toBe ( true ) ;
166+ }
167+ } ) ;
168+ it ( 'reads every instant inside the range, as before — the discriminating half' , ( ) => {
169+ for ( const [ name , value ] of READ ) {
170+ expect ( isUninterpretableTemporalComparand ( 'datetime' , value ) , name ) . toBe ( false ) ;
171+ }
172+ } ) ;
173+ it ( 'a time column judges no year — its rule keeps a time of day' , ( ) => {
174+ expect ( isUninterpretableTemporalComparand ( 'time' , '0000-06-15T10:00:00.000Z' ) ) . toBe ( false ) ;
175+ expect ( isUninterpretableTemporalComparand ( 'time' , '10:00' ) ) . toBe ( false ) ;
176+ } ) ;
177+ } ) ;
0 commit comments