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:
Eunjae Lee
2025-05-06 20:34:45 +02:00
committed by GitHub
co-authored by sean-brydon
parent 3d89b37cc6
commit ab8556790e
5 changed files with 529 additions and 0 deletions
@@ -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");
@@ -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";
@@ -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";
+66
View File
@@ -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())