Merge pull request #8005 from joeyisking/CRM-18270
[civicrm-core.git] / CRM / Core / I18n / Schema.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
7e9e8871 4 | CiviCRM version 4.7 |
6a488035 5 +--------------------------------------------------------------------+
fa938177 6 | Copyright CiviCRM LLC (c) 2004-2016 |
6a488035
TO
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
d25dd0ee 26 */
6a488035
TO
27
28/**
29 *
30 * @package CRM
fa938177 31 * @copyright CiviCRM LLC (c) 2004-2016
6a488035
TO
32 */
33class CRM_Core_I18n_Schema {
34
35 /**
36 * Drop all views (for use by CRM_Core_DAO::dropAllTables() mostly).
6a488035 37 */
00be9182 38 public static function dropAllViews() {
6a488035
TO
39 $domain = new CRM_Core_DAO_Domain();
40 $domain->find(TRUE);
41 if (!$domain->locales) {
42 return;
43 }
44
45 $locales = explode(CRM_Core_DAO::VALUE_SEPARATOR, $domain->locales);
46 $tables = CRM_Core_I18n_SchemaStructure::tables();
47
48 foreach ($locales as $locale) {
49 foreach ($tables as $table) {
50 CRM_Core_DAO::executeQuery("DROP VIEW IF EXISTS {$table}_{$locale}");
51 }
52 }
53 }
54
55 /**
56 * Switch database from single-lang to multi (by adding
57 * the first language and dropping the original columns).
58 *
5a4f6742
CW
59 * @param string $locale
60 * the first locale to create (migrate to).
6a488035 61 */
00be9182 62 public static function makeMultilingual($locale) {
6a488035
TO
63 $domain = new CRM_Core_DAO_Domain();
64 $domain->find(TRUE);
65
66 // break early if the db is already multi-lang
67 if ($domain->locales) {
68 return;
69 }
70
71 $dao = new CRM_Core_DAO();
72
73 // build the column-adding SQL queries
74 $columns = CRM_Core_I18n_SchemaStructure::columns();
75 $indices = CRM_Core_I18n_SchemaStructure::indices();
76 $queries = array();
77 foreach ($columns as $table => $hash) {
78 // drop old indices
79 if (isset($indices[$table])) {
80 foreach ($indices[$table] as $index) {
81 $queries[] = "DROP INDEX {$index['name']} ON {$table}";
82 }
83 }
84 // deal with columns
85 foreach ($hash as $column => $type) {
86 $queries[] = "ALTER TABLE {$table} ADD {$column}_{$locale} {$type}";
87 $queries[] = "UPDATE {$table} SET {$column}_{$locale} = {$column}";
88 $queries[] = "ALTER TABLE {$table} DROP {$column}";
89 }
90
91 // add view
92 $queries[] = self::createViewQuery($locale, $table, $dao);
93
94 // add new indices
95 $queries = array_merge($queries, array_values(self::createIndexQueries($locale, $table)));
96 }
97
98 // execute the queries without i18n rewriting
99 foreach ($queries as $query) {
100 $dao->query($query, FALSE);
101 }
102
103 // update civicrm_domain.locales
104 $domain->locales = $locale;
105 $domain->save();
106 }
107
108 /**
109 * Switch database from multi-lang back to single (by dropping
110 * additional columns and views and retaining only the selected locale).
111 *
5a4f6742
CW
112 * @param string $retain
113 * the locale to retain.
6a488035 114 */
00be9182 115 public static function makeSinglelingual($retain) {
3bdca100 116 $domain = new CRM_Core_DAO_Domain();
6a488035
TO
117 $domain->find(TRUE);
118 $locales = explode(CRM_Core_DAO::VALUE_SEPARATOR, $domain->locales);
119
120 // break early if the db is already single-lang
121 if (!$locales) {
122 return;
123 }
124
125 // lets drop all triggers first
3bdca100 126 $logging = new CRM_Logging_Schema();
481a74f4 127 $logging->dropTriggers();
6a488035
TO
128
129 // turn subsequent tables singlelingual
130 $tables = CRM_Core_I18n_SchemaStructure::tables();
131 foreach ($tables as $table) {
132 self::makeSinglelingualTable($retain, $table);
133 }
134
135 // update civicrm_domain.locales
136 $domain->locales = 'NULL';
137 $domain->save();
138
139 //CRM-6963 -fair assumption.
140 global $dbLocale;
141 $dbLocale = '';
142
143 // now lets rebuild all triggers
481a74f4 144 CRM_Core_DAO::triggerRebuild();
6a488035
TO
145 }
146
147 /**
148 * Switch a given table from multi-lang to single (by retaining only the selected locale).
149 *
5a4f6742
CW
150 * @param string $retain
151 * the locale to retain.
152 * @param string $table
153 * the table containing the column.
154 * @param string $class
155 * schema structure class to use to recreate indices.
6a488035 156 *
da6b46f4 157 * @param array $triggers
6a488035 158 */
3bdca100 159 public static function makeSinglelingualTable(
6a488035
TO
160 $retain,
161 $table,
162 $class = 'CRM_Core_I18n_SchemaStructure',
163 $triggers = array()
164 ) {
3bdca100 165 $domain = new CRM_Core_DAO_Domain();
6a488035
TO
166 $domain->find(TRUE);
167 $locales = explode(CRM_Core_DAO::VALUE_SEPARATOR, $domain->locales);
168
169 // break early if the db is already single-lang
170 if (!$locales) {
171 return;
172 }
173
0e6e8724
DL
174 $columns =& $class::columns();
175 $indices =& $class::indices();
6a488035
TO
176 $queries = array();
177 $dropQueries = array();
178 // drop indices
179 if (isset($indices[$table])) {
180 foreach ($indices[$table] as $index) {
181 foreach ($locales as $loc) {
182 $queries[] = "DROP INDEX {$index['name']}_{$loc} ON {$table}";
183 }
184 }
185 }
186
187 // deal with columns
188 foreach ($columns[$table] as $column => $type) {
189 $queries[] = "ALTER TABLE {$table} ADD {$column} {$type}";
190 $queries[] = "UPDATE {$table} SET {$column} = {$column}_{$retain}";
191 foreach ($locales as $loc) {
192 $dropQueries[] = "ALTER TABLE {$table} DROP {$column}_{$loc}";
193 }
194 }
195
196 // drop views
197 foreach ($locales as $loc) {
198 $queries[] = "DROP VIEW {$table}_{$loc}";
199 }
200
201 // add original indices
202 $queries = array_merge($queries, self::createIndexQueries(NULL, $table));
203
204 // execute the queries without i18n rewriting
3bdca100 205 $dao = new CRM_Core_DAO();
6a488035
TO
206 foreach ($queries as $query) {
207 $dao->query($query, FALSE);
208 }
209
210 foreach ($dropQueries as $query) {
211 $dao->query($query, FALSE);
212 }
213
481a74f4 214 if (!empty($triggers)) {
6a488035 215 if (CRM_Core_Config::isUpgradeMode()) {
353ffa53
TO
216 foreach ($triggers as $triggerInfo) {
217 $when = $triggerInfo['when'];
218 $event = $triggerInfo['event'];
219 $triggerName = "{$table}_{$when}_{$event}";
220 CRM_Core_DAO::executeQuery("DROP TRIGGER IF EXISTS {$triggerName}");
221 }
6a488035 222 }
6a488035 223
353ffa53
TO
224 // invoke the meta trigger creation call
225 CRM_Core_DAO::triggerRebuild($table);
226 }
6a488035
TO
227 }
228
229 /**
230 * Add a new locale to a multi-lang db, setting
231 * its values to the current default locale.
232 *
5a4f6742
CW
233 * @param string $locale
234 * the new locale to add.
235 * @param string $source
236 * the locale to copy from.
6a488035 237 */
00be9182 238 public static function addLocale($locale, $source) {
6a488035
TO
239 // get the current supported locales
240 $domain = new CRM_Core_DAO_Domain();
241 $domain->find(TRUE);
242 $locales = explode(CRM_Core_DAO::VALUE_SEPARATOR, $domain->locales);
243
244 // break early if the locale is already supported
245 if (in_array($locale, $locales)) {
246 return;
247 }
248
249 $dao = new CRM_Core_DAO();
250
251 // build the required SQL queries
252 $columns = CRM_Core_I18n_SchemaStructure::columns();
253 $indices = CRM_Core_I18n_SchemaStructure::indices();
254 $queries = array();
255 foreach ($columns as $table => $hash) {
256 // add new columns
257 foreach ($hash as $column => $type) {
258 // CRM-7854: skip existing columns
259 if (CRM_Core_DAO::checkFieldExists($table, "{$column}_{$locale}", FALSE)) {
260 continue;
261 }
262 $queries[] = "ALTER TABLE {$table} ADD {$column}_{$locale} {$type}";
263 $queries[] = "UPDATE {$table} SET {$column}_{$locale} = {$column}_{$source}";
264 }
265
266 // add view
267 $queries[] = self::createViewQuery($locale, $table, $dao);
268
269 // add new indices
270 $queries = array_merge($queries, array_values(self::createIndexQueries($locale, $table)));
271 }
272
273 // execute the queries without i18n rewriting
274 foreach ($queries as $query) {
275 $dao->query($query, FALSE);
276 }
277
278 // update civicrm_domain.locales
279 $locales[] = $locale;
280 $domain->locales = implode(CRM_Core_DAO::VALUE_SEPARATOR, $locales);
281 $domain->save();
282
283 // invoke the meta trigger creation call
284 CRM_Core_DAO::triggerRebuild();
285 }
286
287 /**
288 * Rebuild multilingual indices, views and triggers (useful for upgrades)
289 *
5a4f6742
CW
290 * @param array $locales
291 * locales to be rebuilt.
292 * @param string $version
293 * version of schema structure to use.
6a488035 294 */
00be9182 295 public static function rebuildMultilingualSchema($locales, $version = NULL) {
6a488035
TO
296 if ($version) {
297 $latest = self::getLatestSchema($version);
298 require_once "CRM/Core/I18n/SchemaStructure_{$latest}.php";
299 $class = "CRM_Core_I18n_SchemaStructure_{$latest}";
300 }
301 else {
302 $class = 'CRM_Core_I18n_SchemaStructure';
303 }
0e6e8724 304 $indices =& $class::indices();
353ffa53 305 $tables =& $class::tables();
6a488035 306 $queries = array();
3bdca100 307 $dao = new CRM_Core_DAO();
6a488035
TO
308
309 // get all of the already existing indices
310 $existing = array();
311 foreach (array_keys($indices) as $table) {
312 $existing[$table] = array();
313 $dao->query("SHOW INDEX FROM $table", FALSE);
314 while ($dao->fetch()) {
315 if (preg_match('/_[a-z][a-z]_[A-Z][A-Z]$/', $dao->Key_name)) {
316 $existing[$table][] = $dao->Key_name;
317 }
318 }
319 }
320
321 // from all of the CREATE INDEX queries fetch the ones creating missing indices
322 foreach ($locales as $locale) {
323 foreach (array_keys($indices) as $table) {
324 $allQueries = self::createIndexQueries($locale, $table, $class);
325 foreach ($allQueries as $name => $query) {
326 if (!in_array("{$name}_{$locale}", $existing[$table])) {
327 $queries[] = $query;
328 }
329 }
330 }
331 }
332
333 // rebuild views
334 foreach ($locales as $locale) {
335 foreach ($tables as $table) {
336 $queries[] = self::createViewQuery($locale, $table, $dao, $class);
337 }
338 }
339
340 // rebuild triggers
341 $last = array_pop($locales);
342
343 foreach ($queries as $query) {
344 $dao->query($query, FALSE);
345 }
346
347 // invoke the meta trigger creation call
348 CRM_Core_DAO::triggerRebuild();
349 }
350
351 /**
352 * Rewrite SQL query to use views to access tables with localized columns.
353 *
5a4f6742
CW
354 * @param string $query
355 * the query for rewrite.
6a488035 356 *
a6c01b45
CW
357 * @return string
358 * the rewritten query
6a488035 359 */
00be9182 360 public static function rewriteQuery($query) {
6a488035
TO
361 global $dbLocale;
362 $tables = self::schemaStructureTables();
363 foreach ($tables as $table) {
364 $query = preg_replace("/([^'\"])({$table})([^_'\"])/", "\\1\\2{$dbLocale}\\3", $query);
365 }
366 // uncomment the below to rewrite the civicrm_value_* queries
367 // $query = preg_replace("/(civicrm_value_[a-z0-9_]+_\d+)([^_])/", "\\1{$dbLocale}\\2", $query);
368 return $query;
369 }
370
a0ee3941
EM
371 /**
372 * @param null $version
373 * @param bool $force
374 *
375 * @return array
376 */
00be9182 377 public static function schemaStructureTables($version = NULL, $force = FALSE) {
6a488035
TO
378 static $_tables = NULL;
379 if ($_tables === NULL || $force) {
380 if ($version) {
381 $latest = self::getLatestSchema($version);
382 // FIXME: Doing require_once is a must here because a call like CRM_Core_I18n_SchemaStructure_4_1_0 makes
383 // class loader look for file like - CRM/Core/I18n/SchemaStructure/4/1/0.php which is not what we want to be loaded
384 require_once "CRM/Core/I18n/SchemaStructure_{$latest}.php";
385 $class = "CRM_Core_I18n_SchemaStructure_{$latest}";
353ffa53 386 $tables =& $class::tables();
6a488035
TO
387 }
388 else {
389 $tables = CRM_Core_I18n_SchemaStructure::tables();
390 }
391 $_tables = $tables;
392 }
393 return $_tables;
394 }
395
a0ee3941
EM
396 /**
397 * @param $version
398 *
399 * @return mixed
400 */
00be9182 401 public static function getLatestSchema($version) {
6a488035
TO
402 // remove any .upgrade sub-str from version. Makes it easy to do version_compare & give right result
403 $version = str_ireplace(".upgrade", "", $version);
404
405 // fetch all the SchemaStructure versions we ship and sort by version
406 $schemas = array();
407 foreach (scandir(dirname(__FILE__)) as $file) {
408 $matches = array();
409 if (preg_match('/^SchemaStructure_([0-9a-z_]+)\.php$/', $file, $matches)) {
410 $schemas[] = str_replace('_', '.', $matches[1]);
411 }
412 }
413 usort($schemas, 'version_compare');
414
415 // find the latest schema structure older than (or equal to) $version
416 do {
417 $latest = array_pop($schemas);
418 } while (version_compare($latest, $version, '>'));
419
420 return str_replace('.', '_', $latest);
421 }
422
423 /**
fe482240 424 * CREATE INDEX queries for a given locale and table.
6a488035 425 *
5a4f6742
CW
426 * @param string $locale
427 * locale for which the queries should be created (null to create original indices).
428 * @param string $table
429 * table for which the queries should be created.
430 * @param string $class
431 * schema structure class to use.
6a488035 432 *
a6c01b45
CW
433 * @return array
434 * array of CREATE INDEX queries
6a488035
TO
435 */
436 private static function createIndexQueries($locale, $table, $class = 'CRM_Core_I18n_SchemaStructure') {
0e6e8724
DL
437 $indices =& $class::indices();
438 $columns =& $class::columns();
6a488035
TO
439 if (!isset($indices[$table])) {
440 return array();
441 }
442
443 $queries = array();
444 foreach ($indices[$table] as $index) {
445 $unique = isset($index['unique']) && $index['unique'] ? 'UNIQUE' : '';
446 foreach ($index['field'] as $i => $col) {
447 // if a given column is localizable, extend its name with the locale
448 if ($locale and isset($columns[$table][$col])) {
449 $index['field'][$i] = "{$col}_{$locale}";
450 }
451 }
452 $cols = implode(', ', $index['field']);
453 $name = $index['name'];
454 if ($locale) {
455 $name .= '_' . $locale;
456 }
457 // CRM-7854: skip existing indices
458 if (CRM_Core_DAO::checkConstraintExists($table, $name)) {
459 continue;
460 }
461 $queries[$index['name']] = "CREATE {$unique} INDEX {$name} ON {$table} ({$cols})";
462 }
463 return $queries;
464 }
465
466 /**
fe482240 467 * CREATE VIEW query for a given locale and table.
6a488035 468 *
5a4f6742
CW
469 * @param string $locale
470 * locale of the view.
471 * @param string $table
472 * table of the view.
6a0b768e
TO
473 * @param CRM_Core_DAO $dao
474 * A DAO object to run DESCRIBE queries.
5a4f6742
CW
475 * @param string $class
476 * schema structure class to use.
6a488035 477 *
a6c01b45
CW
478 * @return array
479 * array of CREATE INDEX queries
6a488035
TO
480 */
481 private static function createViewQuery($locale, $table, &$dao, $class = 'CRM_Core_I18n_SchemaStructure') {
0e6e8724 482 $columns =& $class::columns();
6a488035
TO
483 $cols = array();
484 $dao->query("DESCRIBE {$table}", FALSE);
485 while ($dao->fetch()) {
486 // view non-internationalized columns directly
487 if (!in_array($dao->Field, array_keys($columns[$table])) and
488 !preg_match('/_[a-z][a-z]_[A-Z][A-Z]$/', $dao->Field)
489 ) {
490 $cols[] = $dao->Field;
491 }
492 }
493 // view intrernationalized columns through an alias
494 foreach ($columns[$table] as $column => $_) {
495 $cols[] = "{$column}_{$locale} {$column}";
496 }
497 return "CREATE OR REPLACE VIEW {$table}_{$locale} AS SELECT " . implode(', ', $cols) . " FROM {$table}";
498 }
499
a0ee3941
EM
500 /**
501 * @param $info
502 * @param null $tableName
503 */
00be9182 504 public static function triggerInfo(&$info, $tableName = NULL) {
6a488035
TO
505 // get the current supported locales
506 $domain = new CRM_Core_DAO_Domain();
507 $domain->find(TRUE);
508 if (empty($domain->locales)) {
509 return;
510 }
511
512 $locales = explode(CRM_Core_DAO::VALUE_SEPARATOR, $domain->locales);
513 $locale = array_pop($locales);
514
515 // CRM-10027
516 if (count($locales) == 0) {
517 return;
518 }
519
520 $currentVer = CRM_Core_BAO_Domain::version(TRUE);
521
522 if ($currentVer && CRM_Core_Config::isUpgradeMode()) {
523 // take exact version so that proper schema structure file in invoked
524 $latest = self::getLatestSchema($currentVer);
525 require_once "CRM/Core/I18n/SchemaStructure_{$latest}.php";
526 $class = "CRM_Core_I18n_SchemaStructure_{$latest}";
527 }
528 else {
529 $class = 'CRM_Core_I18n_SchemaStructure';
530 }
531
0e6e8724 532 $columns =& $class::columns();
6a488035
TO
533
534 foreach ($columns as $table => $hash) {
535 if ($tableName &&
536 $tableName != $table
537 ) {
538 continue;
539 }
540
541 $trigger = array();
542
543 foreach ($hash as $column => $_) {
544 $trigger[] = "IF NEW.{$column}_{$locale} IS NOT NULL THEN";
545 foreach ($locales as $old) {
546 $trigger[] = "IF NEW.{$column}_{$old} IS NULL THEN SET NEW.{$column}_{$old} = NEW.{$column}_{$locale}; END IF;";
547 }
548 foreach ($locales as $old) {
549 $trigger[] = "ELSEIF NEW.{$column}_{$old} IS NOT NULL THEN";
550 foreach (array_merge($locales, array(
3bdca100 551 $locale,
353ffa53 552 )) as $loc) {
6a488035
TO
553 if ($loc == $old) {
554 continue;
555 }
556 $trigger[] = "IF NEW.{$column}_{$loc} IS NULL THEN SET NEW.{$column}_{$loc} = NEW.{$column}_{$old}; END IF;";
557 }
558 }
559 $trigger[] = 'END IF;';
560 }
561
562 $sql = implode(' ', $trigger);
6ea503d4
TO
563 $info[] = array(
564 'table' => array($table),
6a488035
TO
565 'when' => 'BEFORE',
566 'event' => array('UPDATE'),
567 'sql' => $sql,
568 );
569 }
570
571 // take care of the ON INSERT triggers
572 foreach ($columns as $table => $hash) {
573 $trigger = array();
574 foreach ($hash as $column => $_) {
575 $trigger[] = "IF NEW.{$column}_{$locale} IS NOT NULL THEN";
576 foreach ($locales as $old) {
577 $trigger[] = "SET NEW.{$column}_{$old} = NEW.{$column}_{$locale};";
578 }
579 foreach ($locales as $old) {
580 $trigger[] = "ELSEIF NEW.{$column}_{$old} IS NOT NULL THEN";
581 foreach (array_merge($locales, array(
3bdca100 582 $locale,
353ffa53 583 )) as $loc) {
6a488035
TO
584 if ($loc == $old) {
585 continue;
586 }
587 $trigger[] = "SET NEW.{$column}_{$loc} = NEW.{$column}_{$old};";
588 }
589 }
590 $trigger[] = 'END IF;';
591 }
592
593 $sql = implode(' ', $trigger);
6ea503d4
TO
594 $info[] = array(
595 'table' => array($table),
6a488035
TO
596 'when' => 'BEFORE',
597 'event' => array('INSERT'),
598 'sql' => $sql,
599 );
600 }
601 }
96025800 602
6a488035 603}