Skip to content

Commit e954268

Browse files
committed
self review addressed adn more tests
1 parent 7c9076e commit e954268

3 files changed

Lines changed: 244 additions & 18 deletions

File tree

packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,4 +1210,44 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
12101210
expect(troubleshooter).not.toBeUndefined();
12111211
});
12121212
});
1213+
1214+
it("should handle non-existent option IDs gracefully", async () => {
1215+
const LocationAttribute = {
1216+
id: "location-attr",
1217+
name: "Location",
1218+
type: "SINGLE_SELECT" as const,
1219+
slug: "location",
1220+
options: [{ id: "ny-opt", value: "New York", slug: "new-york" }],
1221+
};
1222+
1223+
mockAttributesScenario({
1224+
attributes: [LocationAttribute],
1225+
teamMembersWithAttributeOptionValuePerAttribute: [
1226+
{ userId: 1, attributes: { [LocationAttribute.id]: "New York" } },
1227+
],
1228+
});
1229+
1230+
const attributesQueryValue = buildSelectTypeFieldQueryValue({
1231+
rules: [
1232+
{
1233+
raqbFieldId: LocationAttribute.id,
1234+
value: ["non-existent-id"], // Non-existent option ID
1235+
operator: "select_equals",
1236+
},
1237+
],
1238+
}) as AttributesQueryValue;
1239+
1240+
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
1241+
dynamicFieldValueOperands: {
1242+
fields: [],
1243+
response: {},
1244+
},
1245+
attributesQueryValue,
1246+
teamId: 1,
1247+
orgId,
1248+
});
1249+
1250+
// Should not match anyone as the option ID doesn't exist
1251+
expect(result).toEqual([]);
1252+
});
12131253
});

