Merge pull request #10754 from totten/master-actcase-ts
authorcolemanw <coleman@civicrm.org>
Wed, 6 Sep 2017 18:35:55 +0000 (14:35 -0400)
committerGitHub <noreply@github.com>
Wed, 6 Sep 2017 18:35:55 +0000 (14:35 -0400)
CRM-20958 - Track creation+modification times for activities+cases

14 files changed:
CRM/Activity/BAO/Activity.php
CRM/Activity/DAO/Activity.php
CRM/Case/BAO/Case.php
CRM/Case/DAO/Case.php
CRM/Contact/BAO/Contact.php
CRM/Upgrade/Incremental/php/FourSeven.php
CRM/Utils/Check/Component/Case.php
Civi/Core/Container.php
Civi/Core/SqlTrigger/StaticTriggers.php [new file with mode: 0644]
Civi/Core/SqlTrigger/TimestampTriggers.php [new file with mode: 0644]
tests/phpunit/api/v3/ActivityTest.php
tests/phpunit/api/v3/CaseTest.php
xml/schema/Activity/Activity.xml
xml/schema/Case/Case.xml

index 2f4386695d1b78e12b71045ba8c801bfa2f0f8b0..ea9bd39efd1a2a7921652172fee9aba6898f7c78 100644 (file)
@@ -285,6 +285,10 @@ class CRM_Activity_BAO_Activity extends CRM_Activity_DAO_Activity {
    * @return CRM_Activity_BAO_Activity|null|object
    */
   public static function create(&$params) {
+    // CRM-20958 - These fields are managed by MySQL triggers. Watch out for clients resaving stale timestamps.
+    unset($params['created_date']);
+    unset($params['modified_date']);
+
     // check required params
     if (!self::dataExists($params)) {
       throw new CRM_Core_Exception('Not enough data to create activity object');
index 5b099160668d8b2114f13fb2ec34f6716a602359..d619c2e99029b7e99dab11106b7c0e2177748a2c 100644 (file)
@@ -30,7 +30,7 @@
  *
  * Generated from xml/schema/CRM/Activity/Activity.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:a530f1fb1a27c5a15b5d138732b4c581)
+ * (GenCodeChecksum:dfa63754ef6ea1a9c7148e735dd6ff8a)
  */
 require_once 'CRM/Core/DAO.php';
 require_once 'CRM/Utils/Type.php';
@@ -195,6 +195,18 @@ class CRM_Activity_DAO_Activity extends CRM_Core_DAO {
    * @var boolean
    */
   public $is_star;
+  /**
+   * When was the activity was created.
+   *
+   * @var timestamp
+   */
+  public $created_date;
+  /**
+   * When was the activity (or closely related entity) was created or modified or deleted.
+   *
+   * @var timestamp
+   */
+  public $modified_date;
   /**
    * Class constructor.
    */
@@ -642,6 +654,38 @@ class CRM_Activity_DAO_Activity extends CRM_Core_DAO {
           'bao' => 'CRM_Activity_BAO_Activity',
           'localizable' => 0,
         ) ,
+        'activity_created_date' => array(
+          'name' => 'created_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => ts('Created Date') ,
+          'description' => 'When was the activity was created.',
+          'required' => false,
+          'export' => true,
+          'where' => 'civicrm_activity.created_date',
+          'headerPattern' => '',
+          'dataPattern' => '',
+          'default' => 'NULL',
+          'table_name' => 'civicrm_activity',
+          'entity' => 'Activity',
+          'bao' => 'CRM_Activity_BAO_Activity',
+          'localizable' => 0,
+        ) ,
+        'activity_modified_date' => array(
+          'name' => 'modified_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => ts('Modified Date') ,
+          'description' => 'When was the activity (or closely related entity) was created or modified or deleted.',
+          'required' => false,
+          'export' => true,
+          'where' => 'civicrm_activity.modified_date',
+          'headerPattern' => '',
+          'dataPattern' => '',
+          'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
+          'table_name' => 'civicrm_activity',
+          'entity' => 'Activity',
+          'bao' => 'CRM_Activity_BAO_Activity',
+          'localizable' => 0,
+        ) ,
       );
       CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
     }
index 13da2d59419d4d99f3777895bd362201e259f462..89c77bd94f6b7f76d263a9adf15178b1118718f0 100644 (file)
@@ -89,6 +89,10 @@ class CRM_Case_BAO_Case extends CRM_Case_DAO_Case {
    * @return CRM_Case_BAO_Case
    */
   public static function &create(&$params) {
+    // CRM-20958 - These fields are managed by MySQL triggers. Watch out for clients resaving stale timestamps.
+    unset($params['created_date']);
+    unset($params['modified_date']);
+
     $transaction = new CRM_Core_Transaction();
 
     if (!empty($params['id'])) {
index dd1bfe3b027bfd6ef085942e4cf83004a5e315de..66bf0d6aff9460cf40d46087cf5022425ab00110 100644 (file)
@@ -30,7 +30,7 @@
  *
  * Generated from xml/schema/CRM/Case/Case.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:e45e7e2a53a945c4659cf393410a9d7a)
+ * (GenCodeChecksum:2a046fd795b19790f45c5d9dde06a538)
  */
 require_once 'CRM/Core/DAO.php';
 require_once 'CRM/Utils/Type.php';
@@ -97,6 +97,18 @@ class CRM_Case_DAO_Case extends CRM_Core_DAO {
    * @var boolean
    */
   public $is_deleted;
+  /**
+   * When was the case was created.
+   *
+   * @var timestamp
+   */
+  public $created_date;
+  /**
+   * When was the case (or closely related entity) was created or modified or deleted.
+   *
+   * @var timestamp
+   */
+  public $modified_date;
   /**
    * Class constructor.
    */
@@ -275,6 +287,38 @@ class CRM_Case_DAO_Case extends CRM_Core_DAO {
           'bao' => 'CRM_Case_BAO_Case',
           'localizable' => 0,
         ) ,
+        'case_created_date' => array(
+          'name' => 'created_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => ts('Created Date') ,
+          'description' => 'When was the case was created.',
+          'required' => false,
+          'export' => true,
+          'where' => 'civicrm_case.created_date',
+          'headerPattern' => '',
+          'dataPattern' => '',
+          'default' => 'NULL',
+          'table_name' => 'civicrm_case',
+          'entity' => 'Case',
+          'bao' => 'CRM_Case_BAO_Case',
+          'localizable' => 0,
+        ) ,
+        'case_modified_date' => array(
+          'name' => 'modified_date',
+          'type' => CRM_Utils_Type::T_TIMESTAMP,
+          'title' => ts('Modified Date') ,
+          'description' => 'When was the case (or closely related entity) was created or modified or deleted.',
+          'required' => false,
+          'export' => true,
+          'where' => 'civicrm_case.modified_date',
+          'headerPattern' => '',
+          'dataPattern' => '',
+          'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
+          'table_name' => 'civicrm_case',
+          'entity' => 'Case',
+          'bao' => 'CRM_Case_BAO_Case',
+          'localizable' => 0,
+        ) ,
       );
       CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
     }
index df8eaf5929a3442ed097b4cc2e87a8dfaaa78c2a..9c829b29c172ce097eb4214bbd575e2d0c68568f 100644 (file)
@@ -3277,51 +3277,6 @@ LEFT JOIN civicrm_address add2 ON ( add1.master_id = add2.id )
     }
   }
 
