web-dev-qa-db-ja.com

カレンダーの繰り返し/繰り返しイベント-最適な保管方法

私はカスタムイベントシステムを構築していますが、次のような繰り返しイベントがある場合:

イベントAは、2011年3月3日から4日ごとに繰り返されます

または

イベントBは、2011年3月1日から始まる火曜日に2週間ごとに繰り返されます

検索を簡単にする方法でそれをデータベースに保存するにはどうすればよいですか。多数のイベントがある場合、パフォーマンスの問題は望ましくありません。また、カレンダーをレンダリングするときに、すべてのイベントを確認する必要があります。

277

「単純な」繰り返しパターンの保存

PHP/MySQLベースのカレンダーでは、繰り返し/繰り返しのイベント情報を可能な限り効率的に保存したかったのです。行の数を増やしたくありませんでした。特定の日付に発生するすべてのイベントを簡単に検索したかったのです。

以下の方法は、毎日、毎週n日、毎週、毎月など、定期的に発生する繰り返し情報を保存するのに最適です。これには、毎週火曜日と木曜日のタイプパターンが含まれます。同様に、毎週火曜日から始まり、毎週木曜日から始まるように別々に保存されます。

次のようなeventsと呼ばれる2つのテーブルがあると仮定します。

ID    NAME
1     Sample Event
2     Another Event

そして、このようなevents_metaと呼ばれるテーブル:

ID    event_id      meta_key           meta_value
1     1             repeat_start       1299132000
2     1             repeat_interval_1  432000

Repeat_startは、Unixタイムスタンプとして時間のない日付であり、repeat_intervalは、間隔(432000は5日)間の秒数です。

repeat_interval_1にはID 1のrepeat_startが付きます。したがって、毎週火曜日と毎週木曜日に繰り返すイベントがある場合、repeat_intervalは604800(7日間)になり、repeat_startsが2つとrepeat_intervalsが2つになります。テーブルは次のようになります。

ID    event_id      meta_key           meta_value
1     1             repeat_start       1298959200 -- This is for the Tuesday repeat
2     1             repeat_interval_1  604800
3     1             repeat_start       1299132000 -- This is for the Thursday repeat
4     1             repeat_interval_3  604800
5     2             repeat_start       1299132000
6     2             repeat_interval_5  1          -- Using 1 as a value gives us an event that only happens once

次に、毎日ループし、その日のイベントを取得するカレンダーがある場合、クエリは次のようになります。

SELECT EV.*
FROM `events` EV
RIGHT JOIN `events_meta` EM1 ON EM1.`event_id` = EV.`id`
RIGHT JOIN `events_meta` EM2 ON EM2.`meta_key` = CONCAT( 'repeat_interval_', EM1.`id` )
WHERE EM1.meta_key = 'repeat_start'
    AND (
        ( CASE ( 1299132000 - EM1.`meta_value` )
            WHEN 0
              THEN 1
            ELSE ( 1299132000 - EM1.`meta_value` )
          END
        ) / EM2.`meta_value`
    ) = 1
LIMIT 0 , 30

{current_timestamp}を現在の日付のUNIXタイムスタンプに置き換えます(時刻を除くため、時間、分、秒の値は0に設定されます)。

これが他の人にも役立つことを願っています!


「複雑な」繰り返しパターンの保存

この方法は、次のような複雑なパターンの保存に適しています。

Event A repeats every month on the 3rd of the month starting on March 3, 2011

または

Event A repeats Friday of the 2nd week of the month starting on March 11, 2011

最も柔軟性を高めるために、これを上記のシステムと組み合わせることをお勧めします。このテーブルは次のようになります。

ID    NAME
1     Sample Event
2     Another Event

そして、このようなevents_metaと呼ばれるテーブル:

ID    event_id      meta_key           meta_value
1     1             repeat_start       1299132000 -- March 3rd, 2011
2     1             repeat_year_1      *
3     1             repeat_month_1     *
4     1             repeat_week_im_1   2
5     1             repeat_weekday_1   6

repeat_week_imは、現在の月の週を表します。これは、1から5の間である可能性があります。曜日のrepeat_weekday、1〜7。

ここで、日/週をループしてカレンダーに月ビューを作成すると仮定すると、次のようなクエリを作成できます。

