checkin (broken)

This commit is contained in:
Rich Lott / Artful Robot 2025-02-18 17:33:27 +00:00
parent a01abba787
commit 06fec5daf7
21 changed files with 2025 additions and 376 deletions

View file

@ -0,0 +1,28 @@
<?php
namespace CiviMix\Schema;
\pathload()->activatePackage('civimix-schema@5', __DIR__, [
'reloadable' => TRUE,
// The civimix-schema library specifically supports installation processes. From a
// bootstrap/service-availability POV, this is a rough environment which leads to
// the "Multi-Activation Issue" and "Multi-Download Issue". To adapt to them,
// civimix-schema follows "Reloadable Library" patterns.
// More information: https://github.com/totten/pathload-poc/blob/master/doc/issues.md
]);
// When reloading, we make newer instance of the Facade object.
$GLOBALS['CiviMixSchema'] = require __DIR__ . '/src/CiviMixSchema.php';
if (!interface_exists(__NAMESPACE__ . '\SchemaHelperInterface')) {
require __DIR__ . '/src/SchemaHelperInterface.php';
}
// \CiviMix\Schema\loadClass() is a facade. The facade should remain identical across versions.
if (!function_exists(__NAMESPACE__ . '\loadClass')) {
function loadClass(string $class) {
return $GLOBALS['CiviMixSchema']->loadClass($class);
}
spl_autoload_register(__NAMESPACE__ . '\loadClass');
}

View file

@ -0,0 +1,181 @@
<?php
namespace CiviMix\Schema;
use Civi\Test\Invasive;
/**
* The "AutomaticUpgrader" will create and destroy the SQL tables
* using schema files (`SchemaHelper`). It also calls-out to any custom
* upgrade code (eg `CRM_Myext_Upgrader`).
*
* To simplify backport considerations, `AutomaticUpgrader` does not have formal name.
* It is accessed via aliases like "CiviMix\Schema\*\AutomaticUpgrader".
*
* Target: CiviCRM v5.38+
*/
return new class() implements \CRM_Extension_Upgrader_Interface {
use \CRM_Extension_Upgrader_IdentityTrait {
init as initIdentity;
}
/**
* Optionally delegate to "CRM_Myext_Upgrader" or "Civi\Myext\Upgrader".
*
* @var \CRM_Extension_Upgrader_Interface|null
*/
private $customUpgrader;
public function init(array $params) {
$this->initIdentity($params);
if ($info = $this->getInfo()) {
if ($class = $this->getDelegateUpgraderClass($info)) {
$this->customUpgrader = new $class();
$this->customUpgrader->init($params);
if ($errors = $this->checkDelegateCompatibility($this->customUpgrader)) {
throw new \CRM_Core_Exception("AutomaticUpgrader is not compatible with $class:\n" . implode("\n", $errors));
}
}
}
}
public function notify(string $event, array $params = []) {
$info = $this->getInfo();
if (!$info) {
return;
}
if ($event === 'install') {
$GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->install();
}
if ($this->customUpgrader) {
$result = $this->customUpgrader->notify($event, $params);
// for upgrade checks, we need to pass check results up to the caller
// (for now - could definitely be more elegant!)
if ($event === 'upgrade') {
return $result;
}
}
if ($event === 'uninstall') {
$GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->uninstall();
}
}
/**
* Civix-based extensions have a conventional name for their upgrader class ("CRM_Myext_Upgrader"
* or "Civi\Myext\Upgrader"). Figure out if this class exists.
*
* @param \CRM_Extension_Info $info
* @return string|null
* Ex: 'CRM_Myext_Upgrader' or 'Civi\Myext\Upgrader'
*/
public function getDelegateUpgraderClass(\CRM_Extension_Info $info): ?string {
$candidates = [];
if (!empty($info->civix['namespace'])) {
$namespace = $info->civix['namespace'];
$candidates[] = sprintf('%s_Upgrader', str_replace('/', '_', $namespace));
$candidates[] = sprintf('%s\\Upgrader', str_replace('/', '\\', $namespace));
}
foreach ($candidates as $candidate) {
if (class_exists($candidate)) {
return $candidate;
}
}
return NULL;
}
public function getInfo(): ?\CRM_Extension_Info {
try {
return \CRM_Extension_System::singleton()->getMapper()->keyToInfo($this->extensionName);
}
catch (\CRM_Extension_Exception_ParseException $e) {
\Civi::log()->error("Parse error in extension " . $this->extensionName . ": " . $e->getMessage());
return NULL;
}
}
/**
* @param \CRM_Extension_Upgrader_Interface $upgrader
* @return array
* List of error messages.
*/
public function checkDelegateCompatibility($upgrader): array {
$class = get_class($upgrader);
$errors = [];
if (!($upgrader instanceof \CRM_Extension_Upgrader_Base)) {
$errors[] = "$class is not based on CRM_Extension_Upgrader_Base.";
return $errors;
}
// In the future, we will probably modify AutomaticUpgrader to build its own
// sequence of revisions (based on other sources of data). AutomaticUpgrader
// is only regarded as compatible with classes that strictly follow the standard revision-model.
$methodNames = [
'appendTask',
'onUpgrade',
'getRevisions',
'getCurrentRevision',
'setCurrentRevision',
'enqueuePendingRevisions',
'hasPendingRevisions',
];
foreach ($methodNames as $methodName) {
$method = new \ReflectionMethod($upgrader, $methodName);
if ($method->getDeclaringClass()->getName() !== 'CRM_Extension_Upgrader_Base') {
$errors[] = "To ensure future interoperability, AutomaticUpgrader only supports {$class}::{$methodName}() if it's inherited from CRM_Extension_Upgrader_Base";
}
}
return $errors;
}
public function __set($property, $value) {
switch ($property) {
// _queueAdapter() needs these properties.
case 'ctx':
case 'queue':
if (!$this->customUpgrader) {
throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign delegated property: $property (No custom-upgrader found)");
}
// "Invasive": unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits.
Invasive::set([$this->customUpgrader, $property], $value);
return;
}
throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign unknown property: $property");
}
public function __get($property) {
switch ($property) {
// _queueAdapter() needs these properties.
case 'ctx':
case 'queue':
if (!$this->customUpgrader) {
throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read delegated property: $property (No custom-upgrader found)");
}
// "Invasive": Unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits.
return Invasive::get([$this->customUpgrader, $property]);
}
throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read unknown property: $property");
}
public function __call($name, $arguments) {
if ($this->customUpgrader) {
return call_user_func_array([$this->customUpgrader, $name], $arguments);
}
else {
throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot delegate method $name (No custom-upgrader found)");
}
}
};

