Merge pull request #23895 from colemanw/searchKitManaged
[civicrm-core.git] / CRM / Core / BAO / SchemaHandler.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
bc77d7c0 4 | Copyright CiviCRM LLC. All rights reserved. |
6a488035 5 | |
bc77d7c0
TO
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
6a488035 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
6a488035
TO
11
12/**
13 *
14 * @package CRM
ca5cec67 15 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035
TO
16 */
17
18/**
db01bf2f 19 * This file contains functions for creating and altering CiviCRM-tables structure.
6a488035
TO
20 *
21 * $table = array(
db01bf2f 22 * 'name' => TABLE_NAME,
23 * 'attributes' => ATTRIBUTES,
24 * 'fields' => array(
25 * array(
26 * 'name' => FIELD_NAME,
27 * // can be field, index, constraint
28 * 'type' => FIELD_SQL_TYPE,
29 * 'class' => FIELD_CLASS_TYPE,
30 * 'primary' => BOOLEAN,
31 * 'required' => BOOLEAN,
32 * 'searchable' => TRUE,
33 * 'fk_table_name' => FOREIGN_KEY_TABLE_NAME,
34 * 'fk_field_name' => FOREIGN_KEY_FIELD_NAME,
35 * 'comment' => COMMENT,
36 * 'default' => DEFAULT, )
37 * ...
38 * ));
6a488035
TO
39 */
40class CRM_Core_BAO_SchemaHandler {
41
42 /**
c490a46a 43 * Create a CiviCRM-table
6a488035 44 *
c490a46a 45 * @param array $params
6a488035 46 *
72b3a70c
CW
47 * @return bool
48 * TRUE if successfully created, FALSE otherwise
6a488035 49 *
6a488035 50 */
6477f387 51 public static function createTable($params) {
6a488035
TO
52 $sql = self::buildTableSQL($params);
53 // do not i18n-rewrite
be2fb01f 54 CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE);
6a488035 55
6477f387 56 if (CRM_Core_Config::singleton()->logging) {
6a488035 57 // logging support
8d7a9d07 58 $logging = new CRM_Logging_Schema();
fcc20cec 59 $logging->fixSchemaDifferencesFor($params['name']);
6a488035
TO
60 }
61
62 // always do a trigger rebuild for this table
dfcc817d 63 Civi::service('sql_triggers')->rebuild($params['name'], TRUE);
6a488035
TO
64
65 return TRUE;
66 }
67
b5c2afd0 68 /**
c490a46a 69 * @param array $params
b5c2afd0
EM
70 *
71 * @return string
72 */
6477f387 73 public static function buildTableSQL($params) {
6a488035
TO
74 $sql = "CREATE TABLE {$params['name']} (";
75 if (isset($params['fields']) &&
76 is_array($params['fields'])
77 ) {
78 $separator = "\n";
79 $prefix = NULL;
80 foreach ($params['fields'] as $field) {
81 $sql .= self::buildFieldSQL($field, $separator, $prefix);
82 $separator = ",\n";
83 }
84 foreach ($params['fields'] as $field) {
85 $sql .= self::buildPrimaryKeySQL($field, $separator, $prefix);
86 }
87 foreach ($params['fields'] as $field) {
ba9c74ab 88 $sql .= self::buildSearchIndexSQL($field, $separator);
6a488035
TO
89 }
90 if (isset($params['indexes'])) {
91 foreach ($params['indexes'] as $index) {
92 $sql .= self::buildIndexSQL($index, $separator, $prefix);
93 }
94 }
95 foreach ($params['fields'] as $field) {
96 $sql .= self::buildForeignKeySQL($field, $separator, $prefix, $params['name']);
97 }
98 }
99 $sql .= "\n) {$params['attributes']};";
100 return $sql;
101 }
102
b5c2afd0 103 /**
c490a46a 104 * @param array $params
b5c2afd0
EM
105 * @param $separator
106 * @param $prefix
107 *
108 * @return string
109 */
4f89ebb0 110 public static function buildFieldSQL($params, $separator, $prefix) {
6a488035
TO
111 $sql = '';
112 $sql .= $separator;
113 $sql .= str_repeat(' ', 8);
114 $sql .= $prefix;
115 $sql .= "`{$params['name']}` {$params['type']}";
116
a7488080 117 if (!empty($params['required'])) {
6a488035
TO
118 $sql .= " NOT NULL";
119 }
120
a7488080 121 if (!empty($params['attributes'])) {
6a488035
TO
122 $sql .= " {$params['attributes']}";
123 }
124
a7488080 125 if (!empty($params['default']) &&
6a488035
TO
126 $params['type'] != 'text'
127 ) {
128 $sql .= " DEFAULT {$params['default']}";
129 }
130
a7488080 131 if (!empty($params['comment'])) {
6a488035
TO
132 $sql .= " COMMENT '{$params['comment']}'";
133 }
134
135 return $sql;
136 }
137
b5c2afd0 138 /**
c490a46a 139 * @param array $params
b5c2afd0
EM
140 * @param $separator
141 * @param $prefix
142 *
e60f24eb 143 * @return NULL|string
b5c2afd0 144 */
4f89ebb0 145 public static function buildPrimaryKeySQL($params, $separator, $prefix) {
6a488035 146 $sql = NULL;
a7488080 147 if (!empty($params['primary'])) {
6a488035
TO
148 $sql .= $separator;
149 $sql .= str_repeat(' ', 8);
150 $sql .= $prefix;
151 $sql .= "PRIMARY KEY ( {$params['name']} )";
152 }
153 return $sql;
154 }
155
b5c2afd0 156 /**
c490a46a 157 * @param array $params
ba9c74ab
CW
158 * @param string $separator
159 * @param string $prefix
aec7c57d 160 * @param string|null $existingIndex
b5c2afd0 161 *
e60f24eb 162 * @return NULL|string
b5c2afd0 163 */
ba9c74ab 164 public static function buildSearchIndexSQL($params, $separator, $prefix = '', $existingIndex = NULL) {
6a488035
TO
165 $sql = NULL;
166
167 // dont index blob
168 if ($params['type'] == 'text') {
169 return $sql;
170 }
171
ba9c74ab
CW
172 // Add index if field is searchable if it does not reference a foreign key
173 // (skip indexing FK fields because it would be redundant to have 2 indexes)
16fd31ae 174 if (!empty($params['searchable']) && empty($params['fk_table_name']) && substr($existingIndex ?? '', 0, 5) !== 'INDEX') {
6a488035
TO
175 $sql .= $separator;
176 $sql .= str_repeat(' ', 8);
177 $sql .= $prefix;
178 $sql .= "INDEX_{$params['name']} ( {$params['name']} )";
179 }
ba9c74ab 180 // Drop search index if field is no longer searchable
16fd31ae 181 elseif (empty($params['searchable']) && substr($existingIndex ?? '', 0, 5) === 'INDEX') {
6a488035
TO
182 $sql .= $separator;
183 $sql .= str_repeat(' ', 8);
ba9c74ab 184 $sql .= "DROP INDEX $existingIndex";
6a488035
TO
185 }
186 return $sql;
187 }
188
b5c2afd0 189 /**
c490a46a 190 * @param array $params
b5c2afd0
EM
191 * @param $separator
192 * @param $prefix
193 *
194 * @return string
195 */
00be9182 196 public static function buildIndexSQL(&$params, $separator, $prefix) {
6a488035
TO
197 $sql = '';
198 $sql .= $separator;
199 $sql .= str_repeat(' ', 8);
200 if ($params['unique']) {
201 $sql .= 'UNIQUE INDEX';
202 $indexName = 'unique';
203 }
204 else {
205 $sql .= 'INDEX';
206 $indexName = 'index';
207 }
208 $indexFields = NULL;
209
210 foreach ($params as $name => $value) {
211 if (substr($name, 0, 11) == 'field_name_') {
212 $indexName .= "_{$value}";
213 $indexFields .= " $value,";
214 }
215 }
216 $indexFields = substr($indexFields, 0, -1);
217
218 $sql .= " $indexName ( $indexFields )";
219 return $sql;
220 }
221
b5c2afd0 222 /**
100fef9d
CW
223 * @param string $tableName
224 * @param string $fkTableName
b5c2afd0
EM
225 *
226 * @return bool
227 */
00be9182 228 public static function changeFKConstraint($tableName, $fkTableName) {
6a488035
TO
229 $fkName = "{$tableName}_entity_id";
230 if (strlen($fkName) >= 48) {
231 $fkName = substr($fkName, 0, 32) . "_" . substr(md5($fkName), 0, 16);
232 }
233 $dropFKSql = "
234ALTER TABLE {$tableName}
235 DROP FOREIGN KEY `FK_{$fkName}`;";
236
fccb50a6 237 CRM_Core_DAO::executeQuery($dropFKSql);
6a488035
TO
238
239 $addFKSql = "
240ALTER TABLE {$tableName}
241 ADD CONSTRAINT `FK_{$fkName}` FOREIGN KEY (`entity_id`) REFERENCES {$fkTableName} (`id`) ON DELETE CASCADE;";
242 // CRM-7007: do not i18n-rewrite this query
be2fb01f 243 CRM_Core_DAO::executeQuery($addFKSql, [], TRUE, NULL, FALSE, FALSE);
6a488035
TO
244
245 return TRUE;
246 }
247
b5c2afd0 248 /**
c490a46a 249 * @param array $params
b5c2afd0
EM
250 * @param $separator
251 * @param $prefix
100fef9d 252 * @param string $tableName
b5c2afd0 253 *
e60f24eb 254 * @return NULL|string
b5c2afd0 255 */
4f89ebb0 256 public static function buildForeignKeySQL($params, $separator, $prefix, $tableName) {
6a488035 257 $sql = NULL;
8cc574cf 258 if (!empty($params['fk_table_name']) && !empty($params['fk_field_name'])) {
6a488035
TO
259 $sql .= $separator;
260 $sql .= str_repeat(' ', 8);
261 $sql .= $prefix;
051f2455 262 $fkName = $params['fkName'] ?? self::getIndexName($tableName, $params['name']);
6a488035
TO
263
264 $sql .= "CONSTRAINT FK_$fkName FOREIGN KEY ( `{$params['name']}` ) REFERENCES {$params['fk_table_name']} ( {$params['fk_field_name']} ) ";
051f2455 265 $sql .= $params['fk_attributes'] ?? '';
6a488035
TO
266 }
267 return $sql;
268 }
269
6a488035 270 /**
49186f94 271 * Delete a CiviCRM-table.
6a488035 272 *
d3e86119 273 * @param string $tableName
6a0b768e 274 * Name of the table to be created.
6a488035 275 */
00be9182 276 public static function dropTable($tableName) {
6a488035 277 $sql = "DROP TABLE $tableName";
49186f94 278 CRM_Core_DAO::executeQuery($sql);
6a488035
TO
279 }
280
b5c2afd0 281 /**
100fef9d
CW
282 * @param string $tableName
283 * @param string $columnName
3abab0f8 284 * @param bool $l18n
41ace555 285 * @param bool $isUpgradeMode
87568e34 286 *
b5c2afd0 287 */
41ace555 288 public static function dropColumn($tableName, $columnName, $l18n = FALSE, $isUpgradeMode = FALSE) {
242055d3
CW
289 if (self::checkIfFieldExists($tableName, $columnName)) {
290 $sql = "ALTER TABLE $tableName DROP COLUMN $columnName";
3abab0f8 291 if ($l18n) {
87568e34
SL
292 CRM_Core_DAO::executeQuery($sql);
293 }
294 else {
be2fb01f 295 CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE);
87568e34 296 }
394d18d3
CW
297 $locales = CRM_Core_I18n::getMultilingual();
298 if ($locales) {
41ace555 299 CRM_Core_I18n_Schema::rebuildMultilingualSchema($locales, NULL, $isUpgradeMode);
1285e488 300 }
242055d3 301 }
6a488035
TO
302 }
303
b5c2afd0 304 /**
100fef9d 305 * @param string $tableName
b5c2afd0
EM
306 * @param bool $dropUnique
307 */
00be9182 308 public static function changeUniqueToIndex($tableName, $dropUnique = TRUE) {
6a488035
TO
309 if ($dropUnique) {
310 $sql = "ALTER TABLE $tableName
311DROP INDEX `unique_entity_id` ,
312ADD INDEX `FK_{$tableName}_entity_id` ( `entity_id` )";
313 }
314 else {
315 $sql = " ALTER TABLE $tableName
316DROP INDEX `FK_{$tableName}_entity_id` ,
317ADD UNIQUE INDEX `unique_entity_id` ( `entity_id` )";
318 }
49186f94 319 CRM_Core_DAO::executeQuery($sql);
6a488035
TO
320 }
321
b5c2afd0 322 /**
7181119b 323 * Create indexes.
324 *
b5c2afd0 325 * @param $tables
7181119b 326 * Tables to create index for in the format:
d551d3d6 327 * ['civicrm_entity_table' => ['entity_id']]
7181119b 328 * OR
d551d3d6 329 * array['civicrm_entity_table' => array['entity_id', 'entity_table']]
7181119b 330 * The latter will create a combined index on the 2 keys (in order).
331 *
332 * Side note - when creating combined indexes the one with the most variation
333 * goes first - so entity_table always goes after entity_id.
334 *
335 * It probably makes sense to consider more sophisticated options at some point
336 * but at the moment this is only being as enhanced as fast as the test is.
337 *
338 * @todo add support for length & multilingual on combined keys.
339 *
b5c2afd0 340 * @param string $createIndexPrefix
fe891bc6 341 * @param array $substrLengths
b5c2afd0 342 */
be2fb01f
CW
343 public static function createIndexes($tables, $createIndexPrefix = 'index', $substrLengths = []) {
344 $queries = [];
394d18d3 345 $locales = CRM_Core_I18n::getMultilingual();
6a488035
TO
346
347 // if we're multilingual, cache the information on internationalised fields
348 static $columns = NULL;
349 if (!CRM_Utils_System::isNull($locales) and $columns === NULL) {
350 $columns = CRM_Core_I18n_SchemaStructure::columns();
351 }
352
353 foreach ($tables as $table => $fields) {
354 $query = "SHOW INDEX FROM $table";
355 $dao = CRM_Core_DAO::executeQuery($query);
356
be2fb01f 357 $currentIndexes = [];
6a488035
TO
358 while ($dao->fetch()) {
359 $currentIndexes[] = $dao->Key_name;
360 }
361
362 // now check for all fields if the index exists
363 foreach ($fields as $field) {
7181119b 364 $fieldName = implode('_', (array) $field);
365
366 if (is_array($field)) {
367 // No support for these for combined indexes as yet - add a test when you
368 // want to add that.
369 $lengthName = '';
370 $lengthSize = '';
371 }
372 else {
373 // handle indices over substrings, CRM-6245
374 // $lengthName is appended to index name, $lengthSize is the field size modifier
fe891bc6
AS
375 $lengthName = isset($substrLengths[$table][$fieldName]) ? "_{$substrLengths[$table][$fieldName]}" : '';
376 $lengthSize = isset($substrLengths[$table][$fieldName]) ? "({$substrLengths[$table][$fieldName]})" : '';
7181119b 377 }
6a488035 378
be2fb01f 379 $names = [
7181119b 380 "index_{$fieldName}{$lengthName}",
381 "FK_{$table}_{$fieldName}{$lengthName}",
382 "UI_{$fieldName}{$lengthName}",
383 "{$createIndexPrefix}_{$fieldName}{$lengthName}",
be2fb01f 384 ];
6a488035
TO
385
386 // skip to the next $field if one of the above $names exists; handle multilingual for CRM-4126
387 foreach ($names as $name) {
388 $regex = '/^' . preg_quote($name) . '(_[a-z][a-z]_[A-Z][A-Z])?$/';
389 if (preg_grep($regex, $currentIndexes)) {
390 continue 2;
391 }
392 }
393
8a493ab9
CW
394 $indexType = $createIndexPrefix === 'UI' ? 'UNIQUE' : '';
395
6a488035
TO
396 // the index doesn't exist, so create it
397 // if we're multilingual and the field is internationalised, do it for every locale
7181119b 398 // @todo remove is_array check & add multilingual support for combined indexes and add a test.
399 // Note combined indexes currently using this function are on fields like
400 // entity_id + entity_table which are not multilingual.
401 if (!is_array($field) && !CRM_Utils_System::isNull($locales) and isset($columns[$table][$fieldName])) {
6a488035 402 foreach ($locales as $locale) {
8a493ab9 403 $queries[] = "CREATE $indexType INDEX {$createIndexPrefix}_{$fieldName}{$lengthName}_{$locale} ON {$table} ({$fieldName}_{$locale}{$lengthSize})";
6a488035
TO
404 }
405 }
406 else {
8a493ab9 407 $queries[] = "CREATE $indexType INDEX {$createIndexPrefix}_{$fieldName}{$lengthName} ON {$table} (" . implode(',', (array) $field) . "{$lengthSize})";
6a488035
TO
408 }
409 }
410 }
411
412 // run the queries without i18n-rewriting
8d7a9d07 413 $dao = new CRM_Core_DAO();
6a488035
TO
414 foreach ($queries as $query) {
415 $dao->query($query, FALSE);
416 }
417 }
418
49186f94
AS
419 /**
420 * Get indexes for tables
421 * @param array $tables
422 * array of table names to find indexes for
423 *
424 * @return array('tableName' => array('index1', 'index2'))
425 */
426 public static function getIndexes($tables) {
be2fb01f 427 $indexes = [];
49186f94
AS
428 foreach ($tables as $table) {
429 $query = "SHOW INDEX FROM $table";
430 $dao = CRM_Core_DAO::executeQuery($query);
431
be2fb01f 432 $tableIndexes = [];
49186f94
AS
433 while ($dao->fetch()) {
434 $tableIndexes[$dao->Key_name]['name'] = $dao->Key_name;
435 $tableIndexes[$dao->Key_name]['field'][] = $dao->Column_name .
436 ($dao->Sub_part ? '(' . $dao->Sub_part . ')' : '');
437 $tableIndexes[$dao->Key_name]['unique'] = ($dao->Non_unique == 0 ? 1 : 0);
438 }
439 $indexes[$table] = $tableIndexes;
49186f94
AS
440 }
441 return $indexes;
442 }
443
50969d52 444 /**
445 * Drop an index if one by that name exists.
446 *
447 * @param string $tableName
448 * @param string $indexName
449 */
450 public static function dropIndexIfExists($tableName, $indexName) {
451 if (self::checkIfIndexExists($tableName, $indexName)) {
452 CRM_Core_DAO::executeQuery("DROP INDEX $indexName ON $tableName");
453 }
454 }
455
b5c2afd0 456 /**
100fef9d
CW
457 * @param int $customFieldID
458 * @param string $tableName
459 * @param string $columnName
b5c2afd0
EM
460 * @param $length
461 *
ac15829d 462 * @throws CRM_Core_Exception
b5c2afd0 463 */
00be9182 464 public static function alterFieldLength($customFieldID, $tableName, $columnName, $length) {
6a488035
TO
465 // first update the custom field tables
466 $sql = "
467UPDATE civicrm_custom_field
468SET text_length = %1
469WHERE id = %2
470";
be2fb01f
CW
471 $params = [
472 1 => [$length, 'Integer'],
473 2 => [$customFieldID, 'Integer'],
474 ];
6a488035
TO
475 CRM_Core_DAO::executeQuery($sql, $params);
476
477 $sql = "
478SELECT is_required, default_value
479FROM civicrm_custom_field
480WHERE id = %2
481";
482 $dao = CRM_Core_DAO::executeQuery($sql, $params);
483
484 if ($dao->fetch()) {
485 $clause = '';
486
487 if ($dao->is_required) {
488 $clause = " NOT NULL";
489 }
490
491 if (!empty($dao->default_value)) {
492 $clause .= " DEFAULT '{$dao->default_value}'";
493 }
494 // now modify the column
495 $sql = "
496ALTER TABLE {$tableName}
497MODIFY {$columnName} varchar( $length )
498 $clause
499";
500 CRM_Core_DAO::executeQuery($sql);
501 }
502 else {
ac15829d 503 throw new CRM_Core_Exception(ts('Could Not Find Custom Field Details for %1, %2, %3',
be2fb01f 504 [
353ffa53
TO
505 1 => $tableName,
506 2 => $columnName,
507 3 => $customFieldID,
be2fb01f 508 ]
353ffa53 509 ));
6a488035
TO
510 }
511 }
96025800 512
50969d52 513 /**
514 * Check if the table has an index matching the name.
515 *
516 * @param string $tableName
6a8758a6 517 * @param string $indexName
50969d52 518 *
49186f94 519 * @return bool
50969d52 520 */
521 public static function checkIfIndexExists($tableName, $indexName) {
522 $result = CRM_Core_DAO::executeQuery(
523 "SHOW INDEX FROM $tableName WHERE key_name = %1 AND seq_in_index = 1",
be2fb01f 524 [1 => [$indexName, 'String']]
50969d52 525 );
526 if ($result->fetch()) {
527 return TRUE;
528 }
529 return FALSE;
530 }
531
82f5a856 532 /**
49186f94 533 * Check if the table has a specified column.
82f5a856
SL
534 *
535 * @param string $tableName
536 * @param string $columnName
eed7e803
CW
537 * @param bool $i18nRewrite
538 * Whether to rewrite the query on multilingual setups.
82f5a856 539 *
49186f94 540 * @return bool
82f5a856 541 */
eed7e803 542 public static function checkIfFieldExists($tableName, $columnName, $i18nRewrite = TRUE) {
5e282b34
CW
543 $query = "SHOW COLUMNS FROM $tableName LIKE '%1'";
544 $dao = CRM_Core_DAO::executeQuery($query, [1 => [$columnName, 'Alphanumeric']], TRUE, NULL, FALSE, $i18nRewrite);
63d76404 545 return (bool) $dao->fetch();
82f5a856
SL
546 }
547
169475b7 548 /**
f5078cc0
SL
549 * Check if a foreign key Exists
550 * @param string $table_name
551 * @param string $constraint_name
552 * @return bool TRUE if FK is found
169475b7 553 */
f5078cc0 554 public static function checkFKExists($table_name, $constraint_name) {
d85e1802 555 $dao = new CRM_Core_DAO();
169475b7
SL
556 $query = "
557 SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
558 WHERE TABLE_SCHEMA = %1
559 AND TABLE_NAME = %2
560 AND CONSTRAINT_NAME = %3
561 AND CONSTRAINT_TYPE = 'FOREIGN KEY'
562 ";
be2fb01f 563 $params = [
d85e1802 564 1 => [$dao->_database, 'String'],
be2fb01f
CW
565 2 => [$table_name, 'String'],
566 3 => [$constraint_name, 'String'],
567 ];
1494e986 568 $dao = CRM_Core_DAO::executeQuery($query, $params, TRUE, NULL, FALSE, FALSE);
169475b7
SL
569
570 if ($dao->fetch()) {
27e82c24 571 return TRUE;
f5078cc0 572 }
27e82c24 573 return FALSE;
f5078cc0
SL
574 }
575
576 /**
577 * Remove a foreign key from a table if it exists.
578 *
579 * @param $table_name
580 * @param $constraint_name
581 *
582 * @return bool
583 */
584 public static function safeRemoveFK($table_name, $constraint_name) {
27e82c24 585 if (self::checkFKExists($table_name, $constraint_name)) {
1494e986 586 CRM_Core_DAO::executeQuery("ALTER TABLE {$table_name} DROP FOREIGN KEY {$constraint_name}", [], TRUE, NULL, FALSE, FALSE);
9cd5a579 587 return TRUE;
169475b7 588 }
9cd5a579 589 return FALSE;
169475b7
SL
590 }
591
6b86d84f
AS
592 /**
593 * Add index signature hash to DAO file calculation.
594 *
595 * @param string $table table name
596 * @param array $indices index array spec
597 */
598 public static function addIndexSignature($table, &$indices) {
599 foreach ($indices as $indexName => $index) {
600 $indices[$indexName]['sig'] = $table . "::" .
601 (array_key_exists('unique', $index) ? $index['unique'] : 0) . "::" .
602 implode("::", $index['field']);
603 }
604 }
605
49186f94
AS
606 /**
607 * Compare the indices specified in the XML files with those in the DB.
608 *
3d4602c3
JP
609 * @param bool $dropFalseIndices
610 * If set - this function deletes false indices present in the DB which mismatches the expected
611 * values of xml file so that civi re-creates them with correct values using createMissingIndices() function.
aec7c57d 612 * @param array|false $tables
138b4c4c 613 * An optional array of tables - if provided the results will be restricted to these tables.
3d4602c3 614 *
49186f94
AS
615 * @return array
616 * index specifications
617 */
138b4c4c 618 public static function getMissingIndices($dropFalseIndices = FALSE, $tables = FALSE) {
be2fb01f 619 $requiredSigs = $existingSigs = [];
49186f94
AS
620 // Get the indices defined (originally) in the xml files
621 $requiredIndices = CRM_Core_DAO_AllCoreTables::indices();
be2fb01f 622 $reqSigs = [];
138b4c4c 623 if ($tables !== FALSE) {
624 $requiredIndices = array_intersect_key($requiredIndices, array_fill_keys($tables, TRUE));
625 }
49186f94
AS
626 foreach ($requiredIndices as $table => $indices) {
627 $reqSigs[] = CRM_Utils_Array::collect('sig', $indices);
628 }
629 CRM_Utils_Array::flatten($reqSigs, $requiredSigs);
630
631 // Get the indices in the database
632 $existingIndices = CRM_Core_BAO_SchemaHandler::getIndexes(array_keys($requiredIndices));
be2fb01f 633 $extSigs = [];
49186f94
AS
634 foreach ($existingIndices as $table => $indices) {
635 CRM_Core_BAO_SchemaHandler::addIndexSignature($table, $indices);
636 $extSigs[] = CRM_Utils_Array::collect('sig', $indices);
637 }
638 CRM_Utils_Array::flatten($extSigs, $existingSigs);
639
640 // Compare
641 $missingSigs = array_diff($requiredSigs, $existingSigs);
f4f835ed 642
3d4602c3 643 //CRM-20774 - Drop index key which exist in db but the value varies.
f4f835ed 644 $existingKeySigs = array_intersect_key($missingSigs, $existingSigs);
3d4602c3 645 if ($dropFalseIndices && !empty($existingKeySigs)) {
f4f835ed
JP
646 foreach ($existingKeySigs as $sig) {
647 $sigParts = explode('::', $sig);
648 foreach ($requiredIndices[$sigParts[0]] as $index) {
3d4602c3
JP
649 if ($index['sig'] == $sig && !empty($index['name'])) {
650 self::dropIndexIfExists($sigParts[0], $index['name']);
f4f835ed
JP
651 continue;
652 }
653 }
654 }
655 }
656
49186f94 657 // Get missing indices
be2fb01f 658 $missingIndices = [];
49186f94
AS
659 foreach ($missingSigs as $sig) {
660 $sigParts = explode('::', $sig);
f3490f40 661 if (array_key_exists($sigParts[0], $requiredIndices)) {
662 foreach ($requiredIndices[$sigParts[0]] as $index) {
663 if ($index['sig'] == $sig) {
664 $missingIndices[$sigParts[0]][] = $index;
665 continue;
666 }
49186f94
AS
667 }
668 }
669 }
3d4602c3 670 return $missingIndices;
49186f94
AS
671 }
672
673 /**
674 * Create missing indices.
675 *
676 * @param array $missingIndices as returned by getMissingIndices()
677 */
678 public static function createMissingIndices($missingIndices) {
be2fb01f 679 $queries = [];
49186f94
AS
680 foreach ($missingIndices as $table => $indexList) {
681 foreach ($indexList as $index) {
682 $queries[] = "CREATE " .
683 (array_key_exists('unique', $index) && $index['unique'] ? 'UNIQUE ' : '') .
684 "INDEX {$index['name']} ON {$table} (" .
685 implode(", ", $index['field']) .
686 ")";
687 }
688 }
689
690 /* FIXME potential problem if index name already exists, so check before creating */
691 $dao = new CRM_Core_DAO();
692 foreach ($queries as $query) {
693 $dao->query($query, FALSE);
694 }
49186f94
AS
695 }
696
d992b2f5 697 /**
698 * Build the sql to alter the field.
699 *
700 * @param array $params
d992b2f5 701 *
702 * @return string
703 */
ba9c74ab 704 public static function buildFieldChangeSql($params) {
d992b2f5 705 $sql = str_repeat(' ', 8);
706 $sql .= "ALTER TABLE {$params['table_name']}";
ba9c74ab 707 return $sql . self::getFieldAlterSQL($params);
8e2949c4 708 }
709
710 /**
711 * Get the sql to alter an individual field.
712 *
713 * This will need to have an ALTER TABLE statement appended but by getting
714 * by individual field we can do one or many.
715 *
716 * @param array $params
8e2949c4 717 *
718 * @return string
719 */
ba9c74ab 720 public static function getFieldAlterSQL($params) {
8e2949c4 721 $sql = '';
d992b2f5 722 switch ($params['operation']) {
723 case 'add':
724 $separator = "\n";
725 $sql .= self::buildFieldSQL($params, $separator, "ADD COLUMN ");
726 $separator = ",\n";
727 $sql .= self::buildPrimaryKeySQL($params, $separator, "ADD PRIMARY KEY ");
728 $sql .= self::buildSearchIndexSQL($params, $separator, "ADD INDEX ");
729 $sql .= self::buildForeignKeySQL($params, $separator, "ADD ", $params['table_name']);
730 break;
731
732 case 'modify':
733 $separator = "\n";
ba9c74ab
CW
734 $existingIndex = NULL;
735 $dao = CRM_Core_DAO::executeQuery("SHOW INDEX FROM `{$params['table_name']}` WHERE Column_name = '{$params['name']}'");
736 if ($dao->fetch()) {
737 $existingIndex = $dao->Key_name;
738 }
051f2455 739 $fkSql = self::buildForeignKeySQL($params, ",\n", "ADD ", $params['table_name']);
16fd31ae 740 if (substr(($existingIndex ?? ''), 0, 2) === 'FK' && !$fkSql) {
ba9c74ab 741 $sql .= "$separator DROP FOREIGN KEY {$existingIndex},\nDROP INDEX {$existingIndex}";
051f2455
CW
742 $separator = ",\n";
743 }
d992b2f5 744 $sql .= self::buildFieldSQL($params, $separator, "MODIFY ");
745 $separator = ",\n";
ba9c74ab
CW
746 $sql .= self::buildSearchIndexSQL($params, $separator, "ADD INDEX ", $existingIndex);
747 if (!$existingIndex && $fkSql) {
051f2455
CW
748 $sql .= $fkSql;
749 }
d992b2f5 750 break;
751
752 case 'delete':
753 $sql .= " DROP COLUMN `{$params['name']}`";
754 if (!empty($params['primary'])) {
755 $sql .= ", DROP PRIMARY KEY";
756 }
757 if (!empty($params['fk_table_name'])) {
758 $sql .= ", DROP FOREIGN KEY FK_{$params['fkName']}";
759 }
760 break;
761 }
762 return $sql;
763 }
764
051f2455
CW
765 /**
766 * Turns tableName + columnName into a safe & predictable index name
767 *
768 * @param $tableName
769 * @param $columnName
770 * @return string
771 */
772 public static function getIndexName($tableName, $columnName) {
773 $indexName = "{$tableName}_{$columnName}";
774 if (strlen($indexName) >= 48) {
775 $indexName = substr($indexName, 0, 32) . "_" . substr(md5($indexName), 0, 16);
776 }
777 return $indexName;
778 }
779
a0a5d4da 780 /**
781 * Performs the utf8mb4 migration.
782 *
783 * @param bool $revert
784 * Being able to revert if primarily for unit testing.
aedfc3ed
MW
785 * @param array $patterns
786 * Defaults to ['civicrm\_%'] but can be overridden to specify any pattern. eg ['civicrm\_%', 'civi%\_%', 'veda%\_%'].
787 * @param array $databaseList
788 * Allows you to specify an alternative database to the configured CiviCRM database.
a0a5d4da 789 *
790 * @return bool
791 */
aedfc3ed 792 public static function migrateUtf8mb4($revert = FALSE, $patterns = ['civicrm\_%'], $databaseList = NULL) {
a0a5d4da 793 $newCharSet = $revert ? 'utf8' : 'utf8mb4';
794 $newCollation = $revert ? 'utf8_unicode_ci' : 'utf8mb4_unicode_ci';
795 $newBinaryCollation = $revert ? 'utf8_bin' : 'utf8mb4_bin';
796 $tables = [];
797 $dao = new CRM_Core_DAO();
aedfc3ed
MW
798 $databases = $databaseList ?? [$dao->_database];
799
800 $tableNameLikePatterns = [];
801 $logTableNameLikePatterns = [];
802
803 foreach ($patterns as $pattern) {
804 $pattern = CRM_Utils_Type::escape($pattern, 'String');
805 $tableNameLikePatterns[] = "Name LIKE '{$pattern}'";
806 $logTableNameLikePatterns[] = "Name LIKE 'log\_{$pattern}'";
807 }
808
809 foreach ($databases as $database) {
9b6a8a73 810 CRM_Core_DAO::executeQuery("ALTER DATABASE `{$database}` CHARACTER SET = $newCharSet COLLATE = $newCollation");
aedfc3ed
MW
811 $dao = CRM_Core_DAO::executeQuery("SHOW TABLE STATUS FROM `{$database}` WHERE Engine = 'InnoDB' AND (" . implode(' OR ', $tableNameLikePatterns) . ")");
812 while ($dao->fetch()) {
9b6a8a73 813 $tables["`{$database}`.`{$dao->Name}`"] = [
aedfc3ed
MW
814 'Engine' => $dao->Engine,
815 ];
816 }
817 }
818 // If we specified a list of databases assume the user knows what they are doing.
819 // If they specify the database they should also specify the pattern.
820 if (!$databaseList) {
58d1e21e
SL
821 $dsn = defined('CIVICRM_LOGGING_DSN') ? CRM_Utils_SQL::autoSwitchDSN(CIVICRM_LOGGING_DSN) : CRM_Utils_SQL::autoSwitchDSN(CIVICRM_DSN);
822 $dsn = DB::parseDSN($dsn);
aedfc3ed
MW
823 $logging_database = $dsn['database'];
824 $dao = CRM_Core_DAO::executeQuery("SHOW TABLE STATUS FROM `{$logging_database}` WHERE Engine <> 'MyISAM' AND (" . implode(' OR ', $logTableNameLikePatterns) . ")");
825 while ($dao->fetch()) {
9b6a8a73 826 $tables["`{$logging_database}`.`{$dao->Name}`"] = [
aedfc3ed
MW
827 'Engine' => $dao->Engine,
828 ];
829 }
a0a5d4da 830 }
831 foreach ($tables as $table => $param) {
832 $query = "ALTER TABLE $table";
833 $dao = CRM_Core_DAO::executeQuery("SHOW FULL COLUMNS FROM $table", [], TRUE, NULL, FALSE, FALSE);
834 $index = 0;
835 $params = [];
836 $tableCollation = $newCollation;
837 while ($dao->fetch()) {
838 if (!$dao->Collation || $dao->Collation === $newCollation || $dao->Collation === $newBinaryCollation) {
839 continue;
840 }
841 if (strpos($dao->Collation, 'utf8') !== 0) {
842 continue;
843 }
844
845 if (strpos($dao->Collation, '_bin') !== FALSE) {
846 $tableCollation = $newBinaryCollation;
847 }
848 else {
849 $tableCollation = $newCollation;
850 }
851 if ($dao->Null === 'YES') {
852 $null = 'NULL';
853 }
854 else {
855 $null = 'NOT NULL';
856 }
857 $default = '';
858 if ($dao->Default !== NULL) {
859 $index++;
860 $default = "DEFAULT %$index";
861 $params[$index] = [$dao->Default, 'String'];
862 }
863 elseif ($dao->Null === 'YES') {
864 $default = 'DEFAULT NULL';
865 }
866 $index++;
867 $params[$index] = [$dao->Comment, 'String'];
868 $query .= " MODIFY `{$dao->Field}` {$dao->Type} CHARACTER SET $newCharSet COLLATE $tableCollation $null $default {$dao->Extra} COMMENT %$index,";
869 }
870 $query .= " CHARACTER SET = $newCharSet COLLATE = $tableCollation";
871 if ($param['Engine'] === 'InnoDB') {
41a26a7c 872 $query .= ' ROW_FORMAT = Dynamic KEY_BLOCK_SIZE = 0';
a0a5d4da 873 }
874 // Disable i18n rewrite.
875 CRM_Core_DAO::executeQuery($query, $params, TRUE, NULL, FALSE, FALSE);
876 }
586cb32c 877 // Rebuild triggers and other schema reconciliation if needed.
878 $logging = new CRM_Logging_Schema();
879 $logging->fixSchemaDifferences();
a0a5d4da 880 return TRUE;
881 }
882
25374ca7 883 /**
884 * Get the database collation.
885 *
886 * @return string
887 */
888 public static function getDBCollation() {
889 return CRM_Core_DAO::singleValueQuery('SELECT @@collation_database');
890 }
891
8f719ee0 892 /**
893 * Get the collation actually being used by the tables in the database.
894 *
895 * The db collation may not match the collation used by the tables, get what is
896 * set on the tables (represented by civicrm_contact).
897 *
898 * @return string
899 */
91f1ab62 900 public static function getInUseCollation(): string {
8f719ee0 901 if (!isset(\Civi::$statics[__CLASS__][__FUNCTION__])) {
902 $dao = CRM_Core_DAO::executeQuery('SHOW TABLE STATUS LIKE \'civicrm_contact\'');
903 $dao->fetch();
904 \Civi::$statics[__CLASS__][__FUNCTION__] = $dao->Collation;
905 }
906 return \Civi::$statics[__CLASS__][__FUNCTION__];
907 }
908
91f1ab62 909 /**
910 * Does the database support utf8mb4.
911 *
912 * Utf8mb4 is required to support emojis but older databases may not have it enabled.
913 *
914 * This is aggressively cached despite just being a string function
915 * as it is expected it might be called many times.
916 *
917 * @return bool
918 */
919 public static function databaseSupportsUTF8MB4(): bool {
920 if (!isset(\Civi::$statics[__CLASS__][__FUNCTION__])) {
c611f88d 921 \Civi::$statics[__CLASS__][__FUNCTION__] = stripos(self::getInUseCollation(), 'utf8mb4') === 0;
91f1ab62 922 }
923 return \Civi::$statics[__CLASS__][__FUNCTION__];
924 }
925
25374ca7 926 /**
927 * Get the database collation.
928 *
929 * @return string
930 */
931 public static function getDBCharset() {
932 return CRM_Core_DAO::singleValueQuery('SELECT @@character_set_database');
933 }
934
6a488035 935}