SELECT EV . *
FROM `events` AS EV
JOIN `events_meta` EM1 ON EM1.event_id = EV.id
AND EM1.meta_key = 'repeat_start'
LEFT JOIN `events_meta` EM2 ON EM2.meta_key = CONCAT( 'repeat_year_', EM1.id )
LEFT JOIN `events_meta` EM3 ON EM3.meta_key = CONCAT( 'repeat_month_', EM1.id )
LEFT JOIN `events_meta` EM4 ON EM4.meta_key = CONCAT( 'repeat_week_im_', EM1.id )
LEFT JOIN `events_meta` EM5 ON EM5.meta_key = CONCAT( 'repeat_weekday_', EM1.id )
WHERE (
  EM2.meta_value =2011
  OR EM2.meta_value = '*'
)
AND (
  EM3.meta_value =4
  OR EM3.meta_value = '*'
)
AND (
  EM4.meta_value =2
  OR EM4.meta_value = '*'
)
AND (
  EM5.meta_value =6
  OR EM5.meta_value = '*'
)
AND EM1.meta_value >= {current_timestamp}
LIMIT 0 , 30

これを上記の方法と組み合わせて、ほとんどの繰り返し/繰り返しイベントパターンをカバーできます。私が何かを見逃した場合は、コメントを残してください。

194

現在受け入れられている答えは私にとって大きな助けになりましたが、クエリを簡素化し、パフォーマンスを向上させるいくつかの便利な修正を共有したかったのです。


「単純な」繰り返しイベント

次のような定期的な間隔で繰り返されるイベントを処理するには:

Repeat every other day 

または

Repeat every week on Tuesday 

次のようにeventsという2つのテーブルを作成する必要があります。

ID    NAME
1     Sample Event
2     Another Event

そして、このようなevents_metaと呼ばれるテーブル:

ID    event_id      repeat_start       repeat_interval
1     1             1369008000         604800            -- Repeats every Monday after May 20th 2013
1     1             1369008000         604800            -- Also repeats every Friday after May 20th 2013

repeat_startは時刻のないUNIXタイムスタンプ日付(1369008000は2013年5月20日に対応)であり、repeat_intervalは間隔の秒数(604800は7日)です。

カレンダーの毎日をループすることにより、次の簡単なクエリを使用して繰り返しイベントを取得できます。

SELECT EV.*
FROM `events` EV
RIGHT JOIN `events_meta` EM1 ON EM1.`event_id` = EV.`id`
WHERE  (( 1299736800 - repeat_start) % repeat_interval = 0 )

カレンダーの各日付をunix-timestamp(1299736800)に置き換えるだけです。

モジュロ(%記号)の使用に注意してください。このシンボルは通常の除算に似ていますが、商の代わりに「剰余」を返します。現在の日付がrepeat_startのrepeat_intervalの正確な倍数である場合は常に0です。

性能比較

これは、以前に提案された「meta_keys」ベースの回答よりも大幅に高速で、次のとおりです。

SELECT EV.*
FROM `events` EV
RIGHT JOIN `events_meta` EM1 ON EM1.`event_id` = EV.`id`
RIGHT JOIN `events_meta` EM2 ON EM2.`meta_key` = CONCAT( 'repeat_interval_', EM1.`id` )
WHERE EM1.meta_key = 'repeat_start'
    AND (
        ( CASE ( 1299132000 - EM1.`meta_value` )
            WHEN 0
              THEN 1
            ELSE ( 1299132000 - EM1.`meta_value` )
          END
        ) / EM2.`meta_value`
    ) = 1

このクエリをEXPLAINで実行すると、結合バッファを使用する必要があることに気付くでしょう。

+----+-------------+-------+--------+---------------+---------+---------+------------------+------+--------------------------------+
| id | select_type | table | type   | possible_keys | key     | key_len | ref              | rows | Extra                          |
+----+-------------+-------+--------+---------------+---------+---------+------------------+------+--------------------------------+
|  1 | SIMPLE      | EM1   | ALL    | NULL          | NULL    | NULL    | NULL             |    2 | Using where                    |
|  1 | SIMPLE      | EV    | eq_ref | PRIMARY       | PRIMARY | 4       | bcs.EM1.event_id |    1 |                                |
|  1 | SIMPLE      | EM2   | ALL    | NULL          | NULL    | NULL    | NULL             |    2 | Using where; Using join buffer |
+----+-------------+-------+--------+---------------+---------+---------+------------------+------+--------------------------------+

上記の結合が1つのソリューションでは、このようなバッファーは必要ありません。


「複雑な」パターン

