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
843 lines • 62.2 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = reduce;
exports.areEqual = areEqual;
/* eslint-disable capitalized-comments */
const constants_1 = require("../constants");
const emptyThrows_1 = __importDefault(require("./emptyThrows"));
const evaluate_1 = __importStar(require("./evaluate"));
const expression_1 = require("./expression");
const hash_1 = __importDefault(require("./hash"));
const nullThrows_1 = __importDefault(require("./nullThrows"));
const reductionLogs_1 = require("./reductionLogs");
const stableStringify_1 = __importDefault(require("./stableStringify"));
const types_1 = require("../types");
const asError_1 = __importDefault(require("./asError"));
const getNestedValue_1 = __importDefault(require("./getNestedValue"));
const asPlainObjectOrThrow_1 = __importDefault(require("./asPlainObjectOrThrow"));
const getInlineFragment_1 = __importDefault(require("./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
function reduce(splits, commitConfig, query, variableValues, rootExpression, allowMissingVariables) {
var _a, _b;
const innerQuery = getInnerQuery(query, variableValues, allowMissingVariables);
return innerReduce(splits, commitConfig, createAssignmentCache(),
/* enablePartialObjectVariableReduction */ false, (_a = innerQuery === null || innerQuery === void 0 ? void 0 : innerQuery.fragmentDefinitions) !== null && _a !== void 0 ? _a : {}, (_b = innerQuery === null || innerQuery === void 0 ? void 0 : innerQuery.fieldQuery) !== null && _b !== void 0 ? _b : null,
/* args */ null,
/* variables */ {}, rootExpression);
}
function createAssignmentCache() {
const cache = new Map();
return {
get: (splitId, unitId) => {
var _a;
return (_a = cache.get(getAssignmentCacheKey(splitId, unitId))) !== null && _a !== void 0 ? _a : null;
},
set: (splitId, unitId, value) => {
cache.set(getAssignmentCacheKey(splitId, unitId), value);
},
};
}
function getAssignmentCacheKey(splitId, unitId) {
return (0, stableStringify_1.default)({ splitId, unitId });
}
function getInnerQuery(query, variableValues, allowMissingVariables) {
return query
? Object.assign(Object.assign({}, 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, fieldQuery, variableValues, allowMissingVariables) {
return Object.fromEntries(Object.entries(fieldQuery).map(([objectTypeName, fragment]) => [
objectTypeName,
fragment.type === "InlineFragment"
? getInnerInlineFragment(variableDefinitions, fragment, variableValues, allowMissingVariables)
: fragment,
]));
}
function getInnerInlineFragment(variableDefinitions, fragment, variableValues, allowMissingVariables) {
return {
type: "InlineFragment",
objectTypeName: fragment.objectTypeName,
selection: Object.fromEntries(Object.entries(fragment.selection).map(([fieldName, { fieldArguments, fieldQuery }]) => [
fieldName,
{
fieldArguments: (0, expression_1.getAnonymousObjectExpression)(replaceVariables(variableDefinitions, fieldArguments, variableValues, allowMissingVariables),
/* isTransient */ true),
fieldQuery: fieldQuery
? getInnerFieldQuery(variableDefinitions, fieldQuery, variableValues, allowMissingVariables)
: null,
},
])),
};
}
function replaceVariables(variableDefinitions, value, variableValues, allowMissingVariables) {
var _a;
switch (typeof value) {
case "string":
case "boolean":
case "number":
case "bigint":
return value;
case "object": {
if ((0, types_1.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 (_a = variableValues[variableName]) !== null && _a !== void 0 ? _a : 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, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, args, // Reduced expressions (given a null query)
// Reduced expressions (given a null query)
variables, 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 = (0, reductionLogs_1.mergeLogs)(expression.logs, (0, reductionLogs_1.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 Object.assign(Object.assign({}, expression), { fields: Object.fromEntries(Object.keys(expression.fields).map((fieldName) => [
fieldName,
innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.fields[fieldName], `Object expression has null field "${fieldName}".`)),
])) });
}
const fragment = innerQuery[expression.objectTypeName];
const selection = fragment
? (0, getInlineFragment_1.default)(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 Object.assign(Object.assign({}, 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, (0, nullThrows_1.default)(expression.object, "Get field expression has null object."));
const field = getField(object1, (0, nullThrows_1.default)(expression.fieldPath, "Get field expression has null field path."));
if (field) {
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, object1.logs, field.logs);
return Object.assign(Object.assign({}, field), { logs });
}
const object2 = innerReduce(splits, commitConfig, assignmentCache, false, // enablePartialObjectVariableReduction
fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.object, "Get field expression has null object."));
return Object.assign(Object.assign({}, 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, (0, nullThrows_1.default)(expression.object, "Update object expression has null object."));
if (object.type !== "ObjectExpression") {
return Object.assign(Object.assign({}, expression), { object });
}
const { objectTypeName } = object;
const fragment = innerQuery ? innerQuery[objectTypeName] : null;
const selection = !fragment
? null
: (0, getInlineFragment_1.default)(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, (0, nullThrows_1.default)(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 = (0, reductionLogs_1.mergeLogs)(thisLogs, object.logs);
return Object.assign(Object.assign({}, object), { logs });
}
/**
* Return the whole list expression with all items reduced with the
* current query (but no arguments).
*/
case "ListExpression":
return Object.assign(Object.assign({}, expression), { items: expression.items.map((item, index) => innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args
variables, (0, nullThrows_1.default)(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, (0, nullThrows_1.default)(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, (0, nullThrows_1.default)(item.when, `Switch expression case has null 'when' at index ${index}.`)),
then: innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args
variables, (0, nullThrows_1.default)(item.then, `Switch expression case has null 'then' at index ${index}.`)),
}));
const defaultExpression = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, innerQuery, null, // args
variables, (0, nullThrows_1.default)(expression.default, "Switch expression has null default."));
try {
const { value: controlValue, logs: controlLogs } = (0, evaluate_1.default)(control);
let logs = (0, reductionLogs_1.mergeLogs)(thisLogs, controlLogs);
for (const { when, then } of cases) {
const { value: whenValue, logs: whenLogs } = (0, evaluate_1.default)(when);
logs = (0, reductionLogs_1.mergeLogs)(logs, whenLogs);
if (areEqual(controlValue, whenValue)) {
logs = (0, reductionLogs_1.mergeLogs)(logs, then.logs);
return Object.assign(Object.assign({}, then), { logs });
}
}
logs = (0, reductionLogs_1.mergeLogs)(logs, defaultExpression.logs);
return Object.assign(Object.assign({}, defaultExpression), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { control, cases, default: defaultExpression });
}
/**
* Similar to switch expression.
*/
case "EnumSwitchExpression": {
const control = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(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, (0, nullThrows_1.default)(expression.cases[enumValue], `Enum switch expression has null case "${enumValue}".`)),
]));
try {
const { value: controlValue, logs: controlLogs } = (0, evaluate_1.default)(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 = (0, reductionLogs_1.mergeLogs)(thisLogs, controlLogs, then.logs);
return Object.assign(Object.assign({}, then), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, 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, (0, nullThrows_1.default)(expression.a, "Comparison expression has null a."));
const b = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.b, "Comparison expression has null b."));
try {
const { value: aValue, logs: aLogs } = (0, evaluate_1.default)(a);
const { value: bValue, logs: bLogs } = (0, evaluate_1.default)(b);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, aLogs, bLogs);
const operator = (0, nullThrows_1.default)(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 = operator;
throw new Error(`unexpected operator: ${neverOperator}`);
}
}
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { a, b });
}
/**
* Similar to comparison expression.
*/
case "ArithmeticExpression": {
const a = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.a, "Arithmetic expression has null a"));
const b = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.b, "Arithmetic expression has null b"));
try {
const { value: aValue, logs: aLogs } = (0, evaluate_1.default)(a);
const { value: bValue, logs: bLogs } = (0, evaluate_1.default)(b);
if (typeof aValue !== "number" || typeof bValue !== "number") {
throw new Error("Evaluated operands of arithmetic expression are not both " +
"numbers.");
}
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, aLogs, bLogs);
const operator = (0, nullThrows_1.default)(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(Math.pow(aValue, bValue), logs);
case "MOD":
return getArithmeticResultExpression(aValue % bValue, logs);
default: {
const neverOperator = operator;
throw new Error(`unexpected operator: ${neverOperator}`);
}
}
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { a, b });
}
case "RoundNumberExpression": {
const number = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.number, "Round number expression has null number."));
try {
const { value: numberValue, logs: numberLogs } = (0, evaluate_1.default)(number);
if (typeof numberValue !== "number") {
throw new Error("Evaluated number of round number expression is not a number.");
}
const result = (0, expression_1.getIntExpression)(Math.round(numberValue),
/* isTransient */ true);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, numberLogs, result.logs);
return Object.assign(Object.assign({}, result), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { number });
}
case "StringifyNumberExpression": {
const number = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.number, "Stringify number expression has null number."));
try {
const { value: numberValue, logs: numberLogs } = (0, evaluate_1.default)(number);
if (typeof numberValue !== "number") {
throw new Error("Evaluated number of stringify number expression is not a number.");
}
const result = (0, expression_1.getStringExpression)(numberValue.toString(),
/* isTransient */ true);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, numberLogs, result.logs);
return Object.assign(Object.assign({}, result), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { number });
}
case "StringConcatExpression": {
const strings = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.strings, "String concat expression has null strings."));
try {
const { value: stringsValue, logs: stringsLogs } = (0, evaluate_1.default)(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 = (0, expression_1.getStringExpression)(stringsValue.join(""),
/* isTransient */ true);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, stringsLogs, result.logs);
return Object.assign(Object.assign({}, result), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { strings });
}
case "GetUrlQueryParameterExpression": {
const url = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.url, "Get url query parameter expression has null url."));
const queryParameterName = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.queryParameterName, "Get url query parameter expression has null query parameter name."));
try {
const { value: urlValue, logs: urlLogs } = (0, evaluate_1.default)(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 } = (0, evaluate_1.default)(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 = (0, expression_1.getStringExpression)(queryParameterValue,
/* isTransient */ true);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, urlLogs, queryParameterNameLogs, result.logs);
return Object.assign(Object.assign({}, result), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { url, queryParameterName });
}
case "SplitExpression": {
const split = (0, nullThrows_1.default)(splits[(0, nullThrows_1.default)(expression.splitId, "Split expression has null split ID.")], "Split expression has invalid split ID.");
const dimensionId = (0, nullThrows_1.default)(expression.dimensionId, "Split expression has null dimension ID.");
const expose = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.expose, "Split expression has null expose."));
const unitId = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(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 = {
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, (0, nullThrows_1.default)(unreducedDimensionMapping.cases[armId], "Split expression has discrete dimension mapping with null " +
`case for arm "${armId}".`)),
])),
};
try {
const { value: exposeValue, logs: exposeLogs } = (0, evaluate_1.default)(expose);
if (typeof exposeValue !== "boolean") {
throw new Error("Evaluated expose of split expression is not a boolean.");
}
const { value: unitIdValue, logs: unitIdLogs } = (0, evaluate_1.default)(unitId);
if (typeof unitIdValue !== "string") {
throw new Error("Evaluated unit ID of split expression is not a string.");
}
let assignment = null;
let eventObjectTypeName = null;
let eventPayloadValue = null;
let eventPayloadLogs;
// 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
? (0, evaluate_1.default)(eventPayload)
: { value: null, logs: {} };
eventPayloadValue =
payloadResult.value === null
? null
: (0, asPlainObjectOrThrow_1.default)(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 !== constants_1.defaultArmKey);
const dimensionAssignment = (0, nullThrows_1.default)(assignment[dimensionId], "Split expression has invalid dimension ID.");
if (dimensionAssignment.type === "continuous") {
// TODO: Implement
throw new Error("Split expression has continuous dimension.");
}
const result = (0, nullThrows_1.default)(dimensionMapping.cases[dimensionAssignment.armId], `Split expression has reduced dimension mapping with missing case for assigned arm "${dimensionAssignment.armId}".`);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, exposeLogs, unitIdLogs, eventPayloadLogs, shouldExpose
? (0, reductionLogs_1.getExposureLogs)({
splitId: split.id,
unitId: unitIdValue,
event: eventPayloadValue && expression.eventObjectTypeName
? {
objectTypeName: expression.eventObjectTypeName,
payload: eventPayloadValue,
}
: null,
assignment,
})
: undefined, result.logs);
return Object.assign(Object.assign({}, result), { logs });
}
catch (error) {
const { message } = (0, asError_1.default)(error);
if (message !== evaluate_1.complexFormExpressionEvaluationError) {
throw error;
}
}
return Object.assign(Object.assign({}, expression), { expose,
unitId,
dimensionMapping,
eventPayload });
}
case "LogEventExpression": {
const eventPayload = innerReduce(splits, commitConfig, assignmentCache, enablePartialObjectVariableReduction, fragmentDefinitions, null, // innerQuery
null, // args
variables, (0, nullThrows_1.default)(expression.eventPayload, new Error("Log event expression is missing event payload.")));
try {
const { value: rawPayloadValue, logs: payloadLogs } = (0, evaluate_1.default)(eventPayload);
const payloadValue = (0, asPlainObjectOrThrow_1.default)(rawPayloadValue, new Error("Evaluated payload of log event expression is not an object."));
const result = (0, expression_1.getNoOpExpression)(/* isTransient */ true);
const logs = (0, reductionLogs_1.mergeLogs)(thisLogs, payloadLogs, (0, reductionLogs_1.getEventLogs)({
objectTypeName: (0, nullThrows_1.default)(expression.eventObjectTypeName, new Error(`Log event expression with id "${expression.id}" is missing event type name.`)),
payload: payloadValue,
}), result.logs);
r