はじめに
通常、カレンダー上のイベントはカレンダーごとに1対1で管理され、イベントに出席者や添付ファイルを紐づけるのは簡単です。しかし「繰り返しイベント」を定義すると、同じテンプレートから複数回(N回)の予定が生成されるため、カレンダーとイベントの関係は1対Nに拡張されます。このとき、ある特定のイベントだけを変更をする場合、データの持ちかたが非常に難しいです。今回、Googleカレンダーがどのようにこのような課題を解決しているのかを検討していきます。
Googleの設計方針
Googleの開発Blogを元に理解していきます。
A recurring event is a 'template' for a series of events that usually happen with some regularity, for example daily or bi-weekly. To create a recurring event, the client specifies the first instance of the event and includes one or more rules that describe when future events should occur. Google Calendar will then 'expand' the event into the specified occurrences. Individual events in a series may be changed, or even deleted. Such events become exceptions: they are still part of the series, but changes are preserved even if the recurring event itself is updated.
(日本語訳)
「繰り返しイベントとは、日次や隔週など、ある規則性に従って定期的に発生する一連の予定の“テンプレート”のことです。繰り返しイベントを作成する際には、最初のインスタンス(開始日・開始時刻)を指定し、将来の予定がいつ発生するかを示すひとつ以上のルール(RRULEなど)を付与します。Google カレンダーはそのテンプレートをもとに、指定された回数や期間にわたって予定を「展開(エクスパンド)」します。
また、一連の中の個々の予定は変更したり削除したりすることも可能で、そうした変更は「例外(オーバーライド)」として扱われます。例外となった予定はシリーズの一部であり続けますが、親の繰り返し設定が後から更新されても、その変更内容は保持されます。」
→カレンダーに紐づくイベントは実態ではなく、テンプレートとして考える。一連の中の個々のイベントは例外のイベントとして管理する。イベントを実際に利用する時は、テンプレートに従いながら例外イベントで上書きを実施し扱う。
なんとなく、設計の方針はわかってきました。ただ、実際にどのように表現すべきなのかというのがいまいちピンとこないので、TBL設計をして具体化していきます。
TBL設計
-- ==============================================
-- DROP TABLES & ENUM TYPES (依存関係の逆順)
-- ==============================================
DROP TABLE IF EXISTS "event_overrides_attendees" CASCADE;
DROP TABLE IF EXISTS "event_overrides" CASCADE;
DROP TABLE IF EXISTS "event_attendees" CASCADE;
DROP TABLE IF EXISTS "event_rrules" CASCADE;
DROP TABLE IF EXISTS "events" CASCADE;
DROP TABLE IF EXISTS "calendars" CASCADE;
DROP TABLE IF EXISTS "users" CASCADE;
DROP TYPE IF EXISTS "ResponseStatus" CASCADE;
DROP TYPE IF EXISTS "EventRole" CASCADE;
DROP TYPE IF EXISTS "WeekDay" CASCADE;
DROP TYPE IF EXISTS "Frequency" CASCADE;
-- ==============================================
-- CREATE ENUM TYPES (RFC 5545 準拠)
-- ==============================================
-- 繰り返し頻度 (RFC 5545 FREQ)
CREATE TYPE "Frequency" AS ENUM (
'SECONDLY',
'MINUTELY',
'HOURLY',
'DAILY',
'WEEKLY',
'MONTHLY',
'YEARLY'
);
-- 曜日の指定 (RFC 5545 BYDAY)
CREATE TYPE "WeekDay" AS ENUM (
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA'
);
-- 参加者のロール
CREATE TYPE "EventRole" AS ENUM (
'required', -- 必須参加者
'optional' -- 任意参加者
);
-- 出欠ステータス (RFC 5545 ATTENDEE PARTSTAT)
CREATE TYPE "ResponseStatus" AS ENUM (
'needsAction', -- 未回答(招待済み)
'accepted', -- 参加
'declined', -- 辞退
'tentative' -- 仮参加
);
-- ==============================================
-- CREATE TABLES
-- ==============================================
-- ユーザー情報
CREATE TABLE "users" (
"id" bigint PRIMARY KEY, -- ユーザーID
"name" text NOT NULL, -- ユーザー名
"email" text NOT NULL -- メールアドレス(一意性制約は用途に応じて追加)
);
-- カレンダー
CREATE TABLE "calendars" (
"id" bigint PRIMARY KEY, -- カレンダーID
"name" text NOT NULL, -- カレンダー名
"owner_id" bigint NOT NULL
REFERENCES "users"("id") ON DELETE CASCADE -- カレンダー所有者(ユーザーID)
);
-- イベント(単発 or 繰り返しマスター)
CREATE TABLE "events" (
"id" bigint PRIMARY KEY, -- イベントID
"calendar_id" bigint NOT NULL
REFERENCES "calendars"("id") ON DELETE CASCADE, -- 所属カレンダーID
"title" text NOT NULL, -- イベントタイトル
"start_at" timestamptz NOT NULL, -- 開始日時(単発 or マスター初回)
"end_at" timestamptz NOT NULL, -- 終了日時(単発 or マスター初回)
"is_recurring" boolean NOT NULL DEFAULT false, -- 繰り返しフラグ
"recurrence_end" timestamptz -- 繰り返し終了日時 (RRULE UNTIL)
);
-- 繰り返しルール詳細
CREATE TABLE "event_rrules" (
"id" bigint PRIMARY KEY, -- RRULEレコードID
"event_id" bigint NOT NULL
REFERENCES "events"("id") ON DELETE CASCADE, -- 紐づくイベントID
"freq" "Frequency" NOT NULL, -- 繰り返し頻度 (FREQ)
"interval" integer NOT NULL DEFAULT 1, -- インターバル (INTERVAL)
"count" integer, -- 最大回数 (COUNT)
"until" timestamptz, -- 終了日時 (UNTIL)
"byday" "WeekDay"[], -- 曜日リスト (BYDAY)
"bymonth" integer[], -- 月リスト (BYMONTH)
"bymonthday" integer[] -- 日リスト (BYMONTHDAY)
);
-- 共通出席者(マスターイベント用)
CREATE TABLE "event_attendees" (
"id" bigint PRIMARY KEY, -- 出席者レコードID
"event_id" bigint NOT NULL
REFERENCES "events"("id") ON DELETE CASCADE, -- 紐づくイベントID
"user_id" bigint NOT NULL
REFERENCES "users"("id") ON DELETE CASCADE, -- ユーザーID
"role" "EventRole" NOT NULL, -- 参加の必須/任意
"response" "ResponseStatus" NOT NULL DEFAULT 'needsAction' -- 出欠ステータス
);
-- 例外(Override)インスタンス
CREATE TABLE "event_overrides" (
"id" bigint PRIMARY KEY, -- OverrideレコードID
"event_id" bigint NOT NULL
REFERENCES "events"("id") ON DELETE CASCADE, -- 親イベントID
"original_start_at" timestamptz NOT NULL, -- 元のインスタンス開始日時 (RECURRENCE-ID)
"override_start_at" timestamptz, -- 変更後の開始日時
"override_end_at" timestamptz, -- 変更後の終了日時
"override_title" text -- 変更後のタイトル
);
-- 例外イベント用 出席者Override
CREATE TABLE "event_overrides_attendees" (
"id" bigint PRIMARY KEY, -- Override出席者レコードID
"event_overrides_id" bigint NOT NULL
REFERENCES "event_overrides"("id") ON DELETE CASCADE, -- 紐づくOverrideイベントID
"user_id" bigint NOT NULL
REFERENCES "users"("id") ON DELETE CASCADE, -- ユーザーID
"role" "EventRole" NOT NULL, -- 参加の必須/任意
"response" "ResponseStatus" NOT NULL -- 出欠ステータス
);
ユースケース別のデータ作成/更新
seedデータの導入
-- ==============================================
-- SEED DATA
-- ==============================================
-- Users
INSERT INTO "users" ("id", "name", "email") VALUES
(1, 'Alice', 'alice@example.com'),
(2, 'Bob', 'bob@example.com'),
(3, 'Carol', 'carol@example.com'),
(4, 'Dave', 'dave@example.com');
-- Calendars
INSERT INTO "calendars" ("id", "name", "owner_id") VALUES
(1, 'Alice Calendar', 1);
-- Events
INSERT INTO "events" (
"id","calendar_id","title","start_at","end_at","is_recurring","recurrence_end"
) VALUES
-- ① 繰り返しイベント:毎週月曜10:00–10:30、6月30日まで
(1, 1, 'Weekly Meeting',
'2025-06-09T10:00:00+09', '2025-06-09T10:30:00+09',
true, '2025-06-30T10:30:00+09'),
-- ② 単発イベント
(2, 1, 'One-time Event',
'2025-06-10T14:00:00+09', '2025-06-10T15:00:00+09',
false, NULL);
-- Recurrence Rules
INSERT INTO "event_rrules" (
"id","event_id","freq","interval","count","until","byday","bymonth","bymonthday"
) VALUES
(
1,
1,
'WEEKLY',
1,
NULL,
'2025-06-30T00:00:00+09'::timestamptz,
ARRAY['MO']::"WeekDay"[],
NULL,
NULL
);
-- Master Attendees (共通出席者)
INSERT INTO "event_attendees" (
"id","event_id","user_id","role","response"
) VALUES
(1, 1, 1, 'required', 'accepted'), -- Alice
(2, 1, 2, 'required', 'needsAction'), -- Bob
(3, 1, 3, 'optional', 'needsAction'), -- Carol
(4, 2, 1, 'required', 'accepted'); -- Alice for the one-time event
-- Overrides (例外インスタンス)
INSERT INTO "event_overrides" (
"id","event_id","original_start_at","override_start_at","override_end_at","override_title"
) VALUES
-- 6/23の回だけ開始時間を11:00に変更
(1, 1,
'2025-06-23T10:00:00+09', -- 元の開始日時 (RECURRENCE-ID)
'2025-06-23T11:00:00+09', -- 上書き開始日時
'2025-06-23T11:30:00+09', -- 上書き終了日時
'Weekly Meeting (Time Changed)');
-- Override Attendees (例外出席者追加)
INSERT INTO "event_overrides_attendees" (
"id","event_overrides_id","user_id","role","response"
) VALUES
-- 6/23の回だけDaveを必須参加で追加
(1, 1, 4, 'required', 'accepted');
単発のイベントを作成する
まずは繰り返しのイベントでなく、単発のイベントをどのようにデータで表現するか検討します。
ユースケース例
・イベント名:Project Kickoff
・日時:2025-07-01 午前10:00~11:00
・カレンダーID:1
・出席者:Alice(必須)、Bob(任意)
-- 1) 単発イベントを登録
INSERT INTO "events" (
"id", "calendar_id", "title", "start_at", "end_at", "is_recurring", "recurrence_end"
) VALUES (
10, 1, 'Project Kickoff',
'2025-07-01T10:00:00+09'::timestamptz,
'2025-07-01T11:00:00+09'::timestamptz,
false, NULL
);
-- 2) そのイベントの出席者を登録
INSERT INTO "event_attendees" (
"id", "event_id", "user_id", "role", "response"
) VALUES
-- Alice は必須参加として招待済み
(100, 10, 1, 'required','needsAction'),
-- Bob は任意参加として招待済み
(101, 10, 2, 'optional','needsAction');
calendar=# select * from event_attendees as ea inner join events as e on ea.event_id = e.id where e.id = 10;
id | event_id | user_id | role | response | id | calendar_id | title | start_at | end_at | is_recurring | recurrence_end
-----+----------+---------+----------+-------------+----+-------------+-----------------+------------------------+------------------------+--------------+----------------
100 | 10 | 1 | required | needsAction | 10 | 1 | Project Kickoff | 2025-07-01 01:00:00+00 | 2025-07-01 02:00:00+00 | f |
101 | 10 | 2 | optional | needsAction | 10 | 1 | Project Kickoff | 2025-07-01 01:00:00+00 | 2025-07-01 02:00:00+00 | f |
(2 rows)
問題なく取得できていますね。では次に、繰り返しをやります。
繰り返しイベントを作成する
以下は「毎週月・水曜日 10:00~11:00 にチーム定例ミーティングを、7月末まで繰り返し設定する」ユースケースの例です。
出席者は Alice(必須)、Bob(必須)、Carol(任意)とします。
calendar=# -- 1) 繰り返しイベントの登録
INSERT INTO "events" (
"id", "calendar_id", "title", "start_at", "end_at", "is_recurring", "recurrence_end"
) VALUES (
20, 1, 'Team Sync Meeting',
'2025-07-07T10:00:00+09'::timestamptz, -- 初回は7/7(月)
'2025-07-07T11:00:00+09'::timestamptz,
true, '2025-07-31T11:00:00+09'::timestamptz -- 繰り返し終了
);
INSERT 0 1
calendar=# -- 2) 繰り返しルールの詳細
INSERT INTO "event_rrules" (
"id", "event_id", "freq", "interval", "count", "until", "byday", "bymonth", "bymonthday"
) VALUES (
10, 20, 'WEEKLY', 1, NULL, '2025-07-31T00:00:00+09'::timestamptz,
ARRAY['MO','WE']::"WeekDay"[], NULL, NULL
);
INSERT 0 1
calendar=# -- 3) マスター出席者の登録
INSERT INTO "event_attendees" (
"id", "event_id", "user_id", "role", "response"
) VALUES
(200, 20, 1, 'required','needsAction'), -- Alice
(201, 20, 2, 'required','needsAction'), -- Bob
(202, 20, 3, 'optional','needsAction'); -- Carol
INSERT 0 3
calendar=# select * from event_attendees as ea inner join events as e on ea.event_id = e.id where e.id = 20;
id | event_id | user_id | role | response | id | calendar_id | title | start_at | end_at | is_recurring | recurrence_end
-----+----------+---------+----------+-------------+----+-------------+-------------------+------------------------+------------------------+--------------+------------------------
200 | 20 | 1 | required | needsAction | 20 | 1 | Team Sync Meeting | 2025-07-07 01:00:00+00 | 2025-07-07 02:00:00+00 | t | 2025-07-31 02:00:00+00
201 | 20 | 2 | required | needsAction | 20 | 1 | Team Sync Meeting | 2025-07-07 01:00:00+00 | 2025-07-07 02:00:00+00 | t | 2025-07-31 02:00:00+00
202 | 20 | 3 | optional | needsAction | 20 | 1 | Team Sync Meeting | 2025-07-07 01:00:00+00 | 2025-07-07 02:00:00+00 | t | 2025-07-31 02:00:00+00
(3 rows)
calendar=# select * from event_rrules where id = 10;
id | event_id | freq | interval | count | until | byday | bymonth | bymonthday
----+----------+--------+----------+-------+------------------------+---------+---------+------------
10 | 20 | WEEKLY | 1 | | 2025-07-30 15:00:00+00 | {MO,WE} | |
(1 row)
→これで表現できていますね!
繰り返し予約の中に例外処理を追加する
上記の画像のように、繰り返し予約の中で1つ例外を作ります。
ユースケース例
・元の「Team Sync Meeting」は毎週月・水曜 10:00–11:00 に行う
・2025-07-14(次の月曜)だけ、時間を 11:00–12:00 に変更し、かつ Dave(user_id=4)を必須参加で追加、Carol(user_id=3)は欠席にする
calendar=# -- 1) 例外インスタンス(Override)の登録
INSERT INTO "event_overrides" (
"id",
"event_id",
"original_start_at",
"override_start_at",
"override_end_at",
"override_title"
) VALUES (
30, -- OverrideレコードID
20, -- 親イベントID (Team Sync Meeting)
'2025-07-14T10:00:00+09'::timestamptz, -- 元の開始日時 (RECURRENCE-ID)
'2025-07-14T11:00:00+09'::timestamptz, -- 新しい開始日時
'2025-07-14T12:00:00+09'::timestamptz, -- 新しい終了日時
'Team Sync Meeting (Time & Attendees Updated)' -- タイトルOverride
);
INSERT 0 1
calendar=# -- 2) 例外イベント用 出席者Override の登録
INSERT INTO "event_overrides_attendees" (
"id",
"event_overrides_id",
"user_id",
"role",
"response"
) VALUES
-- Dave を必須参加で追加
(300, 30, 4, 'required', 'needsAction'),
-- Carol はこの回だけ辞退
(301, 30, 3, 'optional', 'declined');
INSERT 0 2
→データを追加しました。実際に7月14日のイベントの内容をみてみましょう。
calendar=# WITH instance AS (
SELECT
e.id AS event_id,
COALESCE(o.override_title, e.title) AS summary,
COALESCE(o.override_start_at, '2025-07-14T10:00:00+09'::timestamptz) AS start_at,
COALESCE(o.override_end_at, '2025-07-14T11:00:00+09'::timestamptz) AS end_at,
o.id AS override_id
FROM events e
LEFT JOIN event_overrides o
ON o.event_id = e.id
AND o.original_start_at = '2025-07-14T10:00:00+09'::timestamptz
WHERE e.id = 20
),
master_attendees AS (
SELECT user_id, role, response
FROM event_attendees
WHERE event_id = (SELECT event_id FROM instance)
),
override_attendees AS (
SELECT user_id, role, response
FROM event_overrides_attendees
WHERE event_overrides_id = (SELECT override_id FROM instance)
),
all_attendees AS (
-- Override の行がマスターを上書きできるよう、UNION ALL 後に
-- user_id でグループ化し、最新行(Override側)を取る
SELECT user_id,
MAX(role) AS role,
MAX(response) AS response
FROM (
SELECT * FROM master_attendees
UNION ALL
SELECT * FROM override_attendees
) t
GROUP BY user_id
)
SELECT
i.summary,
i.start_at,
ORDER BY u.name;er_idendees already filtered to this event
summary | start_at | end_at | attendee_name | response_status
----------------------------------------------+------------------------+------------------------+---------------+-----------------
Team Sync Meeting (Time & Attendees Updated) | 2025-07-14 02:00:00+00 | 2025-07-14 03:00:00+00 | Alice | needsAction
Team Sync Meeting (Time & Attendees Updated) | 2025-07-14 02:00:00+00 | 2025-07-14 03:00:00+00 | Bob | needsAction
Team Sync Meeting (Time & Attendees Updated) | 2025-07-14 02:00:00+00 | 2025-07-14 03:00:00+00 | Carol | declined
Team Sync Meeting (Time & Attendees Updated) | 2025-07-14 02:00:00+00 | 2025-07-14 03:00:00+00 | Dave | needsAction
(4 rows)
→取得できましたね。ただ、異常に複雑なSQLができました。更新時にではなく取得にコストを払う方針のようです。
取得はできても色々難しそうですね。。。😅 テンプレートと例外をぶつけるkeyがユニークキーでなく、時刻なのでメンテナンスが異常に難しそう。。。課題を見切るのも難しいし、それをどういうふうに管理していくのか。運用をする上でかなり大きな課題になりそうですね。