From 6247fbbb8127461df96bcd9e668a396983bd2127 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 16 Feb 2024 05:01:14 -0800 Subject: [PATCH] Add civimix-schema with SchemaHelper and AutomaticUpgrader --- mixin/lib/civimix-schema/pathload.main.php | 28 +++ .../civimix-schema/src/AutomaticUpgrader.php | 135 ++++++++++++ .../lib/civimix-schema/src/CiviMixSchema5.php | 46 ++++ mixin/lib/civimix-schema/src/SchemaHelper.php | 204 ++++++++++++++++++ .../src/SchemaHelperInterface.php | 25 +++ mixin/lib/pathload.index.php | 15 ++ 6 files changed, 453 insertions(+) create mode 100644 mixin/lib/civimix-schema/pathload.main.php create mode 100644 mixin/lib/civimix-schema/src/AutomaticUpgrader.php create mode 100644 mixin/lib/civimix-schema/src/CiviMixSchema5.php create mode 100644 mixin/lib/civimix-schema/src/SchemaHelper.php create mode 100644 mixin/lib/civimix-schema/src/SchemaHelperInterface.php create mode 100644 mixin/lib/pathload.index.php diff --git a/mixin/lib/civimix-schema/pathload.main.php b/mixin/lib/civimix-schema/pathload.main.php new file mode 100644 index 000000000000..af9117e49c21 --- /dev/null +++ b/mixin/lib/civimix-schema/pathload.main.php @@ -0,0 +1,28 @@ +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['CiviMixSchema5'] = require __DIR__ . '/src/CiviMixSchema5.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['CiviMixSchema5']->loadClass($class); + } + + spl_autoload_register(__NAMESPACE__ . '\loadClass'); +} diff --git a/mixin/lib/civimix-schema/src/AutomaticUpgrader.php b/mixin/lib/civimix-schema/src/AutomaticUpgrader.php new file mode 100644 index 000000000000..5058c3d693a3 --- /dev/null +++ b/mixin/lib/civimix-schema/src/AutomaticUpgrader.php @@ -0,0 +1,135 @@ +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['CiviMixSchema5']->getHelper($this->getExtensionKey())->install(); + } + + if ($this->customUpgrader) { + $this->customUpgrader->notify($event, $params); + } + + if ($event === 'uninstall') { + $GLOBALS['CiviMixSchema5']->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; + } + +}; diff --git a/mixin/lib/civimix-schema/src/CiviMixSchema5.php b/mixin/lib/civimix-schema/src/CiviMixSchema5.php new file mode 100644 index 000000000000..5f5759e40e9a --- /dev/null +++ b/mixin/lib/civimix-schema/src/CiviMixSchema5.php @@ -0,0 +1,46 @@ +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['CiviMixSchema5-helpers']; + if (!isset($store[$extensionKey])) { + $class = get_class(require __DIR__ . '/SchemaHelper.php'); + $store[$extensionKey] = new $class($extensionKey); + } + return $store[$extensionKey]; + } + +}; diff --git a/mixin/lib/civimix-schema/src/SchemaHelper.php b/mixin/lib/civimix-schema/src/SchemaHelper.php new file mode 100644 index 000000000000..dfd01ba61ab9 --- /dev/null +++ b/mixin/lib/civimix-schema/src/SchemaHelper.php @@ -0,0 +1,204 @@ +key = $key; + } + + public function install(): void { + $codeGen = $this->createCodeGen(); + if ($codeGen) { + $this->runSqls($codeGen->generateCreateSql()); + } + } + + public function uninstall(): void { + $codeGen = $this->createCodeGen(); + if ($codeGen) { + $this->runSqls($codeGen->generateDropSql()); + } + } + + // 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); + } + } + + /** + * Construct the CodeGen configuration, which will be used to define schema + * + * This method is marked as 'private' because exposing the CodeGen Schema as a public contract + * could affect our ability to update/reorganize. + * + * @return \CRM_Core_CodeGen_Schema|null + * @throws \CRM_Core_Exception + * @throws \CRM_Extension_Exception + */ + private function createCodeGen() { + $info = $this->getInfo(); + $namespace = $info->civix['namespace']; + $extensionDir = $this->getExtensionDir(); + + $xmlSchemaGlob = "xml/schema/$namespace/*.xml"; + $xmlSchemas = glob($extensionDir . '/' . $xmlSchemaGlob); + if (empty($xmlSchemas)) { + return NULL; + } + + $specification = new \CRM_Core_CodeGen_Specification(); + $specification->buildVersion = \CRM_Utils_System::majorVersion(); + $config = new \stdClass(); + $config->phpCodePath = $extensionDir; + $config->sqlCodePath = $extensionDir . '/sql/'; + $config->database = $this->getDefaultDatabase(); + + foreach ($xmlSchemas as $xmlSchema) { + $dom = new \DomDocument(); + $xmlString = file_get_contents($xmlSchema); + $dom->loadXML($xmlString); + $xml = simplexml_import_dom($dom); + if (!$xml) { + throw new \CRM_Core_Exception('There is an error in the XML for ' . $xmlSchema); + } + /** @var array $tables */ + $specification->getTable($xml, $config->database, $tables); + $name = (string) $xml->name; + $tables[$name]['name'] = $name; + $sourcePath = strstr($xmlSchema, "/xml/schema/$namespace/"); + $tables[$name]['sourceFile'] = $this->key . $sourcePath; + } + + $config->tables = $tables; + $this->orderTables($tables); + $this->resolveForeignKeys($tables); + $config->tables = $tables; + + return new \CRM_Core_CodeGen_Schema($config); + } + + private function orderTables(&$tables): void { + $ordered = []; + $abort = count($tables); + + while (count($tables)) { + // Safety valve + if ($abort-- == 0) { + \Civi::log()->error("Cannot determine FK ordering of tables. Do you have circular Foreign Keys? Change your FK's or fix your auto_install.sql"); + break; + } + // Consider each table + foreach ($tables as $k => $table) { + // No FK's? Easy - add now + if (!isset($table['foreignKey'])) { + $ordered[$k] = $table; + unset($tables[$k]); + } + if (isset($table['foreignKey'])) { + // If any FK references a table still in our list (but is not a self-reference), + // skip this table for now + foreach ($table['foreignKey'] as $fKey) { + if (in_array($fKey['table'], array_keys($tables)) && $fKey['table'] != $table['name']) { + continue 2; + } + } + // If we get here, all FK's reference already added tables or external tables so add now + $ordered[$k] = $table; + unset($tables[$k]); + } + } + } + $tables = $ordered; + } + + private function resolveForeignKeys(&$tables): void { + foreach ($tables as &$table) { + if (isset($table['foreignKey'])) { + foreach ($table['foreignKey'] as &$key) { + if (isset($tables[$key['table']])) { + $key['className'] = $tables[$key['table']]['className']; + $key['fileName'] = $tables[$key['table']]['fileName']; + $table['fields'][$key['name']]['FKClassName'] = $key['className']; + } + else { + $key['className'] = \CRM_Core_DAO_AllCoreTables::getClassForTable($key['table']); + $key['fileName'] = $key['className'] . '.php'; + $table['fields'][$key['name']]['FKClassName'] = $key['className']; + } + } + } + } + } + + /** + * Get general/default database options (eg character set, collation). + * + * In civicrm-core, the `database` definition comes from + * `xml/schema/Schema.xml` and `$spec->getDatabase($dbXml)`. + * + * @return array + */ + private function getDefaultDatabase(): array { + // 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 [ + 'name' => '', + 'attributes' => '', + 'tableAttributes_modern' => "ENGINE=InnoDB DEFAULT CHARACTER SET {$characterSet} COLLATE {$collation}", + 'tableAttributes_simple' => 'ENGINE=InnoDB', + 'comment' => '', + ]; + } + + public function getInfo(): ?\CRM_Extension_Info { + try { + return \CRM_Extension_System::singleton()->getMapper()->keyToInfo($this->key); + } + catch (\CRM_Extension_Exception_ParseException $e) { + \Civi::log()->error("Parse error in extension " . $this->key . ": " . $e->getMessage()); + return NULL; + } + } + + protected function getExtensionDir(): string { + $system = \CRM_Extension_System::singleton(); + return $system->getMapper()->keyToBasePath($this->key); + } + +}; diff --git a/mixin/lib/civimix-schema/src/SchemaHelperInterface.php b/mixin/lib/civimix-schema/src/SchemaHelperInterface.php new file mode 100644 index 000000000000..93a571378ed3 --- /dev/null +++ b/mixin/lib/civimix-schema/src/SchemaHelperInterface.php @@ -0,0 +1,25 @@ +addSearchDir(__DIR__ . '/lib'); +// +// However, that would detect version#'s from the filenames. In this folder, +// we want all subprojects to have the same version-number as the main +// project. It would be quite inconvenient to rename them every month. +// +// So instead, we use `addSearchItem()` and register with explicit versions. + +$version = \CRM_Utils_System::version() . '.1'; /* Higher priority than contrib copies of same version... */ +\pathload()->addSearchItem('civimix-schema', $version, __DIR__ . '/civimix-schema');