packages/lib/raqb/resolveQueryValue.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,4 +1571,175 @@ describe("resolveQueryValue", () => {
15711571
})
15721572
);
15731573
});
1574+
1575+
it("should handle field templates in deeply nested object structures", () => {
1576+
const queryValue = {
1577+
children1: {
1578+
rule1: {
1579+
type: "rule",
1580+
properties: {
1581+
field: "complex",
1582+
metadata: {
1583+
level1: {
1584+
value: "{field:city}",
1585+
level2: {
1586+
items: ["{field:location}"],
1587+
level3: {
1588+
nested: [["{field:city}"]],
1589+
static: "preserved",
1590+
},
1591+
},
1592+
},
1593+
},
1594+
value: ["test"],
1595+
},
1596+
},
1597+
},
1598+
} as unknown as AttributesQueryValue;
1599+
1600+
const result = resolveQueryValue({
1601+
queryValue,
1602+
dynamicFieldValueOperands: {
1603+
fields: mockFields,
1604+
response: {
1605+
city: { value: "Mumbai", label: "Mumbai" },
1606+
location: { value: ["Delhi", "Chennai"], label: "Delhi, Chennai" },
1607+
},
1608+
},
1609+
attributes: mockAttributes,
1610+
});
1611+
1612+
// Verify all nested properties are preserved with templates resolved
1613+
const props = result.children1?.rule1.properties as any;
1614+
expect(props.metadata.level1.level2.level3.static).toBe("preserved");
1615+
expect(props.metadata.level1.value).toBe("mumbai");
1616+
expect(props.metadata.level1.level2.items).toEqual(["delhi", "chennai"]);
1617+
expect(props.metadata.level1.level2.level3.nested).toEqual([["mumbai"]]);
1618+
expect(props.value).toEqual(["test"]);
1619+
});
1620+
1621+
it("should handle extremely deep object nesting without stack overflow", () => {
1622+
let deepObj: any = { value: "{field:city}" };
1623+
for (let i = 0; i < 100; i++) {
1624+
deepObj = { nested: deepObj, level: i };
1625+
}
1626+
1627+
const queryValue = {
1628+
children1: {
1629+
rule1: {
1630+
type: "rule",
1631+
properties: deepObj,
1632+
},
1633+
},
1634+
} as unknown as AttributesQueryValue;
1635+
1636+
const result = resolveQueryValue({
1637+
queryValue,
1638+
dynamicFieldValueOperands: {
1639+
fields: mockFields,
1640+
response: {
1641+
city: { value: "Mumbai", label: "Mumbai" },
1642+
},
1643+
},
1644+
attributes: mockAttributes,
1645+
});
1646+
1647+
// Navigate to deepest level to verify it was processed
1648+
let current = result.children1?.rule1.properties as any;
1649+
for (let i = 99; i >= 0; i--) {
1650+
expect(current.level).toBe(i);
1651+
current = current.nested;
1652+
}
1653+
expect(current.value).toBe("mumbai");
1654+
});
1655+
1656+
it("should preserve all JavaScript primitive types in nested structures", () => {
1657+
const queryValue = {
1658+
children1: {
1659+
rule1: {
1660+
type: "rule",
1661+
properties: {
1662+
stringField: "{field:city}",
1663+
numberField: 42,
1664+
bigintField: 9007199254740991,
1665+
booleanField: true,
1666+
nullField: null,
1667+
nested: {
1668+
array: ["{field:location}", 123, false, null],
1669+
decimal: 3.14159,
1670+
negative: -100,
1671+
zero: 0,
1672+
emptyString: "",
1673+
specialChars: "!@#$%^&*()",
1674+
},
1675+
},
1676+
},
1677+
},
1678+
} as unknown as AttributesQueryValue;
1679+
1680+
const result = resolveQueryValue({
1681+
queryValue,
1682+
dynamicFieldValueOperands: {
1683+
fields: mockFields,
1684+
response: {
1685+
city: { value: "Mumbai", label: "Mumbai" },
1686+
location: { value: ["Delhi"], label: "Delhi" },
1687+
},
1688+
},
1689+
attributes: mockAttributes,
1690+
});
1691+
1692+
const props = result.children1?.rule1.properties as any;
1693+
expect(props.stringField).toBe("mumbai");
1694+
expect(props.numberField).toBe(42);
1695+
expect(props.bigintField).toBe(9007199254740991);
1696+
expect(props.booleanField).toBe(true);
1697+
expect(props.nullField).toBeNull();
1698+
expect(props.nested.array).toEqual(["delhi", 123, false, null]);
1699+
expect(props.nested.decimal).toBe(3.14159);
1700+
expect(props.nested.negative).toBe(-100);
1701+
expect(props.nested.zero).toBe(0);
1702+
expect(props.nested.emptyString).toBe("");
1703+
expect(props.nested.specialChars).toBe("!@#$%^&*()");
1704+
});
1705+
1706+
it("should process field templates within objects inside arrays", () => {
1707+
const queryValue = {
1708+
children1: {
1709+
rule1: {
1710+
type: "rule",
1711+
properties: {
1712+
value: [
1713+
{ id: 1, city: "{field:city}" },
1714+
{ id: 2, locations: ["{field:location}"] },
1715+
{ id: 3, nested: { value: [["{field:city}"]] } },
1716+
],
1717+
},
1718+
},
1719+
},
1720+
} as unknown as AttributesQueryValue;
1721+
1722+
const result = resolveQueryValue({
1723+
queryValue,
1724+
dynamicFieldValueOperands: {
1725+
fields: mockFields,
1726+
response: {
1727+
city: { value: "Mumbai", label: "Mumbai" },
1728+
location: { value: ["Delhi", "Chennai"], label: "Delhi, Chennai" },
1729+
},
1730+
},
1731+
attributes: mockAttributes,
1732+
});
1733+
1734+
const props = result.children1?.rule1.properties as any;
1735+
expect(props.value[0]).toEqual({ id: 1, city: "mumbai" });
1736+
expect(props.value[1]).toEqual({
1737+
id: 2,
1738+
locations: ["delhi", "chennai"],
1739+
});
1740+
expect(props.value[2]).toEqual({
1741+
id: 3,
1742+
nested: { value: [["mumbai"]] },
1743+
});
1744+
});
15741745
});

packages/lib/raqb/resolveQueryValue.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,20 @@ import { caseInsensitive } from "./utils";
88
// Type for JSON values that can be in the query
99
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
1010

11+
type MinimalField = {
12+
type: string;
13+
options?: {
14+
id: string | null;
15+
label: string;
16+
}[];
17+
};
18+
19+
type FieldResponseValue = dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"];
20+
1121
const moduleLogger = logger.getSubLogger({ prefix: ["raqb/resolveQueryValue"] });
1222