-  /**
-   * Generate triggers to update the timestamp.
-   *
-   * The corresponding civicrm_contact row is updated on insert/update/delete
-   * to a table that extends civicrm_contact.
-   * Don't regenerate triggers for all such tables if only asked for one table.
-   *
-   * @param array $info
-   *   Reference to the array where generated trigger information is being stored
-   * @param string|null $reqTableName
-   *   Name of the table for which triggers are being generated, or NULL if all tables
-   * @param array $relatedTableNames
-   *   Array of all core or all custom table names extending civicrm_contact
-   * @param string $contactRefColumn
-   *   'contact_id' if processing core tables, 'entity_id' if processing custom tables
-   *
-   * @link https://issues.civicrm.org/jira/browse/CRM-15602
-   * @see triggerInfo
-   */
-  public static function generateTimestampTriggers(&$info, $reqTableName, $relatedTableNames, $contactRefColumn) {
-    // Safety
-    $contactRefColumn = CRM_Core_DAO::escapeString($contactRefColumn);
-    // If specific related table requested, just process that one
-    if (in_array($reqTableName, $relatedTableNames)) {
-      $relatedTableNames = array($reqTableName);
-    }
-
-    // If no specific table requested (include all related tables),
-    // or a specific related table requested (as matched above)
-    if (empty($reqTableName) || in_array($reqTableName, $relatedTableNames)) {
-      $info[] = array(
-        'table' => $relatedTableNames,
-        'when' => 'AFTER',
-        'event' => array('INSERT', 'UPDATE'),
-        'sql' => "\nUPDATE civicrm_contact SET modified_date = CURRENT_TIMESTAMP WHERE id = NEW.$contactRefColumn;\n",
-      );
-      $info[] = array(
-        'table' => $relatedTableNames,
-        'when' => 'AFTER',
-        'event' => array('DELETE'),
-        'sql' => "\nUPDATE civicrm_contact SET modified_date = CURRENT_TIMESTAMP WHERE id = OLD.$contactRefColumn;\n",
-      );
-    }
-  }
-
   /**
    * Get a list of triggers for the contact table.
    *
@@ -3343,37 +3298,17 @@ LEFT JOIN civicrm_address add2 ON ( add1.master_id = add2.id )
       }
     }
 
-    // Update timestamp for civicrm_contact itself
-    if ($tableName == NULL || $tableName == self::getTableName()) {
-      $info[] = array(
-        'table' => array(self::getTableName()),
-        'when' => 'BEFORE',
-        'event' => array('INSERT'),
-        'sql' => "\nSET NEW.created_date = CURRENT_TIMESTAMP;\n",
-      );
-    }
-
-    // Update timestamp when modifying closely related core tables
-    $relatedTables = array(
-      'civicrm_address',
-      'civicrm_email',
-      'civicrm_im',
-      'civicrm_phone',
-      'civicrm_website',
-    );
-    self::generateTimestampTriggers($info, $tableName, $relatedTables, 'contact_id');
-
-    // Update timestamp when modifying related custom-data tables
-    $customGroupTables = array();
-    $customGroupDAO = CRM_Core_BAO_CustomGroup::getAllCustomGroupsByBaseEntity('Contact');
-    $customGroupDAO->is_multiple = 0;
-    $customGroupDAO->find();
-    while ($customGroupDAO->fetch()) {
-      $customGroupTables[] = $customGroupDAO->table_name;
-    }
-    if (!empty($customGroupTables)) {
-      self::generateTimestampTriggers($info, $tableName, $customGroupTables, 'entity_id');
-    }
+    // Modifications to these records should update the contact timestamps.
+    \Civi\Core\SqlTrigger\TimestampTriggers::create('civicrm_contact', 'Contact')
+      ->setRelations(array(
+          array('table' => 'civicrm_address', 'column' => 'contact_id'),
+          array('table' => 'civicrm_email', 'column' => 'contact_id'),
+          array('table' => 'civicrm_im', 'column' => 'contact_id'),
+          array('table' => 'civicrm_phone', 'column' => 'contact_id'),
+          array('table' => 'civicrm_website', 'column' => 'contact_id'),
+        )
+      )
+      ->alterTriggerInfo($info, $tableName);
 
     // Update phone table to populate phone_numeric field
     if (!$tableName || $tableName == 'civicrm_phone') {
index 08c8a722fbeceff287905e5eb60843066e5cce54..c49eaad4c82ff9f85f9150cf2fac05268f3fe7b6 100644 (file)
@@ -418,6 +418,17 @@ class CRM_Upgrade_Incremental_php_FourSeven extends CRM_Upgrade_Incremental_Base
       'civicrm_uf_group', 'cancel_button_text', "varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'Custom Text to display on the cancel button when used in create or edit mode'", TRUE);
     $this->addTask('Add Submit button text column to civicrm_uf_group', 'addColumn',
       'civicrm_uf_group', 'submit_button_text', "varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT 'Custom Text to display on the submit button on profile edit/create screens'", TRUE);
+
+    $this->addTask('CRM-20958 - Add created_date to civicrm_activity', 'addColumn',
+      'civicrm_activity', 'created_date', "timestamp NULL  DEFAULT NULL COMMENT 'When was the activity was created.'");
+    $this->addTask('CRM-20958 - Add modified_date to civicrm_activity', 'addColumn',
+      'civicrm_activity', 'modified_date', "timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When was the activity (or closely related entity) was created or modified or deleted.'");
+    $this->addTask('CRM-20958 - Add created_date to civicrm_case', 'addColumn',
+      'civicrm_case', 'created_date', "timestamp NULL  DEFAULT NULL COMMENT 'When was the case was created.'");
+    $this->addTask('CRM-20958 - Add modified_date to civicrm_case', 'addColumn',
+      'civicrm_case', 'modified_date', "timestamp NULL  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'When was the case (or closely related entity) was created or modified or deleted.'");
+
+    return TRUE;
   }
 
 
index 2a7199a40b24735b0e9f4f20b09a8fa3d418d4a9..f6f5a9eb119997580298ddc1ffb164466b404792 100644 (file)
@@ -32,6 +32,8 @@
  */
 class CRM_Utils_Check_Component_Case extends CRM_Utils_Check_Component {
 
+  const DOCTOR_WHEN = 'https://github.com/civicrm/org.civicrm.doctorwhen';
+
   /**
    * @var CRM_Case_XMLRepository
    */
@@ -116,4 +118,48 @@ class CRM_Utils_Check_Component_Case extends CRM_Utils_Check_Component {
     return $messages;
   }
 
+  /**
+   * Check that the timestamp columns are populated. (CRM-20958)
+   *
+   * @return array<CRM_Utils_Check_Message>
+   *   An empty array, or a list of warnings
+   */
+  public function checkNullTimestamps() {
+    $messages = array();
+
+    $nullCount = 0;
+    $nullCount += CRM_Utils_SQL_Select::from('civicrm_activity')
+      ->where('created_date IS NULL OR modified_date IS NULL')
+      ->select('COUNT(*)')
+      ->execute()
+      ->fetchValue();
+    $nullCount += CRM_Utils_SQL_Select::from('civicrm_case')
+      ->where('created_date IS NULL OR modified_date IS NULL')
+      ->select('COUNT(*)')
+      ->execute()
+      ->fetchValue();
+
+    if ($nullCount > 0) {
+      $messages[] = new CRM_Utils_Check_Message(
+        __FUNCTION__,
+        '<p>' .
+        ts('The tables "<em>civicrm_activity</em>" and "<em>civicrm_case</em>" were updated to support two new fields, "<em>created_date</em>" and "<em>modified_date</em>". For historical data, these fields may appear blank. (%1 records have NULL timestamps.)', array(
+          1 => $nullCount,
+        )) .
+        '</p><p>' .
+        ts('At time of writing, this is not a problem. However, future extensions and improvements could rely on these fields, so it may be useful to back-fill them.') .
+        '</p><p>' .
+        ts('For further discussion, please visit %1', array(
+          1 => sprintf('<a href="%s" target="_blank">%s</a>', self::DOCTOR_WHEN, self::DOCTOR_WHEN),
+        )) .
+        '</p>',
+        ts('Timestamps for Activities and Cases'),
+        \Psr\Log\LogLevel::NOTICE,
+        'fa-clock-o'
+      );
+    }
+
+    return $messages;
+  }
+
 }
