Skip to content
Snippets Groups Projects
Commit e69be9ae authored by mattwire's avatar mattwire
Browse files

Release 1.4.0

parent cef2778d
No related branches found
No related tags found
No related merge requests found
......@@ -9,6 +9,11 @@ Releases use the following numbering system:
* **[BC]**: Items marked with [BC] indicate a breaking change that will require updates to your code if you are using that code in your extension.
## Release 1.4.0 (2025-04-04)
* Add SearchKit-based payment processor webhooks display (and remove old one)
* Add support for getting totalAmount from contribution pages in invoice mode - requires [civicrm-core#32574](https://github.com/civicrm/civicrm-core/pull/32574).
## Release 1.3.4 (2025-02-26)
* Make sure we always activate customdata from managed entities
......@@ -34,10 +39,10 @@ Releases use the following numbering system:
## Release 1.3 (2024-07-17)
* Add new API4 functions:
* ContributionRecur.updateAmountOnRecurMJW
* Membership.LinkToRecurMJW
* Membership.UnlinkFromRecurMJW
* PriceFieldValue.GetDefaultPriceFieldValueForContributionMJW
* ContributionRecur.updateAmountOnRecurMJW
* Membership.LinkToRecurMJW
* Membership.UnlinkFromRecurMJW
* PriceFieldValue.GetDefaultPriceFieldValueForContributionMJW
* PriceFieldValue.GetDefaultPriceFieldValueForMembershipMJW
* PaymentMJW.create
......
......@@ -15,8 +15,8 @@
<url desc="Release Notes">https://lab.civicrm.org/extensions/mjwshared/-/blob/master/docs/releasenotes.md</url>
<url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url>
</urls>
<releaseDate>2025-02-26</releaseDate>
<version>1.3.4</version>
<releaseDate>2025-04-04</releaseDate>
<version>1.4.0</version>
<develStage>stable</develStage>
<compatibility>
<ver>5.74</ver>
......
<?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');
}
<?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)");
}
}
};
<?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];
}
};
No preview for this file type
<?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;
}
};
<?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 {
}
<?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";
}
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment