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
text/typescript
/* 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(