index b01595f4cdc831a164e7211d30c09cd9361da07e..75d95000178e578c26ccc9d2ef71e7ef6f4180fa 100644 (file)
@@ -208,6 +208,39 @@ class Container {
         ->setFactory(array($class, 'singleton'));
     }
 
+    $container->setDefinition('civi.activity.triggers', new Definition(
+      'Civi\Core\SqlTrigger\TimestampTriggers',
+      array('civicrm_activity', 'Activity')
+    ))->addTag('kernel.event_listener', array('event' => 'hook_civicrm_triggerInfo', 'method' => 'onTriggerInfo'));
+
+    $container->setDefinition('civi.case.triggers', new Definition(
+      'Civi\Core\SqlTrigger\TimestampTriggers',
+      array('civicrm_case', 'Case')
+    ))->addTag('kernel.event_listener', array('event' => 'hook_civicrm_triggerInfo', 'method' => 'onTriggerInfo'));
+
+    $container->setDefinition('civi.case.staticTriggers', new Definition(
+      'Civi\Core\SqlTrigger\StaticTriggers',
+      array(
+        array(
+          array(
+            'upgrade_check' => array('table' => 'civicrm_case', 'column' => 'modified_date'),
+            'table' => 'civicrm_case_activity',
+            'when' => 'AFTER',
+            'event' => array('INSERT'),
+            'sql' => "\nUPDATE civicrm_case SET modified_date = CURRENT_TIMESTAMP WHERE id = NEW.case_id;\n",
+          ),
+          array(
+            'upgrade_check' => array('table' => 'civicrm_case', 'column' => 'modified_date'),
+            'table' => 'civicrm_activity',
+            'when' => 'BEFORE',
+            'event' => array('UPDATE', 'DELETE'),
+            'sql' => "\nUPDATE civicrm_case SET modified_date = CURRENT_TIMESTAMP WHERE id IN (SELECT ca.case_id FROM civicrm_case_activity ca WHERE ca.activity_id = OLD.id);\n",
+          ),
+        ),
+      )
+    ))
+      ->addTag('kernel.event_listener', array('event' => 'hook_civicrm_triggerInfo', 'method' => 'onTriggerInfo'));
+
     $container->setDefinition('civi_token_compat', new Definition(
       'Civi\Token\TokenCompatSubscriber',
       array()
diff --git a/Civi/Core/SqlTrigger/StaticTriggers.php b/Civi/Core/SqlTrigger/StaticTriggers.php
new file mode 100644 (file)
index 0000000..e4747a8
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2017                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Core\SqlTrigger;
+
+/**
+ * Build a set of simple, literal SQL triggers.
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC (c) 2004-2017
+ */
+class StaticTriggers {
+
+  /**
+   * @var array
+   *   A list of triggers, in the same format as hook_civicrm_triggerInfo.
+   *   Additionally, you may specify `upgrade_check` to ensure that the trigger
+   *   is *not* installed during early upgrade steps (before key dependencies are met).
+   *
+   *   Ex:  $triggers[0]['upgrade_check'] = array('table' => 'civicrm_case', 'column'=> 'modified_date');
+   *
+   * @see \CRM_Utils_Hook::triggerInfo
+   */
+  private $triggers;
+
+  /**
+   * StaticTriggers constructor.
+   * @param $triggers
+   */
+  public function __construct($triggers) {
+    $this->triggers = $triggers;
+  }
+
+
+  /**
+   * Add our list of triggers to the global list.
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   * @see \CRM_Utils_Hook::triggerInfo
+   */
+  public function onTriggerInfo($e) {
+    $this->alterTriggerInfo($e->info, $e->tableName);
+  }
+
+  /**
+   * Add our list of triggers to the global list.
+   *
+   * @see \CRM_Utils_Hook::triggerInfo
+   * @see \CRM_Core_DAO::triggerRebuild
+   *
+   * @param array $info
+   *   See hook_civicrm_triggerInfo.
+   * @param string|NULL $tableFilter
+   *   See hook_civicrm_triggerInfo.
+   */
+  public function alterTriggerInfo(&$info, $tableFilter = NULL) {
+    foreach ($this->getTriggers() as $trigger) {
+      if ($tableFilter !== NULL) {
+        // Because sadism.
+        if (in_array($tableFilter, (array) $trigger['table'])) {
+          $trigger['table'] = $tableFilter;
+        }
+      }
+
+      if (\CRM_Core_Config::isUpgradeMode() && isset($trigger['upgrade_check'])) {
+        $uc = $trigger['upgrade_check'];
+        if (!\CRM_Core_DAO::checkFieldExists($uc['table'], $uc['column'])
+        ) {
+          continue;
+        }
+      }
+      unset($trigger['upgrade_check']);
+      $info[] = $trigger;
+    }
+  }
+
+  /**
+   * @return mixed
+   */
+  public function getTriggers() {
+    return $this->triggers;
+  }
+
+  /**
+   * @param mixed $triggers
+   * @return StaticTriggers
+   */
+  public function setTriggers($triggers) {
+    $this->triggers = $triggers;
+    return $this;
+  }
+
+  /**
+   * @param $trigger
+   * @return StaticTriggers
+   */
+  public function addTrigger($trigger) {
+    $this->triggers[] = $trigger;
+    return $this;
+  }
+
+}
diff --git a/Civi/Core/SqlTrigger/TimestampTriggers.php b/Civi/Core/SqlTrigger/TimestampTriggers.php
new file mode 100644 (file)
index 0000000..60c35a9
--- /dev/null
@@ -0,0 +1,334 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.7                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2017                                |
+ +--------------------------------------------------------------------+
+ | This file is a part of CiviCRM.                                    |
+ |                                                                    |
+ | CiviCRM is free software; you can copy, modify, and distribute it  |
+ | under the terms of the GNU Affero General Public License           |
+ | Version 3, 19 November 2007 and the CiviCRM Licensing Exception.   |
+ |                                                                    |
+ | CiviCRM is distributed in the hope that it will be useful, but     |
+ | WITHOUT ANY WARRANTY; without even the implied warranty of         |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.               |
+ | See the GNU Affero General Public License for more details.        |
+ |                                                                    |
+ | You should have received a copy of the GNU Affero General Public   |
+ | License and the CiviCRM Licensing Exception along                  |
+ | with this program; if not, contact CiviCRM LLC                     |
+ | at info[AT]civicrm[DOT]org. If you have questions about the        |
+ | GNU Affero General Public License or the licensing of CiviCRM,     |
+ | see the CiviCRM license FAQ at http://civicrm.org/licensing        |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Core\SqlTrigger;
+
+use Civi\Core\Event\GenericHookEvent;
+
+/**
+ * Build a set of SQL triggers for tracking timestamps on an entity.
+ *
+ * This class is a generalization of CRM-10554 with the aim of enabling CRM-20958.
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC (c) 2004-2017
+ */
+class TimestampTriggers {
+
+  /**
+   * @var string
+   *   SQL table name.
+   *   Ex: 'civicrm_contact', 'civicrm_activity'.
+   */
+  private $tableName;
+
+  /**
+   * @var string
+   *   An entity name (from civicrm_custom_group.extends).
+   *   Ex: 'Contact', 'Activity'.
+   */
+  private $customDataEntity;
+
+  /**
+   * @var string
+   *   SQL column name.
+   *   Ex: 'created_date'.
+   */
+  private $createdDate;
+
+  /**
+   * @var string
+   *   SQL column name.
+   *   Ex: 'modified_date'.
+   */
+  private $modifiedDate;
+
+  /**
+   * @var array
+   *   Ex: $relations[0] == array('table' => 'civicrm_bar', 'column' => 'foo_id');
+   */
+  private $relations;
+
+  /**
+   * @param string $tableName
+   *   SQL table name.
+   *   Ex: 'civicrm_contact', 'civicrm_activity'.
+   * @param string $customDataEntity
+   *   An entity name (from civicrm_custom_group.extends).
+   *   Ex: 'Contact', 'Activity'.
+   * @return TimestampTriggers
+   */
+  public static function create($tableName, $customDataEntity) {
+    return new static($tableName, $customDataEntity);
+  }
+
+  /**
+   * TimestampTriggers constructor.
+   *
+   * @param string $tableName
+   *   SQL table name.
+   *   Ex: 'civicrm_contact', 'civicrm_activity'.
+   * @param string $customDataEntity
+   *   An entity name (from civicrm_custom_group.extends).
+   *   Ex: 'Contact', 'Activity'.
+   * @param string $createdDate
+   *   SQL column name.
+   *   Ex: 'created_date'.
+   * @param string $modifiedDate
+   *   SQL column name.
+   *   Ex: 'modified_date'.
+   * @param array $relations
+   *   Ex: $relations[0] == array('table' => 'civicrm_bar', 'column' => 'foo_id');
+   */
+  public function __construct(
+    $tableName,
+    $customDataEntity,
+    $createdDate = 'created_date',
+    $modifiedDate = 'modified_date',
+    $relations = array()
+  ) {
+    $this->tableName = $tableName;
+    $this->customDataEntity = $customDataEntity;
+    $this->createdDate = $createdDate;
+    $this->modifiedDate = $modifiedDate;
+    $this->relations = $relations;
+  }
+
+  /**
+   * Add our list of triggers to the global list.
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   * @see \CRM_Utils_Hook::triggerInfo
+   */
+  public function onTriggerInfo($e) {
+    $this->alterTriggerInfo($e->info, $e->tableName);
+  }
+
+  /**
+   * Add our list of triggers to the global list.
+   *
+   * @see \CRM_Utils_Hook::triggerInfo
+   * @see \CRM_Core_DAO::triggerRebuild
+   *
+   * @param array $info
+   *   See hook_civicrm_triggerInfo.
+   * @param string|NULL $tableFilter
+   *   See hook_civicrm_triggerInfo.
+   */
+  public function alterTriggerInfo(&$info, $tableFilter = NULL) {
+    // If we haven't upgraded yet, then the created_date/modified_date may not exist.
+    // In the past, this was a version-based check, but checkFieldExists()
+    // seems more robust.
+    if (\CRM_Core_Config::isUpgradeMode()) {
+      if (!\CRM_Core_DAO::checkFieldExists($this->getTableName(),
+        $this->getCreatedDate())
+      ) {
+        return;
+      }
+    }
+
+    if ($tableFilter == NULL || $tableFilter == $this->getTableName()) {
+      $info[] = array(
+        'table' => array($this->getTableName()),
+        'when' => 'BEFORE',
+        'event' => array('INSERT'),
+        'sql' => "\nSET NEW.{$this->getCreatedDate()} = CURRENT_TIMESTAMP;\n",
+      );
+    }
+
+    // Update timestamp when modifying closely related tables
+    $relIdx = \CRM_Utils_Array::index(
+      array('column', 'table'),
+      $this->getAllRelations()
+    );
+    foreach ($relIdx as $column => $someRelations) {
+      $this->generateTimestampTriggers($info, $tableFilter,
+        array_keys($someRelations), $column);
+    }
+  }
+
+  /**
+   * Generate triggers to update the timestamp.
+   *
+   * The corresponding civicrm_FOO row is updated on insert/update/delete
+   * to a table that extends civicrm_FOO.
+   * Don't regenerate triggers for all such tables if only asked for one table.
+   *
+   * @param array $info
+   *   Reference to the array where generated trigger information is being stored
+   * @param string|null $tableFilter
+   *   Name of the table for which triggers are being generated, or NULL if all tables
+   * @param array $relatedTableNames
+   *   Array of all core or all custom table names extending civicrm_FOO
+   * @param string $contactRefColumn
+   *   'contact_id' if processing core tables, 'entity_id' if processing custom tables
+   *
+   * @link https://issues.civicrm.org/jira/browse/CRM-15602
+   * @see triggerInfo
+   */
+  public function generateTimestampTriggers(
+    &$info,
+    $tableFilter,
+    $relatedTableNames,
+    $contactRefColumn
+  ) {
+    // Safety
+    $contactRefColumn = \CRM_Core_DAO::escapeString($contactRefColumn);
+
+    // If specific related table requested, just process that one.
+    // (Reply: This feels fishy.)
+    if (in_array($tableFilter, $relatedTableNames)) {
+      $relatedTableNames = array($tableFilter);
+    }
+
+    // If no specific table requested (include all related tables),
+    // or a specific related table requested (as matched above)
+    if (empty($tableFilter) || isset($relatedTableNames[$tableFilter])) {
+      $info[] = array(
+        'table' => $relatedTableNames,
+        'when' => 'AFTER',
+        'event' => array('INSERT', 'UPDATE'),
+        'sql' => "\nUPDATE {$this->getTableName()} SET {$this->getModifiedDate()} = CURRENT_TIMESTAMP WHERE id = NEW.$contactRefColumn;\n",
+      );
+      $info[] = array(
+        'table' => $relatedTableNames,
+        'when' => 'AFTER',
+        'event' => array('DELETE'),
+        'sql' => "\nUPDATE {$this->getTableName()} SET {$this->getModifiedDate()} = CURRENT_TIMESTAMP WHERE id = OLD.$contactRefColumn;\n",
+      );
+    }
+  }
+
+  /**
+   * @return string
+   */
+  public function getTableName() {
+    return $this->tableName;
+  }
+
+  /**
+   * @param string $tableName
+   * @return TimestampTriggers
+   */
+  public function setTableName($tableName) {
+    $this->tableName = $tableName;
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getCustomDataEntity() {
+    return $this->customDataEntity;
+  }
+
+  /**
+   * @param string $customDataEntity
+   * @return TimestampTriggers
+   */
+  public function setCustomDataEntity($customDataEntity) {
+    $this->customDataEntity = $customDataEntity;
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getCreatedDate() {
+    return $this->createdDate;
+  }
+
+  /**
+   * @param string $createdDate
+   * @return TimestampTriggers
+   */
+  public function setCreatedDate($createdDate) {
+    $this->createdDate = $createdDate;
+    return $this;
+  }
+
+  /**
+   * @return string
+   */
+  public function getModifiedDate() {
+    return $this->modifiedDate;
+  }
+
+  /**
+   * @param string $modifiedDate
+   * @return TimestampTriggers
+   */
+  public function setModifiedDate($modifiedDate) {
+    $this->modifiedDate = $modifiedDate;
+    return $this;
+  }
+
+  /**
+   * @return array
+   *   Each item is an array('table' => string, 'column' => string)
+   */
+  public function getRelations() {
+    return $this->relations;
+  }
+
+  /**
+   * @param array $relations
+   * @return TimestampTriggers
+   */
+  public function setRelations($relations) {
+    $this->relations = $relations;
+    return $this;
+  }
+
+  /**
+   * Get a list of all tracked relations.
+   *
+   * This is basically the curated list (`$this->relations`) plus any custom data.
+   *
+   * @return array
+   *   Each item is an array('table' => string, 'column' => string)
+   */
+  public function getAllRelations() {
+    $relations = $this->getRelations();
+
+    if ($this->getCustomDataEntity()) {
+      $customGroupDAO = \CRM_Core_BAO_CustomGroup::getAllCustomGroupsByBaseEntity($this->getCustomDataEntity());
+      $customGroupDAO->is_multiple = 0;
+      $customGroupDAO->find();
+      while ($customGroupDAO->fetch()) {
+        $relations[] = array(
+          'table' => $customGroupDAO->table_name,
+          'column' => 'entity_id',
+        );
+      }
+    }
+
+    return $relations;
+  }
+
+}
index 98b9b2f8bbc53dbcb7f89d519758180e7e715107..b2b0f5523c64ee1ae864ce316bb2fb0c70d3bd73 100644 (file)
@@ -447,6 +447,11 @@ class api_v3_ActivityTest extends CiviUnitTestCase {
     require_once 'api/v3/examples/Activity/Create.php';
     $result = activity_create_example();
     $expectedResult = activity_create_expectedresult();
+    // Compare everything *except* timestamps.
+    unset($result['values'][1]['created_date']);
+    unset($result['values'][1]['modified_date']);
+    unset($expectedResult['values'][1]['created_date']);
+    unset($expectedResult['values'][1]['modified_date']);
     $this->assertEquals($result, $expectedResult);
   }
 
index 72ea596cf6cf3910ae619fd2f779f9da2c0fdf72..9330a6958e949a40bf8b32d67aa108381bc52bcc 100644 (file)
@@ -193,8 +193,13 @@ class api_v3_CaseTest extends CiviCaseTestCase {
     $params['subject'] = $case['subject'] = 'Something Else';
     $this->callAPISuccess('case', 'create', $params);
 
-    // Verify that updated case is exactly equal to the original with new subject.
+    // Verify that updated case is equal to the original with new subject.
     $result = $this->callAPISuccessGetSingle('Case', array('case_id' => $id));
+    // Modification dates are likely to differ by 0-2 sec. Check manually.
+    $this->assertGreaterThanOrEqual($result['modified_date'], $case['modified_date']);
+    unset($result['modified_date']);
+    unset($case['modified_date']);
+    // Everything else should be identical.
     $this->assertAPIArrayComparison($result, $case);
   }
 
@@ -849,4 +854,52 @@ class api_v3_CaseTest extends CiviCaseTestCase {
     return $examples;
   }
 
+  public function testTimestamps() {
+    $params = $this->_params;
+    $case_created = $this->callAPISuccess('case', 'create', $params);
+
+    $case_1 = $this->callAPISuccess('Case', 'getsingle', array(
+      'id' => $case_created['id'],
+    ));
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $case_1['created_date']);
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $case_1['modified_date']);
+    $this->assertApproxEquals(strtotime($case_1['created_date']), strtotime($case_1['modified_date']), 2);
+
+    $activity_1 = $this->callAPISuccess('activity', 'getsingle', array(
+      'case_id' => $case_created['id'],
+      'options' => array(
+        'limit' => 1,
+      ),
+    ));
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $activity_1['created_date']);
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $activity_1['modified_date']);
+    $this->assertApproxEquals(strtotime($activity_1['created_date']), strtotime($activity_1['modified_date']), 2);
+
+    usleep(1.5 * 1000000);
+    $this->callAPISuccess('activity', 'create', array(
+      'id' => $activity_1['id'],
+      'subject' => 'Make cheese',
+    ));
+
+    $activity_2 = $this->callAPISuccess('activity', 'getsingle', array(
+      'id' => $activity_1['id'],
+    ));
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $activity_2['created_date']);
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $activity_2['modified_date']);
+    $this->assertNotEquals($activity_2['created_date'], $activity_2['modified_date']);
+
+    $this->assertEquals($activity_1['created_date'], $activity_2['created_date']);
+    $this->assertNotEquals($activity_1['modified_date'], $activity_2['modified_date']);
+    $this->assertLessThan($activity_2['modified_date'], $activity_1['modified_date'],
+      sprintf("Original modification time (%s) should predate later modification time (%s)", $activity_1['modified_date'], $activity_2['modified_date']));
+
+    $case_2 = $this->callAPISuccess('Case', 'getsingle', array(
+      'id' => $case_created['id'],
+    ));
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $case_2['created_date']);
+    $this->assertRegExp(';^\d\d\d\d-\d\d-\d\d \d\d:\d\d;', $case_2['modified_date']);
+    $this->assertEquals($case_1['created_date'], $case_2['created_date']);
+    $this->assertNotEquals($case_2['created_date'], $case_2['modified_date']);
+  }
+
 }
