diff --git a/src/1.ts b/src/1.ts index 8d25872..179f430 100644 --- a/src/1.ts +++ b/src/1.ts @@ -1,4 +1,4 @@ -export {evaluate} from './evaluator' +export {evaluate, evaluateSync} from './evaluator' export type {GroqFunction, GroqFunctionArg, GroqPipeFunction} from './evaluator/functions' export type {Scope} from './evaluator/scope' export type { diff --git a/src/evaluator/constantEvaluate.ts b/src/evaluator/constantEvaluate.ts index 6890b8c..776f1a8 100644 --- a/src/evaluator/constantEvaluate.ts +++ b/src/evaluator/constantEvaluate.ts @@ -1,5 +1,5 @@ import type {ExprNode} from '../nodeTypes' -import {NULL_VALUE, type Value} from '../values' +import {isPromiseLike, NULL_VALUE, type Value} from '../values' import {evaluate} from './evaluate' import {Scope} from './scope' @@ -47,8 +47,8 @@ export function tryConstantEvaluate(node: ExprNode): Value | null { } function constantEvaluate(node: ExprNode): Value { - const value = evaluate(node, DUMMY_SCOPE, constantEvaluate) - if ('then' in value) { + const value = evaluate(node, DUMMY_SCOPE, 'sync') + if (isPromiseLike(value)) { throw new Error('BUG: constant evaluate should never return a promise') } return value diff --git a/src/evaluator/evaluate.ts b/src/evaluator/evaluate.ts index 910c15c..8b3710f 100644 --- a/src/evaluator/evaluate.ts +++ b/src/evaluator/evaluate.ts @@ -5,7 +5,9 @@ import { FALSE_VALUE, fromJS, fromNumber, + isPromiseLike, NULL_VALUE, + StaticValue, StreamValue, TRUE_VALUE, type Value, @@ -13,12 +15,12 @@ import { import {operators} from './operators' import {partialCompare} from './ordering' import {Scope} from './scope' -import type {EvaluateOptions, Executor} from './types' +import type {Context, EvaluateOptions} from './types' export function evaluate( node: ExprNode, scope: Scope, - execute: Executor = evaluate, + mode: 'sync' | 'async', ): Value | PromiseLike { const func = EXECUTORS[node.type] return func( @@ -27,7 +29,7 @@ export function evaluate( // We know by design that each executor handles its corresponding node type. node, scope, - execute, + mode, ) } @@ -35,7 +37,7 @@ type ExecutorMap = { [TKey in ExprNode['type']]: ( node: Extract, scope: Scope, - exec: Executor, + mode: 'sync' | 'async', ) => Value | PromiseLike } @@ -45,8 +47,8 @@ const EXECUTORS: ExecutorMap = { }, Selector() { - // These should be evaluated separely using a different evaluator. - // At the mooment we haven't implemented this. + // These should be evaluated separately using a different evaluator. + // At the moment we haven't implemented this. throw new Error('Selectors can not be evaluated') }, @@ -54,8 +56,8 @@ const EXECUTORS: ExecutorMap = { return scope.source }, - Parameter({name}, scope) { - return fromJS(scope.params[name]) + Parameter({name}, scope, mode) { + return fromJS(scope.params[name], mode) }, Context({key}, scope) { @@ -78,41 +80,41 @@ const EXECUTORS: ExecutorMap = { return current.value }, - OpCall({op, left, right}, scope, execute) { + OpCall({op, left, right}, scope, mode) { return co(function* () { const func = operators[op] if (!func) { throw new Error(`Unknown operator: ${op}`) } - const leftValue = yield execute(left, scope) - const rightValue = yield execute(right, scope) + const leftValue = yield evaluate(left, scope, mode) + const rightValue = yield evaluate(right, scope, mode) - return yield func(leftValue, rightValue) + return yield func(leftValue, rightValue, mode) }) }, - Select({alternatives, fallback}, scope, execute) { + Select({alternatives, fallback}, scope, mode) { return co(function* () { for (const alt of alternatives) { - const altCond = yield execute(alt.condition, scope) + const altCond = yield evaluate(alt.condition, scope, mode) if (altCond.type === 'boolean' && altCond.data === true) { - return yield execute(alt.value, scope) + return yield evaluate(alt.value, scope, mode) } } if (fallback) { - return yield execute(fallback, scope) + return yield evaluate(fallback, scope, mode) } return NULL_VALUE }) }, - InRange({base, left, right, isInclusive}, scope, execute) { + InRange({base, left, right, isInclusive}, scope, mode) { return co(function* (): Generator { - const value = (yield execute(base, scope)) as Value - const leftValue = (yield execute(left, scope)) as Value - const rightValue = (yield execute(right, scope)) as Value + const value = (yield evaluate(base, scope, mode)) as Value + const leftValue = (yield evaluate(left, scope, mode)) as Value + const rightValue = (yield evaluate(right, scope, mode)) as Value const leftCmp = partialCompare(yield value.get(), yield leftValue.get()) if (leftCmp === null) { @@ -131,57 +133,72 @@ const EXECUTORS: ExecutorMap = { }) as Value | PromiseLike }, - Filter({base, expr}, scope, execute) { - return co(function* () { - const baseValue = yield execute(base, scope) + Filter({base, expr}, scope, mode) { + return co(function* (): Generator { + const baseValue = (yield evaluate(base, scope, mode)) as Value if (!baseValue.isArray()) { return NULL_VALUE } + if (mode === 'sync') { + const data = (yield baseValue.get()) as unknown[] + const next: unknown[] = [] + + for (const item of data) { + const elem = fromJS(item, mode) + const newScope = scope.createNested(elem) + const exprValue = (yield evaluate(expr, newScope, mode)) as Value + if (exprValue.type === 'boolean' && exprValue.data === true) { + next.push(item) + } + } + return new StaticValue(next, 'array') + } + return new StreamValue(async function* () { for await (const elem of baseValue) { const newScope = scope.createNested(elem) - const exprValue = await execute(expr, newScope) + const exprValue = await evaluate(expr, newScope, mode) if (exprValue.type === 'boolean' && exprValue.data === true) { yield elem } } }) - }) + }) as Value | PromiseLike }, - Projection({base, expr}, scope, execute) { + Projection({base, expr}, scope, mode) { return co(function* () { - const baseValue = yield execute(base, scope) + const baseValue = yield evaluate(base, scope, mode) if (baseValue.type !== 'object') { return NULL_VALUE } const newScope = scope.createNested(baseValue) - return yield execute(expr, newScope) + return yield evaluate(expr, newScope, mode) }) }, - FuncCall({func, args}: FuncCallNode, scope: Scope, execute) { - return func(args, scope, execute) + FuncCall({func, args}: FuncCallNode, scope: Scope, mode) { + return func(args, scope, mode) }, - PipeFuncCall({func, base, args}: PipeFuncCallNode, scope: Scope, execute) { + PipeFuncCall({func, base, args}: PipeFuncCallNode, scope, mode) { return co(function* () { - const baseValue = yield execute(base, scope) - return yield func(baseValue, args, scope, execute) + const baseValue = yield evaluate(base, scope, mode) + return yield func(baseValue, args, scope, mode) }) }, - AccessAttribute({base, name}, scope, execute) { + AccessAttribute({base, name}, scope, mode) { return co(function* () { let value = scope.value if (base) { - value = yield execute(base, scope) + value = yield evaluate(base, scope, mode) } if (value.type === 'object') { if (value.data.hasOwnProperty(name)) { - return fromJS(value.data[name]) + return fromJS(value.data[name], mode) } } @@ -189,9 +206,9 @@ const EXECUTORS: ExecutorMap = { }) }, - AccessElement({base, index}, scope, execute) { + AccessElement({base, index}, scope, mode) { return co(function* (): Generator { - const baseValue = (yield execute(base, scope)) as Value + const baseValue = (yield evaluate(base, scope, mode)) as Value if (!baseValue.isArray()) { return NULL_VALUE @@ -199,13 +216,13 @@ const EXECUTORS: ExecutorMap = { const data = (yield baseValue.get()) as unknown[] const finalIndex = index < 0 ? index + data.length : index - return fromJS(data[finalIndex]) + return fromJS(data[finalIndex], mode) }) as Value | PromiseLike }, - Slice({base, left, right, isInclusive}, scope, execute) { + Slice({base, left, right, isInclusive}, scope, mode) { return co(function* (): Generator { - const baseValue = (yield execute(base, scope)) as Value + const baseValue = (yield evaluate(base, scope, mode)) as Value if (!baseValue.isArray()) { return NULL_VALUE @@ -240,13 +257,13 @@ const EXECUTORS: ExecutorMap = { // Note: At this point the indices might point out-of-bound, but // .slice handles this correctly. - return fromJS(array.slice(leftIdx, rightIdx)) + return fromJS(array.slice(leftIdx, rightIdx), mode) }) as Value | PromiseLike }, - Deref({base}, scope, execute) { + Deref({base}, scope, mode) { return co(function* (): Generator { - const value = (yield execute(base, scope)) as Value + const value = (yield evaluate(base, scope, mode)) as Value if (!scope.source.isArray()) { return NULL_VALUE @@ -264,7 +281,7 @@ const EXECUTORS: ExecutorMap = { if (scope.context.dereference) { type ScopeDereferenced = Awaited>> const dereferenced = (yield scope.context.dereference({_ref: id})) as ScopeDereferenced - return fromJS(dereferenced) + return fromJS(dereferenced, mode) } type ScopeFirst = Awaited> @@ -279,33 +296,33 @@ const EXECUTORS: ExecutorMap = { }) as Value | PromiseLike }, - Value({value}) { - return fromJS(value) + Value({value}, _scope, mode) { + return fromJS(value, mode) }, - Group({base}, scope, execute) { - return execute(base, scope) + Group({base}, scope, mode) { + return evaluate(base, scope, mode) }, - Object({attributes}, scope, execute) { + Object({attributes}, scope, mode) { return co(function* (): Generator { const result: {[key: string]: unknown} = {} for (const attr of attributes) { const attrType = attr.type switch (attr.type) { case 'ObjectAttributeValue': { - const value = (yield execute(attr.value, scope)) as Value + const value = (yield evaluate(attr.value, scope, mode)) as Value result[attr.name] = yield value.get() break } case 'ObjectConditionalSplat': { - const cond = (yield execute(attr.condition, scope)) as Value + const cond = (yield evaluate(attr.condition, scope, mode)) as Value if (cond.type !== 'boolean' || cond.data === false) { continue } - const value = (yield execute(attr.value, scope)) as Value + const value = (yield evaluate(attr.value, scope, mode)) as Value if (value.type === 'object') { Object.assign(result, value.data) } @@ -313,7 +330,7 @@ const EXECUTORS: ExecutorMap = { } case 'ObjectSplat': { - const value = (yield execute(attr.value, scope)) as Value + const value = (yield evaluate(attr.value, scope, mode)) as Value if (value.type === 'object') { Object.assign(result, value.data) } @@ -324,35 +341,55 @@ const EXECUTORS: ExecutorMap = { throw new Error(`Unknown node type: ${attrType}`) } } - return fromJS(result) + return fromJS(result, mode) }) as Value | PromiseLike }, - Array({elements}, scope, execute) { - return new StreamValue(async function* () { - for (const element of elements) { - const value = await execute(element.value, scope) - if (element.isSplat) { - if (value.isArray()) { - for await (const v of value) { - yield v + Array({elements}, scope, mode) { + return co(function* (): Generator { + if (mode === 'sync') { + const next: unknown[] = [] + + for (const element of elements) { + const value = (yield evaluate(element.value, scope, mode)) as Value + if (element.isSplat) { + if (value.isArray()) { + const nested = (yield value.get()) as unknown[] + next.push(...nested) } + } else { + next.push(yield value.get()) } - } else { - yield value } + + return new StaticValue(next, 'array') } - }) + + return new StreamValue(async function* () { + for (const element of elements) { + const value = await evaluate(element.value, scope, mode) + if (element.isSplat) { + if (value.isArray()) { + for await (const v of value) { + yield v + } + } + } else { + yield value + } + } + }) + }) as Value | PromiseLike }, Tuple() { throw new Error('tuples can not be evaluated') }, - Or({left, right}, scope, execute) { + Or({left, right}, scope, mode) { return co(function* () { - const leftValue = yield execute(left, scope) - const rightValue = yield execute(right, scope) + const leftValue = yield evaluate(left, scope, mode) + const rightValue = yield evaluate(right, scope, mode) if (leftValue.type === 'boolean') { if (leftValue.data === true) { @@ -374,10 +411,10 @@ const EXECUTORS: ExecutorMap = { }) }, - And({left, right}, scope, execute) { + And({left, right}, scope, mode) { return co(function* () { - const leftValue = yield execute(left, scope) - const rightValue = yield execute(right, scope) + const leftValue = yield evaluate(left, scope, mode) + const rightValue = yield evaluate(right, scope, mode) if (leftValue.type === 'boolean') { if (leftValue.data === false) { @@ -399,9 +436,9 @@ const EXECUTORS: ExecutorMap = { }) }, - Not({base}, scope, execute) { + Not({base}, scope, mode) { return co(function* () { - const value = yield execute(base, scope) + const value = yield evaluate(base, scope, mode) if (value.type !== 'boolean') { return NULL_VALUE } @@ -409,9 +446,9 @@ const EXECUTORS: ExecutorMap = { }) }, - Neg({base}, scope, execute) { + Neg({base}, scope, mode) { return co(function* () { - const value = yield execute(base, scope) + const value = yield evaluate(base, scope, mode) if (value.type !== 'number') { return NULL_VALUE } @@ -419,9 +456,9 @@ const EXECUTORS: ExecutorMap = { }) }, - Pos({base}, scope, execute) { + Pos({base}, scope, mode) { return co(function* () { - const value = yield execute(base, scope) + const value = yield evaluate(base, scope, mode) if (value.type !== 'number') { return NULL_VALUE } @@ -437,40 +474,75 @@ const EXECUTORS: ExecutorMap = { return NULL_VALUE }, - ArrayCoerce({base}, scope, execute) { + ArrayCoerce({base}, scope, mode) { return co(function* () { - const value = yield execute(base, scope) + const value = yield evaluate(base, scope, mode) return value.isArray() ? value : NULL_VALUE }) }, - Map({base, expr}, scope, execute) { - return co(function* () { - const value = yield execute(base, scope) + Map({base, expr}, scope, mode) { + return co(function* (): Generator { + const value = (yield evaluate(base, scope, mode)) as Value if (!value.isArray()) { return NULL_VALUE } + if (mode === 'sync') { + const data = (yield value.get()) as unknown[] + const next: unknown[] = [] + + for (const item of data) { + const elem = fromJS(item, 'sync') + const newScope = scope.createHidden(elem) + const exprValue = (yield evaluate(expr, newScope, mode)) as Value + next.push(yield exprValue.get()) + } + + return new StaticValue(next, 'array') + } + return new StreamValue(async function* () { for await (const elem of value) { const newScope = scope.createHidden(elem) - yield await execute(expr, newScope) + yield await evaluate(expr, newScope, mode) } }) - }) + }) as Value | PromiseLike }, - FlatMap({base, expr}, scope, execute) { - return co(function* () { - const value = yield execute(base, scope) + FlatMap({base, expr}, scope, mode) { + return co(function* (): Generator { + const value = (yield evaluate(base, scope, mode)) as Value if (!value.isArray()) { return NULL_VALUE } + if (mode === 'sync') { + const data = (yield value.get()) as unknown[] + const next: unknown[] = [] + + for (const item of data) { + const elem = fromJS(item, 'sync') + const newScope = scope.createHidden(elem) + const innerValue = (yield evaluate(expr, newScope, mode)) as Value + + if (innerValue.isArray()) { + const nested = (yield innerValue.get()) as unknown[] + next.push(...nested) + } else { + const nested = yield innerValue.get() + next.push(nested) + } + } + + return new StaticValue(next, 'array') + } + return new StreamValue(async function* () { for await (const elem of value) { const newScope = scope.createHidden(elem) - const innerValue = await execute(expr, newScope) + const innerValue = await evaluate(expr, newScope, mode) if (innerValue.isArray()) { for await (const inner of innerValue) { yield inner @@ -480,10 +552,21 @@ const EXECUTORS: ExecutorMap = { } } }) - }) + }) as Value | PromiseLike }, } +function getContext(options: EvaluateOptions = {}, mode: 'sync' | 'async'): Context { + return { + timestamp: options.timestamp || new Date(), + identity: options.identity === undefined ? 'me' : options.identity, + sanity: options.sanity, + after: options.after ? fromJS(options.after, mode) : null, + before: options.before ? fromJS(options.before, mode) : null, + dereference: options.dereference, + } +} + /** * Evaluates a query. * @internal @@ -492,23 +575,31 @@ export function evaluateQuery( tree: ExprNode, options: EvaluateOptions = {}, ): Value | PromiseLike { - const root = fromJS(options.root) - const dataset = fromJS(options.dataset) + const root = fromJS(options.root, 'async') + const dataset = fromJS(options.dataset, 'async') const params: {[key: string]: unknown} = {...options.params} - const scope = new Scope( - params, - dataset, - root, - { - timestamp: options.timestamp || new Date(), - identity: options.identity === undefined ? 'me' : options.identity, - sanity: options.sanity, - after: options.after ? fromJS(options.after) : null, - before: options.before ? fromJS(options.before) : null, - dereference: options.dereference, - }, - null, - ) - return evaluate(tree, scope) + const scope = new Scope(params, dataset, root, getContext(options, 'async'), null) + return evaluate(tree, scope, 'async') +} + +/** + * Evaluates a query. + * @internal + */ +export function evaluateQuerySync(tree: ExprNode, options: EvaluateOptions = {}): Value { + const root = fromJS(options.root, 'sync') + const dataset = fromJS(options.dataset, 'sync') + const params: {[key: string]: unknown} = {...options.params} + + const scope = new Scope(params, dataset, root, getContext(options, 'sync'), null) + + const result = evaluate(tree, scope, 'sync') + if (isPromiseLike(result)) { + throw new Error( + `Unexpected promise when evaluating. This expression may not support evaluateSync.`, + ) + } + + return result } diff --git a/src/evaluator/functions.ts b/src/evaluator/functions.ts index 65f07c4..b2dc197 100644 --- a/src/evaluator/functions.ts +++ b/src/evaluator/functions.ts @@ -1,5 +1,6 @@ import type {ExprNode} from '../nodeTypes' import { + co, DateTime, FALSE_VALUE, fromDateTime, @@ -10,16 +11,17 @@ import { getType, NULL_VALUE, Path, + StaticValue, StreamValue, TRUE_VALUE, type Value, } from '../values' +import {isEqual} from './equality' +import {evaluate} from './evaluate' import {totalCompare} from './ordering' import {portableTextContent} from './pt' import {Scope} from './scope' import {evaluateScore} from './scoring' -import type {Executor} from './types' -import {isEqual} from './equality' function hasReference(value: any, pathSet: Set): boolean { switch (getType(value)) { @@ -73,8 +75,8 @@ export type GroqFunctionArity = number | ((count: number) => boolean) export type GroqFunction = ( args: GroqFunctionArg[], scope: Scope, - execute: Executor, -) => PromiseLike + mode: 'sync' | 'async', +) => Value | PromiseLike export type FunctionSet = Record | undefined> @@ -83,171 +85,174 @@ export type NamespaceSet = Record // underscored to not collide with environments like jest that give variables named `global` special treatment const _global: FunctionSet = {} -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -_global['anywhere'] = async function anywhere() { +// not implemented +_global['anywhere'] = function anywhere() { throw new Error('not implemented') } - _global['anywhere'].arity = 1 -_global['coalesce'] = async function coalesce(args, scope, execute) { - for (const arg of args) { - const value = await execute(arg, scope) - if (value.type !== 'null') { - return value +_global['coalesce'] = function coalesce(args, scope, mode) { + return co(function* () { + for (const arg of args) { + const value = yield evaluate(arg, scope, mode) + if (value.type !== 'null') { + return value + } } - } - return NULL_VALUE + return NULL_VALUE + }) } -_global['count'] = async function count(args, scope, execute) { - const inner = await execute(args[0], scope) - if (!inner.isArray()) { - return NULL_VALUE - } +_global['count'] = function count(args, scope, mode) { + return co(function* (): Generator { + const inner = (yield evaluate(args[0], scope, mode)) as Value + if (!inner.isArray()) { + return NULL_VALUE + } - let num = 0 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of inner) { - num++ - } - return fromNumber(num) + const count = (yield inner.reduce((acc) => acc + 1, 0)) as number + return fromNumber(count) + }) as Value | PromiseLike } _global['count'].arity = 1 -_global['dateTime'] = async function dateTime(args, scope, execute) { - const val = await execute(args[0], scope) - if (val.type === 'datetime') { - return val - } - if (val.type !== 'string') { - return NULL_VALUE - } - return DateTime.parseToValue(val.data) +_global['dateTime'] = function dateTime(args, scope, mode) { + return co(function* () { + const val = yield evaluate(args[0], scope, mode) + if (val.type === 'datetime') { + return val + } + if (val.type !== 'string') { + return NULL_VALUE + } + return DateTime.parseToValue(val.data) + }) } _global['dateTime'].arity = 1 -_global['defined'] = async function defined(args, scope, execute) { - const inner = await execute(args[0], scope) - return inner.type === 'null' ? FALSE_VALUE : TRUE_VALUE +_global['defined'] = function defined(args, scope, mode) { + return co(function* () { + const inner = yield evaluate(args[0], scope, mode) + return inner.type === 'null' ? FALSE_VALUE : TRUE_VALUE + }) } _global['defined'].arity = 1 -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -_global['identity'] = async function identity(_args, scope) { +_global['identity'] = function identity(_args, scope) { return fromString(scope.context.identity) } _global['identity'].arity = 0 -_global['length'] = async function length(args, scope, execute) { - const inner = await execute(args[0], scope) +_global['length'] = function length(args, scope, mode) { + return co(function* (): Generator { + const inner = (yield evaluate(args[0], scope, mode)) as Value - if (inner.type === 'string') { - return fromNumber(countUTF8(inner.data)) - } + if (inner.type === 'string') { + return fromNumber(countUTF8(inner.data)) + } - if (inner.isArray()) { - let num = 0 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of inner) { - num++ + if (inner.isArray()) { + const count = (yield inner.reduce((acc) => acc + 1, 0)) as number + return fromNumber(count) } - return fromNumber(num) - } - return NULL_VALUE + return NULL_VALUE + }) as Value | PromiseLike } _global['length'].arity = 1 -_global['path'] = async function path(args, scope, execute) { - const inner = await execute(args[0], scope) - if (inner.type !== 'string') { - return NULL_VALUE - } +_global['path'] = function path(args, scope, mode) { + return co(function* () { + const inner = yield evaluate(args[0], scope, mode) + if (inner.type !== 'string') { + return NULL_VALUE + } - return fromPath(new Path(inner.data)) + return fromPath(new Path(inner.data)) + }) } _global['path'].arity = 1 -_global['string'] = async function string(args, scope, execute) { - const value = await execute(args[0], scope) - switch (value.type) { - case 'number': - case 'string': - case 'boolean': - case 'datetime': - return fromString(`${value.data}`) - default: - return NULL_VALUE - } +_global['string'] = function string(args, scope, mode) { + return co(function* () { + const value = yield evaluate(args[0], scope, mode) + switch (value.type) { + case 'number': + case 'string': + case 'boolean': + case 'datetime': + return fromString(`${value.data}`) + default: + return NULL_VALUE + } + }) } _global['string'].arity = 1 -_global['references'] = async function references(args, scope, execute) { - const pathSet = new Set() - for (const arg of args) { - const path = await execute(arg, scope) - if (path.type === 'string') { - pathSet.add(path.data) - } else if (path.isArray()) { - for await (const elem of path) { - if (elem.type === 'string') { - pathSet.add(elem.data) +_global['references'] = function references(args, scope, mode) { + return co(function* (): Generator { + const pathSet = new Set() + for (const arg of args) { + const pathVal = (yield evaluate(arg, scope, mode)) as Value + if (pathVal.type === 'string') { + pathSet.add(pathVal.data) + } else if (pathVal.isArray()) { + const data = (yield pathVal.get()) as unknown[] + for (const item of data) { + if (typeof item === 'string') { + pathSet.add(item) + } } } } - } - if (pathSet.size === 0) { - return FALSE_VALUE - } + if (pathSet.size === 0) { + return FALSE_VALUE + } - const scopeValue = await scope.value.get() - return hasReference(scopeValue, pathSet) ? TRUE_VALUE : FALSE_VALUE + const scopeValue = yield scope.value.get() + return hasReference(scopeValue, pathSet) ? TRUE_VALUE : FALSE_VALUE + }) as Value | PromiseLike } _global['references'].arity = (c) => c >= 1 -_global['round'] = async function round(args, scope, execute) { - const value = await execute(args[0], scope) - if (value.type !== 'number') { - return NULL_VALUE - } +_global['round'] = function round(args, scope, mode) { + return co(function* () { + const value = yield evaluate(args[0], scope, mode) + if (value.type !== 'number') { + return NULL_VALUE + } - const num = value.data - let prec = 0 + const num = value.data + let prec = 0 - if (args.length === 2) { - const precValue = await execute(args[1], scope) - if (precValue.type !== 'number' || precValue.data < 0 || !Number.isInteger(precValue.data)) { - return NULL_VALUE + if (args.length === 2) { + const precValue = yield evaluate(args[1], scope, mode) + if (precValue.type !== 'number' || precValue.data < 0 || !Number.isInteger(precValue.data)) { + return NULL_VALUE + } + prec = precValue.data } - prec = precValue.data - } - if (prec === 0) { - if (num < 0) { - // JavaScript's round() function will always rounds towards positive infinity (-3.5 -> -3). - // The behavior we're interested in is to "round half away from zero". - return fromNumber(-Math.round(-num)) + if (prec === 0) { + if (num < 0) { + // JavaScript's round() function will always rounds towards positive infinity (-3.5 -> -3). + // The behavior we're interested in is to "round half away from zero". + return fromNumber(-Math.round(-num)) + } + return fromNumber(Math.round(num)) } - return fromNumber(Math.round(num)) - } - return fromNumber(Number(num.toFixed(prec))) + return fromNumber(Number(num.toFixed(prec))) + }) } _global['round'].arity = (count) => count >= 1 && count <= 2 -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -_global['now'] = async function now(_args, scope) { +_global['now'] = function now(_args, scope) { return fromString(scope.context.timestamp.toISOString()) } _global['now'].arity = 0 -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -_global['boost'] = async function boost() { +_global['boost'] = function boost() { // This should be handled by the scoring function. throw new Error('unexpected boost call') } @@ -256,195 +261,255 @@ _global['boost'].arity = 2 const string: FunctionSet = {} -string['lower'] = async function (args, scope, execute) { - const value = await execute(args[0], scope) - - if (value.type !== 'string') { - return NULL_VALUE - } - - return fromString(value.data.toLowerCase()) +string['lower'] = function (args, scope, mode) { + return co(function* () { + const value = (yield evaluate(args[0], scope, mode)) as Value + if (value.type !== 'string') { + return NULL_VALUE + } + return fromString(value.data.toLowerCase()) + }) } string['lower'].arity = 1 -string['upper'] = async function (args, scope, execute) { - const value = await execute(args[0], scope) - - if (value.type !== 'string') { - return NULL_VALUE - } - - return fromString(value.data.toUpperCase()) +string['upper'] = function (args, scope, mode) { + return co(function* () { + const value = yield evaluate(args[0], scope, mode) + if (value.type !== 'string') { + return NULL_VALUE + } + return fromString(value.data.toUpperCase()) + }) } string['upper'].arity = 1 -string['split'] = async function (args, scope, execute) { - const str = await execute(args[0], scope) - if (str.type !== 'string') { - return NULL_VALUE - } - const sep = await execute(args[1], scope) - if (sep.type !== 'string') { - return NULL_VALUE - } +string['split'] = function (args, scope, mode) { + return co(function* () { + const str = yield evaluate(args[0], scope, mode) + if (str.type !== 'string') { + return NULL_VALUE + } + const sep = yield evaluate(args[1], scope, mode) + if (sep.type !== 'string') { + return NULL_VALUE + } - if (str.data.length === 0) { - return fromJS([]) - } - if (sep.data.length === 0) { - // This uses a Unicode codepoint splitting algorithm - return fromJS(Array.from(str.data)) - } - return fromJS(str.data.split(sep.data)) + if (str.data.length === 0) { + return fromJS([], mode) + } + if (sep.data.length === 0) { + // This uses a Unicode codepoint splitting algorithm + return fromJS(Array.from(str.data), mode) + } + return fromJS(str.data.split(sep.data), mode) + }) } string['split'].arity = 2 _global['lower'] = string['lower'] _global['upper'] = string['upper'] -string['startsWith'] = async function (args, scope, execute) { - const str = await execute(args[0], scope) - if (str.type !== 'string') { - return NULL_VALUE - } +string['startsWith'] = function (args, scope, mode) { + return co(function* () { + const str = yield evaluate(args[0], scope, mode) + if (str.type !== 'string') { + return NULL_VALUE + } - const prefix = await execute(args[1], scope) - if (prefix.type !== 'string') { - return NULL_VALUE - } + const prefix = yield evaluate(args[1], scope, mode) + if (prefix.type !== 'string') { + return NULL_VALUE + } - return str.data.startsWith(prefix.data) ? TRUE_VALUE : FALSE_VALUE + return str.data.startsWith(prefix.data) ? TRUE_VALUE : FALSE_VALUE + }) } string['startsWith'].arity = 2 const array: FunctionSet = {} -array['join'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } - const sep = await execute(args[1], scope) - if (sep.type !== 'string') { - return NULL_VALUE - } - let buf = '' - let needSep = false - for await (const elem of arr) { - if (needSep) { - buf += sep.data +array['join'] = function (args, scope, mode) { + return co(function* (): Generator { + const arr = (yield evaluate(args[0], scope, mode)) as Value + if (!arr.isArray()) { + return NULL_VALUE } - switch (elem.type) { - case 'number': - case 'string': - case 'boolean': - case 'datetime': - buf += `${elem.data}` - break - default: - return NULL_VALUE + const sep = (yield evaluate(args[1], scope, mode)) as Value + if (sep.type !== 'string') { + return NULL_VALUE } - needSep = true - } - return fromJS(buf) + let buf = '' + let needSep = false + + const data = (yield arr.get()) as unknown[] + for (const item of data) { + const elem = fromJS(item, mode) + if (needSep) { + buf += sep.data + } + switch (elem.type) { + case 'number': + case 'string': + case 'boolean': + case 'datetime': + buf += `${elem.data}` + break + default: + return NULL_VALUE + } + needSep = true + } + + return fromJS(buf, mode) + }) as Value | PromiseLike } array['join'].arity = 2 -array['compact'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } +array['compact'] = function (args, scope, mode) { + return co(function* () { + const arr = yield evaluate(args[0], scope, mode) + if (!arr.isArray()) { + return NULL_VALUE + } - return new StreamValue(async function* () { - for await (const elem of arr) { - if (elem.type !== 'null') { - yield elem + return new StreamValue(async function* () { + for await (const elem of arr) { + if (elem.type !== 'null') { + yield elem + } } - } + }) }) } array['compact'].arity = 1 -array['unique'] = async function (args, scope, execute) { - const value = await execute(args[0], scope) - if (!value.isArray()) { - return NULL_VALUE - } +array['unique'] = function (args, scope, mode) { + return co(function* (): Generator { + const value = (yield evaluate(args[0], scope, mode)) as Value + if (!value.isArray()) { + return NULL_VALUE + } - return new StreamValue(async function* () { - const added = new Set() - for await (const iter of value) { - switch (iter.type) { - case 'number': - case 'string': - case 'boolean': - case 'datetime': - if (!added.has(iter.data)) { - added.add(iter.data) - yield iter - } - break - default: - yield iter + if (mode === 'sync') { + const data = (yield value.get()) as unknown[] + + const added = new Set() + const result: Value[] = [] + for (const item of data) { + const elem = fromJS(item, 'sync') + + switch (elem.type) { + case 'number': + case 'string': + case 'boolean': + case 'datetime': + if (!added.has(item)) { + added.add(item) + result.push(elem) + } + break + default: + result.push(elem) + } } + return new StaticValue(result, 'array') } - }) + + return new StreamValue(async function* () { + const added = new Set() + for await (const iter of value) { + switch (iter.type) { + case 'number': + case 'string': + case 'boolean': + case 'datetime': + if (!added.has(iter.data)) { + added.add(iter.data) + yield iter + } + break + default: + yield iter + } + } + }) + }) as Value | PromiseLike } array['unique'].arity = 1 -array['intersects'] = async function (args, scope, execute) { +array['intersects'] = function (args, scope, mode) { // Intersects returns true if the two arrays have at least one element in common. Only // primitives are supported; non-primitives are ignored. - const arr1 = await execute(args[0], scope) - if (!arr1.isArray()) { - return NULL_VALUE - } - const arr2 = await execute(args[1], scope) - if (!arr2.isArray()) { - return NULL_VALUE + if (mode === 'sync') { + return co(function* (): Generator { + const arr1 = (yield evaluate(args[0], scope, mode)) as Value + if (!arr1.isArray()) { + return NULL_VALUE + } + + const arr2 = (yield evaluate(args[1], scope, mode)) as Value + if (!arr2.isArray()) { + return NULL_VALUE + } + + const arr1Data = (yield arr1.get()) as unknown[] + const arr2Data = (yield arr2.get()) as unknown[] + + for (const v1 of arr1Data) { + for (const v2 of arr2Data) { + if (isEqual(fromJS(v1, 'sync'), fromJS(v2, 'sync'))) { + return TRUE_VALUE + } + } + } + + return FALSE_VALUE + }) as Value | PromiseLike } - for await (const v1 of arr1) { - for await (const v2 of arr2) { - if (isEqual(v1, v2)) { - return TRUE_VALUE + return (async () => { + const arr1 = await evaluate(args[0], scope, mode) + const arr2 = await evaluate(args[1], scope, mode) + + for await (const v1 of arr1) { + for await (const v2 of arr2) { + if (isEqual(v1, v2)) { + return TRUE_VALUE + } } } - } - return FALSE_VALUE + return FALSE_VALUE + })() } array['intersects'].arity = 2 const pt: FunctionSet = {} -pt['text'] = async function (args, scope, execute) { - const value = await execute(args[0], scope) - const text = await portableTextContent(value) +pt['text'] = function (args, scope, mode) { + return co(function* (): Generator { + const value = (yield evaluate(args[0], scope, mode)) as Value + const text = (yield portableTextContent(value, mode)) as string | null - if (text === null) { - return NULL_VALUE - } + if (text === null) { + return NULL_VALUE + } - return fromString(text) + return fromString(text) + }) as Value | PromiseLike } pt['text'].arity = 1 const sanity: FunctionSet = {} -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -sanity['projectId'] = async function (_args, scope) { +sanity['projectId'] = function (_args, scope) { if (scope.context.sanity) { return fromString(scope.context.sanity.projectId) } return NULL_VALUE } -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -sanity['dataset'] = async function (_args, scope) { +sanity['dataset'] = function (_args, scope) { if (scope.context.sanity) { return fromString(scope.context.sanity.dataset) } @@ -452,67 +517,70 @@ sanity['dataset'] = async function (_args, scope) { return NULL_VALUE } -// eslint-disable-next-line require-await -sanity['versionsOf'] = async function (args, scope, execute) { - if (!scope.source.isArray()) return NULL_VALUE - - const value = await execute(args[0], scope) - if (value.type !== 'string') return NULL_VALUE - const baseId = value.data - - // All the document are a version of the given ID if: - // 1. Document ID is of the form bundleId.documentGroupId - // 2. And, they have a field called _version which is an object. - const versionIds: string[] = [] - for await (const value of scope.source) { - if (getType(value) === 'object') { - const val = await value.get() - if ( - val && - '_id' in val && - val._id.split('.').length === 2 && - val._id.endsWith(`.${baseId}`) && - '_version' in val && - typeof val._version === 'object' - ) { - versionIds.push(val._id) +sanity['versionsOf'] = function (args, scope, mode) { + return co(function* () { + if (!scope.source.isArray()) return NULL_VALUE + + const value = (yield evaluate(args[0], scope, mode)) as Value + if (value.type !== 'string') return NULL_VALUE + const baseId = value.data + + // All the document are a version of the given ID if: + // 1. Document ID is of the form bundleId.documentGroupId + // 2. And, they have a field called _version which is an object. + const versionIds = (yield scope.source.reduce((acc, value) => { + if (getType(value) === 'object') { + const val = value.get() + if ( + val && + '_id' in val && + val._id.split('.').length === 2 && + val._id.endsWith(`.${baseId}`) && + '_version' in val && + typeof val._version === 'object' + ) { + acc.push(val._id) + } } - } - } + return acc + }, [])) as string[] - return fromJS(versionIds) + return fromJS(versionIds, mode) + }) as Value | PromiseLike } sanity['versionsOf'].arity = 1 -// eslint-disable-next-line require-await -sanity['partOfRelease'] = async function (args, scope, execute) { - if (!scope.source.isArray()) return NULL_VALUE - - const value = await execute(args[0], scope) - if (value.type !== 'string') return NULL_VALUE - const baseId = value.data - - // A document belongs to a bundle ID if: - // 1. Document ID is of the form bundleId.documentGroupId - // 2. And, they have a field called _version which is an object. - const documentIdsInBundle: string[] = [] - for await (const value of scope.source) { - if (getType(value) === 'object') { - const val = await value.get() - if ( - val && - '_id' in val && - val._id.split('.').length === 2 && - val._id.startsWith(`${baseId}.`) && - '_version' in val && - typeof val._version === 'object' - ) { - documentIdsInBundle.push(val._id) +sanity['partOfRelease'] = function (args, scope, mode) { + return co(function* (): Generator { + if (!scope.source.isArray()) return NULL_VALUE + + const value = (yield evaluate(args[0], scope, mode)) as Value + if (value.type !== 'string') return NULL_VALUE + const baseId = value.data + + // A document belongs to a bundle ID if: + // 1. Document ID is of the form bundleId.documentGroupId + // 2. And, they have a field called _version which is an object. + const documentIdsInBundle = (yield scope.source.reduce((acc, value) => { + if (getType(value) === 'object') { + const val = value.get() + if ( + val && + '_id' in val && + val._id.split('.').length === 2 && + val._id.startsWith(`${baseId}.`) && + '_version' in val && + typeof val._version === 'object' + ) { + acc.push(val._id) + } } - } - } - return fromJS(documentIdsInBundle) + return acc + }, [])) as string[] + + return fromJS(documentIdsInBundle, mode) + }) as Value | PromiseLike } sanity['partOfRelease'].arity = 1 @@ -520,99 +588,106 @@ export type GroqPipeFunction = ( base: Value, args: ExprNode[], scope: Scope, - execute: Executor, -) => PromiseLike + mode: 'sync' | 'async', +) => Value | PromiseLike export const pipeFunctions: {[key: string]: WithOptions} = {} -pipeFunctions['order'] = async function order(base, args, scope, execute) { - // eslint-disable-next-line max-len - // This is a workaround for https://github.com/rpetrich/babel-plugin-transform-async-to-promises/issues/59 - await true +pipeFunctions['order'] = function order(base, args, scope, mode) { + return co(function* (): Generator { + if (!base.isArray()) { + return NULL_VALUE + } - if (!base.isArray()) { - return NULL_VALUE - } + const mappers: ExprNode[] = [] + const directions: string[] = [] + let n = 0 - const mappers = [] - const directions: string[] = [] - let n = 0 + for (let mapper of args) { + let direction = 'asc' - for (let mapper of args) { - let direction = 'asc' + if (mapper.type === 'Desc') { + direction = 'desc' + mapper = mapper.base + } else if (mapper.type === 'Asc') { + mapper = mapper.base + } - if (mapper.type === 'Desc') { - direction = 'desc' - mapper = mapper.base - } else if (mapper.type === 'Asc') { - mapper = mapper.base + mappers.push(mapper) + directions.push(direction) + n++ } - mappers.push(mapper) - directions.push(direction) - n++ - } - - const aux = [] - let idx = 0 - - for await (const value of base) { - const newScope = scope.createNested(value) - const tuple = [await value.get(), idx] - for (let i = 0; i < n; i++) { - const result = await execute(mappers[i], newScope) - tuple.push(await result.get()) + const aux: Array<[unknown, number, ...unknown[]]> = [] + let idx = 0 + + const data = (yield base.get()) as unknown[] + for (const item of data) { + const value = fromJS(item, mode) + const newScope = scope.createNested(value) + // First element is the original value. + const tuple: [unknown, number, ...unknown[]] = [yield value.get(), idx] + for (let i = 0; i < n; i++) { + const res = (yield evaluate(mappers[i], newScope, mode)) as Value + tuple.push(yield res.get()) + } + aux.push(tuple) + idx++ } - aux.push(tuple) - idx++ - } - aux.sort((aTuple, bTuple) => { - for (let i = 0; i < n; i++) { - let c = totalCompare(aTuple[i + 2], bTuple[i + 2]) - if (directions[i] === 'desc') { - c = -c - } - if (c !== 0) { - return c + aux.sort((aTuple, bTuple) => { + for (let i = 0; i < n; i++) { + let c = totalCompare(aTuple[i + 2], bTuple[i + 2]) + if (directions[i] === 'desc') { + c = -c + } + if (c !== 0) { + return c + } } - } - // Fallback to sorting on the original index for stable sorting. - return aTuple[1] - bTuple[1] - }) + // Fallback to sorting on the original index for stable sorting. + return aTuple[1] - bTuple[1] + }) - return fromJS(aux.map((v) => v[0])) + return fromJS( + aux.map((v) => v[0]), + mode, + ) + }) as Value | PromiseLike } pipeFunctions['order'].arity = (count) => count >= 1 -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -pipeFunctions['score'] = async function score(base, args, scope, execute) { - if (!base.isArray()) return NULL_VALUE +pipeFunctions['score'] = function score(base, args, scope, mode) { + return co(function* (): Generator { + if (!base.isArray()) return NULL_VALUE - // Anything that isn't an object should be sorted first. - const unknown: Array = [] - const scored: Array = [] + // Anything that isn't an object should be sorted first. + const unknown: Array = [] + const scored: Array = [] - for await (const value of base) { - if (value.type !== 'object') { - unknown.push(await value.get()) - continue - } + const data = (yield base.get()) as unknown[] - const newScope = scope.createNested(value) - let valueScore = typeof value.data['_score'] === 'number' ? value.data['_score'] : 0 + for (const item of data) { + const value = fromJS(item, mode) + if (value.type !== 'object') { + unknown.push(yield value.get()) + continue + } - for (const arg of args) { - valueScore += await evaluateScore(arg, newScope, execute) - } + const newScope = scope.createNested(value) + let valueScore = typeof value.data['_score'] === 'number' ? value.data['_score'] : 0 - const newObject = Object.assign({}, value.data, {_score: valueScore}) - scored.push(newObject) - } + for (const arg of args) { + valueScore += (yield evaluateScore(arg, newScope, mode)) as number + } - scored.sort((a, b) => b._score - a._score) - return fromJS(scored) + const newObject = Object.assign({}, value.data, {_score: valueScore}) + scored.push(newObject) + } + + scored.sort((a, b) => b._score - a._score) + return fromJS(scored, mode) + }) as Value | PromiseLike } pipeFunctions['score'].arity = (count) => count >= 1 @@ -620,9 +695,7 @@ pipeFunctions['score'].arity = (count) => count >= 1 type ObjectWithScore = Record & {_score: number} const delta: FunctionSet = {} -// eslint-disable-next-line require-await -// eslint-disable-next-line require-await -delta['operation'] = async function (_args, scope) { +delta['operation'] = function (_args, scope) { const hasBefore = scope.context.before !== null const hasAfter = scope.context.after !== null @@ -665,89 +738,127 @@ diff['changedOnly'] = () => { diff['changedOnly'].arity = 3 const math: FunctionSet = {} -math['min'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } +math['min'] = function (args, scope, mode) { + return co(function* (): Generator { + const arr = (yield evaluate(args[0], scope, mode)) as Value + if (!arr.isArray()) { + return NULL_VALUE + } - let n: number | undefined - for await (const elem of arr) { - if (elem.type === 'null') continue - if (elem.type !== 'number') { + // early exit if a non-null, non-number is found + const hasNonNumber = (yield arr.first( + (elem) => elem.type !== 'null' && elem.type !== 'number', + )) as Value | undefined + + if (hasNonNumber) { return NULL_VALUE } - if (n === undefined || elem.data < n) { - n = elem.data + + const data = (yield arr.get()) as unknown[] + + let n: number | undefined + for (const item of data) { + if (typeof item !== 'number') continue + if (n === undefined || item < n) { + n = item + } } - } - return fromJS(n) + return fromJS(n, mode) + }) as Value | PromiseLike } math['min'].arity = 1 -math['max'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } +math['max'] = function (args, scope, mode) { + return co(function* (): Generator { + const arr = (yield evaluate(args[0], scope, mode)) as Value + if (!arr.isArray()) { + return NULL_VALUE + } - let n: number | undefined - for await (const elem of arr) { - if (elem.type === 'null') continue - if (elem.type !== 'number') { + // early exit if a non-null, non-number is found + const hasNonNumber = (yield arr.first( + (elem) => elem.type !== 'null' && elem.type !== 'number', + )) as Value | undefined + + if (hasNonNumber) { return NULL_VALUE } - if (n === undefined || elem.data > n) { - n = elem.data + + const data = (yield arr.get()) as unknown[] + + let n: number | undefined + for (const item of data) { + if (typeof item !== 'number') continue + if (n === undefined || item > n) { + n = item + } } - } - return fromJS(n) + return fromJS(n, mode) + }) as Value | PromiseLike } math['max'].arity = 1 -math['sum'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } +math['sum'] = function (args, scope, mode) { + return co(function* (): Generator { + const arr = (yield evaluate(args[0], scope, mode)) as Value + if (!arr.isArray()) { + return NULL_VALUE + } + + // early exit if a non-null, non-number is found + const hasNonNumber = (yield arr.first( + (elem) => elem.type !== 'null' && elem.type !== 'number', + )) as Value | undefined - let n = 0 - for await (const elem of arr) { - if (elem.type === 'null') continue - if (elem.type !== 'number') { + if (hasNonNumber) { return NULL_VALUE } - n += elem.data - } - return fromJS(n) + + const sum = (yield arr.reduce((acc, elem) => { + if (elem.type !== 'number') return acc + return acc + elem.data + }, 0)) as number + + return fromJS(sum, mode) + }) as Value | PromiseLike } math['sum'].arity = 1 -math['avg'] = async function (args, scope, execute) { - const arr = await execute(args[0], scope) - if (!arr.isArray()) { - return NULL_VALUE - } +math['avg'] = function (args, scope, mode) { + return co(function* (): Generator { + const arr = (yield evaluate(args[0], scope, mode)) as Value + if (!arr.isArray()) { + return NULL_VALUE + } + + // early exit if a non-null, non-number is found + const hasNonNumber = (yield arr.first( + (elem) => elem.type !== 'null' && elem.type !== 'number', + )) as Value | undefined - let n = 0 - let c = 0 - for await (const elem of arr) { - if (elem.type === 'null') continue - if (elem.type !== 'number') { + if (hasNonNumber) { return NULL_VALUE } - n += elem.data - c++ - } - if (c === 0) { - return NULL_VALUE - } - return fromJS(n / c) + + const c = (yield arr.reduce((acc, elem) => { + if (elem.type !== 'number') return acc + return acc + 1 + }, 0)) as number + const n = (yield arr.reduce((acc, elem) => { + if (elem.type !== 'number') return acc + return acc + elem.data + }, 0)) as number + + if (c === 0) { + return NULL_VALUE + } + return fromJS(n / c, mode) + }) as Value | PromiseLike } math['avg'].arity = 1 const dateTime: FunctionSet = {} -dateTime['now'] = async function now(_args, scope) { +dateTime['now'] = function now(_args, scope) { return fromDateTime(new DateTime(scope.context.timestamp)) } dateTime['now'].arity = 0 diff --git a/src/evaluator/index.ts b/src/evaluator/index.ts index f864e24..4a085ac 100644 --- a/src/evaluator/index.ts +++ b/src/evaluator/index.ts @@ -1,2 +1,2 @@ export {tryConstantEvaluate} from './constantEvaluate' -export {evaluateQuery as evaluate} from './evaluate' +export {evaluateQuery as evaluate, evaluateQuerySync as evaluateSync} from './evaluate' diff --git a/src/evaluator/matching.ts b/src/evaluator/matching.ts index 1102ee8..f4de44d 100644 --- a/src/evaluator/matching.ts +++ b/src/evaluator/matching.ts @@ -1,5 +1,3 @@ -import type {Value} from '../values' - const CHARS = /([^!@#$%^&*(),\\/?";:{}|[\]+<>\s-])+/g const CHARS_WITH_WILDCARD = /([^!@#$%^&(),\\/?";:{}|[\]+<>\s-])+/g const EDGE_CHARS = /(\b\.+|\.+\b)/g diff --git a/src/evaluator/operators.ts b/src/evaluator/operators.ts index 7bc0a0b..bdd0b57 100644 --- a/src/evaluator/operators.ts +++ b/src/evaluator/operators.ts @@ -7,6 +7,7 @@ import { fromNumber, fromString, NULL_VALUE, + StaticValue, StreamValue, TRUE_VALUE, type Value, @@ -15,7 +16,11 @@ import {isEqual} from './equality' import {matchAnalyzePattern, matchText, matchTokenize} from './matching' import {partialCompare} from './ordering' -type GroqOperatorFn = (left: Value, right: Value) => Value | PromiseLike +type GroqOperatorFn = ( + left: Value, + right: Value, + mode: 'sync' | 'async', +) => Value | PromiseLike export const operators: {[key in OpCall]: GroqOperatorFn} = { '==': function eq(left, right) { @@ -118,7 +123,7 @@ export const operators: {[key in OpCall]: GroqOperatorFn} = { }) }, - '+': function plus(left, right) { + '+': function plus(left, right, mode) { if (left.type === 'datetime' && right.type === 'number') { return fromDateTime(left.data.add(right.data)) } @@ -132,26 +137,36 @@ export const operators: {[key in OpCall]: GroqOperatorFn} = { } if (left.type === 'object' && right.type === 'object') { - return fromJS({...left.data, ...right.data}) + return fromJS({...left.data, ...right.data}, mode) } if (left.type === 'array' && right.type === 'array') { - return fromJS(left.data.concat(right.data)) + return fromJS(left.data.concat(right.data), mode) } - if (left.isArray() && right.isArray()) { - return new StreamValue(async function* () { - for await (const val of left) { - yield val - } + if (!left.isArray() || !right.isArray()) { + return NULL_VALUE + } - for await (const val of right) { - yield val - } - }) + if (mode === 'sync') { + return co(function* (): Generator { + const leftData = (yield left.get()) as unknown[] + const rightData = (yield right.get()) as unknown[] + const next = [...leftData, ...rightData] + + return new StaticValue(next, 'array') + }) as Value | PromiseLike } - return NULL_VALUE + return new StreamValue(async function* () { + for await (const val of left) { + yield val + } + + for await (const val of right) { + yield val + } + }) }, '-': function minus(left, right) { diff --git a/src/evaluator/pt.ts b/src/evaluator/pt.ts index a28a3d8..604fd9c 100644 --- a/src/evaluator/pt.ts +++ b/src/evaluator/pt.ts @@ -1,29 +1,45 @@ -import type {Value} from '../values' +import {co, fromJS, type Value} from '../values' -export async function portableTextContent(value: Value): Promise { - if (value.type === 'object') { - return blockText(value.data) - } else if (value.isArray()) { - const texts = await arrayText(value) - if (texts.length > 0) { - return texts.join('\n\n') +export function portableTextContent( + value: Value, + mode: 'sync' | 'async', +): string | null | PromiseLike { + return co(function* (): Generator< + string[] | PromiseLike, + string | null, + string[] + > { + if (value.type === 'object') { + return blockText(value.data) + } else if (value.isArray()) { + const texts = yield arrayText(value, mode) + if (texts.length > 0) { + return texts.join('\n\n') + } } - } - return null + return null + }) as string | null | PromiseLike } -async function arrayText(value: Value, result: string[] = []): Promise { - for await (const block of value) { - if (block.type === 'object') { - const text = blockText(block.data) - if (text !== null) result.push(text) - } else if (block.isArray()) { - await arrayText(block, result) +function arrayText(value: Value, mode: 'sync' | 'async'): string[] | PromiseLike { + return co(function* (): Generator { + const result: string[] = [] + + const data = (yield value.get()) as unknown[] + for (const item of data) { + const block = fromJS(item, mode) + if (block.type === 'object') { + const text = blockText(block.data) + if (text !== null) result.push(text) + } else if (block.isArray()) { + const children = (yield arrayText(block, mode)) as string[] + result.push(...children) + } } - } - return result + return result + }) as string[] | PromiseLike } function blockText(obj: Record): string | null { diff --git a/src/evaluator/scoring.ts b/src/evaluator/scoring.ts index 69e4ba9..9edbaa8 100644 --- a/src/evaluator/scoring.ts +++ b/src/evaluator/scoring.ts @@ -1,8 +1,8 @@ import type {ExprNode} from '../nodeTypes' import {co, type Value} from '../values' +import {evaluate} from './evaluate' import {matchPatternRegex, matchTokenize} from './matching' import {Scope} from './scope' -import type {Executor} from './types' // BM25 similarity constants const BM25k = 1.2 @@ -10,18 +10,18 @@ const BM25k = 1.2 export function evaluateScore( node: ExprNode, scope: Scope, - execute: Executor, + mode: 'sync' | 'async', ): number | PromiseLike { return co(function* () { if (node.type === 'OpCall' && node.op === 'match') { - const left = (yield execute(node.left, scope)) as Value - const right = (yield execute(node.right, scope)) as Value + const left = (yield evaluate(node.left, scope, mode)) as Value + const right = (yield evaluate(node.right, scope, mode)) as Value return evaluateMatchScore(left, right) } if (node.type === 'FuncCall' && node.name === 'boost') { - const innerScore = (yield evaluateScore(node.args[0], scope, execute)) as number - const boost = (yield execute(node.args[1], scope)) as Value + const innerScore = (yield evaluateScore(node.args[0], scope, mode)) as number + const boost = (yield evaluate(node.args[1], scope, mode)) as Value if (boost.type === 'number' && innerScore > 0) { return innerScore + boost.data } @@ -31,18 +31,18 @@ export function evaluateScore( switch (node.type) { case 'Or': { - const leftScore = (yield evaluateScore(node.left, scope, execute)) as number - const rightScore = (yield evaluateScore(node.right, scope, execute)) as number + const leftScore = (yield evaluateScore(node.left, scope, mode)) as number + const rightScore = (yield evaluateScore(node.right, scope, mode)) as number return leftScore + rightScore } case 'And': { - const leftScore = (yield evaluateScore(node.left, scope, execute)) as number - const rightScore = (yield evaluateScore(node.right, scope, execute)) as number + const leftScore = (yield evaluateScore(node.left, scope, mode)) as number + const rightScore = (yield evaluateScore(node.right, scope, mode)) as number if (leftScore === 0 || rightScore === 0) return 0 return leftScore + rightScore } default: { - const res = (yield execute(node, scope)) as Value + const res = (yield evaluate(node, scope, mode)) as Value return res.type === 'boolean' && res.data === true ? 1 : 0 } } diff --git a/src/evaluator/types.ts b/src/evaluator/types.ts index 134c0ab..d717640 100644 --- a/src/evaluator/types.ts +++ b/src/evaluator/types.ts @@ -2,7 +2,12 @@ import type {ExprNode} from '../nodeTypes' import type {Value} from '../values' import {Scope} from './scope' -export type Executor = (node: N, scope: Scope) => Value | PromiseLike +export type Executor = ( + node: N, + scope: Scope, + executor: Executor, + mode: 'sync' | 'async', +) => Value | PromiseLike export type Document = { _id?: string _type?: string diff --git a/src/values/StreamValue.ts b/src/values/StreamValue.ts index fa064eb..3a1d76a 100644 --- a/src/values/StreamValue.ts +++ b/src/values/StreamValue.ts @@ -38,6 +38,14 @@ export class StreamValue { return undefined } + async reduce(reducer: (acc: R, value: Value) => R | Promise, initial: R): Promise { + let accumulator = initial + for await (const value of this) { + accumulator = await reducer(accumulator, value) + } + return accumulator + } + async *[Symbol.asyncIterator](): AsyncGenerator { let i = 0 while (true) { diff --git a/src/values/utils.ts b/src/values/utils.ts index 3e9dbd4..f7d8b20 100644 --- a/src/values/utils.ts +++ b/src/values/utils.ts @@ -29,7 +29,7 @@ export class StaticValue { const array = this.get() as unknown[] for (const item of array) { - const value = fromJS(item) + const value = fromJS(item, 'sync') if (predicate(value)) { return value } @@ -38,11 +38,24 @@ export class StaticValue { return undefined } + reduce(reducer: (acc: R, value: Value) => R, initial: R): R { + if (!this.isArray()) { + throw new Error('`reduce` can only be called on array `StaticValue`s') + } + const array = this.get() as unknown[] + let accumulator = initial + for (const item of array) { + const value = fromJS(item, 'sync') + accumulator = reducer(accumulator, value) + } + return accumulator + } + [Symbol.asyncIterator](): Generator { if (Array.isArray(this.data)) { return (function* (data) { for (const element of data) { - yield fromJS(element) + yield fromJS(element, 'async') } })(this.data) } @@ -120,11 +133,11 @@ function isIterator(obj?: Iterator) { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function fromJS(val: any): Value { - if (isIterator(val)) { +export function fromJS(val: any, mode: 'sync' | 'async'): Value { + if (isIterator(val) && mode !== 'sync') { return new StreamValue(async function* () { for await (const value of val) { - yield fromJS(value) + yield fromJS(value, 'async') } }) } else if (val === null || val === undefined) { @@ -153,7 +166,7 @@ export function getType(data: any): GroqType { return typeof data as GroqType } -const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => +export const isPromiseLike = (value: T | PromiseLike): value is PromiseLike => typeof value === 'object' && !!value && 'then' in value && typeof value.then === 'function' /** diff --git a/tap-snapshots/test/parse.test.ts.test.cjs b/tap-snapshots/test/parse.test.ts.test.cjs index b777b02..6908d07 100644 --- a/tap-snapshots/test/parse.test.ts.test.cjs +++ b/tap-snapshots/test/parse.test.ts.test.cjs @@ -4,7 +4,7 @@ * Re-generate by setting TAP_SNAPSHOT=1 and running tests. * Make sure to inspect the output below. Do not ignore changes! */ -'use strict' + exports[`test/parse.test.ts TAP Basic parsing Comment with no text > must match snapshot 1`] = ` Object { "type": "Value", @@ -62,7 +62,7 @@ Object { "value": "drafts.**", }, ], - "func": AsyncFunction path(args, scope, execute), + "func": Function path(args, scope, mode), "name": "path", "namespace": "global", "type": "FuncCall", @@ -102,13 +102,13 @@ Object { "type": "Map", }, ], - "func": AsyncFunction (args, scope, execute), + "func": Function (args, scope, mode), "name": "unique", "namespace": "array", "type": "FuncCall", }, ], - "func": AsyncFunction count(args, scope, execute), + "func": Function count(args, scope, mode), "name": "count", "namespace": "global", "type": "FuncCall",