これらのタイプの繰り返しルールをサポートするために、より複雑なタイプのサポートを追加できます。

Event A repeats every month on the 3rd of the month starting on March 3, 2011

または

Event A repeats second Friday of the month starting on March 11, 2011

イベントテーブルはまったく同じように見えます。

ID    NAME
1     Sample Event
2     Another Event

次に、これらの複雑なルールのサポートを追加するには、次のようにevents_metaに列を追加します。

ID    event_id      repeat_start       repeat_interval    repeat_year    repeat_month    repeat_day    repeat_week    repeat_weekday
1     1             1369008000         604800             NULL           NULL            NULL          NULL           NULL             -- Repeats every Monday after May 20, 2013
1     1             1368144000         604800             NULL           NULL            NULL          NULL           NULL             -- Repeats every Friday after May 10, 2013
2     2             1369008000         NULL               2013           *               *             2              5                -- Repeats on Friday of the 2nd week in every month    

repeat_intervalまたはrepeat_yearrepeat_monthrepeat_dayrepeat_week、およびrepeat_weekdayデータのセットを指定する必要があることに注意してください。

これにより、両方のタイプの選択が同時に非常に簡単になります。毎日ループして、正しい値を入力します(2013年6月7日の場合は1370563200、次に次のように年、月、日、週番号、曜日):

SELECT EV.*
FROM `events` EV
RIGHT JOIN `events_meta` EM1 ON EM1.`event_id` = EV.`id`
WHERE  (( 1370563200 - repeat_start) % repeat_interval = 0 )
  OR ( 
    (repeat_year = 2013 OR repeat_year = '*' )
    AND
    (repeat_month = 6 OR repeat_month = '*' )
    AND
    (repeat_day = 7 OR repeat_day = '*' )
    AND
    (repeat_week = 2 OR repeat_week = '*' )
    AND
    (repeat_weekday = 5 OR repeat_weekday = '*' )
    AND repeat_start <= 1370563200
  )

これにより、2週目の金曜日に繰り返されるすべてのイベント、、および毎週金曜日に繰り返されるすべてのイベントが返されるため、イベントID 1と2:

ID    NAME
1     Sample Event
2     Another Event

*上記のSQLのサイドノートでは、 PHP Date's デフォルトの曜日インデックスを使用したため、金曜日は「5」


これが元の答えが私を助けたのと同じくらい他の人を助けることを願っています!

168
ahoffner

拡張:タイムスタンプを日付に置き換え

Ahoffnerによってその後改良された、受け入れられた回答に対する小さな拡張として-タイムスタンプではなく日付形式を使用することが可能です。利点は次のとおりです。

  1. データベース内の読み取り可能な日付
  2. 2038年以上およびタイムスタンプに関する問題はありません
  3. 削除は、季節調整された日付に基づくタイムスタンプに注意する必要があります。つまり、英国では6月28日は12月28日よりも1時間早く開始するため、日付からタイムスタンプを取得すると再帰アルゴリズムが壊れる可能性があります。

これを行うには、DB repeat_startを 'date'タイプとして保存するように変更し、repeat_intervalが秒ではなく日を保持するようにします。つまり、7日間の繰り返しの場合は7です。

sql行を変更します。

WHERE (( 1370563200 - repeat_start) % repeat_interval = 0 )

に:

WHERE ( DATEDIFF( '2013-6-7', repeat_start ) % repeat_interval = 0)

他のすべては同じままです。シンプル!

23
user3781087

これに興味のあるすべての人は、コピーして貼り付けるだけで数分で開始できます。私はできる限りコメントでアドバイスを受けました。何か足りない場合は教えてください。

「複雑なバージョン」:

イベント

 + ---------- + ---------------- + 
 | ID | NAME | 
 + ---------- + ---------------- + 
 | 1 |サンプルイベント1 | 
 | 2 | 2番目のイベント| 
 | 3 | 3番目のイベント| 
 + ---------- + ---------------- + 

events_meta

 + ---- + ---------- + -------------- + ------------- ----- + ------------- + -------------- + ------------ +- ----------- + ---------------- + 
 | ID | event_id | repeat_start | repeat_interval | repeat_year | repeat_month | repeat_day | repeat_week | repeat_weekday | 
 + ---- + ---------- + -------------- + ----------- ------- + ------------- + -------------- ++ ------------ + ------------- + ---------------- + 
 | 1 | 1 | 2014-07-04 | 7 | NULL | NULL | NULL | NULL | NULL | 
 | 2 | 2 | 2014-06-26 | NULL | 2014 | * | * | 2 | 5 | 
 | 3 | 3 | 2014-07-04 | NULL | * | * | * | * | 5 | 
 + ---- + ---------- + -------------- + --------------- ------- + ------------- + -------------- ++ ------------ + ------------- + ---------------- + 