index 5c5d013bcca78c1890b83fe5261e8f139831a4f6..6b6da122492cd095a3346467dbef37733ace6dd8 100644 (file)
     <headerPattern>/(activity.)?(star|favorite)/i</headerPattern>
     <add>4.7</add>
   </field>
+  <field>
+    <name>created_date</name>
+    <uniqueName>activity_created_date</uniqueName>
+    <type>timestamp</type>
+    <comment>When was the activity was created.</comment>
+    <required>false</required>
+    <export>true</export>
+    <default>NULL</default>
+    <add>4.7</add>
+  </field>
+  <field>
+    <name>modified_date</name>
+    <uniqueName>activity_modified_date</uniqueName>
+    <type>timestamp</type>
+    <comment>When was the activity (or closely related entity) was created or modified or deleted.</comment>
+    <required>false</required>
+    <export>true</export>
+    <default>CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP</default>
+    <add>4.7</add>
+  </field>
 </table>
index 37d19180771f609da7c182b413ab7b7a66d289d5..beeaaa98741380951611ebb65a49f13b193f20f6 100644 (file)
     <fieldName>is_deleted</fieldName>
     <add>2.2</add>
   </index>
+  <field>
+    <name>created_date</name>
+    <uniqueName>case_created_date</uniqueName>
+    <type>timestamp</type>
+    <comment>When was the case was created.</comment>
+    <required>false</required>
+    <export>true</export>
+    <default>NULL</default>
+    <add>4.7</add>
+  </field>
+  <field>
+    <name>modified_date</name>
+    <uniqueName>case_modified_date</uniqueName>
+    <type>timestamp</type>
+    <comment>When was the case (or closely related entity) was created or modified or deleted.</comment>
+    <required>false</required>
+    <export>true</export>
+    <default>CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP</default>
+    <add>4.7</add>
+  </field>
 </table>