Skip to content

Commit 93e4335

Browse files
authored
fix(evaluator): handle numerical addition/subtraction from datetime (#316)
### Description Adds support for add/subtract operations where the left operand is a datetime string and the right operand is a number. If a valid datetime string is passed the type is a string, while for any invalid datatime values the type is null. Based on this we return a `string-null` union. This fixes the underlying issue reported in sanity-io/sanity#8255 where the generated type for the following query would not be correct: ``` (dateTime("2025-03-01T00:00:00Z") + 60) > dateTime("2024-03-01T00:00:00Z") ``` it now yields a `boolean-null` union, as is correct since an invalid dateTime string would result in `null` any comparison operation that includes a `null` operand evaluates to `null`. https://linear.app/sanity/issue/CLDX-3609/groq-js-support-datetime-numerical-operations-in-type-evaluator ### What to review The code ### Testing Have tested the whole typegen flow in a local project with both a simple subtraction and a subtraction as part of a comparison operation. Also added a test for the case that failed before, with `dateTime(...) - number`.
1 parent 1c67668 commit 93e4335

File tree

4 files changed

+45
-19
lines changed

4 files changed

+45
-19
lines changed

src/typeEvaluator/functions.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {optimizeUnions} from './optimizations'
44
import type {Scope} from './scope'
55
import {walk} from './typeEvaluate'
66
import {createGeoJson, mapNode, nullUnion} from './typeHelpers'
7-
import type {NullTypeNode, TypeNode} from './types'
7+
import {STRING_TYPE_DATETIME, type NullTypeNode, type TypeNode} from './types'
88

99
function unionWithoutNull(unionTypeNode: TypeNode): TypeNode {
1010
if (unionTypeNode.type === 'union') {
@@ -140,10 +140,10 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
140140
})
141141
}
142142
case 'dateTime.now': {
143-
return {type: 'string'}
143+
return {type: 'string', [STRING_TYPE_DATETIME]: true}
144144
}
145145
case 'global.now': {
146-
return {type: 'string'}
146+
return {type: 'string', [STRING_TYPE_DATETIME]: true}
147147
}
148148
case 'global.defined': {
149149
const arg = walk({node: node.args[0], scope})
@@ -233,11 +233,12 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode {
233233

234234
return mapNode(arg, scope, (arg) => {
235235
if (arg.type === 'unknown') {
236-
return nullUnion({type: 'string'})
236+
return nullUnion({type: 'string', [STRING_TYPE_DATETIME]: true})
237237
}
238238

239239
if (arg.type === 'string') {
240-
return nullUnion({type: 'string'}) // we don't know wether the string is a valid date or not, so we return a [null, string]-union
240+
// 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})
241242
}
242243

243244
return {type: 'null'} satisfies NullTypeNode

src/typeEvaluator/typeEvaluate.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,21 @@ import {match} from './matching'
3333
import {optimizeUnions} from './optimizations'
3434
import {Context, Scope} from './scope'
3535
import {isFuncCall, mapNode, nullUnion, resolveInline} from './typeHelpers'
36-
import type {
37-
ArrayTypeNode,
38-
BooleanTypeNode,
39-
Document,
40-
NullTypeNode,
41-
NumberTypeNode,
42-
ObjectAttribute,
43-
ObjectTypeNode,
44-
PrimitiveTypeNode,
45-
Schema,
46-
StringTypeNode,
47-
TypeNode,
48-
UnionTypeNode,
49-
UnknownTypeNode,
36+
import {
37+
STRING_TYPE_DATETIME,
38+
type ArrayTypeNode,
39+
type BooleanTypeNode,
40+
type Document,
41+
type NullTypeNode,
42+
type NumberTypeNode,
43+
type ObjectAttribute,
44+
type ObjectTypeNode,
45+
type PrimitiveTypeNode,
46+
type Schema,
47+
type StringTypeNode,
48+
type TypeNode,
49+
type UnionTypeNode,
50+
type UnknownTypeNode,
5051
} from './types'
5152

5253
const $trace = debug('typeEvaluator:evaluate:trace')
@@ -616,6 +617,10 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
616617
: undefined,
617618
}
618619
}
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+
}
619624

620625
if (left.type === 'number' && right.type === 'number') {
621626
return {
@@ -647,6 +652,10 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode {
647652
if (left.type === 'unknown' || right.type === 'unknown') {
648653
return nullUnion({type: 'number'})
649654
}
655+
// datetime - number -> datetime (datetimes are represented as strings with STRING_TYPE_DATETIME marker)
656+
if (left.type === 'string' && left[STRING_TYPE_DATETIME] && right.type === 'number') {
657+
return {type: 'string', [STRING_TYPE_DATETIME]: true}
658+
}
650659
if (left.type === 'number' && right.type === 'number') {
651660
return {
652661
type: 'number',

src/typeEvaluator/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ export interface TypeDeclaration {
2121
/** A schema consisting of a list of Document or TypeDeclaration items, allowing for complex type definitions. */
2222
export type Schema = (Document | TypeDeclaration)[]
2323

24+
/** Symbol to mark a string type as a datetime */
25+
export const STRING_TYPE_DATETIME = Symbol('groq-js.type.string_datetime')
26+
2427
/** Describes a type node for string values, optionally including a value. If a value is provided it will always be the given string value. */
2528
export interface StringTypeNode {
2629
/** can be used to identify the type of the node, in this case it's always 'string' */
2730
type: 'string'
2831
/** an optional value of the string, if provided it will always be the given string value */
2932
value?: string
33+
/** marks this string as a datetime type for arithmetic operations */
34+
[STRING_TYPE_DATETIME]?: true
3035
}
3136

3237
/** Describes a type node for number values, optionally including a value. If a value is provided it will always be the given numeric value.*/

test/typeEvaluate.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3735,6 +3735,17 @@ t.test('splatting array', (t) => {
37353735
t.end()
37363736
})
37373737

3738+
t.test('dateTime with numerical operation', (t) => {
3739+
const query = `(global::dateTime("2025-03-01T00:00:00Z") + 60) > global::dateTime("2024-03-01T00:00:00Z")`
3740+
const ast = parse(query)
3741+
const res = typeEvaluate(ast, schemas)
3742+
t.same(res, {
3743+
type: 'union',
3744+
of: [{type: 'boolean', value: undefined}, {type: 'null'}],
3745+
})
3746+
t.end()
3747+
})
3748+
37383749
function findSchemaType(name: string): TypeNode {
37393750
const type = schemas.find((s) => s.name === name)
37403751
if (!type) {

0 commit comments

Comments
 (0)