View file

@ -0,0 +1,46 @@
<?php
namespace CiviMix\Schema;
/**
* This object is known as $GLOBALS['CiviMixSchema']. It is a reloadable service-object.
* (It may be reloaded if you enable a new extension that includes an upgraded copy.)
*/
return new class() {
/**
* @var string
* Regular expression. Note the 2 groupings. $m[1] identifies a per-extension namespace. $m[2] identifies the actual class.
*/
private $regex = ';^CiviMix\\\Schema\\\(\w+)\\\(AutomaticUpgrader|DAO)$;';
/**
* If someone requests a class like:
*
* CiviMix\Schema\MyExt\AutomaticUpgrader
*
* then load the latest version of:
*
* civimix-schema/src/Helper.php
*/
public function loadClass(string $class) {
if (preg_match($this->regex, $class, $m)) {
$absPath = __DIR__ . DIRECTORY_SEPARATOR . $m[2] . '.php';
class_alias(get_class(require $absPath), $class);
}
}
/**
* @param string $extensionKey
* Ex: 'org.civicrm.flexmailer'
* @return \CiviMix\Schema\SchemaHelperInterface
*/
public function getHelper(string $extensionKey) {
$store = &\Civi::$statics['CiviMixSchema-helpers'];
if (!isset($store[$extensionKey])) {
$class = get_class(require __DIR__ . '/SchemaHelper.php');
$store[$extensionKey] = new $class($extensionKey);
}
return $store[$extensionKey];
}
};

View file