1323
/**
14-
* Replace attribute option Ids with the attribute option label(compatible to be matched with form field value)
24+
* Replace "Attribute Options' Ids" with "Attribute Options' Labels". It makes the query value compatible to be matched with 'Value of Field' operand
1525
*/
1626
const replaceAttributeOptionIdsWithOptionLabel = ({
1727
queryValue,
@@ -38,14 +48,8 @@ function getFieldResponseValueAsLabel({
3848
field,
3949
fieldResponseValue,
4050
}: {
41-
fieldResponseValue: dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"];
42-
field: {
43-
type: string;
44-
options?: {
45-
id: string | null;
46-
label: string;
47-
}[];
48-
};
51+
fieldResponseValue: FieldResponseValue;
52+
field: MinimalField;
4953
}) {
5054
const nonNumberFieldResponseValue =
5155
typeof fieldResponseValue === "number" ? fieldResponseValue.toString() : fieldResponseValue;
@@ -67,17 +71,22 @@ function getFieldResponseValueAsLabel({
6771
if (foundOptionById) {
6872
return foundOptionById.label;
6973
} else {
70-
return idOrLabel.toString();
74+
return idOrLabel;
7175
}
7276
}
7377
}
7478

7579
/**
76-
* Resolves field template placeholders ({field:fieldId}) in a JSON string with actual values from form responses.
77-
* Handles arrays properly for JSONLogic compatibility by spreading array values when needed.
80+
* Resolves query values by:
81+
* 1. Converting attribute option IDs to their lowercase labels
82+
* 2. Replacing field template placeholders (e.g., "{field:location}") with actual field values
7883
*
79-
* @param queryValueString - JSON string containing RAQB query with field template placeholders like {field:fieldId}
80-
* @param dynamicFieldValueOperands - Optional object containing fields metadata and user's form responses
84+
* Important: When a field template resolves to an array value, the array items are flattened
85+
* into the parent array. For example:
86+
* - Input: [["{{field:location}}", "Delhi"]] where location = ["New York", "Amsterdam"]
87+
* - Output: [["new york", "amsterdam", "Delhi"]] (flattened into single array)
88+
* @param queryValue - JSON string containing RAQB query with field template placeholders like {field:fieldId}
89+
* @param dynamicFieldValueOperands - Optional object containing fields metadata and user's form field responses
8190
* @param dynamicFieldValueOperands.fields - Array of field definitions with id, type, label, etc.
8291
* @param dynamicFieldValueOperands.response - Object mapping field IDs to their response values
8392
*
@@ -96,8 +105,8 @@ function getFieldResponseValueAsLabel({
96105
* "operator": "multiselect_some_in",
97106
* "value": [
98107
* [
99-
* "{field:0bf77a89-2bd0-4df7-9648-758014ba3189}",
100-
* "899c846d-7c02-43dc-9057-c3f8c118d41f"
108+
* "{field:0bf77a89-2bd0-4df7-9648-758014ba3189}", // 'Value of field' is stored like this and we call it a 'field template'
109+
* "899c846d-7c02-43dc-9057-c3f8c118d41f" // A regular attribute option id
101110
* ]
102111
* ],
103112
* "valueSrc": [
@@ -186,10 +195,12 @@ export const resolveQueryValue = ({
186195
return caseInsensitive(resolvedValue);
187196
};
188197

189-
// Process an array that might contain field templates. We keep it generic to handle any updates to the queryValue from the RAQB automatically
198+
// Process an array that might contain field templates. We keep it generic instead of typing it as QueryValue to handle any property where the queryValue can have a field template
199+
// Note: When a field template resolves to an array, its items are flattened into the parent array
190200
const processArray = (arr: JsonValue[]): JsonValue[] => {
191201
const hasFieldTemplate = arr.some((item) => typeof item === "string" && isFieldTemplate(item));
192202

203+
// Short circuit if there are no field templates
193204
if (!hasFieldTemplate) {
194205
return arr.map((item) => processAnyValue(item));
195206
}
@@ -199,9 +210,13 @@ export const resolveQueryValue = ({
199210
if (typeof item === "string" && isFieldTemplate(item)) {
200211
const processed = processStringValue(item);
201212
if (Array.isArray(processed)) {
202-
// [{field:location}, Delhi] -> becomes [New York, Amsterdam, Delhi]. New York and Amsterdam are the values of the field `location`
213+
// Flattening behavior: Array values from field templates are spread into parent array
214+
// Example: ["{field:location}", "Delhi"] where location = ["New York", "Amsterdam"]
215+
// Result: ["New York", "Amsterdam", "Delhi"] (items flattened)
216+
// This is intentional to merge field values at the same array level
203217
result.push(...processed);
204218
} else {
219+
// [{field:location}, Delhi] -> becomes [New York, Delhi]. New York was the response for the field `location`
205220
result.push(processed);
206221
}
207222
} else {

0 commit comments

Comments
 (0)