UNPKG

hypertune

Version:

[Hypertune](https://www.hypertune.com/) is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git-style version control and local, synchronous, in-memory flag evaluation. Opt

1,598 lines (1,465 loc) 60.1 kB
/* eslint-disable capitalized-comments */ import { defaultArmKey, fieldPathSeparator, isPartialObjectKey, numHashBuckets, } from "../constants"; import emptyThrows from "./emptyThrows"; import evaluate, { complexFormExpressionEvaluationError } from "./evaluate"; import { getAnonymousObjectExpression, getBooleanExpression, getFloatExpression, getIntExpression, getNoOpExpression, getStringExpression, } from "./expression"; import hash from "./hash"; import nullThrows from "./nullThrows"; import { getExpressionEvaluationCountLogs, getEventLogs, getExposureLogs, mergeLogs, } from "./reductionLogs"; import stableStringify from "./stableStringify"; import { BooleanExpression, CommitConfig, SplitAssignment, DimensionMapping, Expression, FieldQuery, FloatExpression, FragmentDefinitions, InlineFragment, IntExpression, isQueryVariable, ObjectExpression, ObjectValue, ObjectValueWithVariables, Query, Split, SplitMap, Value, ValueWithVariables, Logs, } from "../types"; import asError from "./asError"; import getNestedValue from "./getNestedValue"; import asPlainObjectOrThrow from "./asPlainObjectOrThrow"; import getInlineFragment from "./getInlineFragment"; /** * Summary * * Expression reduction takes a query and/or some arguments and applies * interpreter steps to reduce the expression as much as possible. It is * analogous to beta-reduction in the lambda calculus. If all function arguments * are supplied, we return a fully reduced expression in "normal form". Normal * form expressions can then be converted into JSON. Otherwise, we return a * partially reduced expression in "complex form" which requires further * reduction with additional arguments for the function expressions it contains. * * Each time we reduce an expression into a simpler form, we keep track of the * expressions we've needed to "evaluate" to compute the reduction. We track * this via "logs" which we attach to the result expression. This lets users * visualize how often different parts of the expression tree have been used. * * Partial Field Arguments * * When a query field has a partial field arguments object, we reduce the * expression tree as much as possible given the available fields within this * object and leave the function expression that references it intact so that on * a subsequent reduction, when the full field arguments object can be provided, * we can fully reduce the tree correctly. There are 3 changes to the main * reduction logic that allow for this: * * 1. We leave function expressions intact if any of the arguments passed to it * is a partial object. * * 2. We have a reduction flag that toggles partial object variable reduction. * If it's off (which it is by default), we do not reduce variable expressions * that reference partial objects. * * 3. The only time we enable partial object variable reduction is in the * (initial) object reduction in a get field expression when we check to see if * we can access the desired field. (And if we can't, we return the get field * expression with its object reduced but with partial object variable reduction * disabled.) * * Complexity and Logic * * For any expression, there are a finite number of reduction steps until it * cannot be reduced further. The reduction logic of each expression type * attempts to reduce the expression and its children as much as possible given * the current context. The logic for application and variable expressions is * especially prone to complexity blow-up. In particular, an application * argument should be reduced as much as possible before being passed to a * function expression reduction, where it becomes a variable, so that the * reduction isn't repeated for every variable expression in the function body * that references it. */ // TODO: Refactor using fold with pass down? // eslint-disable-next-line max-params export default function reduce( splits: SplitMap, commitConfig: CommitConfig, query: Query<ObjectValueWithVariables> | null, variableValues: ObjectValue, rootExpression: Expression, allowMissingVariables: boolean ): Expression { const innerQuery = getInnerQuery( query, variableValues, allowMissingVariables ); return innerReduce( splits, commitConfig, createAssignmentCache(), /* enablePartialObjectVariableReduction */ false, innerQuery?.fragmentDefinitions ?? {}, innerQuery?.fieldQuery ?? null, /* args */ null, /* variables */ {}, rootExpression ); } type AssignmentCache = { get(splitId: string, unitId: string): AssignmentCacheValue | null; set(splitId: string, unitId: string, value: AssignmentCacheValue): void; }; type AssignmentCacheValue = { assignment: SplitAssignment; eventObjectTypeName: string | null; eventPayload: ObjectValue | null; eventPayloadLogs: Logs; }; function createAssignmentCache(): AssignmentCache { const cache = new Map<string, AssignmentCacheValue>(); return { get: (splitId, unitId) => { return cache.get(getAssignmentCacheKey(splitId, unitId)) ?? null; }, set: (splitId, unitId, value) => { cache.set(getAssignmentCacheKey(splitId, unitId), value); }, }; } function getAssignmentCacheKey(splitId: string, unitId: string): string { return stableStringify({ splitId, unitId }); } function getInnerQuery( query: Query<ObjectValueWithVariables> | null, variableValues: ObjectValue, allowMissingVariables: boolean ): Query<ObjectExpression> | null { return query ? { ...query, fragmentDefinitions: Object.fromEntries( Object.entries(query.fragmentDefinitions).map( ([fragmentName, fragment]) => [ fragmentName, getInnerInlineFragment( query.variableDefinitions, fragment, variableValues, allowMissingVariables ), ] ) ), fieldQuery: getInnerFieldQuery( query.variableDefinitions, query.fieldQuery, variableValues, allowMissingVariables ), } : null; } function getInnerFieldQuery( variableDefinitions: Query<ObjectValueWithVariables>["variableDefinitions"], fieldQuery: FieldQuery<ObjectValueWithVariables>, variableValues: ObjectValue, allowMissingVariables: boolean ): FieldQuery<ObjectExpression> { return Object.fromEntries( Object.entries(fieldQuery).map(([objectTypeName, fragment]) => [ objectTypeName, fragment.type === "InlineFragment" ? getInnerInlineFragment( variableDefinitions, fragment, variableValues, allowMissingVariables ) : fragment, ]) ); } function getInnerInlineFragment( variableDefinitions: Query<ObjectValueWithVariables>["variableDefinitions"], fragment: InlineFragment<ObjectValueWithVariables>, variableValues: ObjectValue, allowMissingVariables: boolean ): InlineFragment<ObjectExpression> { return { type: "InlineFragment", objectTypeName: fragment.objectTypeName, selection: Object.fromEntries( Object.entries(fragment.selection).map( ([fieldName, { fieldArguments, fieldQuery }]) => [ fieldName, { fieldArguments: getAnonymousObjectExpression( replaceVariables( variableDefinitions, fieldArguments, variableValues, allowMissingVariables ) as ObjectValue, /* isTransient */ true ), fieldQuery: fieldQuery ? getInnerFieldQuery( variableDefinitions, fieldQuery, variableValues, allowMissingVariables ) : null, }, ] ) ), }; } function replaceVariables( variableDefinitions: Query<ObjectValueWithVariables>["variableDefinitions"], value: ValueWithVariables, variableValues: ObjectValue, allowMissingVariables: boolean ): Value { switch (typeof value) { case "string": case "boolean": case "number": case "bigint": return value; case "object": { if (isQueryVariable(value)) { const variableName = value.name; if (!(variableName in variableDefinitions)) { throw new Error(`Unknown query variable ${variableName}`); } const { defaultValue } = variableDefinitions[variableName]; if ( !(variableName in variableValues) && !defaultValue && !allowMissingVariables ) { throw new Error(`Missing query variable ${variableName}`); } // TODO: this will actually return undefined when the variable is not present // This is expected behavior when running codegen, as we allowMissingVariables // The undefined is handled by filtering it out in the ObjectValueWithVariables case // However, it makes the types of this function a bit of a lie, which would be nice to fix. return variableValues[variableName] ?? defaultValue; } if (Array.isArray(value)) { return value.map((v) => replaceVariables( variableDefinitions, v, variableValues, allowMissingVariables ) ); } return Object.fromEntries( Object.entries(value) .map(([fieldName, fieldValue]) => [ fieldName, replaceVariables( variableDefinitions, fieldValue, variableValues, allowMissingVariables ), ]) .filter(([, fieldValue]) => fieldValue !== undefined) ); } default: { throw new Error(`Unexpected value type: ${typeof value}`); } } } // eslint-disable-next-line max-params function innerReduce( splits: SplitMap, commitConfig: CommitConfig, assignmentCache: AssignmentCache, enablePartialObjectVariableReduction: boolean, fragmentDefinitions: FragmentDefinitions<ObjectExpression>, innerQuery: FieldQuery<ObjectExpression> | null, args: Expression[] | null, // Reduced expressions (given a null query) // Reduced expressions (given a null query) variables: { [variableId: string]: Expression }, expression: Expression ): Expression { if (!!args && expression.type !== "FunctionExpression") { throw new Error( `Reduction arguments (${JSON.stringify( args )}) given for an expression that isn't a function (${JSON.stringify( expression.type )}).` ); } const thisLogs = mergeLogs( expression.logs, getExpressionEvaluationCountLogs(expression) ); switch (expression.type) { case "NoOpExpression": case "BooleanExpression": case "IntExpression": case "FloatExpression": case "StringExpression": case "RegexExpression": case "EnumExpression": return expression; /** * If there's no query, return the whole object expression with all fields * reduced (with no query or arguments). * * Else return the object expression with only the query fields included. * Pre-reduce query fields without query field arguments first. Then if * the result is a function expression, reduce them again with query field * arguments. */ case "ObjectExpression": { if (!innerQuery) { return { ...expression, fields: Object.fromEntries( Object.keys(expression.fields).map((fieldName) => [ fieldName, innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.fields[fieldName], `Object expression has null field "${fieldName}".` ) ), ]) ), }; } const fragment = innerQuery[expression.objectTypeName]; const selection = fragment ? getInlineFragment(fragmentDefinitions, fragment).selection : {}; const fields = Object.fromEntries( Object.keys(selection).flatMap((fieldName) => { const unreducedField = expression.fields[fieldName]; if (!unreducedField) { return []; } const field = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, selection[fieldName].fieldQuery, null, // args variables, unreducedField ); return [ [ fieldName, field.type === "FunctionExpression" ? innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, selection[fieldName].fieldQuery, [selection[fieldName].fieldArguments], variables, field ) : field, ], ]; }) ); return { ...expression, fields }; } /** * Reduce the object without any query or arguments with partial object * variable reduction enabled (where we reduce partial object variable * expressions to the partial object expressions they reference in the * variables map). Then check if we can get the field we want. If we can, * return it. * * Else return the whole get field expression with the object reduced but * this time with partial object variable reduction disabled. So we reduce * what we can in the object while leaving partial object variable * references intact for subsequent reductions which may pass different * partial objects for these variables (with different fields present). */ case "GetFieldExpression": { const object1 = innerReduce( splits, commitConfig, assignmentCache, true, // enablePartialObjectVariableReduction fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.object, "Get field expression has null object.") ); const field = getField( object1, nullThrows( expression.fieldPath, "Get field expression has null field path." ) ); if (field) { const logs = mergeLogs(thisLogs, object1.logs, field.logs); return { ...field, logs }; } const object2 = innerReduce( splits, commitConfig, assignmentCache, false, // enablePartialObjectVariableReduction fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.object, "Get field expression has null object.") ); return { ...expression, object: object2, }; } /** * Reduce the object with the current query. If the result is not an * object expression, return the whole update object expression with the * reduced object. * * Else return the object expression but with its fields updated with the * field update expressions. Only apply a field update if there's no query * or its field is in the query. Pre-reduce field updates without query * field arguments first. Then if we have a query and the result is a * function expression, reduce them again with query field arguments. */ case "UpdateObjectExpression": { const object = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows( expression.object, "Update object expression has null object." ) ); if (object.type !== "ObjectExpression") { return { ...expression, object }; } const { objectTypeName } = object; const fragment = innerQuery ? innerQuery[objectTypeName] : null; const selection = !fragment ? null : getInlineFragment(fragmentDefinitions, fragment).selection; Object.keys(expression.updates).forEach((fieldName) => { if (!selection || selection[fieldName]) { if (!object.fields[fieldName]) { throw new Error( "Update object expression has object with missing update " + `field "${fieldName}".` ); } const update = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, selection ? selection[fieldName].fieldQuery : null, null, // args variables, nullThrows( expression.updates[fieldName], `Update object expression has null update "${fieldName}".` ) ); object.fields[fieldName] = selection && update.type === "FunctionExpression" ? innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, selection[fieldName].fieldQuery, [selection[fieldName].fieldArguments], variables, update ) : update; } }); const logs = mergeLogs(thisLogs, object.logs); return { ...object, logs }; } /** * Return the whole list expression with all items reduced with the * current query (but no arguments). */ case "ListExpression": return { ...expression, items: expression.items.map((item, index) => innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows(item, `List expression has null item at index ${index}.`) ) ), }; /** * Reduce the control, each case 'when' and 'then', and the default. The * case 'then' and default expressions get reduced with the current query * as they are the possible return values and so on the query path. The * control and case 'when' expressions are not reduced with the current * query as they are not possible return values and so off the query path. * * We try to evaluate the control and then each case 'when'. As soon as * a 'when' value matches the control value, we return the corresponding * 'then' expression. We attach the merged logs of the switch expression, * the control and each case 'when' that was evaluated, to the returned * 'then' expression. * * If none of the 'when' values matched the control value, we return the * default expression. * * If evaluation fails at any point due to expressions being in complex * form, i.e. not fully reduced, we return the whole switch expression but * with the reduced control, case 'when's and 'then's, and default. */ case "SwitchExpression": { const control = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.control, "Switch expression has null control.") ); const cases = expression.cases.map((item, index) => ({ id: item.id, when: innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( item.when, `Switch expression case has null 'when' at index ${index}.` ) ), then: innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows( item.then, `Switch expression case has null 'then' at index ${index}.` ) ), })); const defaultExpression = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows(expression.default, "Switch expression has null default.") ); try { const { value: controlValue, logs: controlLogs } = evaluate(control); let logs = mergeLogs(thisLogs, controlLogs); for (const { when, then } of cases) { const { value: whenValue, logs: whenLogs } = evaluate(when); logs = mergeLogs(logs, whenLogs); if (areEqual(controlValue, whenValue)) { logs = mergeLogs(logs, then.logs); return { ...then, logs }; } } logs = mergeLogs(logs, defaultExpression.logs); return { ...defaultExpression, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, control, cases, default: defaultExpression }; } /** * Similar to switch expression. */ case "EnumSwitchExpression": { const control = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.control, "Enum switch expression has null control." ) ); const cases = Object.fromEntries( Object.keys(expression.cases).map((enumValue) => [ enumValue, innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows( expression.cases[enumValue], `Enum switch expression has null case "${enumValue}".` ) ), ]) ); try { const { value: controlValue, logs: controlLogs } = evaluate(control); if (typeof controlValue !== "string") { throw new Error( "Evaluated control of enum switch expression is not a string." ); } const then = cases[controlValue]; if (!then) { throw new Error( `Evaluated control of enum switch expression "${controlValue}" is missing in cases.` ); } const logs = mergeLogs(thisLogs, controlLogs, then.logs); return { ...then, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, control, cases }; } /** * Reduce a and b. Evaluate them both. Compare their values. Return a new * "transient" result expression that represents the comparison result. * Attach the merged logs of the comparison expression, a and b, to the * result expression logs. If evaluation fails, return the whole * comparison expression with its reduced subexpressions. */ case "ComparisonExpression": { const a = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.a, "Comparison expression has null a.") ); const b = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.b, "Comparison expression has null b.") ); try { const { value: aValue, logs: aLogs } = evaluate(a); const { value: bValue, logs: bLogs } = evaluate(b); const logs = mergeLogs(thisLogs, aLogs, bLogs); const operator = nullThrows( expression.operator, "Comparison expression has null operator." ); switch (operator) { case "==": case "!=": { const result = areEqual(aValue, bValue); return getComparisonResultExpression( operator === "==" ? result : !result, logs ); } case "<": { if (typeof aValue !== "number" || typeof bValue !== "number") { throw new Error( "Evaluated operands of comparison expression with '<' " + "operator are not both numbers." ); } return getComparisonResultExpression(aValue < bValue, logs); } case "<=": { if (typeof aValue !== "number" || typeof bValue !== "number") { throw new Error( "Evaluated operands of comparison expression with '<=' " + "operator are not both numbers." ); } return getComparisonResultExpression(aValue <= bValue, logs); } case ">": { if (typeof aValue !== "number" || typeof bValue !== "number") { throw new Error( "Evaluated operands of comparison expression with '>' " + "operator are not both numbers." ); } return getComparisonResultExpression(aValue > bValue, logs); } case ">=": { if (typeof aValue !== "number" || typeof bValue !== "number") { throw new Error( "Evaluated operands of comparison expression with '>=' " + "operator are not both numbers." ); } return getComparisonResultExpression(aValue >= bValue, logs); } case "AND": { if (typeof aValue !== "boolean" || typeof bValue !== "boolean") { throw new Error( "Evaluated operands of comparison expression with 'AND' " + "operator are not both booleans." ); } return getComparisonResultExpression(aValue && bValue, logs); } case "OR": { if (typeof aValue !== "boolean" || typeof bValue !== "boolean") { throw new Error( "Evaluated operands of comparison expression with 'OR' " + "operator are not both booleans." ); } return getComparisonResultExpression(aValue || bValue, logs); } case "in": case "notIn": { if (!Array.isArray(bValue)) { throw new Error( `Second evaluated operand of comparison expression with " + "'${operator}' operator is not an array.` ); } const result = bValue.some((itemValue) => areEqual(aValue, itemValue) ); return getComparisonResultExpression( operator === "in" ? result : !result, logs ); } case "startsWith": case "notStartsWith": { const shouldInvert = operator === "notStartsWith"; if (typeof aValue === "string" && typeof bValue === "string") { const result = aValue.startsWith(bValue); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } if (Array.isArray(aValue)) { const result = aValue.length === 0 ? false : areEqual(aValue[0], bValue); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } throw new Error( `Evaluated operands of comparison expression with '${operator}'` + "operator are not both strings or an array and an element." ); } case "endsWith": case "notEndsWith": { const shouldInvert = operator === "notEndsWith"; if (typeof aValue === "string" && typeof bValue === "string") { const result = aValue.endsWith(bValue); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } if (Array.isArray(aValue)) { const result = aValue.length === 0 ? false : areEqual(aValue[aValue.length - 1], bValue); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } throw new Error( `Evaluated operands of comparison expression with '${operator}'` + "operator are not both strings or an array and an element." ); } case "contains": case "notContains": { const shouldInvert = operator === "notContains"; if (typeof aValue === "string" && typeof bValue === "string") { const result = aValue.includes(bValue); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } if (Array.isArray(aValue)) { const result = aValue.some((itemValue) => areEqual(itemValue, bValue) ); return getComparisonResultExpression( shouldInvert ? !result : result, logs ); } throw new Error( `Evaluated operands of comparison expression with '${operator}'` + "operator are not both strings or an array and an element." ); } case "matches": case "notMatches": { if (typeof aValue !== "string" || typeof bValue !== "string") { throw new Error( `Evaluated operands of comparison expression with " + "'${operator}' operator are not both strings.` ); } const bRegex = new RegExp(bValue); const result = bRegex.test(aValue); return getComparisonResultExpression( operator === "matches" ? result : !result, logs ); } default: { const neverOperator: never = operator; throw new Error(`unexpected operator: ${neverOperator}`); } } } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, a, b }; } /** * Similar to comparison expression. */ case "ArithmeticExpression": { const a = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.a, "Arithmetic expression has null a") ); const b = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.b, "Arithmetic expression has null b") ); try { const { value: aValue, logs: aLogs } = evaluate(a); const { value: bValue, logs: bLogs } = evaluate(b); if (typeof aValue !== "number" || typeof bValue !== "number") { throw new Error( "Evaluated operands of arithmetic expression are not both " + "numbers." ); } const logs = mergeLogs(thisLogs, aLogs, bLogs); const operator = nullThrows( expression.operator, "Arithmetic expression has null operator." ); switch (operator) { case "+": return getArithmeticResultExpression(aValue + bValue, logs); case "-": return getArithmeticResultExpression(aValue - bValue, logs); case "*": return getArithmeticResultExpression(aValue * bValue, logs); case "/": return getArithmeticResultExpression(aValue / bValue, logs); case "POW": return getArithmeticResultExpression(aValue ** bValue, logs); case "MOD": return getArithmeticResultExpression(aValue % bValue, logs); default: { const neverOperator: never = operator; throw new Error(`unexpected operator: ${neverOperator}`); } } } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, a, b }; } case "RoundNumberExpression": { const number = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.number, "Round number expression has null number." ) ); try { const { value: numberValue, logs: numberLogs } = evaluate(number); if (typeof numberValue !== "number") { throw new Error( "Evaluated number of round number expression is not a number." ); } const result = getIntExpression( Math.round(numberValue), /* isTransient */ true ); const logs = mergeLogs(thisLogs, numberLogs, result.logs); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, number }; } case "StringifyNumberExpression": { const number = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.number, "Stringify number expression has null number." ) ); try { const { value: numberValue, logs: numberLogs } = evaluate(number); if (typeof numberValue !== "number") { throw new Error( "Evaluated number of stringify number expression is not a number." ); } const result = getStringExpression( numberValue.toString(), /* isTransient */ true ); const logs = mergeLogs(thisLogs, numberLogs, result.logs); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, number }; } case "StringConcatExpression": { const strings = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.strings, "String concat expression has null strings." ) ); try { const { value: stringsValue, logs: stringsLogs } = evaluate(strings); if (!Array.isArray(stringsValue)) { throw new Error( "Evaluated strings of string concat expression is not an array." ); } if (!stringsValue.every((x) => typeof x === "string")) { throw new Error( "Evaluated strings array of string concat expression contains " + "a value that isn't a string." ); } const result = getStringExpression( stringsValue.join(""), /* isTransient */ true ); const logs = mergeLogs(thisLogs, stringsLogs, result.logs); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, strings }; } case "GetUrlQueryParameterExpression": { const url = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.url, "Get url query parameter expression has null url." ) ); const queryParameterName = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.queryParameterName, "Get url query parameter expression has null query parameter name." ) ); try { const { value: urlValue, logs: urlLogs } = evaluate(url); if (typeof urlValue !== "string") { throw new Error( "Evaluated url of get url query parameter expression is not a " + "string." ); } const { value: queryParameterNameValue, logs: queryParameterNameLogs } = evaluate(queryParameterName); if (typeof queryParameterNameValue !== "string") { throw new Error( "Evaluated query parameter name of get url query parameter " + "expression is not a string." ); } let queryParameterValue = ""; try { const urlObject = new URL(urlValue); queryParameterValue = urlObject.searchParams.get(queryParameterNameValue) || ""; } catch (error) { // } const result = getStringExpression( queryParameterValue, /* isTransient */ true ); const logs = mergeLogs( thisLogs, urlLogs, queryParameterNameLogs, result.logs ); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, url, queryParameterName }; } case "SplitExpression": { const split = nullThrows( splits[ nullThrows(expression.splitId, "Split expression has null split ID.") ], "Split expression has invalid split ID." ); const dimensionId = nullThrows( expression.dimensionId, "Split expression has null dimension ID." ); const expose = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.expose, "Split expression has null expose.") ); const unitId = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows(expression.unitId, "Split expression has null unit ID.") ); const eventPayload = expression.eventPayload ? innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, expression.eventPayload ) : null; const unreducedDimensionMapping = expression.dimensionMapping; if (unreducedDimensionMapping.type !== "discrete") { // TODO: Implement throw new Error( "Split expression has dimension mapping which isn't discrete." ); } const dimensionMapping: DimensionMapping = { type: "discrete", cases: Object.fromEntries( Object.keys(unreducedDimensionMapping.cases).map((armId) => [ armId, // TODO: Validate armId innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args variables, nullThrows( unreducedDimensionMapping.cases[armId], "Split expression has discrete dimension mapping with null " + `case for arm "${armId}".` ) ), ]) ), }; try { const { value: exposeValue, logs: exposeLogs } = evaluate(expose); if (typeof exposeValue !== "boolean") { throw new Error( "Evaluated expose of split expression is not a boolean." ); } const { value: unitIdValue, logs: unitIdLogs } = evaluate(unitId); if (typeof unitIdValue !== "string") { throw new Error( "Evaluated unit ID of split expression is not a string." ); } let assignment: SplitAssignment | null = null; let eventObjectTypeName: string | null = null; let eventPayloadValue: ObjectValue | null = null; let eventPayloadLogs: Logs; // If we've previously assigned this unit for this split, reuse the // previous assignment and also reuse its feature values in the // exposure log. const cacheEntry = assignmentCache.get(split.id, unitIdValue); if (cacheEntry) { assignment = cacheEntry.assignment; eventObjectTypeName = cacheEntry.eventObjectTypeName; eventPayloadValue = cacheEntry.eventPayload; eventPayloadLogs = cacheEntry.eventPayloadLogs; } else { const payloadResult = eventPayload ? evaluate(eventPayload) : { value: null, logs: {} }; eventPayloadValue = payloadResult.value === null ? null : asPlainObjectOrThrow( payloadResult.value, new Error( "Evaluated payload of split expression is not an object." ) ); eventPayloadLogs = payloadResult.logs; eventObjectTypeName = expression.eventObjectTypeName; if (!!eventPayloadValue !== !!eventObjectTypeName) { // If we have value and not name or vice versa, // then the expression is broken so we just throw an exception. throw new Error( "Evaluated payload of split expression or event object type name is missing." ); } assignment = getAssignment( commitConfig, split, unitIdValue, eventPayloadValue ); assignmentCache.set(split.id, unitIdValue, { assignment, eventObjectTypeName, eventPayloadLogs, eventPayload: eventPayloadValue, }); } const shouldExpose = exposeValue && Object.values(assignment).some( (dimensionAssignment) => dimensionAssignment.type !== "discrete" || dimensionAssignment.armId !== defaultArmKey ); const dimensionAssignment = nullThrows( assignment[dimensionId], "Split expression has invalid dimension ID." ); if (dimensionAssignment.type === "continuous") { // TODO: Implement throw new Error("Split expression has continuous dimension."); } const result = nullThrows( dimensionMapping.cases[dimensionAssignment.armId], `Split expression has reduced dimension mapping with missing case for assigned arm "${dimensionAssignment.armId}".` ); const logs = mergeLogs( thisLogs, exposeLogs, unitIdLogs, eventPayloadLogs, shouldExpose ? getExposureLogs({ splitId: split.id, unitId: unitIdValue, event: eventPayloadValue && expression.eventObjectTypeName ? { objectTypeName: expression.eventObjectTypeName, payload: eventPayloadValue, } : null, assignment, }) : undefined, result.logs ); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, expose, unitId, dimensionMapping, eventPayload, }; } case "LogEventExpression": { const eventPayload = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery null, // args variables, nullThrows( expression.eventPayload, new Error("Log event expression is missing event payload.") ) ); try { const { value: rawPayloadValue, logs: payloadLogs } = evaluate(eventPayload); const payloadValue = asPlainObjectOrThrow( rawPayloadValue, new Error( "Evaluated payload of log event expression is not an object." ) ); const result = getNoOpExpression(/* isTransient */ true); const logs = mergeLogs( thisLogs, payloadLogs, getEventLogs({ objectTypeName: nullThrows( expression.eventObjectTypeName, new Error( `Log event expression with id "${expression.id}" is missing event type name.` ) ), payload: payloadValue, }), result.logs ); return { ...result, logs }; } catch (error) { const { message } = asError(error); if (message !== complexFormExpressionEvaluationError) { throw error; } } return { ...expression, eventPayload }; } /** * If we have arguments, we create a new variables map (by extending the * existing one with them), then reduce the function body with this new * variables map and return the result. * * However, if one of the arguments was a partial object, we keep the * function expression intact, i.e. we return the whole function * expression but with the body still set to the result we got. This * allows for partial reduction. * * If we don't have any arguments, we return the whole function expression * with the body reduced with no arguments. */ case "FunctionExpression": { if (Array.isArray(args)) { if (expression.parameters.length !== args.length) { throw new Error( `Function expression has ${expression.parameters.length} ` + `parameters but was passed ${args.length} arguments.` ); } const newVariables = Object.fromEntries( expression.parameters.map((parameter, index) => [ parameter.id, args[index], ]) ); const result = innerReduce( splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args { ...variables, ...newVariables, }, nullThrows(