@ -0,0 +1,350 @@
<?php
namespace CiviMix\Schema;
/**
* To simplify backport considerations, `DAO` does not have formal name.
* It is accessed via aliases like "CiviMix\Schema\*\DAO".
*
* Target: TBD (5.38+? 5.51+)
*/
return new class() extends \CRM_Core_DAO {
public function __construct() {
if (strpos(static::class, '@') !== FALSE) {
// Template instance. Fake news!
return;
}
parent::__construct();
// Historically a generated DAO would have one class variable per field.
// To prevent undefined property warnings, this dynamic DAO mimics that by
// initializing the object with a property for each field.
foreach (static::getEntityDefinition()['getFields']() as $name => $field) {
$this->$name = NULL;
}
}
/**
* @inheritDoc
*/
public function keys(): array {
$keys = [];
foreach (static::getEntityDefinition()['getFields']() as $name => $field) {
if (!empty($field['primary_key'])) {
$keys[] = $name;
}
}
return $keys;
}
public static function getEntityTitle($plural = FALSE) {
$info = static::getEntityInfo();
return ($plural && isset($info['title_plural'])) ? $info['title_plural'] : $info['title'];
}
/**
* @inheritDoc
*/
public static function getEntityPaths(): array {
$definition = static::getEntityDefinition();
if (isset($definition['getPaths'])) {
return $definition['getPaths']();
}
return [];
}
public static function getLabelField(): ?string {
return static::getEntityInfo()['label_field'] ?? NULL;
}
/**
* @inheritDoc
*/
public static function getEntityDescription(): ?string {
return static::getEntityInfo()['description'] ?? NULL;
}
/**
* @inheritDoc
*/
public static function getTableName() {
return static::getEntityDefinition()['table'];
}
/**
* @inheritDoc
*/
public function getLog(): bool {
return static::getEntityInfo()['log'] ?? FALSE;
}
/**
* @inheritDoc
*/
public static function getEntityIcon(string $entityName, ?int $entityId = NULL): ?string {
return static::getEntityInfo()['icon'] ?? NULL;
}
/**
* @inheritDoc
*/
protected static function getTableAddVersion(): string {
return static::getEntityInfo()['add'] ?? '1.0';
}
/**
* @inheritDoc
*/
public static function getExtensionName(): ?string {
return static::getEntityDefinition()['module'];
}
/**
* @inheritDoc
*/
public static function &fields() {
$fields = [];
foreach (static::getSchemaFields() as $field) {
$key = $field['uniqueName'] ?? $field['name'];
unset($field['uniqueName']);
$fields[$key] = $field;
}
return $fields;
}
private static function getSchemaFields(): array {
if (!isset(\Civi::$statics[static::class]['fields'])) {
\Civi::$statics[static::class]['fields'] = static::loadSchemaFields();
}
return \Civi::$statics[static::class]['fields'];
}
private static function loadSchemaFields(): array {
$fields = [];
$entityDef = static::getEntityDefinition();
$baoName = \CRM_Core_DAO_AllCoreTables::getBAOClassName(static::class);
foreach ($entityDef['getFields']() as $fieldName => $fieldSpec) {
$field = [
'name' => $fieldName,
'type' => !empty($fieldSpec['data_type']) ? \CRM_Utils_Type::getValidTypes()[$fieldSpec['data_type']] : static::getCrmTypeFromSqlType($fieldSpec['sql_type']),
'title' => $fieldSpec['title'],
'description' => $fieldSpec['description'] ?? NULL,
];
if (!empty($fieldSpec['required'])) {
$field['required'] = TRUE;
}
if (strpos($fieldSpec['sql_type'], 'decimal(') === 0) {
$precision = self::getFieldLength($fieldSpec['sql_type']);
$field['precision'] = array_map('intval', explode(',', $precision));
}
foreach (['maxlength', 'size', 'rows', 'cols'] as $attr) {
if (isset($fieldSpec['input_attrs'][$attr])) {
$field[$attr] = $fieldSpec['input_attrs'][$attr];
unset($fieldSpec['input_attrs'][$attr]);
}
}
if (strpos($fieldSpec['sql_type'], 'char(') !== FALSE) {
$length = self::getFieldLength($fieldSpec['sql_type']);
if (!isset($field['size'])) {
$field['size'] = constant(static::getDefaultSize($length));
}
if (!isset($field['maxlength'])) {
$field['maxlength'] = $length;
}
}
$usage = $fieldSpec['usage'] ?? [];
$field['usage'] = [
'import' => in_array('import', $usage),
'export' => in_array('export', $usage),
'duplicate_matching' => in_array('duplicate_matching', $usage),
'token' => in_array('token', $usage),
];
if ($field['usage']['import']) {
$field['import'] = TRUE;
}
$field['where'] = $entityDef['table'] . '.' . $field['name'];
if ($field['usage']['export'] || (!$field['usage']['export'] && $field['usage']['import'])) {
$field['export'] = $field['usage']['export'];
}
if (!empty($fieldSpec['contact_type'])) {
$field['contactType'] = $fieldSpec['contact_type'];
}
if (!empty($fieldSpec['permission'])) {
$field['permission'] = $fieldSpec['permission'];
}
if (array_key_exists('default', $fieldSpec)) {
$field['default'] = isset($fieldSpec['default']) ? (string) $fieldSpec['default'] : NULL;
if (is_bool($fieldSpec['default'])) {
$field['default'] = $fieldSpec['default'] ? '1' : '0';
}
}
$field['table_name'] = $entityDef['table'];
$field['entity'] = $entityDef['name'];
$field['bao'] = $baoName;
$field['localizable'] = intval($fieldSpec['localizable'] ?? 0);
if (!empty($fieldSpec['localize_context'])) {
$field['localize_context'] = (string) $fieldSpec['localize_context'];
}
if (!empty($fieldSpec['entity_reference'])) {
if (!empty($fieldSpec['entity_reference']['entity'])) {
$field['FKClassName'] = static::getDAONameForEntity($fieldSpec['entity_reference']['entity']);
}
if (!empty($fieldSpec['entity_reference']['dynamic_entity'])) {
$field['DFKEntityColumn'] = $fieldSpec['entity_reference']['dynamic_entity'];
}
$field['FKColumnName'] = $fieldSpec['entity_reference']['key'] ?? 'id';
}
if (!empty($fieldSpec['component'])) {
$field['component'] = $fieldSpec['component'];
}
if (!empty($fieldSpec['serialize'])) {
$field['serialize'] = $fieldSpec['serialize'];
}
if (!empty($fieldSpec['unique_name'])) {
$field['uniqueName'] = $fieldSpec['unique_name'];
}
if (!empty($fieldSpec['unique_title'])) {
$field['unique_title'] = $fieldSpec['unique_title'];
}
if (!empty($fieldSpec['deprecated'])) {
$field['deprecated'] = TRUE;
}
if (!empty($fieldSpec['input_attrs'])) {
$field['html'] = \CRM_Utils_Array::rekey($fieldSpec['input_attrs'], function($str) {
return \CRM_Utils_String::convertStringToCamel($str, FALSE);
});
}
if (!empty($fieldSpec['input_type'])) {
$field['html']['type'] = $fieldSpec['input_type'];
}
if (!empty($fieldSpec['pseudoconstant'])) {
$field['pseudoconstant'] = \CRM_Utils_Array::rekey($fieldSpec['pseudoconstant'], function($str) {
return \CRM_Utils_String::convertStringToCamel($str, FALSE);
});
if (!isset($field['pseudoconstant']['optionEditPath']) && !empty($field['pseudoconstant']['optionGroupName'])) {
$field['pseudoconstant']['optionEditPath'] = 'civicrm/admin/options/' . $field['pseudoconstant']['optionGroupName'];
}
}
if (!empty($fieldSpec['primary_key']) || !empty($fieldSpec['readonly'])) {
$field['readonly'] = TRUE;
}
$field['add'] = $fieldSpec['add'] ?? NULL;
$fields[$fieldName] = $field;
}
\CRM_Core_DAO_AllCoreTables::invoke(static::class, 'fields_callback', $fields);
return $fields;
}
private static function getFieldLength($sqlType): ?string {
$open = strpos($sqlType, '(');
if ($open) {
return substr($sqlType, $open + 1, -1);
}
return NULL;
}
/**
* @inheritDoc
*/
public static function indices(bool $localize = TRUE): array {
$definition = static::getEntityDefinition();
$indices = [];
if (isset($definition['getIndices'])) {
$fields = $definition['getFields']();
foreach ($definition['getIndices']() as $name => $info) {
$index = [
'name' => $name,
'field' => [],
'localizable' => FALSE,
];
foreach ($info['fields'] as $fieldName => $length) {
if (!empty($fields[$fieldName]['localizable'])) {
$index['localizable'] = TRUE;
}
if (is_int($length)) {
$fieldName .= "($length)";
}
$index['field'][] = $fieldName;
}
if (!empty($info['unique'])) {
$index['unique'] = TRUE;
}
$index['sig'] = ($definition['table']) . '::' . intval($info['unique'] ?? 0) . '::' . implode('::', $index['field']);
$indices[$name] = $index;
}
}
return ($localize && $indices) ? \CRM_Core_DAO_AllCoreTables::multilingualize(static::class, $indices) : $indices;
}
public static function getEntityDefinition(): array {
if (!isset(\Civi::$statics[static::class]['definition'])) {
$class = new \ReflectionClass(static::class);
$file = substr(basename($class->getFileName()), 0, -4) . '.entityType.php';
$folder = dirname($class->getFileName(), 4) . '/schema/';
$path = $folder . $file;
\Civi::$statics[static::class]['definition'] = include $path;
}
return \Civi::$statics[static::class]['definition'];
}
private static function getEntityInfo(): array {
return static::getEntityDefinition()['getInfo']();
}
private static function getDefaultSize($length) {
// Infer from <length> tag if <size> was not explicitly set or was invalid
// This map is slightly different from CRM_Core_Form_Renderer::$_sizeMapper
// Because we usually want fields to render as smaller than their maxlength
$sizes = [
2 => 'TWO',
4 => 'FOUR',
6 => 'SIX',
8 => 'EIGHT',
16 => 'TWELVE',
32 => 'MEDIUM',
64 => 'BIG',
];
foreach ($sizes as $size => $name) {
if ($length <= $size) {
return "CRM_Utils_Type::$name";
}
}
return 'CRM_Utils_Type::HUGE';
}
private static function getCrmTypeFromSqlType(string $sqlType): int {
[$type] = explode('(', $sqlType);
switch ($type) {
case 'varchar':
case 'char':
return \CRM_Utils_Type::T_STRING;
case 'datetime':
return \CRM_Utils_Type::T_DATE + \CRM_Utils_Type::T_TIME;
case 'decimal':
return \CRM_Utils_Type::T_MONEY;
case 'double':
return \CRM_Utils_Type::T_FLOAT;
case 'int unsigned':
case 'tinyint':
return \CRM_Utils_Type::T_INT;
default:
return constant('CRM_Utils_Type::T_' . strtoupper($type));
}
}
private static function getDAONameForEntity($entity) {
if (is_callable(['CRM_Core_DAO_AllCoreTables', 'getDAONameForEntity'])) {
return \CRM_Core_DAO_AllCoreTables::getDAONameForEntity($entity);
}
else {
return \CRM_Core_DAO_AllCoreTables::getFullName($entity);
}
}
};

