Skip to content

Commit 3292a2e

Browse files
authored
fix(typeEvaluator): handle datetime type in string functions and operations (#321)
### Description This PR improves datetime handling in type evaluator, ensuring it correctly models the runtime behavior of datetime values across all operators and functions. It also adds datetime the list of primitives in the type evaluate compare test suite that tests operations and value types to ensure matching runtime / type eval behavior. https://linear.app/sanity/issue/CLDX-4472/add-support-for-datetime-in-typeevaluatecompare **Changes:** 1. **`satisfies`** **function**: Recognize `DateTime` instances when validating against types with the `STRING_TYPE_DATETIME` marker 2. **Union optimization**: Include the datetime marker in hash calculations so datetime strings and regular strings are treated as distinct types when deduplicating unions 3. **Comparison operators** (`>`, `>=`, `<`, `<=`): Return `null` when comparing a datetime string with a regular string 4. **Arithmetic operators**: - `+`: Return `null` for datetime + string, string + datetime, and datetime + datetime - `-`: Return `number` for datetime - datetime (difference in seconds), and handle unknown operands that could be datetime 5. **Built-in functions**: - `array.join`: Return `null` when separator is a datetime - `upper`/`lower`: Return `null` for datetime strings - `length`: Return `null` for datetime strings ### What to review - `src/typeEvaluator/satisfies.ts`: DateTime instance recognition - `src/typeEvaluator/optimizations.ts`: Hash function changes for union deduplication - `src/typeEvaluator/typeEvaluate.ts`: Operator handling for datetime types - `src/typeEvaluator/functions.ts`: Function handling for datetime types - `test/typeEvaluateCompare.test.ts`: New datetime test primitive ### Testing Added a `datetime` primitive to the `typeEvaluateCompare` tests, which automatically generates test cases for all operators and functions with datetime values. This ensures the type evaluator's predictions match the actual runtime behavior.
1 parent 31e70ee commit 3292a2e

File tree

6 files changed

+116
-41
lines changed

6 files changed

+116
-41
lines changed

src/typeEvaluator/functions.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import type {FuncCallNode} from '../nodeTypes'
33
import {optimizeUnions} from './optimizations'
44
import type {Scope} from './scope'
55
import {walk} from './typeEvaluate'
6-
import {createGeoJson, mapNode, nullUnion} from './typeHelpers'
7-
import {STRING_TYPE_DATETIME, type NullTypeNode, type TypeNode} from './types'
6+
import {createGeoJson, dateTimeString, isString, mapNode, nullUnion} from './typeHelpers'
7+
import {type NullTypeNode, type TypeNode} from './types'
88

99
function unionWithoutNull(unionTypeNode: TypeNode): TypeNode {
1010
if (unionTypeNode.type === 'union') {
@@ -47,7 +47,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
4747
if (arrayArg.type === 'unknown' || sepArg.type === 'unknown') {
4848
return nullUnion({type: 'string'})
4949
}
50-
if (arrayArg.type !== 'array' || sepArg.type !== 'string') {
50+
if (arrayArg.type !== 'array' || !isString(sepArg)) {
5151
return {type: 'null'}
5252
}
5353

@@ -107,8 +107,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
107107
if (arg.type === 'unknown') {
108108
return nullUnion({type: 'string'})
109109
}
110-
111-
if (arg.type !== 'string') {
110+
if (!isString(arg)) {
112111
return {type: 'null'}
113112
}
114113
if (arg.value !== undefined) {
@@ -127,7 +126,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
127126
if (arg.type === 'unknown') {
128127
return nullUnion({type: 'string'})
129128
}
130-
if (arg.type !== 'string') {
129+
if (!isString(arg)) {
131130
return {type: 'null'}
132131
}
133132
if (arg.value !== undefined) {
@@ -140,10 +139,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
140139
})
141140
}
142141
case 'dateTime.now': {
143-
return {type: 'string', [STRING_TYPE_DATETIME]: true}
142+
return dateTimeString()
144143
}
145144
case 'global.now': {
146-
return {type: 'string', [STRING_TYPE_DATETIME]: true}
145+
return dateTimeString()
147146
}
148147
case 'global.defined': {
149148
const arg = walk({node: node.args[0], scope})
@@ -233,12 +232,12 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
233232

234233
return mapNode(arg, scope, (arg) => {
235234
if (arg.type === 'unknown') {
236-
return nullUnion({type: 'string', [STRING_TYPE_DATETIME]: true})
235+
return nullUnion(dateTimeString())
237236
}
238237

239238
if (arg.type === 'string') {
240239
// we don't know whether the string is a valid date or not, so we return a [null, string]-union
241-
return nullUnion({type: 'string', [STRING_TYPE_DATETIME]: true})
240+
return nullUnion(dateTimeString())
242241
}
243242

244243
return {type: 'null'} satisfies NullTypeNode
@@ -252,7 +251,7 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
252251
if (arg.type === 'unknown') {
253252
return nullUnion({type: 'number'})
254253
}
255-
if (arg.type === 'array' || arg.type === 'string') {
254+
if (arg.type === 'array' || isString(arg)) {
256255
return {type: 'number'}
257256
}
258257

src/typeEvaluator/optimizations.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {isDateTime} from './typeHelpers'
12
import type {TypeNode} from './types'
23

34
const {compare} = new Intl.Collator('en')
@@ -21,7 +22,6 @@ export function hashField(field: TypeNode): string {
2122

2223
function calculateFieldHash(field: TypeNode): string {
2324
switch (field.type) {
24-
case 'string':
2525
case 'number':
2626
case 'boolean': {
2727
if (field.value !== undefined) {
@@ -31,6 +31,21 @@ function calculateFieldHash(field: TypeNode): string {
3131
return `${field.type}`
3232
}
3333

34+
case 'string':
35+
if (isDateTime(field) && field.value !== undefined) {
36+
return `${field.type}(${field.value}):datetime`
37+
}
38+
39+
if (isDateTime(field)) {
40+
return `${field.type}:datetime`
41+
}
42+
43+
if (field.value !== undefined) {
44+
return `${field.type}(${field.value})`
45+
}
46+
47+
return `${field.type}`
48+
3449
case 'null':
3550
case 'unknown': {
3651
return field.type

src/typeEvaluator/satisfies.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {DateTime} from '../values'
2+
import {isDateTime} from './typeHelpers'
13
import type {TypeNode} from './types'
24

35
/**
@@ -17,6 +19,9 @@ export function satisfies(type: TypeNode, value: unknown): boolean {
1719
}
1820
case 'string': {
1921
if (type.value !== undefined) return value === type.value
22+
if (isDateTime(type)) {
23+
return value instanceof DateTime
24+
}
2025
return typeof value === 'string'
2126
}
2227
case 'array':

src/typeEvaluator/typeEvaluate.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,15 @@ import {handleFuncCallNode} from './functions'
3232
import {match} from './matching'
3333
import {optimizeUnions} from './optimizations'
3434
import {Context, Scope} from './scope'
35-
import {isFuncCall, mapNode, nullUnion, resolveInline} from './typeHelpers'
35+
import {
36+
dateTimeString,
37+
isDateTime,
38+
isFuncCall,
39+
isString,
40+
mapNode,
41+
nullUnion,
42+
resolveInline,
43+
} from './typeHelpers'
3644
import {
3745
type ArrayTypeNode,
3846
type BooleanTypeNode,
@@ -43,7 +51,6 @@ import {
4351
type ObjectTypeNode,
4452
type PrimitiveTypeNode,
4553
type Schema,
46-
STRING_TYPE_DATETIME,
4754
type StringTypeNode,
4855
type TypeNode,
4956
type UnionTypeNode,
@@ -532,6 +539,12 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
532539
if (left.type !== right.type) {
533540
return {type: 'null'} satisfies NullTypeNode
534541
}
542+
// we represent datetimes as the string type, but can only compare them if both/none are the datetime subtype
543+
if (left.type === 'string' && right.type === 'string') {
544+
if (isDateTime(left) !== isDateTime(right)) {
545+
return {type: 'null'} satisfies NullTypeNode
546+
}
547+
}
535548
if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) {
536549
return {type: 'null'} satisfies NullTypeNode
537550
}
@@ -608,7 +621,13 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
608621
// + is ambiguous without the concrete types of the operands, so we return unknown and leave the excersise to the caller
609622
return {type: 'unknown'}
610623
}
611-
if (left.type === 'string' && right.type === 'string') {
624+
if (isDateTime(left) && right.type === 'number') {
625+
return dateTimeString()
626+
}
627+
if (left.type === 'number' && isDateTime(right)) {
628+
return dateTimeString()
629+
}
630+
if (isString(left) && isString(right)) {
612631
return {
613632
type: 'string',
614633
value:
@@ -617,15 +636,6 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
617636
: undefined,
618637
}
619638
}
620-
// datetime + number -> datetime (datetimes are represented as strings with STRING_TYPE_DATETIME marker)
621-
if (left.type === 'string' && left[STRING_TYPE_DATETIME] && right.type === 'number') {
622-
return {type: 'string', [STRING_TYPE_DATETIME]: true}
623-
}
624-
// number + datetime -> datetime (commutative)
625-
if (left.type === 'number' && right.type === 'string' && right[STRING_TYPE_DATETIME]) {
626-
return {type: 'string', [STRING_TYPE_DATETIME]: true}
627-
}
628-
629639
if (left.type === 'number' && right.type === 'number') {
630640
return {
631641
type: 'number',
@@ -653,12 +663,29 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
653663
return {type: 'null'}
654664
}
655665
case '-': {
656-
if (left.type === 'unknown' || right.type === 'unknown') {
657-
return nullUnion({type: 'number'})
666+
if (isDateTime(left) && isDateTime(right)) {
667+
return {type: 'number'}
658668
}
659-
// datetime - number -> datetime (datetimes are represented as strings with STRING_TYPE_DATETIME marker)
660-
if (left.type === 'string' && left[STRING_TYPE_DATETIME] && right.type === 'number') {
661-
return {type: 'string', [STRING_TYPE_DATETIME]: true}
669+
// datetime - unknown could be datetime (if unknown is number) or number (if unknown is datetime)
670+
if (isDateTime(left) && right.type === 'unknown') {
671+
return nullUnion({
672+
type: 'union',
673+
of: [{type: 'number'}, dateTimeString()],
674+
})
675+
}
676+
// datetime - number -> datetime
677+
if (isDateTime(left) && right.type === 'number') {
678+
return dateTimeString()
679+
}
680+
// unknown - unknown could be number (if both are datetime or number) or datetime (if datetime - number)
681+
if (left.type === 'unknown') {
682+
return nullUnion({
683+
type: 'union',
684+
of: [{type: 'number'}, dateTimeString()],
685+
})
686+
}
687+
if (right.type === 'unknown') {
688+
return nullUnion({type: 'number'})
662689
}
663690
if (left.type === 'number' && right.type === 'number') {
664691
return {

src/typeEvaluator/typeHelpers.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
import type {ExprNode} from '../nodeTypes'
22
import {optimizeUnions} from './optimizations'
33
import type {Scope} from './scope'
4-
import type {
5-
ArrayTypeNode,
6-
BooleanTypeNode,
7-
InlineTypeNode,
8-
NullTypeNode,
9-
NumberTypeNode,
10-
ObjectAttribute,
11-
ObjectTypeNode,
12-
StringTypeNode,
13-
TypeNode,
14-
UnionTypeNode,
15-
UnknownTypeNode,
4+
import {
5+
type ArrayTypeNode,
6+
type BooleanTypeNode,
7+
type InlineTypeNode,
8+
type NullTypeNode,
9+
type NumberTypeNode,
10+
type ObjectAttribute,
11+
type ObjectTypeNode,
12+
STRING_TYPE_DATETIME,
13+
type StringTypeNode,
14+
type TypeNode,
15+
type UnionTypeNode,
16+
type UnknownTypeNode,
1617
} from './types'
1718

1819
/**
@@ -78,6 +79,13 @@ export function unionOf(...nodes: TypeNode[]): UnionTypeNode {
7879
} satisfies UnionTypeNode
7980
}
8081

82+
export function dateTimeString(): StringTypeNode {
83+
return {
84+
type: 'string',
85+
[STRING_TYPE_DATETIME]: true,
86+
}
87+
}
88+
8189
export type ConcreteTypeNode =
8290
| BooleanTypeNode
8391
| NullTypeNode
@@ -137,6 +145,20 @@ export function isFuncCall(node: ExprNode, name: string): boolean {
137145
return node.type === 'FuncCall' && `${node.namespace}::${node.name}` === name
138146
}
139147

148+
export function isString(
149+
node: TypeNode,
150+
): node is StringTypeNode & {[STRING_TYPE_DATETIME]?: undefined} {
151+
if (node.type === 'string' && !node[STRING_TYPE_DATETIME]) return true
152+
return false
153+
}
154+
155+
export function isDateTime(
156+
node: TypeNode,
157+
): node is StringTypeNode & {[STRING_TYPE_DATETIME]: true} {
158+
if (node.type === 'string' && node[STRING_TYPE_DATETIME]) return true
159+
return false
160+
}
161+
140162
export function createGeoJson(type: 'Point' | 'LineString' | 'Polygon' = 'Point'): ObjectTypeNode {
141163
let coordinateAttribute: ArrayTypeNode = {
142164
type: 'array',

test/typeEvaluateCompare.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {type GroqFunction, namespaces} from '../src/evaluator/functions'
66
import {operators} from '../src/evaluator/operators'
77
import type {ExprNode, OpCall} from '../src/nodeTypes'
88
import type {TypeNode} from '../src/typeEvaluator'
9+
import {DateTime} from '../src/typeEvaluator'
910
import {satisfies} from '../src/typeEvaluator/satisfies'
1011
import {overrideTypeForNode, typeEvaluate} from '../src/typeEvaluator/typeEvaluate'
12+
import {STRING_TYPE_DATETIME} from '../src/typeEvaluator/types'
1113

1214
/**
1315
* The following tests uses the following strategy:
@@ -106,6 +108,11 @@ const primitives: AnnotatedValue[] = [
106108
{desc: 'boolean(undefined)', type: {type: 'boolean'}},
107109
],
108110
},
111+
{
112+
key: 'datetime',
113+
value: new DateTime(new Date('2024-01-01T00:00:00.000Z')),
114+
types: [{desc: 'dateTime', type: {type: 'string', [STRING_TYPE_DATETIME]: true}}],
115+
},
109116
]
110117

111118
/**

0 commit comments

Comments
 (0)