Skip to content

Commit

Permalink
civix#175 - Add support for mixins
Browse files Browse the repository at this point in the history
Overview
--------

(NOTE: For this description, I reference the term "API" in the general sense of a programmatic interface -- such as
a hook or file-naming convention. It is not specifically about CRUD/DB APIs.)

The `civix` code-generator provides support for additional coding-conventions -- ones which are more amenable to
code-generation.  For example, it autoloads files from `xml/Menu/*.xml` and `**/*.mgd.php`.  The technique for
implementing this traditionally relies on generating a lot of boilerplate.

This patch introduces a new construct ("mixin") which allows boilerplate to be maintained more easily.  A mixin
inspects an extension programmatically, registering new hooks as needed.  A mixin may start out as a file in `civix`
(or even as a bespoke file in some module) - and then be migrated into `civicrm-core`. Each mixin has a name and
version, which means that (at runtime) it will only load the mixin once (ie the best-available version).

See: totten/civix#175

Before
------

The civix templates generate a few files, such as `mymod.php` and `mymod.civix.php`.
A typical example looks like this:

```php
// mymod.php - Implement hook_civicrm_xmlMenu
require_once 'mymod.civix.php';
function mymod_civicrm_xmlMenu(&$all, $the, $params) {
  _mymod_civix_civicrm_xmlMenu($all, $the, $params);
}
```

and

```php
// mymod.civix.php - Implement hook_civicrm_xmlMenu
function _mymod_civix_civicrm_xmlMenu(&$all, $the, $params) {
  foreach (_mosaico_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) {
    $files[] = $file;
  }
}
```

These two files are managed differently: `mymod.php` is owned by the developer, and they may add/remove/manage the
hooks in this file.  `mymod.civix.php` is owned by `civix` and must be autogenerated.

This structure allows `civix` (and any `civix`-based extension) to take advantage of new coding-convention
immediately. However, it comes with a few pain-points:

* If you want to write a patch for `_mymod_civix_civicrm_xmlMenu`, the dev-test-loop requires several steps.
* If `civix` needs to add a new `hook_civicrm_foo`, then the author must manually create the stub
  function in `mymod.php`. `civix` has documentation (`UPGRADE.md`) which keeps a long list of stubs that must
  be manually added.
* If `civix` has an update for `_mymod_civix_civicrm_xmlMenu`, then the author must regenerate `mymod.civix.php`.
* If `mymod_civix_xmlMenu` needs a change, then the author must apply it manually.
* If `civix`'s spin on `hook_civicrm_xmlMenu` becomes widespread, then the `xmlMenu` boilerplate is duplicated
  across many extensions.

After
-----

An extension may enable a mixin in `info.xml`, eg:

```xml
<mixins>
  <mixin>civix-register-files@2.0</mixin>
</mixins>
```

Civi will look for a file `mixin/civicrm-register-files@2.0.0.mixin.php` (either in the extension or core). The file follows this pattern:

```php
return function(\CRM_Extension_MixInfo $mixInfo, \CRM_Extension_BootCache $bootCache) {
  // echo "This is " . $mixInfo->longName . "!\n";
  \Civi::dispatcher()->addListener("hook_civicrm_xmlMenu", function($e) use ($mixInfo) {
    ...
  });
}
```

The mixin file is a plain PHP file that can be debugged/copied/edited verbatim, and it can register for hooks on its
own.  The code is no longer a "template", and it doesn't need to be interwoven between `mymod.php` and
`mymod.civix.php`.

It is expected that a system may have multiple copies of a mixin.  It will choose the newest compatible copy.
Hypothetically, if there were a security update or internal API change, core might ship a newer version to supplant the
old copy in any extensions.

Technical Details
-----------------

Mixins may define internal classes/interfaces/functions. However, each major-version
must have a distinct prefix (e.g. `\V2\Mymixin\FooInterface`). Minor-versions may be
provide incremental revisions over the same symbol (but it's imperative for newer
increments to provide the backward-compatibility).
  • Loading branch information
totten committed Apr 6, 2021
1 parent f9c1000 commit efdb75c
Show file tree
Hide file tree
Showing 6 changed files with 522 additions and 13 deletions.
85 changes: 85 additions & 0 deletions CRM/Extension/BootCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?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 |
+--------------------------------------------------------------------+
*/