View file

@ -0,0 +1,87 @@
<?php
namespace CiviMix\Schema;
/**
* The "SchemaHelper" class provides helper methods for an extension to manage its schema.
*
* Target: CiviCRM v5.38+
*/
return new class() implements SchemaHelperInterface {
/**
* @var string
*
* Ex: 'org.civicrm.flexmailer'
*/
private $key;
private $sqlGenerator;
public function __construct(?string $key = NULL) {
$this->key = $key;
}
public function install(): void {
$this->runSqls([$this->generateInstallSql()]);
}
public function uninstall(): void {
$this->runSqls([$this->generateUninstallSql()]);
}
public function generateInstallSql(): ?string {
return $this->getSqlGenerator()->getCreateTablesSql();
}
public function generateUninstallSql(): string {
return $this->getSqlGenerator()->getDropTablesSql();
}
public function hasSchema(): bool {
return file_exists($this->getExtensionDir() . '/schema');
}
public function arrayToSql(array $entityDefn): string {
$generator = $this->getSqlGenerator();
return $generator->generateCreateTableWithConstraintSql($entityDefn);
}
// FIXME: You can add more utility methods here
// public function addTables(array $names): void {
// throw new \RuntimeException("TODO: Install a single tables");
// }
//
// public function addColumn(string $table, string $column): void {
// throw new \RuntimeException("TODO: Install a single tables");
// }
/**
* @param array $sqls
* List of SQL scripts.
*/
private function runSqls(array $sqls): void {
foreach ($sqls as $sql) {
\CRM_Utils_File::runSqlQuery(CIVICRM_DSN, $sql);
}
}
protected function getExtensionDir(): string {
if ($this->key === 'civicrm') {
$r = new \ReflectionClass('CRM_Core_ClassLoader');
return dirname($r->getFileName(), 3);
}
$system = \CRM_Extension_System::singleton();
return $system->getMapper()->keyToBasePath($this->key);
}
private function getSqlGenerator() {
if ($this->sqlGenerator === NULL) {
$gen = require __DIR__ . '/SqlGenerator.php';
$this->sqlGenerator = $gen::createFromFolder($this->key, $this->getExtensionDir() . '/schema', $this->key === 'civicrm');
}
return $this->sqlGenerator;
}
};