SQLコード:

CREATE TABLE IF NOT EXISTS `events` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `NAME` varchar(255) NOT NULL,
  PRIMARY KEY (`ID`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 AUTO_INCREMENT=7 ;

--
-- Dumping data for table `events`
--

INSERT INTO `events` (`ID`, `NAME`) VALUES
(1, 'Sample event'),
(2, 'Another event'),
(3, 'Third event...');

CREATE TABLE IF NOT EXISTS `events_meta` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `event_id` int(11) NOT NULL,
  `repeat_start` date NOT NULL,
  `repeat_interval` varchar(255) NOT NULL,
  `repeat_year` varchar(255) NOT NULL,
  `repeat_month` varchar(255) NOT NULL,
  `repeat_day` varchar(255) NOT NULL,
  `repeat_week` varchar(255) NOT NULL,
  `repeat_weekday` varchar(255) NOT NULL,
  PRIMARY KEY (`ID`),
  UNIQUE KEY `ID` (`ID`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 AUTO_INCREMENT=6 ;

--
-- Dumping data for table `events_meta`
--

INSERT INTO `events_meta` (`ID`, `event_id`, `repeat_start`, `repeat_interval`, `repeat_year`, `repeat_month`, `repeat_day`, `repeat_week`, `repeat_weekday`) VALUES
(1, 1, '2014-07-04', '7', 'NULL', 'NULL', 'NULL', 'NULL', 'NULL'),
(2, 2, '2014-06-26', 'NULL', '2014', '*', '*', '2', '5'),
(3, 3, '2014-07-04', 'NULL', '*', '*', '*', '*', '1');

MySQL export としても利用可能(簡単にアクセスできるように)

PHPサンプルコードindex.php:

<?php
    require 'connect.php';    

    $now = strtotime("yesterday");

    $pushToFirst = -11;
    for($i = $pushToFirst; $i < $pushToFirst+30; $i++)
    {
        $now = strtotime("+".$i." day");
        $year = date("Y", $now);
        $month = date("m", $now);
        $day = date("d", $now);
        $nowString = $year . "-" . $month . "-" . $day;
        $week = (int) ((date('d', $now) - 1) / 7) + 1;
        $weekday = date("N", $now);

        echo $nowString . "<br />";
        echo $week . " " . $weekday . "<br />";



        $sql = "SELECT EV.*
                FROM `events` EV
                RIGHT JOIN `events_meta` EM1 ON EM1.`event_id` = EV.`id`
                WHERE ( DATEDIFF( '$nowString', repeat_start ) % repeat_interval = 0 )
                OR ( 
                    (repeat_year = $year OR repeat_year = '*' )
                    AND
                    (repeat_month = $month OR repeat_month = '*' )
                    AND
                    (repeat_day = $day OR repeat_day = '*' )
                    AND
                    (repeat_week = $week OR repeat_week = '*' )
                    AND
                    (repeat_weekday = $weekday OR repeat_weekday = '*' )
                    AND repeat_start <= DATE('$nowString')
                )";
        foreach ($dbConnect->query($sql) as $row) {
            print $row['ID'] . "\t";
            print $row['NAME'] . "<br />";
        }

        echo "<br /><br /><br />";
    }
?>

PHPのサンプルコードconnect.php:

<?
// ----------------------------------------------------------------------------------------------------
//                                       Connecting to database
// ----------------------------------------------------------------------------------------------------
// Database variables
$username = "";
$password = "";
$hostname = ""; 
$database = ""; 

// Try to connect to database and set charset to UTF8
try {
    $dbConnect = new PDO("mysql:Host=$hostname;dbname=$database;charset=utf8", $username, $password);
    $dbConnect->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

} catch(PDOException $e) {
    echo 'ERROR: ' . $e->getMessage();
}
// ----------------------------------------------------------------------------------------------------
//                                      / Connecting to database
// ----------------------------------------------------------------------------------------------------
?>

また、PHPコードはここから入手できます(読みやすくするため)。
index.php
そして
connect.php
この設定には数分かかります。時間ではありません。 :)

23
Alex

私はこのガイドに従います: https://github.com/bmoeskau/Extensible/blob/master/recurrence-overview.md

また、ホイールを再発明しないようにiCal形式を使用し、ルール#0を忘れないようにしてください:個々の繰り返しイベントインスタンスをデータベースの行として保存しないでください!

19
Gal Bracha

提案されたソリューションは機能しますが、フルカレンダーで実装しようとしていましたが、各ビューに90を超えるデータベース呼び出しが必要になりました(現在、前、および次の月を読み込むため)。

再帰ライブラリを見つけました https://github.com/tplaner/When ルールをデータベースに保存し、1つのクエリですべての関連ルールを取得します。

うまくいけば、これが他の人の助けになることを願っています。私が何時間もかけて、良い解決策を見つけようとしたからです。

編集:このライブラリはPHP用です

15
Tim Ramsey

Apache cronジョブに似たメカニズムを使用しないのはなぜですか? http://en.wikipedia.org/wiki/Cron

カレンダー\スケジューリングでは、「ビット」にわずかに異なる値を使用して、標準のカレンダー再発イベントに対応します-[曜日(0-7)、月(1-12)、日(1-31)の代わりに、時間(0-23)、分(0-59)]

-[年(N年ごとに繰り返す)、月(1-12)、日(1-31)、週(1-5)、曜日(0-7)などを使用します]

お役に立てれば。

14
Vladimir

この場合のために、難解なプログラミング言語を開発しました。それについての最もよい部分は、スキーマが少なく、プラットフォームに依存しないということです。ここで説明する一連のルールによって構文が制約される、スケジュール用のセレクタプログラムを作成するだけです。

https://github.com/tusharmath/sheql/wiki/Rules

ルールは拡張可能であり、スキーマの移行などを気にせずに、実行する繰り返しロジックの種類に基づいて任意の種類のカスタマイズを追加できます。

これは完全に異なるアプローチであり、独自の欠点がある場合があります。

4
tusharmath

システムテーブルに保存されているMySQLイベントに非常によく似ています。構造を見て、どの列が不要かを判断できます。

   EVENT_CATALOG: NULL
    EVENT_SCHEMA: myschema
      EVENT_NAME: e_store_ts
         DEFINER: jon@ghidora
      EVENT_BODY: SQL
EVENT_DEFINITION: INSERT INTO myschema.mytable VALUES (UNIX_TIMESTAMP())
      EVENT_TYPE: RECURRING
      EXECUTE_AT: NULL
  INTERVAL_VALUE: 5
  INTERVAL_FIELD: SECOND
        SQL_MODE: NULL
          STARTS: 0000-00-00 00:00:00
            ENDS: 0000-00-00 00:00:00
          STATUS: ENABLED
   ON_COMPLETION: NOT PRESERVE
         CREATED: 2006-02-09 22:36:06
    LAST_ALTERED: 2006-02-09 22:36:06
   LAST_EXECUTED: NULL
   EVENT_COMMENT:
4
Valentin Kuzub

@Rogue Coder

これは素晴らしい!

モジュロ演算(mysqlのMODまたは%)を使用して、最後にコードを単純にすることができます。

の代わりに:

AND (
    ( CASE ( 1299132000 - EM1.`meta_value` )
        WHEN 0
          THEN 1
        ELSE ( 1299132000 - EM1.`meta_value` )
      END
    ) / EM2.`meta_value`
) = 1

行う:

$current_timestamp = 1299132000 ;

AND ( ('$current_timestamp' - EM1.`meta_value` ) MOD EM2.`meta_value`) = 1")

これをさらに進めるために、永久に再発しないイベントを含めることができます。

最後の「repeat_interval_1」の日付を示す「repeat_interval_1_end」などを追加できます。しかし、これはクエリをより複雑にし、これを行う方法を本当に理解することはできません...

たぶん誰かが助けることができます!

3
dorogz

挙げた2つの例は非常に単純です。それらは単純な間隔として表すことができます(最初は4日間、2番目は14日間)。これをどのようにモデル化するかは、繰り返しの複雑さに完全に依存します。上記が本当に単純なものである場合は、開始日と繰り返し間隔の日数を保存します。

ただし、次のようなサポートが必要な場合

イベントAは、2011年3月3日から毎月3日に繰り返されます。

または

イベントAは、2011年3月11日に始まる月の第2金曜日を繰り返します

それはもっと複雑なパターンです。

1
Adam Robinson