Files
calendar/packages/features/attributes/lib/getAttributes.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
9de621eb23 perf: replace O(n*m) array lookups with O(1) Map lookups in attribute routing (#27599)
* Replace n x m array functions with maps

* test: add tests and benchmark for Map-based attribute lookup optimization

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add real-world enterprise scenario (14 attrs x 6,530 opts) to benchmark

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* chore: remove benchmark file

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Rename function

* Pass built maps to functions

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-04 12:43:16 -05:00

536 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TODO: Queries in this file are not optimized. Need to optimize them.
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import type {
AttributeId,
AttributeOptionValueWithType,
} from "@calcom/app-store/routing-forms/types/types";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository";
import logger from "@calcom/lib/logger";
import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { AttributeToUser } from "@calcom/prisma/client";
import type { AttributeType } from "@calcom/prisma/enums";
/**
* Extracts attribute IDs referenced in a routing rule's attributesQueryValue.
* This allows us to only fetch the attributes that are actually needed for evaluation.
*/
export function extractAttributeIdsFromQueryValue(
queryValue: AttributesQueryValue | null,
fallbackQueryValue?: AttributesQueryValue | null
): string[] {
const attributeIds = new Set<string>();
function walkRules(obj: unknown) {
if (!obj || typeof obj !== "object") return;
const record = obj as Record<string, unknown>;
// Check if this is a rule with a field property (the attribute ID)
if (record.type === "rule" && record.properties) {
const properties = record.properties as Record<string, unknown>;
if (typeof properties.field === "string") {
attributeIds.add(properties.field);
}
}
// Recursively walk children1 (the RAQB structure for nested rules)
if (record.children1 && typeof record.children1 === "object") {
for (const child of Object.values(record.children1 as Record<string, unknown>)) {
walkRules(child);
}
}
}
if (queryValue) {
walkRules(queryValue);
}
if (fallbackQueryValue) {
walkRules(fallbackQueryValue);
}
return Array.from(attributeIds);
}
type UserId = number;
type OrgMembershipId = number;
type AttributeOptionId = string;
type AssignmentForTheTeam = {
userId: number;
attributeOption: {
id: string;
value: string;
slug: string;
contains: string[];
isGroup: boolean;
};
attribute: {
id: string;
name: string;
type: AttributeType;
};
};
type FullAttribute = {
name: string;
slug: string;
type: AttributeType;
id: string;
options: {
id: string;
value: string;
slug: string;
isGroup: boolean;
contains: string[];
}[];
};
async function _findMembershipsForBothOrgAndTeam({
orgId,
teamId,
}: {
orgId: number;
teamId: number;
}) {
const memberships = await prisma.membership.findMany({
where: {
teamId: {
in: [orgId, teamId],
},
},
});
type Membership = (typeof memberships)[number];
const { teamMemberships, orgMemberships } = memberships.reduce<{
teamMemberships: Membership[];
orgMemberships: Membership[];
}>(
(acc, membership) => {
if (membership.teamId === teamId) {
acc.teamMemberships.push(membership);
} else if (membership.teamId === orgId) {
acc.orgMemberships.push(membership);
}
return acc;
},
{ teamMemberships: [], orgMemberships: [] }
);
return {
teamMemberships,
orgMemberships,
};
}
function _prepareAssignmentData({
assignmentsForTheTeam,
lookupMaps,
}: {
assignmentsForTheTeam: AssignmentForTheTeam[];
lookupMaps: AttributeLookupMaps;
}) {
const { optionIdToOption, attributeIdToOptions } = lookupMaps;
const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce(
(acc, attributeToUser) => {
const userId = attributeToUser.userId;
const attributeOption = attributeToUser.attributeOption;
const attribute = attributeToUser.attribute;
if (!acc[userId]) {
acc[userId] = { userId, attributes: {} };
}
const attributes = acc[userId].attributes;
const currentAttributeOptionValue =
attributes[attribute.id]?.attributeOption;
const newAttributeOptionValue = {
isGroup: attributeOption.isGroup,
value: attributeOption.value,
contains: tranformContains({
contains: attributeOption.contains,
attribute,
}),
};
if (currentAttributeOptionValue instanceof Array) {
attributes[attribute.id].attributeOption = [
...currentAttributeOptionValue,
newAttributeOptionValue,
];
} else if (currentAttributeOptionValue) {
attributes[attribute.id].attributeOption = [
currentAttributeOptionValue,
{
isGroup: attributeOption.isGroup,
value: attributeOption.value,
contains: tranformContains({
contains: attributeOption.contains,
attribute,
}),
},
];
} else {
// Set the first value
attributes[attribute.id] = {
type: attribute.type,
attributeOption: newAttributeOptionValue,
};
}
return acc;
},
{} as Record<
UserId,
{
userId: UserId;
attributes: Record<AttributeId, AttributeOptionValueWithType>;
}
>
);
return Object.values(teamMembersThatHaveOptionAssigned);
/**
* Transforms ["optionId1", "optionId2"] to [{
* id: "optionId1",
* value: "optionValue1",
* slug: "optionSlug1",
* }, {
* id: "optionId2",
* value: "optionValue2",
* slug: "optionSlug2",
* }]
*/
function tranformContains({
contains,
attribute,
}: {
contains: string[];
attribute: { id: string; name: string };
}) {
return contains
.map((optionId) => {
const option = optionIdToOption.get(optionId);
if (!option) {
const allOptions = attributeIdToOptions.get(attribute.id);
console.error(
`Enriching "contains" for attribute ${
attribute.name
}: Option with id ${optionId} not found. Looked up in ${JSON.stringify(
allOptions
)}`
);
return null;
}
return {
id: option.id,
value: option.value,
slug: option.slug,
};
})
.filter(
(option): option is NonNullable<typeof option> => option !== null
);
}
}
/**
* Builds lookup maps for O(1) attribute and option lookups by option ID.
* This replaces O(n×m) linear scans with O(1) Map lookups.
*/
function _buildAttributeLookupMaps(attributesOfTheOrg: FullAttribute[]) {
const optionIdToAttribute = new Map<AttributeOptionId, FullAttribute>();
const optionIdToOption = new Map<
AttributeOptionId,
FullAttribute["options"][number]
>();
const attributeIdToOptions = new Map<string, FullAttribute["options"]>();
for (const attribute of attributesOfTheOrg) {
attributeIdToOptions.set(attribute.id, attribute.options);
for (const option of attribute.options) {
optionIdToAttribute.set(option.id, attribute);
optionIdToOption.set(option.id, option);
}
}
return { optionIdToAttribute, optionIdToOption, attributeIdToOptions };
}
type AttributeLookupMaps = ReturnType<typeof _buildAttributeLookupMaps>;
function _getAttributeFromAttributeOption({
lookupMaps,
attributeOptionId,
}: {
lookupMaps: AttributeLookupMaps;
attributeOptionId: AttributeOptionId;
}) {
return lookupMaps.optionIdToAttribute.get(attributeOptionId);
}
function _getAttributeOptionFromId({
lookupMaps,
attributeOptionId,
}: {
lookupMaps: AttributeLookupMaps;
attributeOptionId: AttributeOptionId;
}) {
return lookupMaps.optionIdToOption.get(attributeOptionId);
}
async function _getOrgMembershipToUserIdForTeam({
orgId,
teamId,
}: {
orgId: number;
teamId: number;
}) {
const { orgMemberships, teamMemberships } =
await _findMembershipsForBothOrgAndTeam({
orgId,
teamId,
});
// Using map for performance lookup as it matters in the below loop working with 1000s of records
const orgMembershipsByUserId = new Map(
orgMemberships.map((m) => [m.userId, m])
);
/**
* Holds the records of orgMembershipId to userId for the sub-team's members only.
*/
const orgMembershipToUserIdForTeamMembers = new Map<
OrgMembershipId,
UserId
>();
/**
* For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300
* So, these are not a lot of records and we could afford to do in memory computations on them.
*/
teamMemberships.forEach((teamMembership) => {
const orgMembership = orgMembershipsByUserId.get(teamMembership.userId);
if (!orgMembership) {
// console.error(
// `Org membership not found for userId ${teamMembership.userId} in the organization's memberships`
// );
return;
}
orgMembershipToUserIdForTeamMembers.set(
orgMembership.id,
orgMembership.userId
);
});
return orgMembershipToUserIdForTeamMembers;
}
async function _queryAllData({
orgId,
teamId,
attributeIds,
}: {
orgId: number;
teamId: number;
/** If provided, only fetch attribute assignments for these attribute IDs */
attributeIds?: string[];
}) {
const attributeRepo = new PrismaAttributeRepository(prisma);
const attributeToUserRepo = new PrismaAttributeToUserRepository(prisma);
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] =
await Promise.all([
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
attributeRepo.findManyByOrgId({ orgId, attributeIds }),
]);
const orgMembershipIds = Array.from(
orgMembershipToUserIdForTeamMembers.keys()
);
// Get the attributes assigned to the members of the team
// If attributeIds is provided, only fetch assignments for those specific attributes
const attributesToUsersForTeam =
await attributeToUserRepo.findManyByOrgMembershipIds({
orgMembershipIds,
attributeIds,
});
return {
attributesOfTheOrg,
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
};
}
async function getAttributesAssignedToMembersOfTeam({
teamId,
userId,
}: {
teamId: number;
userId?: number;
}) {
const log = logger.getSubLogger({
prefix: ["getAttributeToUserWithMembershipAndAttributes"],
});
const whereClauseForAttributesAssignedToMembersOfTeam = {
options: {
some: {
assignedUsers: {
some: {
member: {
userId,
user: {
teams: {
some: {
teamId,
},
},
},
},
},
},
},
},
};
log.debug(
safeStringify({
teamId,
whereClauseForAttributesAssignedToMembersOfTeam,
})
);
const assignedAttributeOptions = await prisma.attribute.findMany({
where: whereClauseForAttributesAssignedToMembersOfTeam,
select: {
id: true,
name: true,
type: true,
isWeightsEnabled: true,
options: {
select: {
id: true,
value: true,
slug: true,
contains: true,
isGroup: true,
},
},
slug: true,
},
});
return assignedAttributeOptions;
}
function _buildAssignmentsForTeam({
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
lookupMaps,
}: {
attributesToUsersForTeam: AttributeToUser[];
orgMembershipToUserIdForTeamMembers: Map<OrgMembershipId, UserId>;
lookupMaps: AttributeLookupMaps;
}) {
return attributesToUsersForTeam
.map((attributeToUser) => {
const orgMembershipId = attributeToUser.memberId;
const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId);
if (!userId) {
console.error(
`No org membership found for membership id ${orgMembershipId}`
);
return null;
}
const attribute = _getAttributeFromAttributeOption({
lookupMaps,
attributeOptionId: attributeToUser.attributeOptionId,
});
const attributeOption = _getAttributeOptionFromId({
lookupMaps,
attributeOptionId: attributeToUser.attributeOptionId,
});
if (!attributeOption || !attribute) {
console.error(
`Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes`
);
return null;
}
return {
...attributeToUser,
userId,
attribute,
attributeOption,
};
})
.filter(
(assignment): assignment is NonNullable<typeof assignment> =>
assignment !== null
);
}
export async function getAttributesAssignmentData({
orgId,
teamId,
attributeIds,
}: {
orgId: number;
teamId: number;
/** If provided, only fetch attribute assignments for these attribute IDs.
* This significantly improves performance when only a few attributes are needed. */
attributeIds?: string[];
}) {
const {
attributesOfTheOrg,
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
} = await _queryAllData({
orgId,
teamId,
attributeIds,
});
const lookupMaps = _buildAttributeLookupMaps(attributesOfTheOrg);
const assignmentsForTheTeam = _buildAssignmentsForTeam({
attributesToUsersForTeam,
orgMembershipToUserIdForTeamMembers,
lookupMaps,
});
const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({
assignmentsForTheTeam,
lookupMaps,
});
return {
attributesOfTheOrg,
attributesAssignedToTeamMembersWithOptions,
};
}
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
return attributes satisfies Attribute[];
}
export async function getUsersAttributes({
userId,
teamId,
}: {
userId: number;
teamId: number;
}) {
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
}