From 8ad70a8d9cc12c6ab5b1a71424fe069e183d2c82 Mon Sep 17 00:00:00 2001 From: Sindre Gulseth Date: Sun, 18 Aug 2024 22:13:12 +0200 Subject: [PATCH] refactor(typeEvaluator): remove resolve condition, use walk to determine conditions --- src/typeEvaluator/functions.ts | 10 + src/typeEvaluator/matching.ts | 79 +++ src/typeEvaluator/typeEvaluate.ts | 645 ++++++++---------- src/typeEvaluator/typeHelpers.ts | 54 +- .../test/typeEvaluate.test.ts.test.cjs | 15 +- test/typeEvaluate.test.ts | 35 +- test/typeEvaluateCompare.test.ts | 38 +- 7 files changed, 477 insertions(+), 399 deletions(-) create mode 100644 src/typeEvaluator/matching.ts diff --git a/src/typeEvaluator/functions.ts b/src/typeEvaluator/functions.ts index daaae63c..b06374d2 100644 --- a/src/typeEvaluator/functions.ts +++ b/src/typeEvaluator/functions.ts @@ -113,6 +113,16 @@ export function handleFuncCallNode(node: FuncCallNode, scope: Scope): TypeNode { case 'global.defined': { return {type: 'boolean'} } + case 'global.path': { + const arg = walk({node: node.args[0], scope}) + return mapConcrete(arg, scope, (arg) => { + if (arg.type === 'string') { + return {type: 'string'} + } + + return {type: 'null'} + }) + } case 'global.coalesce': { if (node.args.length === 0) { return {type: 'null'} satisfies NullTypeNode diff --git a/src/typeEvaluator/matching.ts b/src/typeEvaluator/matching.ts new file mode 100644 index 00000000..92a185b0 --- /dev/null +++ b/src/typeEvaluator/matching.ts @@ -0,0 +1,79 @@ +import { + matchAnalyzePattern, + matchText, + matchTokenize, + type Pattern, + type Token, +} from '../evaluator/matching' +import type {ConcreteTypeNode} from './typeHelpers' + +export function match(left: ConcreteTypeNode, right: ConcreteTypeNode): boolean | undefined { + let tokens: Token[] = [] + let patterns: Pattern[] = [] + if (left.type === 'string') { + if (left.value === undefined) { + return undefined + } + tokens = tokens.concat(matchTokenize(left.value)) + } + if (left.type === 'array') { + if (left.of.type === 'unknown') { + return undefined + } + if (left.of.type === 'string') { + // eslint-disable-next-line max-depth + if (left.of.value === undefined) { + return undefined + } + + tokens = tokens.concat(matchTokenize(left.of.value)) + } + if (left.of.type === 'union') { + // eslint-disable-next-line max-depth + for (const node of left.of.of) { + // eslint-disable-next-line max-depth + if (node.type === 'string' && node.value !== undefined) { + tokens = tokens.concat(matchTokenize(node.value)) + } + } + } + } + + if (right.type === 'string') { + if (right.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(right.value)) + } + if (right.type === 'array') { + if (right.of.type === 'unknown') { + return undefined + } + if (right.of.type === 'string') { + // eslint-disable-next-line max-depth + if (right.of.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(right.of.value)) + } + if (right.of.type === 'union') { + // eslint-disable-next-line max-depth + for (const node of right.of.of) { + // eslint-disable-next-line max-depth + if (node.type === 'string') { + // eslint-disable-next-line max-depth + if (node.value === undefined) { + return undefined + } + patterns = patterns.concat(matchAnalyzePattern(node.value)) + } + + // eslint-disable-next-line max-depth + if (node.type !== 'string') { + return false + } + } + } + } + return matchText(tokens, patterns) +} diff --git a/src/typeEvaluator/typeEvaluate.ts b/src/typeEvaluator/typeEvaluate.ts index 98421e8b..673a52b1 100644 --- a/src/typeEvaluator/typeEvaluate.ts +++ b/src/typeEvaluator/typeEvaluate.ts @@ -1,15 +1,9 @@ import debug from 'debug' -import { - matchAnalyzePattern, - matchText, - matchTokenize, - type Pattern, - type Token, -} from '../evaluator/matching' import type { AccessAttributeNode, AccessElementNode, + AndNode, ArrayCoerceNode, ArrayNode, DerefNode, @@ -23,7 +17,9 @@ import type { ObjectConditionalSplatNode, ObjectNode, ObjectSplatNode, + OpCall, OpCallNode, + OrNode, ParentNode, PosNode, ProjectionNode, @@ -32,8 +28,10 @@ import type { ValueNode, } from '../nodeTypes' import {handleFuncCallNode} from './functions' +import {match} from './matching' import {optimizeUnions} from './optimizations' import {Context, Scope} from './scope' +import {isFuncCall, mapConcrete, mapUnion, nullUnion, resolveInline} from './typeHelpers' import type { ArrayTypeNode, BooleanTypeNode, @@ -49,7 +47,6 @@ import type { UnionTypeNode, UnknownTypeNode, } from './types' -import {mapConcrete, nullUnion, reduceUnion, resolveInline} from './typeHelpers' const $trace = debug('typeEvaluator:evaluate:trace') $trace.log = console.log.bind(console) // eslint-disable-line no-console @@ -223,7 +220,7 @@ function handleObjectNode(node: ObjectNode, scope: Scope): TypeNode { } if (attr.type === 'ObjectConditionalSplat') { - const condition = resolveCondition(attr.condition, scope) + const condition = booleanValue(walk({node: attr.condition, scope})) $trace('object.conditional.splat.condition %O', condition) // condition is never met, skip this attribute if (condition === false) { @@ -460,55 +457,149 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { $trace('opcall.node %O', node) const lhs = walk({node: node.left, scope}) const rhs = walk({node: node.right, scope}) - return mapConcrete(lhs, scope, (left) => - // eslint-disable-next-line complexity - mapConcrete(rhs, scope, (right) => { + return mapUnion(lhs, scope, (left) => + // eslint-disable-next-line complexity, max-statements + mapUnion(rhs, scope, (right) => { $trace('opcall.node.concrete "%s" %O', node.op, {left, right}) switch (node.op) { - case '==': + case '==': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'boolean'} + } + if (left.type !== right.type) { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + if (left.type === 'null') { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + return { + type: 'boolean', + value: evaluateComparison(node.op, left, right), + } satisfies BooleanTypeNode + } case '!=': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'boolean'} + } + if (left.type !== right.type) { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + if (left.type === 'null') { + return { + type: 'boolean', + value: false, + } satisfies BooleanTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return { + type: 'boolean', + value: true, + } satisfies BooleanTypeNode + } + + let value = evaluateComparison('==', left, right) + if (value !== undefined) value = !value return { type: 'boolean', - value: resolveCondition(node, scope), + value, } satisfies BooleanTypeNode } case '>': case '>=': case '<': case '<=': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type !== right.type) { + return {type: 'null'} satisfies NullTypeNode + } + if (!isPrimitiveTypeNode(left) || !isPrimitiveTypeNode(right)) { + return {type: 'null'} satisfies NullTypeNode + } + return { + type: 'boolean', + value: evaluateComparison(node.op, left, right), + } satisfies BooleanTypeNode + } + case 'in': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } + if (right.type !== 'array') { + // Special case for global::path, since it can be used with in operator, but the type returned otherwise is a string + if (isFuncCall(node.right, 'global::path')) { + return {type: 'boolean'} + } return {type: 'null'} } - if (isPrimitiveTypeNode(left)) { - const resolved = resolveCondition(node, scope) + if (!isPrimitiveTypeNode(left) && left.type !== 'null') { return { type: 'boolean', - value: resolved, + value: false, } satisfies BooleanTypeNode } + return mapConcrete(right.of, scope, (arrayTypeNode) => { + if (left.type === 'null') { + return { + type: 'boolean', + value: arrayTypeNode.type === 'null', + } satisfies BooleanTypeNode + } + + if (left.value === undefined) { + return { + type: 'boolean', + } satisfies BooleanTypeNode + } + if (isPrimitiveTypeNode(arrayTypeNode)) { + if (arrayTypeNode.value === undefined) { + return { + type: 'boolean', + } satisfies BooleanTypeNode + } + + return { + type: 'boolean', + value: left.value === arrayTypeNode.value, + } satisfies BooleanTypeNode + } - return {type: 'null'} - } - case 'in': { - if (right.type === 'array') { - const resolved = resolveCondition(node, scope) return { type: 'boolean', - value: resolved, + value: false, } satisfies BooleanTypeNode - } - return {type: 'null'} + }) } case 'match': { - const resolved = resolveCondition(node, scope) - + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } return { type: 'boolean', - value: resolved, + value: match(left, right), } satisfies BooleanTypeNode } case '+': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'string' && right.type === 'string') { return { type: 'string', @@ -546,6 +637,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '-': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -558,6 +652,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '*': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -570,6 +667,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '/': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -582,6 +682,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '**': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -594,6 +697,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } case '%': { + if (left.type === 'unknown' || right.type === 'unknown') { + return {type: 'unknown'} + } if (left.type === 'number' && right.type === 'number') { return { type: 'number', @@ -606,6 +712,9 @@ function handleOpCallNode(node: OpCallNode, scope: Scope): TypeNode { return {type: 'null'} } default: { + // make sure we handle all cases + node.op satisfies never + return { type: 'unknown', } satisfies UnknownTypeNode @@ -866,28 +975,38 @@ function handleParentNode({n}: ParentNode, scope: Scope): TypeNode { function handleNotNode(node: NotNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type === 'boolean' && base.value !== undefined) { - return {type: 'boolean', value: base.value === false} - } - return {type: 'boolean'} + return mapConcrete(base, scope, (base) => { + if (base.type === 'boolean') { + if (base.value !== undefined) { + return {type: 'boolean', value: base.value === false} + } + return {type: 'boolean'} + } + + return {type: 'null'} + }) } -function handleNegNode(node: NegNode, scope: Scope): NumberTypeNode | NullTypeNode { +function handleNegNode(node: NegNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type !== 'number') { - return {type: 'null'} - } - if (base.value !== undefined) { - return {type: 'number', value: -base.value} - } - return base + return mapConcrete(base, scope, (base) => { + if (base.type !== 'number') { + return {type: 'null'} + } + if (base.value !== undefined) { + return {type: 'number', value: -base.value} + } + return base + }) } -function handlePosNode(node: PosNode, scope: Scope): NumberTypeNode | NullTypeNode { +function handlePosNode(node: PosNode, scope: Scope): TypeNode { const base = walk({node: node.base, scope}) - if (base.type !== 'number') { - return {type: 'null'} - } - return base + return mapConcrete(base, scope, (base) => { + if (base.type !== 'number') { + return {type: 'null'} + } + return base + }) } function handleEverythingNode(_: EverythingNode, scope: Scope): TypeNode { @@ -905,6 +1024,73 @@ function handleEverythingNode(_: EverythingNode, scope: Scope): TypeNode { } satisfies ArrayTypeNode> } +function handleAndNode(node: AndNode, scope: Scope): TypeNode { + const left = walk({node: node.left, scope}) + const right = walk({node: node.right, scope}) + return mapConcrete(left, scope, (lhs) => + mapConcrete(right, scope, (rhs) => { + if ( + (lhs.type === 'boolean' && lhs.value === false) || + (rhs.type === 'boolean' && rhs.value === false) + ) { + return {type: 'boolean', value: false} + } + + if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { + if ( + (lhs.type === 'boolean' && lhs.value === undefined) || + (rhs.type === 'boolean' && rhs.value === undefined) + ) { + return nullUnion({type: 'boolean'}) + } + return {type: 'null'} + } + + if (lhs.value === true && rhs.value === true) { + return {type: 'boolean', value: true} + } + + return {type: 'boolean'} + }), + ) +} + +function handleOrNode(node: OrNode, scope: Scope): TypeNode { + const left = walk({node: node.left, scope}) + const right = walk({node: node.right, scope}) + return mapConcrete(left, scope, (lhs) => + mapConcrete(right, scope, (rhs) => { + // one of the sides is true the condition is true + if ( + (lhs.type === 'boolean' && lhs.value === true) || + (rhs.type === 'boolean' && rhs.value === true) + ) { + return {type: 'boolean', value: true} + } + + // if one of the sides is not a boolean, it's either a null or + // a null|boolean if the other side is an undefined boolean + if (lhs.type !== 'boolean' || rhs.type !== 'boolean') { + if ( + (lhs.type === 'boolean' && lhs.value === undefined) || + (rhs.type === 'boolean' && rhs.value === undefined) + ) { + return nullUnion({type: 'boolean'}) + } + + return {type: 'null'} + } + + // both sides are false, the condition is false + if (lhs.value === false && rhs.value === false) { + return {type: 'boolean', value: false} + } + + return {type: 'boolean'} + }), + ) +} + const OVERRIDE_TYPE_SYMBOL = Symbol('groq-js.type') /** @@ -960,12 +1146,12 @@ export function walk({node, scope}: {node: ExprNode; scope: Scope}): TypeNode { return handleOpCallNode(node, scope) } - case 'And': + case 'And': { + return handleAndNode(node, scope) + } + case 'Or': { - return { - type: 'boolean', - value: resolveCondition(node, scope), - } satisfies BooleanTypeNode + return handleOrNode(node, scope) } case 'Select': { @@ -1047,334 +1233,69 @@ function isPrimitiveTypeNode(node: TypeNode): node is PrimitiveTypeNode { return node.type === 'string' || node.type === 'number' || node.type === 'boolean' } -function evaluateEquality(left: TypeNode, right: TypeNode): boolean | undefined { - $trace('evaluateEquality %O', {left, right}) - - if (left.type === 'null' && right.type === 'null') { - return true +function evaluateComparison( + opcall: OpCall, + left: PrimitiveTypeNode, + right: PrimitiveTypeNode, +): boolean | undefined { + if (left.value === undefined || right.value === undefined) { + return undefined } - - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right) && left.type === right.type) { - if (left.value === undefined || right.value === undefined) { - return undefined + switch (opcall) { + case '==': { + return left.value === right.value } - - return left.value === right.value - } - - if (left.type !== right.type) { - return false - } - return undefined -} - -/** - * Resolves the condition expression and returns a boolean value or undefined. - * Undefined is returned when the condition can't be resolved. - * - * @param expr - The expression node to resolve. - * @param scope - The scope in which the expression is evaluated. - * @returns The resolved boolean value or undefined. - */ - -// eslint-disable-next-line complexity, max-statements -function resolveCondition(expr: ExprNode, scope: Scope): boolean | undefined { - $trace('resolveCondition.expr %O', expr) - - switch (expr.type) { - case 'AccessAttribute': - case 'AccessElement': - case 'Value': { - const value = mapConcrete(walk({node: expr, scope}), scope, (node) => node) - if (value.type === 'boolean') { - return value.value - } - - if (value.type === 'null' || value.type === 'object' || value.type === 'array') { - return false - } - - return undefined + case '<': { + return left.value < right.value } - case 'And': { - const left = resolveCondition(expr.left, scope) - $trace('resolveCondition.and.left %O', left) - if (left === false) { - return false - } - - const right = resolveCondition(expr.right, scope) - $trace('resolveCondition.and.right %O', right) - if (right === false) { - return false - } - - if (left === undefined || right === undefined) { - return undefined - } - - return true + case '<=': { + return left.value <= right.value } - case 'Or': { - $trace('resolveCondition.or.expr %O', expr) - const left = resolveCondition(expr.left, scope) - $trace('resolveCondition.or.left %O', left) - if (left === true) { - return true - } - - const right = resolveCondition(expr.right, scope) - $trace('resolveCondition.or.right %O', right) - if (right === true) { - return true - } - if (left === undefined || right === undefined) { - return undefined - } - - return false + case '>': { + return left.value > right.value } - case 'OpCall': { - const left = walk({node: expr.left, scope}) - const right = walk({node: expr.right, scope}) - $trace('opcall "%s" %O', expr.op, {left, right}) - - return reduceUnion( - left, - (lhsCurrent, left) => { - if (lhsCurrent) { - return lhsCurrent - } - if (left.type === 'unknown') { - return undefined - } - return reduceUnion( - right, - // eslint-disable-next-line max-statements, complexity - (rhsCurrent, right) => { - if (rhsCurrent) { - return rhsCurrent - } - if (right.type === 'unknown') { - return undefined - } - - switch (expr.op) { - case '==': { - const isEq = evaluateEquality(left, right) - if (isEq !== false) { - return isEq - } - return rhsCurrent - } - case '!=': { - const result = evaluateEquality(left, right) - if (result === undefined) { - return undefined - } - if (!result) { - return true - } - return rhsCurrent - } - case 'in': { - if (right.type !== 'array') { - return rhsCurrent - } - const lhs = left satisfies TypeNode - const rhs = right satisfies ArrayTypeNode - - // we need null or a primitive type on the left side - if (!isPrimitiveTypeNode(lhs) && lhs.type !== 'null') { - return rhsCurrent - } - - // reduce over the array node, it can be an union, so we need to check each type - return reduceUnion( - rhs.of, - (curr, arrayTypeNode) => { - if (curr === true) { - return curr - } - if (arrayTypeNode.type === 'unknown') { - return undefined - } - - if (lhs.type === 'null') { - if (arrayTypeNode.type === 'null') { - return true - } - - return curr - } - - if (lhs.value === undefined) { - return undefined - } - - if (isPrimitiveTypeNode(arrayTypeNode)) { - if (arrayTypeNode.value === undefined) { - return undefined - } - if (lhs.value === arrayTypeNode.value) { - return true - } - } - - // no match - return curr - }, - rhsCurrent, - ) - } - case 'match': { - let tokens: Token[] = [] - let patterns: Pattern[] = [] - if (left.type === 'string') { - if (left.value === undefined) { - return undefined - } - tokens = tokens.concat(matchTokenize(left.value)) - } - if (left.type === 'array') { - if (left.of.type === 'unknown') { - return undefined - } - if (left.of.type === 'string') { - // eslint-disable-next-line max-depth - if (left.of.value === undefined) { - return undefined - } - - tokens = tokens.concat(matchTokenize(left.of.value)) - } - if (left.of.type === 'union') { - // eslint-disable-next-line max-depth - for (const node of left.of.of) { - // eslint-disable-next-line max-depth - if (node.type === 'string' && node.value !== undefined) { - tokens = tokens.concat(matchTokenize(node.value)) - } - } - } - } - - if (right.type === 'string') { - if (right.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(right.value)) - } - if (right.type === 'array') { - if (right.of.type === 'unknown') { - return undefined - } - if (right.of.type === 'string') { - // eslint-disable-next-line max-depth - if (right.of.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(right.of.value)) - } - if (right.of.type === 'union') { - // eslint-disable-next-line max-depth - for (const node of right.of.of) { - // eslint-disable-next-line max-depth - if (node.type === 'string') { - // eslint-disable-next-line max-depth - if (node.value === undefined) { - return undefined - } - patterns = patterns.concat(matchAnalyzePattern(node.value)) - } - - // eslint-disable-next-line max-depth - if (node.type !== 'string') { - return false - } - } - } - } - return matchText(tokens, patterns) - } - case '<': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value < right.value - } - - return undefined - } - case '<=': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value <= right.value - } - - return undefined - } - case '>': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value > right.value - } - - return undefined - } - case '>=': { - if (isPrimitiveTypeNode(left) && isPrimitiveTypeNode(right)) { - if (left.value === undefined || right.value === undefined) { - return undefined - } - return left.value >= right.value - } - - return undefined - } - - default: { - return undefined - } - } - }, - lhsCurrent, - ) - }, - false, - ) + case '>=': { + return left.value >= right.value } - - case 'Not': { - const result = resolveCondition(expr.base, scope) - // check if the result is undefined or false. Undefined means that the condition can't be resolved, and we should keep the node - return result === undefined ? undefined : result === false + default: { + throw new Error(`unknown comparison operator ${opcall}`) } + } +} - case 'Group': { - return resolveCondition(expr.base, scope) - } +function booleanValue(node: TypeNode): boolean | undefined { + // if the node is unknown, we can't match it so we return undefined + if (node.type === 'unknown') { + return undefined + } - default: { - return undefined + // if the node is a boolean, we can match it, reuse the value + if (node.type === 'boolean') { + return node.value + } + + if (node.type === 'union') { + for (const sub of node.of) { + const match = booleanValue(sub) + if (match !== false) { + return match + } } } + + return false } // eslint-disable-next-line complexity, max-statements function resolveFilter(expr: ExprNode, scope: Scope): UnionTypeNode { $trace('resolveFilter.expr %O', expr) - const filtered = scope.value.of.filter( - (node) => - // create a new scope with the current scopes parent as the parent. It's only a temporary scope since we only want to resolve the condition - // check if the result is true or undefined. Undefined means that the condition can't be resolved, and we should keep the node - resolveCondition(expr, scope.createHidden([node])) !== false, - ) + const filtered = scope.value.of.filter((node) => { + // create a new scope with the current scopes parent as the parent. It's only a temporary scope since we only want to resolve the condition + // and check if the result is "matchable" + const cond = walk({node: expr, scope: scope.createHidden([node])}) + const isMatch = booleanValue(cond) + return isMatch === undefined || isMatch === true + }) $trace( `resolveFilter ${expr.type === 'OpCall' ? `${expr.type}/${expr.op}` : expr.type} %O`, filtered, diff --git a/src/typeEvaluator/typeHelpers.ts b/src/typeEvaluator/typeHelpers.ts index 646cc644..19953acb 100644 --- a/src/typeEvaluator/typeHelpers.ts +++ b/src/typeEvaluator/typeHelpers.ts @@ -1,3 +1,4 @@ +import type {ExprNode} from '../nodeTypes' import {optimizeUnions} from './optimizations' import type {Scope} from './scope' import type { @@ -11,6 +12,7 @@ import type { StringTypeNode, TypeNode, UnionTypeNode, + UnknownTypeNode, } from './types' /** @@ -99,7 +101,7 @@ export function resolveInline(node: TypeNode, scope: Scope): Exclude TypeNode, mergeUnions: (nodes: TypeNode[]) => TypeNode = (nodes) => optimizeUnions({type: 'union', of: nodes}), +): TypeNode { + return mapUnion( + node, + scope, + (node) => { + if (node.type === 'unknown') { + return node + } + return mapper(node) + }, + mergeUnions, + ) +} + +/** + * mapUnion extracts a _concrete type_ OR an unknown type from a type node, applies the mapping + * function to it and returns. Most notably, this will work through unions + * (applying the mapping function for each variant) and inline (resolving the + * reference). + * This method should _only_ be used if you need to handle unknown types, ie when resolving two sides of an and node, and we don't want to abort if one side is unknown. + * In most cases, you should use `mapConcrete` instead. + **/ +export function mapUnion( + node: TypeNode, + scope: Scope, + mapper: (node: ConcreteTypeNode | UnknownTypeNode) => T, + mergeUnions: (nodes: TypeNode[]) => TypeNode = (nodes) => + optimizeUnions({type: 'union', of: nodes}), ): TypeNode { switch (node.type) { case 'boolean': @@ -118,14 +148,13 @@ export function mapConcrete( case 'object': case 'string': case 'number': - return mapper(node) case 'unknown': - return node + return mapper(node) case 'union': - return mergeUnions(node.of.map((inner) => mapConcrete(inner, scope, mapper), mergeUnions)) + return mergeUnions(node.of.map((inner) => mapUnion(inner, scope, mapper), mergeUnions)) case 'inline': { const resolvedInline = resolveInline(node, scope) - return mapConcrete(resolvedInline, scope, mapper, mergeUnions) + return mapUnion(resolvedInline, scope, mapper, mergeUnions) } default: // @ts-expect-error - all types should be handled @@ -133,15 +162,10 @@ export function mapConcrete( } } -export function reduceUnion( - node: TypeNode, - callbackfn: (acc: T, node: TypeNode, currentIndex: number, array: TypeNode[]) => T, - initialValue: T, -): T { - const _node = optimizeUnions(node) - - if (_node.type !== 'union') { - return callbackfn(initialValue, _node, 0, [_node]) +export function isFuncCall(node: ExprNode, name: string): boolean { + if (node.type === 'Group') { + return isFuncCall(node.base, name) } - return _node.of.reduce(callbackfn, initialValue) + + return node.type === 'FuncCall' && `${node.namespace}::${node.name}` === name } diff --git a/tap-snapshots/test/typeEvaluate.test.ts.test.cjs b/tap-snapshots/test/typeEvaluate.test.ts.test.cjs index 5422281d..af61a8f5 100644 --- a/tap-snapshots/test/typeEvaluate.test.ts.test.cjs +++ b/tap-snapshots/test/typeEvaluate.test.ts.test.cjs @@ -1131,11 +1131,10 @@ exports[`test/typeEvaluate.test.ts TAP misc > must match snapshot 1`] = ` Object { "of": Object { "attributes": Object { - "andWithAttriute": Object { + "andWithAttribute": Object { "type": "objectAttribute", "value": Object { "type": "boolean", - "value": true, }, }, "group": Object { @@ -1158,6 +1157,18 @@ Object { "type": "boolean", }, }, + "notMissingAttribute": Object { + "type": "objectAttribute", + "value": Object { + "type": "null", + }, + }, + "notNumber": Object { + "type": "objectAttribute", + "value": Object { + "type": "null", + }, + }, "pt": Object { "type": "objectAttribute", "value": Object { diff --git a/test/typeEvaluate.test.ts b/test/typeEvaluate.test.ts index b974dd29..5eed3561 100644 --- a/test/typeEvaluate.test.ts +++ b/test/typeEvaluate.test.ts @@ -735,8 +735,9 @@ t.test('values in projection', (t) => { "exp": 3 ** 3, "mod": 3 % 2, "arr": [1, 2, 3] + [4, 5, 6], - "and": 3 > foo && 3 > bar, - "or": 3 > foo || 3 > bar, + "andNotExists": 3 > foo && 3 > bar, + "and": 3 > age && age < 5, + "or": 3 > age || 3 > bar, }` const ast = parse(query) const res = typeEvaluate(ast, schemas) @@ -885,19 +886,23 @@ t.test('values in projection', (t) => { }, }, }, + andNotExists: { + type: 'objectAttribute', + value: { + type: 'null', + }, + }, and: { type: 'objectAttribute', value: { type: 'boolean', - value: undefined, }, }, or: { type: 'objectAttribute', - value: { + value: nullUnion({ type: 'boolean', - value: undefined, - }, + }), }, }, }, @@ -1364,7 +1369,7 @@ t.test('with select, not guaranteed & with fallback', (t) => { const query = `*[_type == "author" || _type == "post"] { _type, "something": select( - _id > 5 => _id, + _id == "5" => _id, "old id" ) }` @@ -1723,14 +1728,24 @@ t.test('misc', (t) => { const query = `*[]{ "group": ((3 + 4) * 5), "notBool": !false, - "notField": !someAttriute, + "notField": !someAttribute, + "notNumber": !34, + "notMissingAttribute": !missingAttribute, "unknownParent": ^._id, "unknownParent2": ^.^.^.^.^.^.^.^._id, - "andWithAttriute": !false && !someAttriute, + "andWithAttribute": !false && !someAttribute, "pt": pt::text(block) }` const ast = parse(query) - const res = typeEvaluate(ast, [{type: 'document', name: 'foo', attributes: {}}]) + const res = typeEvaluate(ast, [ + { + type: 'document', + name: 'foo', + attributes: { + someAttribute: {type: 'objectAttribute', value: {type: 'boolean'}}, + }, + }, + ]) t.matchSnapshot(res) t.end() diff --git a/test/typeEvaluateCompare.test.ts b/test/typeEvaluateCompare.test.ts index af66d33a..3b37a0b5 100644 --- a/test/typeEvaluateCompare.test.ts +++ b/test/typeEvaluateCompare.test.ts @@ -78,32 +78,32 @@ const primitives: AnnotatedValue[] = [ key: '1', value: 1, types: [ - {desc: '1', type: {type: 'number', value: 1}}, - {desc: 'number', type: {type: 'number'}}, + {desc: 'number(1)', type: {type: 'number', value: 1}}, + {desc: 'number(undefined)', type: {type: 'number'}}, ], }, { key: 'hello', value: 'hello', types: [ - {desc: 'hello', type: {type: 'string', value: 'hello'}}, - {desc: 'string', type: {type: 'string'}}, + {desc: 'string(hello)', type: {type: 'string', value: 'hello'}}, + {desc: 'string(undefined)', type: {type: 'string'}}, ], }, { key: 'true', value: true, types: [ - {desc: 'true', type: {type: 'boolean', value: true}}, - {desc: 'boolean', type: {type: 'boolean'}}, + {desc: 'boolean(true)', type: {type: 'boolean', value: true}}, + {desc: 'boolean(undefined)', type: {type: 'boolean'}}, ], }, { key: 'false', value: false, types: [ - {desc: 'false', type: {type: 'boolean', value: false}}, - {desc: 'boolean', type: {type: 'boolean'}}, + {desc: 'boolean(false)', type: {type: 'boolean', value: false}}, + {desc: 'boolean(undefined)', type: {type: 'boolean'}}, ], }, ] @@ -207,6 +207,8 @@ const ALL_CATEGORIES = [ Category.ARRAYS_IN_ARRAYS, ] +const trivialVariant = [Category.PRIMITIVES, Category.OBJECT, Category.ARRAY] + type CachedResult = { params: ExprNode[] node: ExprNode @@ -379,13 +381,29 @@ t.test('Slice', async (t) => { }) }) +t.test(`And`, async (t) => { + subtestBinary({ + t, + variants1: trivialVariant, + variants2: trivialVariant, + build: (left, right) => ({type: 'And', left, right}), + }) +}) + +t.test(`Or`, async (t) => { + subtestBinary({ + t, + variants1: trivialVariant, + variants2: trivialVariant, + build: (left, right) => ({type: 'Or', left, right}), + }) +}) + // It's too much to test _every_ possible combination of binary operations. For // each operator we therefore keep track of which variants are interesting to // test for. We already know that many operations don't care about deeply nested // objects/arrays so we avoid testing for those. -const trivialVariant = [Category.PRIMITIVES, Category.OBJECT, Category.ARRAY] - const opVariants: Record = { // + is very polymorphic so we want to test it for everything. '+': ALL_CATEGORIES,