CRM-14811 - Add CRM_Core_InnoDBIndexer
authorTim Otten <totten@civicrm.org>
Sat, 7 Jun 2014 01:23:24 +0000 (18:23 -0700)
committerTim Otten <totten@civicrm.org>
Sat, 7 Jun 2014 01:23:24 +0000 (18:23 -0700)
CRM/Core/InnoDBIndexer.php [new file with mode: 0644]
tests/phpunit/CRM/Core/InnoDBIndexerTest.php [new file with mode: 0644]

diff --git a/CRM/Core/InnoDBIndexer.php b/CRM/Core/InnoDBIndexer.php
new file mode 100644 (file)
index 0000000..a505fee
--- /dev/null
@@ -0,0 +1,252 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | CiviCRM version 4.5                                                |
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC (c) 2004-2014                                |
+ +--------------------------------------------------------------------+
+ | 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        |
+ +--------------------------------------------------------------------+
+*/
+
+/**
+ * The InnoDB indexer is responsible for creating and destroying
+ * full-text indices on InnoDB classes.
+ */
+class CRM_Core_InnoDBIndexer {
+  const IDX_PREFIX = "civicrm_fts_";
+
+  /**
+   * @var CRM_Core_InnoDBIndexer
+   */
+  private static $singleton = NULL;
+
+  public static function singleton($fresh = FALSE) {
+    if ($fresh || self::$singleton === NULL) {
+      $indices = array(
+        'civicrm_address' => array(
+          array('street_address', 'city', 'postal_code')
+        ),
+        'civicrm_contact' => array(
+          array('sort_name', 'nick_name', 'display_name'),
+        ),
+        'civicrm_email' => array(
+          array('email')
+        ),
+        'civicrm_note' => array(
+          array('subject', 'note'),
+        ),
+        'civicrm_phone' => array(
+          array('phone'),
+        ),
+        'civicrm_tag' => array(
+          array('name'),
+        ),
+      );
+      self::$singleton = new self(TRUE, $indices);
+    }
+    return self::$singleton;
+  }
+
+  /**
+   * (Setting Callback)
+   * Respond to changes in the "enable_innodb_fts" setting
+   *
+   * @param bool $oldValue
+   * @param bool $newValue
+   * @param array $metadata Specification of the setting (per *.settings.php)
+   */
+  public static function onToggleFts($oldValue, $newValue, $metadata) {
+    $indexer = CRM_Core_InnoDBIndexer::singleton();
+    $indexer->setActive($newValue);
+    $indexer->fixSchemaDifferences();
+  }
+
+  /**
+   * @var array (string $table => array $indices)
+   *
+   * ex: $indices['civicrm_contact'][0] = array('first_name', 'last_name');
+   */
+  protected $indices;
+
+  /**
+   * @var bool
+   */
+  protected $isActive;
+
+  public function __construct($isActive, $indices) {
+    $this->isActive = $isActive;
+    $this->indices = $this->normalizeIndices($indices);
+  }
+
+  public function fixSchemaDifferences() {
+    // Limitation: This won't pick up stale indices on tables which are not
+    // declared in $this->indices. That's not much of an issue for now b/c
+    // we have a static list of tables.
+    foreach ($this->indices as $tableName => $ign) {
+      $todoSqls = $this->reconcileIndexSqls($tableName);
+      foreach ($todoSqls as $todoSql) {
+        CRM_Core_DAO::executeQuery($todoSql);
+      }
+    }
+  }
+
+  /**
+   * Determine if an index is expected to exist
+   *
+   * @param string $table
+   * @param array $fields list of field names that must be in the index
+   * @return bool
+   */
+  public function hasDeclaredIndex($table, $fields) {
+    if (!$this->isActive) {
+      return FALSE;
+    }
+
+    if (isset($this->indices[$table])) {
+      foreach ($this->indices[$table] as $idxFields) {
+        // TODO determine if $idxFields must be exact match or merely a subset
+        // if (sort($fields) == sort($idxFields)) {
+        if (array_diff($fields, $idxFields) == array()) {
+          return TRUE;
+        }
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * Get a list of FTS index names that are currently defined in the database.
+   *
+   * @param string $table
+   * @return array (string $indexName => string $indexName)
+   */
+  public function findActualFtsIndexNames($table) {
+    // Note: this only works in MySQL 5.6,  but this whole system is intended to only work in MySQL 5.6
+    $sql = "
+      SELECT i.name as index_name
+      FROM information_schema.innodb_sys_tables t
+      JOIN information_schema.innodb_sys_indexes i USING (table_id)
+      WHERE t.name = concat(database(),'/$table')
+      AND i.name like '" . self::IDX_PREFIX . "%'
+      ";
+    $dao = CRM_Core_DAO::executeQuery($sql);
+    $indexNames = array();
+    while ($dao->fetch()) {
+      $indexNames[$dao->index_name] = $dao->index_name;
+    }
+    return $indexNames;
+  }
+
+  /**
+   * Generate a "CREATE INDEX" statement for each desired
+   * FTS index.
+   *
+   * @param $table
+   * @return array (string $indexName => string $sql)
+   */
+  public function buildIndexSql($table) {
+    $sqls = array(); // array (string $idxName => string $sql)
+    if ($this->isActive && isset($this->indices[$table])) {
+      foreach ($this->indices[$table] as $fields) {
+        $name = self::IDX_PREFIX . md5($table . '::' . implode(',', $fields));
+        $sqls[$name] = sprintf("CREATE FULLTEXT INDEX %s ON %s (%s)", $name, $table, implode(',', $fields));
+      }
+    }
+    return $sqls;
+  }
+
+  /**
+   * Generate a "DROP INDEX" statement for each existing FTS index
+   *
+   * @param string $table
+   * @return array (string $idxName => string $sql)
+   */
+  public function dropIndexSql($table) {
+    $sqls = array();
+    $names = $this->findActualFtsIndexNames($table);
+    foreach ($names as $name) {
+      $sqls[$name] = sprintf("DROP INDEX %s ON %s", $name, $table);
+    }
+    return $sqls;
+  }
+
+  /**
+   * Construct a set of SQL statements which will create (or preserve)
+   * required indices and destroy unneeded indices.
+   *
+   * @param $table
+   * @return array
+   */
+  public function reconcileIndexSqls($table) {
+    $buildIndexSqls = $this->buildIndexSql($table);
+    $dropIndexSqls = $this->dropIndexSql($table);
+
+    $allIndexNames = array_unique(array_merge(
+      array_keys($dropIndexSqls),
+      array_keys($buildIndexSqls)
+    ));
+
+    $todoSqls = array();
+    foreach ($allIndexNames as $indexName) {
+      if (isset($buildIndexSqls[$indexName]) && isset($dropIndexSqls[$indexName])) {
+        // already exists
+      }
+      elseif (isset($buildIndexSqls[$indexName])) {
+        $todoSqls[] = $buildIndexSqls[$indexName];
+      }
+      else {
+        $todoSqls[] = $dropIndexSqls[$indexName];
+      }
+    }
+    return $todoSqls;
+  }
+
+  /**
+   * Put the indices into a normalized format
+   *
+   * @param $indices
+   * @return array
+   */
+  public function normalizeIndices($indices) {
+    $result = array();
+    foreach ($indices as $table => $indicesByTable) {
+      foreach ($indicesByTable as $k => $fields) {
+        sort($fields);
+        $result[$table][] = $fields;
+      }
+    }
+    return $result;
+  }
+
+  /**
+   * @param boolean $isActive
+   */
+  public function setActive($isActive) {
+    $this->isActive = $isActive;
+  }
+
+  /**
+   * @return boolean
+   */
+  public function getActive() {
+    return $this->isActive;
+  }
+}
diff --git a/tests/phpunit/CRM/Core/InnoDBIndexerTest.php b/tests/phpunit/CRM/Core/InnoDBIndexerTest.php
new file mode 100644 (file)
index 0000000..4d340a2
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+require_once 'CiviTest/CiviUnitTestCase.php';
+
+/**
+ * Class CRM_Core_InnoDBIndexerTest
+ */
+class CRM_Core_InnoDBIndexerTest extends CiviUnitTestCase {
+  function tearDown() {
+    // May or may not cleanup well if there's a bug in the indexer.
+    // This is better than nothing -- and better than duplicating the
+    // cleanup logic.
+    $idx = new CRM_Core_InnoDBIndexer(FALSE, array());
+    $idx->fixSchemaDifferences();
+
+    parent::tearDown();
+  }
+
+  function testHasDeclaredIndex() {
+    $idx = new CRM_Core_InnoDBIndexer(TRUE, array(
+      'civicrm_contact' => array(
+        array('first_name', 'last_name'),
+        array('foo')
+      ),
+      'civicrm_email' => array(
+        array('whiz'),
+      ),
+    ));
+
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_contact', array('first_name', 'last_name')));
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_contact', array('last_name', 'first_name')));
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_contact', array('first_name'))); // not sure if this is right behavior
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_contact', array('last_name'))); // not sure if this is right behavior
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_contact', array('foo')));
+    $this->assertFalse($idx->hasDeclaredIndex('civicrm_contact', array('whiz')));
+
+    $this->assertFalse($idx->hasDeclaredIndex('civicrm_email', array('first_name', 'last_name')));
+    $this->assertFalse($idx->hasDeclaredIndex('civicrm_email', array('foo')));
+    $this->assertTrue($idx->hasDeclaredIndex('civicrm_email', array('whiz')));
+  }
+
+  /**
+   * When disabled, there is no FTS index, so queries that rely on FTS index fail.
+   */
+  function testDisabled() {
+    $idx = new CRM_Core_InnoDBIndexer(FALSE, array(
+      'civicrm_contact' => array(
+        array('first_name', 'last_name'),
+      ),
+    ));
+    $idx->fixSchemaDifferences();
+
+    try {
+      CRM_Core_DAO::executeQuery('SELECT id FROM civicrm_contact WHERE MATCH(first_name,last_name) AGAINST ("joe")');
+      $this->fail("Missed expected exception");
+    } catch (Exception $e) {
+      $this->assertTrue(TRUE, 'Received expected exception');
+    }
+  }
+
+  /**
+   * When enabled, the FTS index is created, so queries that rely on FTS work.
+   */
+  function testEnabled() {
+    if (!$this->supportsFts()) {
+      $this->markTestSkipped("Local installation of InnoDB does not support FTS.");
+      return;
+    }
+
+    $idx = new CRM_Core_InnoDBIndexer(TRUE, array(
+      'civicrm_contact' => array(
+        array('first_name', 'last_name'),
+      ),
+    ));
+    $idx->fixSchemaDifferences();
+
+    CRM_Core_DAO::executeQuery('SELECT id FROM civicrm_contact WHERE MATCH(first_name,last_name) AGAINST ("joe")');
+  }
+
+  function supportsFts() {
+    return version_compare(CRM_Core_DAO::singleValueQuery('SELECT VERSION()'), '5.6.0', '>=');
+  }
+}