feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus * Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> * add more indexes * improvement * update schema * improve indexes * apply feedback from mrge * split update trigger for better perf * remove unnecessary checks * split migration into two * less logging * address feedback * add updatedAt and userUsername * remove timeStatus * add deletion trigger * clean up denorm table on row deletion at EventType or users * add timeStatus back as a part of a new view * keep user and eventType nullable * rename triggers * add tests * clean up migrations * update tests * update tests * update test clean up * one more try * similar updates * use e2e test instead --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
co-authored by
sean-brydon
parent
3d89b37cc6
commit
ab8556790e
@@ -0,0 +1,201 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import type { User, EventType } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("BookingDenormalized", () => {
|
||||
let userId: number;
|
||||
let eventTypeId: number;
|
||||
let bookingId: number;
|
||||
let user: User;
|
||||
let eventType: EventType;
|
||||
|
||||
test.beforeEach(async ({ prisma }) => {
|
||||
// Create test user with random email to avoid conflicts
|
||||
const randomId = Math.floor(Math.random() * 1000000);
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: `booking-denorm-${randomId}@example.com`,
|
||||
username: `booking-denorm-testuser-${randomId}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
// Create test event type
|
||||
eventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event",
|
||||
slug: "test-event",
|
||||
length: 60,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
eventTypeId = eventType.id;
|
||||
|
||||
// Create test booking with PENDING status (no idempotencyKey needed)
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `booking-denorm-${randomId}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000), // 1 hour later
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
bookingId = booking.id;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ prisma }) => {
|
||||
// Clean up test data in reverse order of creation to avoid foreign key constraints
|
||||
if (bookingId) {
|
||||
await prisma.booking.deleteMany({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
}
|
||||
if (eventTypeId) {
|
||||
await prisma.eventType.deleteMany({
|
||||
where: { id: eventTypeId },
|
||||
});
|
||||
}
|
||||
if (userId) {
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("should create denormalized entry when booking is created", async ({ prisma }) => {
|
||||
const denormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
expect(denormalizedBooking.eventTypeId).toBe(eventTypeId);
|
||||
expect(denormalizedBooking.userId).toBe(userId);
|
||||
expect(denormalizedBooking.title).toBe("Test Booking");
|
||||
expect(denormalizedBooking.eventLength).toBe(60);
|
||||
expect(denormalizedBooking.userName).toBe("Test User");
|
||||
});
|
||||
|
||||
test("should update denormalized entry when booking is updated", async ({ prisma }) => {
|
||||
await prisma.booking.update({
|
||||
where: { id: bookingId },
|
||||
data: {
|
||||
title: "Updated Booking",
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
expect(updatedDenormalizedBooking.title).toBe("Updated Booking");
|
||||
expect(updatedDenormalizedBooking.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test("should update denormalized entry when user is updated", async ({ prisma }) => {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
name: "Updated User",
|
||||
email: "updated@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const userUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
expect(userUpdatedDenormalizedBooking.userName).toBe("Updated User");
|
||||
expect(userUpdatedDenormalizedBooking.userEmail).toBe("updated@example.com");
|
||||
});
|
||||
|
||||
test("should update denormalized entry when event type is updated", async ({ prisma }) => {
|
||||
await prisma.eventType.update({
|
||||
where: { id: eventTypeId },
|
||||
data: {
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypeUpdatedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
expect(eventTypeUpdatedDenormalizedBooking.eventLength).toBe(30);
|
||||
});
|
||||
|
||||
test("should delete denormalized entry when booking is deleted", async ({ prisma }) => {
|
||||
await prisma.booking.delete({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
const deletedDenormalizedBooking = await prisma.bookingDenormalized.findUnique({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
expect(deletedDenormalizedBooking).toBeNull();
|
||||
});
|
||||
|
||||
test("should update denormalized entries when event type is deleted", async ({ prisma }) => {
|
||||
// Create an additional booking for this test
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${Math.floor(Math.random() * 1000000)}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: userId,
|
||||
eventTypeId: eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventType.delete({
|
||||
where: { id: eventTypeId },
|
||||
});
|
||||
|
||||
const eventTypeDeletedDenormalizedBooking = await prisma.bookingDenormalized.findUniqueOrThrow({
|
||||
where: { id: booking2.id },
|
||||
});
|
||||
|
||||
expect(eventTypeDeletedDenormalizedBooking.eventTypeId).toBeNull();
|
||||
expect(eventTypeDeletedDenormalizedBooking.eventLength).toBeNull();
|
||||
});
|
||||
|
||||
test("should delete denormalized entries when user is deleted", async ({ prisma }) => {
|
||||
// Create an additional booking for this test
|
||||
const booking2 = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${Math.floor(Math.random() * 1000000)}`,
|
||||
title: "Test Booking 2",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: userId,
|
||||
eventTypeId: eventTypeId,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
const userDeletedDenormalizedBooking1 = await prisma.bookingDenormalized.findUnique({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
const userDeletedDenormalizedBooking2 = await prisma.bookingDenormalized.findUnique({
|
||||
where: { id: booking2.id },
|
||||
});
|
||||
|
||||
expect(userDeletedDenormalizedBooking1).toBeNull();
|
||||
expect(userDeletedDenormalizedBooking2).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "BookingDenormalized" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"uid" TEXT NOT NULL,
|
||||
"eventTypeId" INTEGER,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"startTime" TIMESTAMP(3) NOT NULL,
|
||||
"endTime" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3),
|
||||
"location" TEXT,
|
||||
"paid" BOOLEAN NOT NULL,
|
||||
"status" "BookingStatus" NOT NULL,
|
||||
"rescheduled" BOOLEAN,
|
||||
"userId" INTEGER,
|
||||
"teamId" INTEGER,
|
||||
"eventLength" INTEGER,
|
||||
"eventParentId" INTEGER,
|
||||
"userEmail" TEXT,
|
||||
"userName" TEXT,
|
||||
"userUsername" TEXT,
|
||||
"ratingFeedback" TEXT,
|
||||
"rating" INTEGER,
|
||||
"noShowHost" BOOLEAN,
|
||||
"isTeamBooking" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "BookingDenormalized_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_userId_idx" ON "BookingDenormalized"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_createdAt_idx" ON "BookingDenormalized"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_eventTypeId_idx" ON "BookingDenormalized"("eventTypeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_eventParentId_idx" ON "BookingDenormalized"("eventParentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_teamId_idx" ON "BookingDenormalized"("teamId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_startTime_idx" ON "BookingDenormalized"("startTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_endTime_idx" ON "BookingDenormalized"("endTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_status_idx" ON "BookingDenormalized"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_teamId_isTeamBooking_idx" ON "BookingDenormalized"("teamId", "isTeamBooking");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "BookingDenormalized_userId_isTeamBooking_idx" ON "BookingDenormalized"("userId", "isTeamBooking");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BookingDenormalized_id_key" ON "BookingDenormalized"("id");
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
-- Function to calculate isTeamBooking
|
||||
CREATE OR REPLACE FUNCTION calculate_is_team_booking(team_id INTEGER)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN team_id IS NOT NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to refresh a single booking's data
|
||||
CREATE OR REPLACE FUNCTION refresh_booking_time_status_denormalized(booking_id INTEGER)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Delete existing entry if any
|
||||
DELETE FROM "BookingDenormalized" WHERE id = booking_id;
|
||||
|
||||
-- Insert a denormalized booking joined with EventType and user
|
||||
INSERT INTO "BookingDenormalized" (
|
||||
id,
|
||||
uid,
|
||||
"eventTypeId",
|
||||
title,
|
||||
description,
|
||||
"startTime",
|
||||
"endTime",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
location,
|
||||
paid,
|
||||
status,
|
||||
rescheduled,
|
||||
"userId",
|
||||
"teamId",
|
||||
"eventLength",
|
||||
"eventParentId",
|
||||
"userEmail",
|
||||
"userName",
|
||||
"userUsername",
|
||||
"ratingFeedback",
|
||||
"rating",
|
||||
"noShowHost",
|
||||
"isTeamBooking"
|
||||
)
|
||||
SELECT
|
||||
"Booking".id,
|
||||
"Booking".uid,
|
||||
"Booking"."eventTypeId",
|
||||
"Booking".title,
|
||||
"Booking".description,
|
||||
"Booking"."startTime",
|
||||
"Booking"."endTime",
|
||||
"Booking"."createdAt",
|
||||
"Booking"."updatedAt",
|
||||
"Booking".location,
|
||||
"Booking".paid,
|
||||
"Booking".status,
|
||||
"Booking".rescheduled,
|
||||
"Booking"."userId",
|
||||
et."teamId",
|
||||
et.length AS "eventLength",
|
||||
et."parentId" AS "eventParentId",
|
||||
"u"."email" AS "userEmail",
|
||||
"u"."name" AS "userName",
|
||||
"u"."username" AS "userUsername",
|
||||
"Booking"."ratingFeedback",
|
||||
"Booking"."rating",
|
||||
"Booking"."noShowHost",
|
||||
calculate_is_team_booking(et."teamId") as "isTeamBooking"
|
||||
FROM "Booking"
|
||||
LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id
|
||||
LEFT JOIN users u ON u.id = "Booking"."userId"
|
||||
WHERE "Booking".id = booking_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger function for booking changes (insert/update)
|
||||
CREATE OR REPLACE FUNCTION trigger_refresh_booking_time_status_denormalized()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
PERFORM refresh_booking_time_status_denormalized(NEW.id);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to handle booking change for id %', NEW.id;
|
||||
END;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger function for booking deletions
|
||||
CREATE OR REPLACE FUNCTION trigger_delete_booking_time_status_denormalized()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
DELETE FROM "BookingDenormalized" WHERE id = OLD.id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to delete denormalized booking id %', OLD.id;
|
||||
END;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers for Booking table
|
||||
CREATE TRIGGER booking_denorm_booking_insert_update_trigger
|
||||
AFTER INSERT OR UPDATE ON "Booking"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_refresh_booking_time_status_denormalized();
|
||||
|
||||
CREATE TRIGGER booking_denorm_booking_delete_trigger
|
||||
AFTER DELETE ON "Booking"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_delete_booking_time_status_denormalized();
|
||||
|
||||
-- Function for teamId changes on EventType
|
||||
CREATE OR REPLACE FUNCTION refresh_booking_time_status_team_id()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "BookingDenormalized" btsd
|
||||
SET
|
||||
"teamId" = NEW."teamId",
|
||||
"isTeamBooking" = calculate_is_team_booking(NEW."teamId")
|
||||
WHERE btsd."eventTypeId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function for length changes on EventType
|
||||
CREATE OR REPLACE FUNCTION refresh_booking_time_status_length()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "BookingDenormalized" btsd
|
||||
SET "eventLength" = NEW.length
|
||||
WHERE btsd."eventTypeId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function for parentId changes on EventType
|
||||
CREATE OR REPLACE FUNCTION refresh_booking_time_status_parent_id()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "BookingDenormalized" btsd
|
||||
SET "eventParentId" = NEW."parentId"
|
||||
WHERE btsd."eventTypeId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create separate triggers for each property on EventType
|
||||
CREATE TRIGGER booking_denorm_event_type_team_id_update_trigger
|
||||
AFTER UPDATE OF "teamId" ON "EventType"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION refresh_booking_time_status_team_id();
|
||||
|
||||
CREATE TRIGGER booking_denorm_event_type_length_update_trigger
|
||||
AFTER UPDATE OF length ON "EventType"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION refresh_booking_time_status_length();
|
||||
|
||||
CREATE TRIGGER booking_denorm_event_type_parent_id_update_trigger
|
||||
AFTER UPDATE OF "parentId" ON "EventType"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION refresh_booking_time_status_parent_id();
|
||||
|
||||
-- Create triggers for users table
|
||||
CREATE OR REPLACE FUNCTION trigger_refresh_booking_time_status_denormalized_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
BEGIN
|
||||
UPDATE "BookingDenormalized" btsd
|
||||
SET
|
||||
"userEmail" = NEW.email,
|
||||
"userName" = NEW.name,
|
||||
"userUsername" = NEW.username
|
||||
WHERE btsd."userId" = NEW.id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'DENORM_ERROR: BookingDenormalized - Failed to update user changes for id %', NEW.id;
|
||||
END;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER booking_denorm_user_update_trigger
|
||||
AFTER UPDATE OF email, name, username ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_refresh_booking_time_status_denormalized_user();
|
||||
|
||||
-- Populate the table with initial data
|
||||
-- DELETE FROM "BookingDenormalized";
|
||||
-- INSERT INTO "BookingDenormalized"
|
||||
-- SELECT * FROM "BookingTimeStatus";
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
CREATE OR REPLACE VIEW public."BookingTimeStatusDenormalized" AS
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN "rescheduled" IS TRUE THEN 'rescheduled'
|
||||
WHEN "status" = 'cancelled'::public."BookingStatus" AND "rescheduled" IS NULL THEN 'cancelled'
|
||||
WHEN "endTime" < now() THEN 'completed'
|
||||
WHEN "endTime" > now() THEN 'uncompleted'
|
||||
ELSE NULL
|
||||
END as "timeStatus"
|
||||
FROM public."BookingDenormalized";
|
||||
@@ -1472,6 +1472,72 @@ view BookingTimeStatus {
|
||||
isTeamBooking Boolean
|
||||
}
|
||||
|
||||
model BookingDenormalized {
|
||||
id Int @id @unique
|
||||
uid String
|
||||
eventTypeId Int?
|
||||
title String
|
||||
description String?
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
createdAt DateTime
|
||||
updatedAt DateTime?
|
||||
location String?
|
||||
paid Boolean
|
||||
status BookingStatus
|
||||
rescheduled Boolean?
|
||||
userId Int?
|
||||
teamId Int?
|
||||
eventLength Int?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
userName String?
|
||||
userUsername String?
|
||||
ratingFeedback String?
|
||||
rating Int?
|
||||
noShowHost Boolean?
|
||||
isTeamBooking Boolean
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([eventTypeId])
|
||||
@@index([eventParentId])
|
||||
@@index([teamId])
|
||||
@@index([startTime])
|
||||
@@index([endTime])
|
||||
@@index([status])
|
||||
@@index([teamId, isTeamBooking])
|
||||
@@index([userId, isTeamBooking])
|
||||
}
|
||||
|
||||
view BookingTimeStatusDenormalized {
|
||||
id Int @id @unique
|
||||
uid String
|
||||
eventTypeId Int?
|
||||
title String
|
||||
description String?
|
||||
startTime DateTime
|
||||
endTime DateTime
|
||||
createdAt DateTime
|
||||
updatedAt DateTime?
|
||||
location String?
|
||||
paid Boolean
|
||||
status BookingStatus
|
||||
rescheduled Boolean?
|
||||
userId Int?
|
||||
teamId Int?
|
||||
eventLength Int?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
userName String?
|
||||
userUsername String?
|
||||
ratingFeedback String?
|
||||
rating Int?
|
||||
noShowHost Boolean?
|
||||
isTeamBooking Boolean
|
||||
timeStatus String? // this is the addition on top of BookingDenormalized
|
||||
}
|
||||
|
||||
model CalendarCache {
|
||||
// To be made required in a followup
|
||||
id String? @default(uuid())
|
||||
|
||||
Reference in New Issue
Block a user