/**
*/
class CRM_Extension_BootCache {

protected $locked = FALSE;

protected $data;

/**
* Define a persistent value in the extension's boot-cache.
*
* This value is retained as part of the boot-cache. It will be loaded
* very quickly (eg via php op-code caching). However, as a trade-off,
* you will not be able to change/reset at runtime - it will only
* reset in response to a system-wide flush or redeployment.
*
* Ex: $mix->define('initTime', function() { return time(); });
*
* @param string $key
* @param mixed $callback
* @return mixed
* The value of $callback (either cached or fresh)
*/
public function define($key, $callback) {
if (!isset($this->data[$key])) {
$this->set($key, $callback($this));
}
return $this->data[$key];
}

/**
* Determine if $key has been set.
*
* @param string $key
* @return bool
*/
public function has($key) {
return isset($this->data[$key]);
}

/**
* Get the value of $key.
*
* @param string $key
* @param mixed $default
* @return mixed
*/
public function get($key, $default = NULL) {
return $this->data[$key] ?? $default;
}

/**
* Set a value in the cache.
*
* This operation is only valid on the first page-load when a cache is built.
*
* @param string $key
* @param mixed $value
* @return static
* @throws \Exception
*/
public function set($key, $value) {
if ($this->locked) {
throw new \Exception("Cannot modify a locked boot-cache.");
}
$this->data[$key] = $value;
return $this;
}

public function lock() {
$this->locked = TRUE;
}

}
37 changes: 24 additions & 13 deletions CRM/Extension/ClassLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,32 @@ public function __destruct() {
*/
public function register() {
// In pre-installation environments, don't bother with caching.
if (!defined('CIVICRM_DSN') || defined('CIVICRM_TEST') || \CRM_Utils_System::isInUpgradeMode()) {
return $this->buildClassLoader()->register();
}
$cacheFile = (defined('CIVICRM_DSN') && !defined('CIVICRM_TEST') && !\CRM_Utils_System::isInUpgradeMode())
? $this->getCacheFile() : NULL;

$file = $this->getCacheFile();
if (file_exists($file)) {
$loader = require $file;
if (file_exists($cacheFile)) {
[$classLoader, $mixinLoader, $bootCache] = require $cacheFile;
$cacheUpdate = NULL;
}
else {
$loader = $this->buildClassLoader();
$ser = serialize($loader);
file_put_contents($file,
sprintf("<?php\nreturn unserialize(%s);", var_export($ser, 1))
);
$classLoader = $this->buildClassLoader();
$mixinLoader = (new CRM_Extension_MixinScanner($this->mapper, $this->manager, $cacheFile !== NULL))->createLoader();
$bootCache = new CRM_Extension_BootCache();
// We don't own Composer\Autoload\ClassLoader, so we clone to prevent register() from potentially leaking data.
// We do own MixinLoader, and we want its state - like $bootCache - to be written.
$cacheUpdate = $cacheFile ? [clone $classLoader, clone $mixinLoader, $bootCache] : NULL;
}
return $loader->register();

$classLoader->register();
$mixinLoader->run($bootCache);

if ($cacheUpdate !== NULL) {
// Save cache after $mixinLoader has a chance to fill $bootCache.
$export = var_export(serialize($cacheUpdate), 1);
file_put_contents($cacheFile, sprintf("<?php\nreturn unserialize(%s);", $export));
}

return $classLoader;
}

/**
Expand Down Expand Up @@ -135,7 +145,8 @@ public function refresh() {
* @return string
*/
protected function getCacheFile() {
$envId = \CRM_Core_Config_Runtime::getId();
$formatRev = '_2';
$envId = \CRM_Core_Config_Runtime::getId() . $formatRev;
$file = \Civi::paths()->getPath("[civicrm.compile]/CachedExtLoader.{$envId}.php");
return $file;
}
Expand Down
13 changes: 13 additions & 0 deletions CRM/Extension/Info.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ class CRM_Extension_Info {
*/
public $requires = [];

/**
* @var array
* List of expected mixins.
* Ex: ['civix@2.0.0']
*/
public $mixins = [];

/**
* @var array
* List of strings (tag-names).
Expand Down Expand Up @@ -189,6 +196,12 @@ public function parse($info) {
$this->tags[] = (string) $tag;
}
}
elseif ($attr === 'mixins') {
$this->mixins = [];
foreach ($val->mixin as $mixin) {
$this->mixins[] = (string) $mixin;
}
}
elseif ($attr === 'requires') {
$this->requires = $this->filterRequirements($val);
}
Expand Down
73 changes: 73 additions & 0 deletions CRM/Extension/MixInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?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 |
+--------------------------------------------------------------------+
*/

/**
* "Mixins" allow extensions to be initialized with small, reusable chunks of code.
*
* Example: A mixin might scan an extension for YAML files, aggregate them, add that
* to the boot-cache, and use the results to register event-listeners during initialization.
*
* Mixins have the following characteristics:
*
* - They are defined by standalone PHP files, e.g. `civix@1.0.2.mixin.php`
* - They are implicitly versioned via strict SemVer. (`1.1.0` can replace `1.0.0`; `2.0.0` and `1.0.0` are separate/parallel things).
* - They are activated via `info.xml` (`<mix>civix@1.0</mix>`).
* - They may be copied/reproduced in multiple extensions.
* - They are de-duped - such that a major-version (eg `civix@1` or `civix@2`) is only loaded once.
*
* The "MixInfo" record tracks the mixins needed by an extension. You may consider this an
* optimized subset of the 'info.xml'. (The mix-info is loaded on every page-view, so this
* record is serialized and stored in the MixinLoader cache.)
*/
class CRM_Extension_MixInfo {

/**
* @var string
*
* Ex: 'org.civicrm.flexmailer'
*/
public $longName;

/**
* @var string
*
* Ex: 'flexmailer'
*/
public $shortName;

/**
* @var string|null
*
* Ex: '/var/www/modules/civicrm/ext/flexmailer'.
*/
public $path;

/**
* @var array
* Ex: ['civix@2.0', 'menu@1.0']
*/
public $mixins;

/**
* Get a path relative to the target extension.
*
* @param string $relPath
* @return string
*/
public function getPath($relPath = NULL) {
return $relPath === NULL ? $this->path : $this->path . DIRECTORY_SEPARATOR . ltrim($relPath, '/');
}

public function isActive() {
return \CRM_Extension_System::singleton()->getMapper()->isActiveModule($this->shortName);
}

}
Loading

0 comments on commit efdb75c

Please sign in to comment.