View file

@ -0,0 +1,30 @@
<?php
namespace CiviMix\Schema;
/**
* The SchemaHelperInterface provides utility methods for managing the schema
* in an extension (e.g. installing or uninstalling all SQL tables).
*
* The interface is implemented by the reloadable library (civimix-schema@5). To ensure
* newer revisions of the library can be loaded, the implementation is an anonymous-class,
* and the interface uses soft type-hints.
*
* @method bool hasSchema()
*
* @method void install()
* @method void uninstall()
*
* @method string generateInstallSql()
* @method string generateUninstallSql()
*
* TODO: void addTables(string[] $tables)
* TODO: void addColumn(string $table, string $column)
*
* To see the latest implementation:
*
* @see ./SchemaHelper.php
*/
interface SchemaHelperInterface {
}

View file

@ -0,0 +1,232 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
return new class() {
/**
* @var array
*/
private $entities;
/**
* @var callable
*/
private $findExternalTable;
/**
* @param string $module
* Ex: 'civicrm' or 'org.example.mymodule'
* @param string $path
* Ex: '/var/www/sites/all/modules/civicrm/schema'
* @param bool $isolated
* TRUE if these entities should be a self-sufficient (i.e. no external references).
* FALSE if these entities may include references to other tables.
* TRUE would make sense in (eg) civicrm-core, before installation or bootstrap
* FALSE would make sense in (eg) an extension on an active system.
*
* @return static
*/
public static function createFromFolder(string $module, string $path, bool $isolated) {
$files = \CRM_Utils_File::findFiles($path, '*.entityType.php');
$entities = [];
foreach ($files as $file) {
$entity = include $file;
$entity['module'] = $module;
$entities[$entity['name']] = $entity;
}
$findExternalTable = $isolated ? NULL : (['CRM_Core_DAO_AllCoreTables', 'getTableForEntityName']);
return new static($entities, $findExternalTable);
}
public function __construct(array $entities = [], ?callable $findExternalTable = NULL) {
// Filter out entities without a sql table (e.g. Afform)
$this->entities = array_filter($entities, function($entity) {
return !empty($entity['table']);
});
$this->findExternalTable = $findExternalTable ?: function() {
return NULL;
};
}
public function getEntities(): array {
return $this->entities;
}
public function getCreateTablesSql(): string {
$sql = '';
foreach ($this->entities as $entity) {
$sql .= $this->generateCreateTableSql($entity);
}
foreach ($this->entities as $entity) {
$sql .= $this->generateConstraintsSql($entity);
}
return $sql;
}
public function getCreateTableSql(string $entityName): string {
$sql = $this->generateCreateTableSql($this->entities[$entityName]);
$sql .= $this->generateConstraintsSql($this->entities[$entityName]);
return $sql;
}
public function getDropTablesSql(): string {
$sql = "SET FOREIGN_KEY_CHECKS=0;\n";
foreach ($this->entities as $entity) {
$sql .= "DROP TABLE IF EXISTS `{$entity['table']}`;\n";
}
$sql .= "SET FOREIGN_KEY_CHECKS=1;\n";
return $sql;
}
public function generateCreateTableWithConstraintSql(array $entity): string {
$definition = $this->getTableDefinition($entity);
$constraints = $this->getTableConstraints($entity);
$sql = "CREATE TABLE IF NOT EXISTS `{$entity['table']}` (\n " .
implode(",\n ", $definition);
if ($constraints) {
$sql .= ",\n " . implode(",\n ", $constraints);
}
$sql .= "\n)\n" . $this->getTableOptions() . ";\n";
return $sql;
}
private function generateCreateTableSql(array $entity): string {
$definition = $this->getTableDefinition($entity);
$sql = "CREATE TABLE `{$entity['table']}` (\n " .
implode(",\n ", $definition) .
"\n)\n" .
$this->getTableOptions() . ";\n";
return $sql;
}
private function getTableDefinition(array $entity): array {
$definition = [];
$primaryKeys = [];
foreach ($entity['getFields']() as $fieldName => $field) {
if (!empty($field['primary_key'])) {
$primaryKeys[] = "`$fieldName`";
}
$definition[] = "`$fieldName` " . self::generateFieldSql($field);
}
if ($primaryKeys) {
$definition[] = 'PRIMARY KEY (' . implode(', ', $primaryKeys) . ')';
}
$indices = isset($entity['getIndices']) ? $entity['getIndices']() : [];
foreach ($indices as $indexName => $index) {
$indexFields = [];
foreach ($index['fields'] as $fieldName => $length) {
$indexFields[] = "`$fieldName`" . (is_int($length) ? "($length)" : '');
}
$definition[] = (!empty($index['unique']) ? 'UNIQUE ' : '') . "INDEX `$indexName`(" . implode(', ', $indexFields) . ')';
}
return $definition;
}
private function generateConstraintsSql(array $entity): string {
$constraints = $this->getTableConstraints($entity);
$sql = '';
if ($constraints) {
$sql .= "ALTER TABLE `{$entity['table']}`\n ";
$sql .= 'ADD ' . implode(",\n ADD ", $constraints) . ";\n";
}
return $sql;
}
private function getTableConstraints(array $entity): array {
$constraints = [];
foreach ($entity['getFields']() as $fieldName => $field) {
if (!empty($field['entity_reference']['entity'])) {
$constraint = "CONSTRAINT `FK_{$entity['table']}_$fieldName` FOREIGN KEY (`$fieldName`)" .
" REFERENCES `" . $this->getTableForEntity($field['entity_reference']['entity']) . "`(`{$field['entity_reference']['key']}`)";
if (!empty($field['entity_reference']['on_delete'])) {
$constraint .= " ON DELETE {$field['entity_reference']['on_delete']}";
}
$constraints[] = $constraint;
}
}
return $constraints;
}
public static function generateFieldSql(array $field) {
$fieldSql = $field['sql_type'];
if (!empty($field['collate'])) {
$fieldSql .= " COLLATE {$field['collate']}";
}
// Required fields and booleans cannot be null
// FIXME: For legacy support this doesn't force boolean fields to be NOT NULL... but it really should.
if (!empty($field['required'])) {
$fieldSql .= ' NOT NULL';
}
else {
$fieldSql .= ' NULL';
}
if (!empty($field['auto_increment'])) {
$fieldSql .= " AUTO_INCREMENT";
}
$fieldSql .= self::getDefaultSql($field);
if (!empty($field['description'])) {
$fieldSql .= " COMMENT '" . \CRM_Core_DAO::escapeString($field['description']) . "'";
}
return $fieldSql;
}
private static function getDefaultSql(array $field): string {
// Booleans always have a default
if ($field['sql_type'] === 'boolean') {
$field += ['default' => FALSE];
}
if (!array_key_exists('default', $field)) {
return '';
}
if (is_null($field['default'])) {
$default = 'NULL';
}
elseif (is_bool($field['default'])) {
$default = $field['default'] ? 'TRUE' : 'FALSE';
}
elseif (!is_string($field['default']) || str_starts_with($field['default'], 'CURRENT_TIMESTAMP')) {
$default = $field['default'];
}
else {
$default = "'" . \CRM_Core_DAO::escapeString($field['default']) . "'";
}
return ' DEFAULT ' . $default;
}
private function getTableForEntity(string $entityName): string {
return $this->entities[$entityName]['table'] ?? call_user_func($this->findExternalTable, $entityName);
}
/**
* Get general/default options for use in CREATE TABLE (eg character set, collation).
*/
private function getTableOptions(): string {
if (!Civi\Core\Container::isContainerBooted()) {
// Pre-installation environment ==> aka new install
$collation = CRM_Core_BAO_SchemaHandler::DEFAULT_COLLATION;
}
else {
// What character-set is used for CiviCRM core schema? What collation?
// This depends on when the DB was *initialized*:
// - civicrm-core >= 5.33 has used `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
// - civicrm-core 4.3-5.32 has used `CHARACTER SET utf8 COLLATE utf8_unicode_ci`
// - civicrm-core <= 4.2 -- I haven't checked, but it's probably the same.
// Some systems have migrated (eg APIv3's `System.utf8conversion`), but (as of Feb 2024)
// we haven't made any effort to push to this change.
$collation = \CRM_Core_BAO_SchemaHandler::getInUseCollation();
}
$characterSet = (stripos($collation, 'utf8mb4') !== FALSE) ? 'utf8mb4' : 'utf8';
return "ENGINE=InnoDB DEFAULT CHARACTER SET {$characterSet} COLLATE {$collation} ROW_FORMAT=DYNAMIC";
}
};