diff --git a/box.json b/box.json
index 565cfd48..e8057ccd 100644
--- a/box.json
+++ b/box.json
@@ -2,6 +2,7 @@
"chmod": "0755",
"directories": [
"src",
+ "lib",
"upgrades",
"extern/src",
"extern/mixin"
diff --git a/lib/civimix-schema@5.78.beta1.phar b/lib/civimix-schema@5.78.beta1.phar
new file mode 100644
index 00000000..079ec362
Binary files /dev/null and b/lib/civimix-schema@5.78.beta1.phar differ
diff --git a/lib/pathload-0.php b/lib/pathload-0.php
new file mode 100644
index 00000000..76d64254
--- /dev/null
+++ b/lib/pathload-0.php
@@ -0,0 +1,711 @@
+top = $top;
+ }
+ public function offsetExists($version): bool {
+ return ($version === 'top' || $version <= $this->top->version);
+ }
+ public function offsetGet($version): ?\PathLoadInterface {
+ if ($version === 'top' || $version <= $this->top->version) {
+ return $this->top;
+ }
+ return NULL;
+ }
+ public function offsetSet($offset, $value): void {
+ error_log("Cannot overwrite PathLoad[$offset]");
+ }
+ public function offsetUnset($offset): void {
+ error_log("Cannot remove PathLoad[$offset]");
+ }
+ }
+ class Package {
+ /**
+ * Split a package identifier into its parts.
+ *
+ * @param string $package
+ * Ex: 'foobar@1.2.3'
+ * @return array
+ * Tuple: [$majorName, $name, $version]
+ * Ex: 'foobar@1', 'foobar', '1.2.3'
+ */
+ public static function parseExpr(string $package): array {
+ if (strpos($package, '@') === FALSE) {
+ throw new \RuntimeException("Malformed package name: $package");
+ }
+ [$prefix, $suffix] = explode('@', $package, 2);
+ $prefix = str_replace('/', '~', $prefix);
+ [$major] = explode('.', $suffix, 2);
+ return ["$prefix@$major", $prefix, $suffix];
+ }
+ public static function parseFileType(string $file): array {
+ if (substr($file, -4) === '.php') {
+ return ['php', substr(basename($file), 0, -4)];
+ }
+ elseif (substr($file, '-5') === '.phar') {
+ return ['phar', substr(basename($file), 0, -5)];
+ }
+ elseif (is_dir($file)) {
+ return ['dir', basename($file)];
+ }
+ else {
+ return [NULL, NULL];
+ }
+ }
+ /**
+ * @param string $file
+ * Ex: '/var/www/app-1/lib/foobar@.1.2.3.phar'
+ * @return \PathLoad\Vn\Package|null
+ */
+ public static function create(string $file): ?Package {
+ [$type, $base] = self::parseFileType($file);
+ if ($type === NULL) {
+ return NULL;
+ }
+ $self = new Package();
+ [$self->majorName, $self->name, $self->version] = static::parseExpr($base);
+ $self->file = $file;
+ $self->type = $type;
+ return $self;
+ }
+ /**
+ * @var string
+ * Ex: '/var/www/app-1/lib/cloud-file-io@1.2.3.phar'
+ */
+ public $file;
+ /**
+ * @var string
+ * Ex: 'cloud-file-io'
+ */
+ public $name;
+ /**
+ * @var string
+ * Ex: 'cloud-file-io@1'
+ */
+ public $majorName;
+ /**
+ * @var string
+ * Ex: '1.2.3'
+ */
+ public $version;
+ /**
+ * @var string
+ * Ex: 'php' or 'phar' or 'dir'
+ */
+ public $type;
+ public $reloadable = FALSE;
+ }
+ class Scanner {
+ /**
+ * @var array
+ * Array(string $id => [package => string, glob => string])
+ * @internal
+ */
+ public $allRules = [];
+ /**
+ * @var array
+ * Array(string $id => [package => string, glob => string])
+ * @internal
+ */
+ public $newRules = [];
+ /**
+ * @param array $rule
+ * Ex: ['package' => '*', 'glob' => '/var/www/lib/*@*']
+ * Ex: ['package' => 'cloud-file-io@1', 'glob' => '/var/www/lib/cloud-io@1*.phar'])
+ * @return void
+ */
+ public function addRule(array $rule): void {
+ $id = static::id($rule);
+ $this->newRules[$id] = $this->allRules[$id] = $rule;
+ }
+ public function reset(): void {
+ $this->newRules = $this->allRules;
+ }
+ /**
+ * Evaluate any rules that have a chance of finding $packageHint.
+ *
+ * @param string $packageHint
+ * Give a hint about what package we're looking for.
+ * The scanner will try to target packages based on this hint.
+ * Ex: '*' or 'cloud-file-io'
+ * @return \Generator
+ * A list of packages. These may not be the exact package you're looking for.
+ * You should assimilate knowledge of all outputs because you may not get them again.
+ */
+ public function scan(string $packageHint): \Generator {
+ yield from [];
+ foreach (array_keys($this->newRules) as $id) {
+ $searchRule = $this->newRules[$id];
+ if ($searchRule['package'] === '*' || $searchRule['package'] === $packageHint) {
+ unset($this->newRules[$id]);
+ if (isset($searchRule['glob'])) {
+ foreach ((array) glob($searchRule['glob']) as $file) {
+ if (($package = Package::create($file)) !== NULL) {
+ yield $package;
+ }
+ }
+ }
+ if (isset($searchRule['file'])) {
+ $package = new Package();
+ $package->file = $searchRule['file'];
+ $package->name = $searchRule['package'];
+ $package->majorName = $searchRule['package'] . '@' . explode('.', $searchRule['version'])[0];
+ $package->version = $searchRule['version'];
+ $package->type = $searchRule['type'] ?: Package::parseFileType($searchRule['file'])[0];
+ yield $package;
+ }
+ }
+ }
+ }
+ protected static function id(array $rule): string {
+ if (isset($rule['glob'])) {
+ return $rule['glob'];
+ }
+ elseif (isset($rule['file'])) {
+ return md5(implode(' ', [$rule['file'], $rule['package'], $rule['version']]));
+ }
+ else {
+ throw new \RuntimeException("Cannot identify rule: " . json_encode($rule));
+ }
+ }
+ }
+ class Psr0Loader {
+ /**
+ * @var array
+ * Ex: $paths['F']['Foo_'][0] = '/var/www/app/lib/foo@1.0.0/src/';
+ * @internal
+ */
+ public $paths = [];
+ /**
+ * @param string $dir
+ * @param array $config
+ * Ex: ['Foo_' => ['src/']] or ['Foo_' => ['Foo_']]
+ */
+ public function addAll(string $dir, array $config) {
+ $dir = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ foreach ($config as $prefix => $relPaths) {
+ $bucket = $prefix[0];
+ foreach ((array) $relPaths as $relPath) {
+ $this->paths[$bucket][$prefix][] = $dir . $relPath;
+ }
+ }
+ }
+ /**
+ * Loads the class file for a given class name.
+ *
+ * @param string $class The fully-qualified class name.
+ * @return mixed The mapped file name on success, or boolean false on failure.
+ */
+ public function loadClass(string $class) {
+ $bucket = $class[0];
+ if (!isset($this->paths[$bucket])) {
+ return FALSE;
+ }
+ $file = DIRECTORY_SEPARATOR . str_replace(['_', '\\'], [DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR], $class) . '.php';
+ foreach ($this->paths[$bucket] as $prefix => $paths) {
+ if ($prefix === substr($class, 0, strlen($prefix))) {
+ foreach ($paths as $path) {
+ $fullFile = $path . $file;
+ if (file_exists($fullFile)) {
+ doRequire($fullFile);
+ return $fullFile;
+ }
+ }
+ }
+ }
+ return FALSE;
+ }
+ }
+ class Psr4Loader {
+ /**
+ * @var array
+ * Ex: $prefixes['Foo\\'][0] = '/var/www/app/lib/foo@1.0.0/src/']
+ * @internal
+ */
+ public $prefixes = [];
+ public function addAll(string $dir, array $config) {
+ foreach ($config as $prefix => $relPaths) {
+ foreach ($relPaths as $relPath) {
+ $this->addNamespace($prefix, $dir . '/' . $relPath);
+ }
+ }
+ }
+ /**
+ * Adds a base directory for a namespace prefix.
+ *
+ * @param string $prefix
+ * The namespace prefix.
+ * @param string $baseDir
+ * A base directory for class files in the namespace.
+ * @return void
+ */
+ private function addNamespace($prefix, $baseDir) {
+ $prefix = trim($prefix, '\\') . '\\';
+ $baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . '/';
+ if (isset($this->prefixes[$prefix]) === FALSE) {
+ $this->prefixes[$prefix] = [];
+ }
+ array_push($this->prefixes[$prefix], $baseDir);
+ }
+ /**
+ * Loads the class file for a given class name.
+ *
+ * @param string $class The fully-qualified class name.
+ * @return mixed The mapped file name on success, or boolean false on failure.
+ */
+ public function loadClass(string $class) {
+ $prefix = $class;
+ while (FALSE !== $pos = strrpos($prefix, '\\')) {
+ $prefix = substr($class, 0, $pos + 1);
+ $relativeClass = substr($class, $pos + 1);
+ if ($mappedFile = $this->findRelativeClass($prefix, $relativeClass)) {
+ doRequire($mappedFile);
+ return $mappedFile;
+ }
+ $prefix = rtrim($prefix, '\\');
+ }
+ return FALSE;
+ }
+ /**
+ * Load the mapped file for a namespace prefix and relative class.
+ *
+ * @param string $prefix
+ * The namespace prefix.
+ * @param string $relativeClass
+ * The relative class name.
+ * @return string|FALSE
+ * Matched file name, or FALSE if none found.
+ */
+ private function findRelativeClass($prefix, $relativeClass) {
+ if (isset($this->prefixes[$prefix]) === FALSE) {
+ return FALSE;
+ }
+ $relFile = str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php';
+ foreach ($this->prefixes[$prefix] as $baseDir) {
+ $file = $baseDir . $relFile;
+ if (file_exists($file)) {
+ return $file;
+ }
+ }
+ return FALSE;
+ }
+ }
+ class PathLoad implements \PathLoadInterface {
+ /**
+ * @var null|int
+ */
+ public $version;
+ /**
+ * @var Scanner
+ * @internal
+ */
+ public $scanner;
+ /**
+ * List of best-known versions for each package.
+ *
+ * Packages are loaded lazily. Once loaded, the data is moved to $loadedPackages.
+ *
+ * @var Package[]
+ * Ex: ['cloud-file-io@1' => new Package('/usr/share/php-pathload/cloud-file-io@1.2.3.phar',
+ * ...)]
+ * @internal
+ */
+ public $availablePackages = [];
+ /**
+ * List of packages that have already been resolved.
+ *
+ * @var Package[]
+ * Ex: ['cloud-file-io@1' => new Package('/usr/share/php-pathload/cloud-file-io@1.2.3.phar',
+ * ...)] Note: If PathLoad version is super-ceded, then the loadedPackages may be instances of
+ * an old `Package` class. Be mindful of duck-type compatibility. We don't strictly need to
+ * retain this data, but it feels it'd be handy for debugging.
+ * @internal
+ */
+ public $loadedPackages = [];
+ /**
+ * Log of package activations. Used to re-initialize class-loader if we upgrade.
+ *
+ * @var array
+ * @internal
+ */
+ public $activatedPackages = [];
+ /**
+ * List of hints for class-loading. If someone tries to use a matching class, then
+ * load the corresponding package.
+ *
+ * Namespace-rules are evaluated lazily. Once evaluated, the data is removed.
+ *
+ * @var array
+ * Array(string $prefix => [string $package => string $package])
+ * Ex: ['Super\Cloud\IO\' => ['cloud-io@1' => 'cloud-io@1']
+ * @internal
+ */
+ public $availableNamespaces;
+ /**
+ * @var \PathLoad\Vn\Psr0Loader
+ * @internal
+ */
+ public $psr0;
+ /**
+ * @var \PathLoad\Vn\Psr4Loader
+ * @internal
+ */
+ public $psr4;
+ /**
+ * @param int $version
+ * Identify the version being instantiated.
+ * @param \PathLoadInterface|null $old
+ * If this instance is a replacement for an older instance, then it will be passed in.
+ * @return \ArrayAccess
+ * Versioned work-a-like array.
+ */
+ public static function create(int $version, ?\PathLoadInterface $old = NULL) {
+ if ($old !== NULL) {
+ $old->unregister();
+ }
+ $new = new static();
+ $new->version = $version;
+ $new->scanner = new Scanner();
+ $new->psr0 = new Psr0Loader();
+ $new->psr4 = new Psr4Loader();
+ $new->register();
+ // The exact protocol for assimilating $old instances may need change.
+ // This seems like a fair guess as long as old properties are forward-compatible.
+
+ if ($old === NULL) {
+ $baseDirs = getenv('PHP_PATHLOAD') ? explode(PATH_SEPARATOR, getenv('PHP_PATHLOAD')) : [];
+ foreach ($baseDirs as $baseDir) {
+ $new->addSearchDir($baseDir);
+ }
+ }
+ else {
+ // TIP: You might use $old->version to decide what to use.
+ foreach ($old->scanner->allRules as $rule) {
+ $new->scanner->addRule($rule);
+ }
+ $new->loadedPackages = $old->loadedPackages;
+ $new->availableNamespaces = $old->availableNamespaces;
+ foreach ($old->activatedPackages as $activatedPackage) {
+ $new->activatePackage($activatedPackage['name'], $activatedPackage['dir'], $activatedPackage['config']);
+ }
+ }
+ return new Versions($new);
+ }
+ public function register(): \PathLoadInterface {
+ spl_autoload_register([$this, 'loadClass']);
+ return $this;
+ }
+ public function unregister(): \PathLoadInterface {
+ spl_autoload_unregister([$this, 'loadClass']);
+ return $this;
+ }
+ public function reset(): \PathLoadInterface {
+ $this->scanner->reset();
+ return $this;
+ }
+ /**
+ * Append a directory (with many packages) to the search-path.
+ *
+ * @param string $baseDir
+ * The path to a base directory (e.g. `/var/www/myapp/lib`) which contains many packages (e.g.
+ * `foo@1.2.3.phar` or `bar@4.5.6/autoload.php`).
+ */
+ public function addSearchDir(string $baseDir): \PathLoadInterface {
+ $this->scanner->addRule(['package' => '*', 'glob' => "$baseDir/*@*"]);
+ return $this;
+ }
+ /**
+ * Append one specific item to the search list.
+ *
+ * @param string $name
+ * Ex: 'cloud-file-io'
+ * @param string $version
+ * Ex: '1.2.3'
+ * @param string $file
+ * Full path to the file or folder.
+ * @param string|null $type
+ * One of: 'php', 'phar', or 'dir'. NULL will auto-detect.
+ *
+ * @return \PathLoadInterface
+ */
+ public function addSearchItem(string $name, string $version, string $file, ?string $type = NULL): \PathLoadInterface {
+ $this->scanner->addRule(['package' => $name, 'version' => $version, 'file' => $file, 'type' => $type]);
+ return $this;
+ }
+ /**
+ * Add auto-loading hints. If someone requests a class in $namespace, then we load $package.
+ *
+ * Consecutive/identical calls to addNamespace() are de-duplicated.
+ *
+ * @param string $package
+ * Ex: 'cloud-io@1'
+ * @param string|string[] $namespaces
+ * Ex: 'Super\Cloud\IO\'
+ */
+ public function addNamespace(string $package, $namespaces): \PathLoadInterface {
+ foreach ((array) $namespaces as $namespace) {
+ $this->availableNamespaces[$namespace][$package] = $package;
+ }
+ return $this;
+ }
+ public function loadClass(string $class) {
+ if (strpos($class, '\\') !== FALSE) {
+ $this->loadPackagesByNamespace('\\', explode('\\', $class));
+ }
+ elseif (strpos($class, '_') !== FALSE) {
+ $this->loadPackagesByNamespace('_', explode('_', $class));
+ }
+ return $this->psr4->loadClass($class) || $this->psr0->loadClass($class);
+ }
+ /**
+ * If the application requests class "Foo\Bar\Whiz\Bang", then you should load
+ * any packages related to "Foo\*", "Foo\Bar\*", or "Foo\Bar\Whiz\*".
+ *
+ * @param string $delim
+ * Ex: '\\' or '_'
+ * @param string[] $classParts
+ * Ex: ['Symfony', 'Components', 'Filesystem', 'Filesystem']
+ */
+ private function loadPackagesByNamespace(string $delim, array $classParts): void {
+ array_pop($classParts);
+ do {
+ $foundPackages = FALSE;
+ $namespace = '';
+ foreach ($classParts as $nsPart) {
+ $namespace .= $nsPart . $delim;
+ if (isset($this->availableNamespaces[$namespace])) {
+ $packages = $this->availableNamespaces[$namespace];
+ foreach ($packages as $package) {
+ unset($this->availableNamespaces[$namespace][$package]);
+ if ($this->loadPackage($package)) {
+ $foundPackages = TRUE;
+ }
+ else {
+ trigger_error("PathLoad: Failed to locate package \"$package\" required for namespace \"$namespace\"", E_USER_WARNING);
+ $this->availableNamespaces[$namespace][$package] = $package; /* Maybe some other time */
+ }
+ }
+ }
+ }
+ } while ($foundPackages);
+ // Loading a package could produce metadata about other packages. Assimilate those too.
+ }
+ /**
+ * Load the content of a package.
+ *
+ * @param string $majorName
+ * Ex: 'cloud-io@1'
+ * @param bool $reload
+ * @return string|NULL
+ * The version# of the loaded package. Otherwise, NULL
+ */
+ public function loadPackage(string $majorName, bool $reload = FALSE): ?string {
+ if (isset($this->loadedPackages[$majorName])) {
+ if ($reload && $this->loadedPackages[$majorName]->reloadable) {
+ $this->scanner->reset();
+ }
+ else {
+ if ($reload) {
+ trigger_error("PathLoad: Declined to reload \"$majorName\". Package is not reloadable.", E_USER_WARNING);
+ }
+ return $this->loadedPackages[$majorName]->version;
+ }
+ }
+ $this->scanAvailablePackages(explode('@', $majorName, 2)[0], $this->availablePackages);
+ if (!isset($this->availablePackages[$majorName])) {
+ return NULL;
+ }
+ $package = $this->loadedPackages[$majorName] = $this->availablePackages[$majorName];
+ unset($this->availablePackages[$majorName]);
+ switch ($package->type ?? NULL) {
+ case 'php':
+ doRequire($package->file);
+ return $package->version;
+ case 'phar':
+ doRequire($package->file);
+ $this->useMetadataFiles($package, 'phar://' . $package->file);
+ return $package->version;
+ case 'dir':
+ $this->useMetadataFiles($package, $package->file);
+ return $package->version;
+ default:
+ \error_log("PathLoad: Package (\"$majorName\") appears malformed.");
+ return NULL;
+ }
+ }
+ private function scanAvailablePackages(string $hint, array &$avail): void {
+ foreach ($this->scanner->scan($hint) as $package) {
+ /** @var Package $package */
+ if (!isset($avail[$package->majorName]) || \version_compare($package->version, $avail[$package->majorName]->version, '>')) {
+ $avail[$package->majorName] = $package;
+ }
+ }
+ }
+ /**
+ * When loading a package, execute metadata files like "pathload.main.php" or "pathload.json".
+ *
+ * @param Package $package
+ * @param string $dir
+ * Ex: '/var/www/lib/cloud-io@1.2.0'
+ * Ex: 'phar:///var/www/lib/cloud-io@1.2.0.phar'
+ */
+ private function useMetadataFiles(Package $package, string $dir): void {
+ $phpFile = "$dir/pathload.main.php";
+ $jsonFile = "$dir/pathload.json";
+ if (file_exists($phpFile)) {
+ require $phpFile;
+ }
+ elseif (file_exists($jsonFile)) {
+ $jsonData = json_decode(file_get_contents($jsonFile), TRUE);
+ $id = $package->name . '@' . $package->version;
+ $this->activatePackage($id, $dir, $jsonData);
+ }
+ }
+ /**
+ * Given a configuration for the package, activate the correspond autoloader rules.
+ *
+ * @param string $majorName
+ * Ex: 'cloud-io@1'
+ * @param string|null $dir
+ * Used for applying the 'autoload' rules.
+ * Ex: '/var/www/lib/cloud-io@1.2.3'
+ * @param array $config
+ * Ex: ['autoload' => ['psr4' => ...], 'require-namespace' => [...], 'require-package' => [...]]
+ * @return \PathLoadInterface
+ */
+ public function activatePackage(string $majorName, ?string $dir, array $config): \PathLoadInterface {
+ if (isset($config['reloadable'])) {
+ $this->loadedPackages[$majorName]->reloadable = $config['reloadable'];
+ }
+ if (!isset($config['autoload'])) {
+ return $this;
+ }
+ if ($dir === NULL) {
+ throw new \RuntimeException("Cannot activate package $majorName. The 'autoload' property requires a base-directory.");
+ }
+ $this->activatedPackages[] = ['name' => $majorName, 'dir' => $dir, 'config' => $config];
+ if (!empty($config['autoload']['include'])) {
+ foreach ($config['autoload']['include'] as $file) {
+ doRequire($dir . DIRECTORY_SEPARATOR . $file);
+ }
+ }
+ if (isset($config['autoload']['psr-0'])) {
+ $this->psr0->addAll($dir, $config['autoload']['psr-0']);
+ }
+ if (isset($config['autoload']['psr-4'])) {
+ $this->psr4->addAll($dir, $config['autoload']['psr-4']);
+ }
+ foreach ($config['require-namespace'] ?? [] as $nsRule) {
+ foreach ((array) $nsRule['package'] as $package) {
+ foreach ((array) $nsRule['prefix'] as $prefix) {
+ $this->availableNamespaces[$prefix][$package] = $package;
+ }
+ }
+ }
+ foreach ($config['require-package'] ?? [] as $package) {
+ $this->loadPackage($package);
+ }
+ return $this;
+ }
+ }
+ }
+}
+
+namespace {
+ // New or upgraded instance.
+ $GLOBALS['_PathLoad'] = \PathLoad\V0\PathLoad::create(0, $GLOBALS['_PathLoad']['top'] ?? NULL);
+ if (!function_exists('pathload')) {
+ /**
+ * Get a reference the PathLoad manager.
+ *
+ * @param int|string $version
+ * @return \PathLoadInterface
+ */
+ function pathload($version = 'top') {
+ return $GLOBALS['_PathLoad'][$version];
+ }
+ }
+ return pathload();
+}
diff --git a/mixin-backports.php b/mixin-backports.php
index 6b466921..179a2fe1 100644
--- a/mixin-backports.php
+++ b/mixin-backports.php
@@ -50,7 +50,7 @@
'remote' => 'https://raw.githubusercontent.com/civicrm/civicrm-core/5d5c59c9453fabf1c3f709c7b8e26c680312d9a8/mixin/entity-types-php@2/mixin.php',
'local' => 'extern/mixin/entity-types-php@2/mixin.php',
'provided-by' => '5.73',
- 'minimum' => '5.51',
+ 'minimum' => '5.45',
],
'menu-xml@1' => [
'version' => '1.0.0',
diff --git a/scoper.inc.php b/scoper.inc.php
index f8145230..f0127e81 100644
--- a/scoper.inc.php
+++ b/scoper.inc.php
@@ -41,6 +41,8 @@
// Do not filter template files
'exclude-files' => array_merge(
+ glob('lib/*.phar'),
+ glob('lib/*.php'),
glob('src/CRM/CivixBundle/Resources/views/*/*.php'),
glob('extern/*/*/*.php'),
glob('extern/*/*.php'),
diff --git a/scripts/make-snapshots.sh b/scripts/make-snapshots.sh
index b01e0f86..a7e02dd7 100755
--- a/scripts/make-snapshots.sh
+++ b/scripts/make-snapshots.sh
@@ -86,13 +86,11 @@ function build_snapshot() {
$CIVIX $VERBOSITY generate:upgrader
$CIVIX $VERBOSITY generate:entity MyEntityThree -A3
# $CIVIX $VERBOSITY generate:entity MyEntityThree
- $CIVIX $VERBOSITY generate:entity-boilerplate
;;
entity34)
$CIVIX $VERBOSITY generate:upgrader
$CIVIX $VERBOSITY generate:entity MyEntityThreeFour -A3,4
- $CIVIX $VERBOSITY generate:entity-boilerplate
;;
kitchensink)
@@ -104,7 +102,6 @@ function build_snapshot() {
$CIVIX $VERBOSITY generate:entity MyEntityThree -A3
# $CIVIX $VERBOSITY generate:entity MyEntityThree
$CIVIX $VERBOSITY generate:entity MyEntityThreeFour -A3,4
- $CIVIX $VERBOSITY generate:entity-boilerplate
$CIVIX $VERBOSITY generate:form MyForm civicrm/my-form
$CIVIX $VERBOSITY generate:form My_StuffyForm civicrm/my-stuffy-form
$CIVIX $VERBOSITY generate:page MyPage civicrm/my-page
diff --git a/src/CRM/CivixBundle/Application.php b/src/CRM/CivixBundle/Application.php
index b9f41284..3adf9436 100644
--- a/src/CRM/CivixBundle/Application.php
+++ b/src/CRM/CivixBundle/Application.php
@@ -31,7 +31,6 @@ public function createCommands($context = 'default') {
$commands[] = new Command\AddCaseTypeCommand();
$commands[] = new Command\AddCustomDataCommand();
$commands[] = new Command\AddEntityCommand();
- $commands[] = new Command\AddEntityBoilerplateCommand();
$commands[] = new Command\AddFormCommand();
$commands[] = new Command\AddManagedEntityCommand();
$commands[] = new Command\AddPageCommand();
diff --git a/src/CRM/CivixBundle/Builder/Module.php b/src/CRM/CivixBundle/Builder/Module.php
index 76d63477..050c4c54 100644
--- a/src/CRM/CivixBundle/Builder/Module.php
+++ b/src/CRM/CivixBundle/Builder/Module.php
@@ -1,6 +1,7 @@
updateMixinLibraries(function(MixinLibraries $libs) {
+ $useSchema = \Civix::checker()->hasUpgrader()
+ && !\Civix::checker()->coreProvidesLibrary('civimix-schema@5');
+ $libs->toggle('civimix-schema@5', $useSchema);
+ });
+
$module = new Template(
'module.php.php',
$basedir->string($ctx['mainFile'] . '.php'),
diff --git a/src/CRM/CivixBundle/Checker.php b/src/CRM/CivixBundle/Checker.php
new file mode 100644
index 00000000..b739ff0b
--- /dev/null
+++ b/src/CRM/CivixBundle/Checker.php
@@ -0,0 +1,98 @@
+generator = $generator;
+ }
+
+ /**
+ * Check if the compatibility-target is greater than or less than X.
+ *
+ * Ex: '$this->isCoreVersion('<', '5.38.beta1')`
+ *
+ * @param string $op
+ * Ex: '<'
+ * @param string $version
+ * '5.38.beta1'
+ * @return bool
+ * TRUE if this extension targets a version less than 5.38.beta1.
+ */
+ public function coreVersionIs(string $op, string $version): bool {
+ $compatibility = $this->generator->infoXml->getCompatibilityVer() ?: '5.0';
+ return version_compare($compatibility, $version, $op);
+ }
+
+ /**
+ * Does CiviCRM include our preferred version of PathLoad?
+ *
+ * @return bool
+ */
+ public function coreHasPathload(): bool {
+ return $this->coreVersionIs('>=', '5.73.beta1');
+ }
+
+ /**
+ * Determine if a mixin-library is already provided by civicrm-core.
+ *
+ * @param string $majorName
+ * @return bool
+ */
+ public function coreProvidesLibrary(string $majorName): bool {
+ if (!preg_match(';^civimix-;', $majorName)) {
+ return FALSE;
+ }
+
+ // What version are we bundling into civix?
+ $avail = $this->generator->mixinLibraries->available[$majorName] ?? NULL;
+ if (!$avail) {
+ throw new \RuntimeException("Unrecognized library: $majorName");
+ }
+
+ // civimix-* libraries track the version#s in core.
+ // If core is v5.78, and if we want library v5.75, then core already provides.
+ // If core is v5.63, and if we want library v5.75, then our version is required.
+ return $this->coreVersionIs('>=', $avail->version);
+ }
+
+ /**
+ * @param string $pattern
+ * Regex.
+ * @return bool
+ * TRUE if the upgrader exists and matches the expression.
+ */
+ public function hasUpgrader(string $pattern = '/.+/'): bool {
+ $upgrader = $this->generator->infoXml->get()->upgrader;
+ return $upgrader && preg_match($pattern, $upgrader);
+ }
+
+ /**
+ * Determine if this extension bundles-in a mixin-library.
+ *
+ * @param string $majorName
+ * Either major-name or a wildcard.
+ * Ex: 'civimix-schema@5' or '*'
+ * @return bool
+ */
+ public function hasMixinLibrary(string $majorName = '*'): bool {
+ return $this->generator->mixinLibraries->hasActive($majorName);
+ }
+
+ /**
+ * Determine defines any schema/entities using `schema/*.entityType.php`.
+ *
+ * @return bool
+ */
+ public function hasSchemaPhp(): bool {
+ $files = is_dir(\Civix::extDir('schema')) && \Civix::extDir()->search('glob:schema/*.entityType.php');
+ return !empty($files);
+ }
+
+}
diff --git a/src/CRM/CivixBundle/Command/AddApiCommand.php b/src/CRM/CivixBundle/Command/AddApiCommand.php
index 9e4006ee..b816872e 100644
--- a/src/CRM/CivixBundle/Command/AddApiCommand.php
+++ b/src/CRM/CivixBundle/Command/AddApiCommand.php
@@ -9,7 +9,6 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use CRM\CivixBundle\Builder\Dirs;
-use CRM\CivixBundle\Builder\PHPUnitGenerateInitFiles;
use CRM\CivixBundle\Builder\PhpData;
use CRM\CivixBundle\Utils\Path;
use Exception;
@@ -156,9 +155,7 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$output->writeln(sprintf('Skip %s: file already exists', Files::relativize($ctx['apiTestFile'])));
}
- $phpUnitInitFiles = new PHPUnitGenerateInitFiles();
- $phpUnitInitFiles->initPhpunitXml($basedir->string('phpunit.xml.dist'), $ctx, $output);
- $phpUnitInitFiles->initPhpunitBootstrap($basedir->string('tests', 'phpunit', 'bootstrap.php'), $ctx, $output);
+ Civix::generator()->addPhpunit();
$info->save($ctx, $output);
return 0;
diff --git a/src/CRM/CivixBundle/Command/AddEntityBoilerplateCommand.php b/src/CRM/CivixBundle/Command/AddEntityBoilerplateCommand.php
deleted file mode 100644
index 84f05760..00000000
--- a/src/CRM/CivixBundle/Command/AddEntityBoilerplateCommand.php
+++ /dev/null
@@ -1,220 +0,0 @@
-setName('generate:entity-boilerplate')
- ->setDescription('Generates boilerplate code for entities based on xml schema definition files (*EXPERIMENTAL AND INCOMPLETE*)')
- ->setHelp(
- "Creates DAOs, mysql install and uninstall instructions.\n" .
- "\n" .
- "Typically you will run this command after creating or updating one or more\n" .
- "xml/schema/CRM/NameSpace/EntityName.xml files.\n"
- );
- }
-
- /**
- * Note: this function replicates a fair amount of the functionality of
- * CRM_Core_CodeGen_Specification (which is a bit messy and hard to interact
- * with). It's tempting to completely rewrite / rethink entity generation. Until
- * then...
- */
- protected function execute(InputInterface $input, OutputInterface $output) {
- Civix::boot(['output' => $output]);
- $civicrm_api3 = Civix::api3();
-
- if (!$civicrm_api3 || !$civicrm_api3->local) {
- $output->writeln("Require access to local CiviCRM source tree. Configure civicrm_api3_conf_path.");
- return 1;
- }
- if (version_compare(\CRM_Utils_System::version(), '4.7.0', '<=')) {
- $output->writeln("This command requires CiviCRM 4.7+.");
- return 1;
- }
-
- $this->assertCurrentFormat();
-
- $ctx = [];
- $ctx['type'] = 'module';
- $ctx['basedir'] = \CRM\CivixBundle\Application::findExtDir();
- $basedir = new Path($ctx['basedir']);
- $info = new Info($basedir->string('info.xml'));
- $info->load($ctx);
-
- $xmlSchemaGlob = "xml/schema/{$ctx['namespace']}/*.xml";
- $absXmlSchemaGlob = $basedir->string($xmlSchemaGlob);
- $xmlSchemas = glob($absXmlSchemaGlob);
-
- if (!count($xmlSchemas)) {
- throw new Exception("Could not find files matching '$xmlSchemaGlob'. You may want to run `civix generate:entity` before running this command.");
- }
-
- $specification = new \CRM_Core_CodeGen_Specification();
- $specification->buildVersion = \CRM_Utils_System::majorVersion();
- $config = new \stdClass();
- $config->phpCodePath = $basedir->string('');
- $config->sqlCodePath = $basedir->string('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) {
- $output->writeln('There is an error in the XML for ' . $xmlSchema . '');
- continue;
- }
- /** @var array $tables */
- $specification->getTable($xml, $config->database, $tables);
- $name = (string) $xml->name;
- $tables[$name]['name'] = $name;
- $sourcePath = strstr($xmlSchema, "/xml/schema/{$ctx['namespace']}/");
- $tables[$name]['sourceFile'] = $ctx['fullName'] . $sourcePath;
- }
-
- $config->tables = $tables;
- $_namespace = ' ' . preg_replace(':/:', '_', $ctx['namespace']);
- $this->orderTables($tables, $output);
- $this->resolveForeignKeys($tables);
- $config->tables = $tables;
-
- foreach ($tables as $table) {
- $dao = new \CRM_Core_CodeGen_DAO($config, (string) $table['name'], "{$_namespace}_ExtensionUtil::ts");
- // Don't display gencode's output
- ob_start();
- $dao->run();
- ob_end_clean();
- $daoFileName = $basedir->string("{$table['base']}{$table['fileName']}");
- $output->writeln("Write" . Files::relativize($daoFileName));
- }
-
- $schema = new \CRM_Core_CodeGen_Schema($config);
- \CRM_Core_CodeGen_Util_File::createDir($config->sqlCodePath);
-
- /**
- * @param string $generator
- * The desired $schema->$generator() function which will produce the file.
- * @param string $fileName
- * The desired basename of the SQL file.
- */
- $createSql = function($generator, $fileName) use ($output, $schema, $config) {
- $filePath = $config->sqlCodePath . $fileName;
- // We're poking into an internal class+function (`$schema->$generator()`) that changed in v5.23.
- // Beginning in 5.23: $schema->$function() returns an array with file content.
- // Before 5.23: $schema->$function($fileName) creates $fileName and returns void.
- $output->writeln("Write " . Files::relativize($filePath));
- if (version_compare(\CRM_Utils_System::version(), '5.23.alpha1', '>=')) {
- $data = $schema->$generator();
- if (!file_put_contents($filePath, reset($data))) {
- $output->writeln("Failed to write data to {$filePath}");
- }
- }
- else {
- $output->writeln("WARNING: Support for generating entities on <5.23 is deprecated.");
- // Don't display gencode's output
- ob_start();
- $schema->$generator($fileName);
- ob_end_clean();
- }
- };
- $createSql('generateCreateSql', 'auto_install.sql');
- $createSql('generateDropSql', 'auto_uninstall.sql');
-
- $module = new Module(Civix::templating());
- $module->loadInit($ctx);
- $module->save($ctx, $output);
- $upgraderClass = str_replace('/', '_', $ctx['namespace']) . '_Upgrader';
-
- if (!class_exists($upgraderClass)) {
- $output->writeln('You are missing an upgrader class. Your generated SQL files will not be executed on enable and uninstall. Fix this by running `civix generate:upgrader`.');
- }
-
- return 0;
- }
-
- private function orderTables(&$tables, $output) {
-
- $ordered = [];
- $abort = count($tables);
-
- while (count($tables)) {
- // Safety valve
- if ($abort-- == 0) {
- $output->writeln("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) {
- foreach ($tables as &$table) {
- if (isset($table['foreignKey'])) {
- foreach ($table['foreignKey'] as &$key) {
- $key['className'] = $tables[$key['table']]['className'] ?? \CRM_Core_DAO_AllCoreTables::getClassForTable($key['table']);
- $table['fields'][$key['name']]['FKClassName'] = $key['className'];
- $table['fields'][$key['name']]['FKColumnName'] = $key['key'];
- }
- }
- }
- }
-
- /**
- * 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)`.
- *
- * Civix uses different defaults. Explanations are inlined below.
- *
- * @return array
- */
- private function getDefaultDatabase(): array {
- return [
- 'name' => '',
- 'attributes' => '',
- 'tableAttributes_modern' => 'ENGINE=InnoDB',
- 'tableAttributes_simple' => 'ENGINE=InnoDB',
- // ^^ Set very limited defaults.
- // Existing deployments may be inconsistent with respect to charsets and collations, and
- // it's hard to attune with static code. This represents a compromise (until we can
- // rework the process in a way that clearly addresses the inconsistencies among deployments).
- 'comment' => '',
- ];
- }
-
-}
diff --git a/src/CRM/CivixBundle/Command/AddEntityCommand.php b/src/CRM/CivixBundle/Command/AddEntityCommand.php
index fb931f44..5730f747 100644
--- a/src/CRM/CivixBundle/Command/AddEntityCommand.php
+++ b/src/CRM/CivixBundle/Command/AddEntityCommand.php
@@ -2,7 +2,6 @@
namespace CRM\CivixBundle\Command;
use CRM\CivixBundle\Builder\Mixins;
-use CRM\CivixBundle\Builder\PHPUnitGenerateInitFiles;
use Civix;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@@ -56,6 +55,8 @@ protected function execute(InputInterface $input, OutputInterface $output) {
throw new Exception("In --api-versions, found unrecognized versions. Expected: '3' and/or '4'");
}
+ Civix::generator()->addUpgrader('if-forced');
+
$ctx = [];
$ctx['type'] = 'module';
$ctx['basedir'] = \CRM\CivixBundle\Application::findExtDir();
@@ -77,7 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output) {
throw new Exception("Failed to determine proper API function name. Perhaps the API internals have changed?");
}
- $mixins = new Mixins($info, $basedir->string('mixin'), ['entity-types-php@1.0']);
+ $mixins = new Mixins($info, $basedir->string('mixin'), ['entity-types-php@2.0']);
$mixins->save($ctx, $output);
$info->save($ctx, $output);
@@ -87,54 +88,51 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$ctx['daoClassFile'] = $basedir->string(strtr($ctx['daoClassName'], '_', '/') . '.php');
$ctx['baoClassName'] = strtr($ctx['namespace'], '/', '_') . '_BAO_' . $input->getArgument('');
$ctx['baoClassFile'] = $basedir->string(strtr($ctx['baoClassName'], '_', '/') . '.php');
- $ctx['schemaFile'] = $basedir->string('xml', 'schema', $ctx['namespace'], $input->getArgument('') . '.xml');
- $ctx['entityTypeFile'] = $basedir->string('xml', 'schema', $ctx['namespace'], $input->getArgument('') . '.entityType.php');
+ $ctx['entityTypeFile'] = $basedir->string('schema', $input->getArgument('') . '.entityType.php');
$ctx['extensionName'] = $info->getExtensionName();
$ctx['testApi3ClassName'] = 'api_v3_' . $ctx['entityNameCamel'] . 'Test';
$ctx['testApi3ClassFile'] = $basedir->string('tests', 'phpunit', strtr($ctx['testApi3ClassName'], '_', '/') . '.php');
$ext = new Collection();
$ext->builders['dirs'] = new Dirs([
- dirname($ctx['apiFile']),
- dirname($ctx['api4File']),
- dirname($ctx['daoClassFile']),
dirname($ctx['baoClassFile']),
- dirname($ctx['schemaFile']),
- dirname($ctx['testApi3ClassFile']),
]);
$ext->builders['dirs']->save($ctx, $output);
+ $hasPhpUnit = FALSE;
if (in_array('3', $apiVersions)) {
+ $ext->builders['dirs']->addPath(dirname($ctx['apiFile']));
$ext->builders['api.php'] = new Template('entity-api.php.php', $ctx['apiFile'], FALSE, Civix::templating());
+ $ext->builders['dirs']->addPath(dirname($ctx['testApi3ClassFile']));
$ext->builders['test.php'] = new Template('entity-api3-test.php.php', $ctx['testApi3ClassFile'], FALSE, Civix::templating());
+ $hasPhpUnit = TRUE;
}
if (in_array('4', $apiVersions)) {
+ $ext->builders['dirs']->addPath(dirname($ctx['api4File']));
$ext->builders['api4.php'] = new Template('entity-api4.php.php', $ctx['api4File'], FALSE, Civix::templating());
}
$ext->builders['bao.php'] = new Template('entity-bao.php.php', $ctx['baoClassFile'], FALSE, Civix::templating());
- $ext->builders['entity.xml'] = new Template('entity-schema.xml.php', $ctx['schemaFile'], FALSE, Civix::templating());
if (!file_exists($ctx['entityTypeFile'])) {
- $mgdEntities = [
- [
- 'name' => $ctx['entityNameCamel'],
- 'class' => $ctx['daoClassName'],
- 'table' => $ctx['tableName'],
- ],
- ];
- $header = "// This file declares a new entity type. For more details, see \"hook_civicrm_entityTypes\" at:\n"
- . "// https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes";
- $ext->builders['entityType.php'] = new PhpData($ctx['entityTypeFile'], $header);
- $ext->builders['entityType.php']->set($mgdEntities);
+ $entityDefn = $this->createDefaultSchema($ctx['entityNameCamel'], $ctx['tableName'], $ctx['daoClassName']);
+ $ext->builders['entityType.php'] = new PhpData($ctx['entityTypeFile']);
+ $ext->builders['entityType.php']->useExtensionUtil($info->getExtensionUtilClass());
+ $ext->builders['entityType.php']->useTs(['title', 'title_plural', 'label', 'description']);
+ $ext->builders['entityType.php']->setCallbacks(['getPaths', 'getFields', 'getIndices', 'getInfo']);
+ $ext->builders['entityType.php']->set($entityDefn);
}
- $phpUnitInitFiles = new PHPUnitGenerateInitFiles();
- $phpUnitInitFiles->initPhpunitXml($basedir->string('phpunit.xml.dist'), $ctx, $output);
- $phpUnitInitFiles->initPhpunitBootstrap($basedir->string('tests', 'phpunit', 'bootstrap.php'), $ctx, $output);
-
$ext->init($ctx);
$ext->save($ctx, $output);
+ Civix::generator()->addDaoClass($ctx['daoClassName'], $ctx['tableName'], 'if-forced');
+
+ if ($hasPhpUnit) {
+ Civix::generator()->addPhpunit();
+ }
+
+ Civix::generator()->updateModuleCivixPhp();
+
if (count($apiVersions) >= 2) {
$output->writeln('Generated API skeletons for APIv3 and APIv4.');
}
@@ -145,10 +143,56 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$output->writeln('Generated API skeletons for APIv4. To generate APIv3, specify --api-version=3');
}
- $output->writeln('You should now make any changes to the entity xml file and run `civix generate:entity-boilerplate` to generate necessary boilerplate.');
$output->writeln('Note: no changes have been made to the database. You can update the database by uninstalling and re-enabling the extension.');
return 0;
}
+ /**
+ * @param string $entityNameCamel
+ * Ex: 'Mailing'
+ * @param string $tableName
+ * Ex: 'civicrm_mailing'
+ * @param string $daoClassName
+ * Ex: 'CRM_Foo_DAO_Mailing'
+ * @return array
+ */
+ protected function createDefaultSchema(string $entityNameCamel, string $tableName, string $daoClassName): array {
+ return [
+ 'name' => $entityNameCamel,
+ 'table' => $tableName,
+ 'class' => $daoClassName,
+ 'getInfo' => [
+ 'title' => $entityNameCamel,
+ 'title_plural' => \CRM_Utils_String::pluralize($entityNameCamel),
+ 'description' => 'FIXME',
+ 'log' => TRUE,
+ ],
+ 'getFields' => [
+ 'id' => [
+ 'title' => 'ID',
+ 'sql_type' => 'int unsigned',
+ 'input_type' => 'Number',
+ 'required' => TRUE,
+ 'description' => sprintf('Unique %s ID', $entityNameCamel),
+ 'primary_key' => TRUE,
+ 'auto_increment' => TRUE,
+ ],
+ 'contact_id' => [
+ 'title' => 'Contact ID',
+ 'sql_type' => 'int unsigned',
+ 'input_type' => 'EntityRef',
+ 'description' => 'FK to Contact',
+ 'entity_reference' => [
+ 'entity' => 'Contact',
+ 'key' => 'id',
+ 'on_delete' => 'CASCADE',
+ ],
+ ],
+ ],
+ 'getIndices' => [],
+ 'getPaths' => [],
+ ];
+ }
+
}
diff --git a/src/CRM/CivixBundle/Command/AddServiceCommand.php b/src/CRM/CivixBundle/Command/AddServiceCommand.php
index 9107c8a3..162b242c 100644
--- a/src/CRM/CivixBundle/Command/AddServiceCommand.php
+++ b/src/CRM/CivixBundle/Command/AddServiceCommand.php
@@ -56,7 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$gen->addClass($className, 'service.php.php', [
'service' => $serviceName,
- ]);
+ ], 'ask');
}
}
diff --git a/src/CRM/CivixBundle/Command/AddTestCommand.php b/src/CRM/CivixBundle/Command/AddTestCommand.php
index d3d16dd2..0a7f8006 100644
--- a/src/CRM/CivixBundle/Command/AddTestCommand.php
+++ b/src/CRM/CivixBundle/Command/AddTestCommand.php
@@ -10,7 +10,6 @@
use CRM\CivixBundle\Builder\Dirs;
use CRM\CivixBundle\Builder\Info;
use CRM\CivixBundle\Utils\Path;
-use CRM\CivixBundle\Builder\PHPUnitGenerateInitFiles;
use Exception;
class AddTestCommand extends AbstractCommand {
@@ -65,9 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$info = new Info($basedir->string('info.xml'));
$info->load($ctx);
- $phpUnitInitFiles = new PHPUnitGenerateInitFiles();
- $phpUnitInitFiles->initPhpunitXml($basedir->string('phpunit.xml.dist'), $ctx, $output);
- $phpUnitInitFiles->initPhpunitBootstrap($basedir->string('tests', 'phpunit', 'bootstrap.php'), $ctx, $output);
+ Civix::generator()->addPhpunit();
$this->initTestClass(
$input->getArgument(''), $this->getTestTemplate($input->getOption('template')), $basedir, $ctx, $output);
diff --git a/src/CRM/CivixBundle/Command/AddUpgraderCommand.php b/src/CRM/CivixBundle/Command/AddUpgraderCommand.php
index 3338899f..f43003f6 100644
--- a/src/CRM/CivixBundle/Command/AddUpgraderCommand.php
+++ b/src/CRM/CivixBundle/Command/AddUpgraderCommand.php
@@ -1,15 +1,9 @@
assertCurrentFormat();
-
- $ctx = [];
- $ctx['type'] = 'module';
- $ctx['basedir'] = Application::findExtDir();
- $basedir = new Path($ctx['basedir']);
-
- $info = new Info($basedir->string('info.xml'));
- $info->load($ctx);
-
- $dirs = [
- $basedir->string('sql'),
- $basedir->string($ctx['namespace']),
- ];
- (new Dirs($dirs))->save($ctx, $output);
-
- $crmPrefix = preg_replace(':/:', '_', $ctx['namespace']);
- $ctx['baseUpgrader'] = 'CRM_Extension_Upgrader_Base';
-
- $phpFile = $basedir->string($ctx['namespace'], 'Upgrader.php');
- if (!file_exists($phpFile)) {
- $output->writeln(sprintf('Write %s', Files::relativize($phpFile)));
- file_put_contents($phpFile, Civix::templating()
- ->render('upgrader.php.php', $ctx));
- }
- else {
- $output->writeln(sprintf('Skip %s: file already exists, defer to customized version', Files::relativize($phpFile)));
- }
-
- if (!$info->get()->xpath('upgrader')) {
- $info->get()->addChild('upgrader', $crmPrefix . '_Upgrader');
- }
- $info->raiseCompatibilityMinimum('5.38');
- $info->save($ctx, $output);
-
- $module = new Module(Civix::templating());
- $module->loadInit($ctx);
- $module->save($ctx, $output);
-
+ Civix::generator()->addUpgrader('if-forced');
return 0;
}
diff --git a/src/CRM/CivixBundle/Command/ConvertEntityCommand.php b/src/CRM/CivixBundle/Command/ConvertEntityCommand.php
index 4f34e197..1bda8a18 100644
--- a/src/CRM/CivixBundle/Command/ConvertEntityCommand.php
+++ b/src/CRM/CivixBundle/Command/ConvertEntityCommand.php
@@ -6,7 +6,7 @@
use Civix;
use CRM\CivixBundle\Builder\PhpData;
use CRM\CivixBundle\Utils\Files;
-use Symfony\Component\Console\Helper\Table;
+use CRM\CivixBundle\Utils\SchemaBackport;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -31,19 +31,6 @@ protected function execute(InputInterface $input, OutputInterface $output) {
Civix::boot(['output' => $output]);
$this->assertCurrentFormat();
- $ctx = [];
- $ctx['type'] = 'module';
- $ctx['basedir'] = \Civix::extDir();
- $basedir = new Path($ctx['basedir']);
- $info = $this->getModuleInfo($ctx);
-
- // Switch mixin from v1 to v2
- $mixins = new Mixins($info, $basedir->string('mixin'));
- $mixins->removeMixin('entity-types-php@1');
- $mixins->addMixin('entity-types-php@2');
- $mixins->save($ctx, $output);
- $info->save($ctx, $output);
-
\Civix::io()->note("Finding entities");
$isCore = $input->getOption('core-style');
@@ -51,15 +38,41 @@ protected function execute(InputInterface $input, OutputInterface $output) {
if (empty($input->getArgument('xmlFiles'))) {
$schemaPath = $isCore ? 'xml/schema' : 'xml/schema/CRM';
$xmlFiles = array_merge(
- (array) glob($basedir->string("$schemaPath/*/*.xml")),
- (array) glob($basedir->string("$schemaPath/*/*/*.xml"))
+ (array) glob(\Civix::extDir()->string("$schemaPath/*/*.xml")),
+ (array) glob(\Civix::extDir()->string("$schemaPath/*/*/*.xml"))
);
}
else {
$xmlFiles = $input->getArgument('xmlFiles');
}
+
+ static::convertEntities($xmlFiles, $isCore);
+
+ return 0;
+ }
+
+ /**
+ * @param $xmlFiles
+ * @param $isCore
+ * @throws \Exception
+ */
+ public static function convertEntities(array $xmlFiles, bool $isCore): void {
$xmlFiles = preg_grep('/files.xml$/', $xmlFiles, PREG_GREP_INVERT);
$xmlFiles = preg_grep('/Schema.xml$/', $xmlFiles, PREG_GREP_INVERT);
+
+ $ctx = [];
+ $ctx['type'] = 'module';
+ $ctx['basedir'] = \Civix::extDir();
+ $basedir = new Path($ctx['basedir']);
+ $info = Civix::generator()->reloadInfo();
+
+ // Switch mixin from v1 to v2
+ $mixins = new Mixins($info, $basedir->string('mixin'));
+ $mixins->removeMixin('entity-types-php@1');
+ $mixins->addMixin('entity-types-php@2');
+ $mixins->save($ctx, Civix::output());
+ $info->save($ctx, Civix::output());
+
$thisTables = self::getTablesForThisExtension($xmlFiles);
\Civix::io()->note("Found: " . implode(' ', $thisTables));
@@ -67,7 +80,8 @@ protected function execute(InputInterface $input, OutputInterface $output) {
foreach ($xmlFiles as $fileName) {
$entity = self::convertXmlToEntity($fileName, $thisTables);
if (!$entity) {
- \Civix::io()->writeln("Failed to find entity. Skip file: " . Files::relativize($fileName, getcwd()));
+ \Civix::io()
+ ->writeln("Failed to find entity. Skip file: " . Files::relativize($fileName, getcwd()));
continue;
}
if ($isCore) {
@@ -91,16 +105,14 @@ protected function execute(InputInterface $input, OutputInterface $output) {
$phpData->setLiterals(['serialize']);
$phpData->setCallbacks(['getInfo', 'getPaths', 'getFields', 'getIndices']);
$phpData->set($entity);
- $phpData->save($ctx, $output);
+ $phpData->save($ctx, Civix::output());
+
+ Civix::generator()->addDaoClass($entity['class'], $entity['table'], 'overwrite');
}
// Cleanup old files
array_map('unlink', glob($basedir->string('xml/schema/CRM/*/*.entityType.php')));
- // array_map('unlink', $xmlFiles);
- // unlink($basedir->string('sql/auto_install.sql'));
- // unlink($basedir->string('sql/auto_uninstall.sql'));
-
- return 0;
+ array_map('unlink', $xmlFiles);
}
public static function getTablesForThisExtension($xmlFiles): array {
@@ -121,8 +133,8 @@ public static function convertXmlToEntity(string $fileName, $thisTables):? array
\Civix::io()->writeln("Parse " . Files::relativize($fileName, getcwd()));
[$xml, $error] = \CRM_Utils_XML::parseFile($fileName);
if ($error) {
- \Civix::io()->writeln("Failed to parse file: " . Files::relativize($fileName, getcwd()));
- return NULL;
+ \Civix::io()->writeln("Failed to parse file: " . Files::relativize($fileName, getcwd()));
+ return NULL;
}
elseif (!empty($xml->drop)) {
\Civix::io()->writeln("Entity was previously dropped. Skipping: " . Files::relativize($fileName, getcwd()));
@@ -203,18 +215,20 @@ private static function getIndicesFromXml($xml): array {
}
private static function getFieldsFromXml($xml, $thisTables): array {
+ $utilSchem = class_exists('CRM_Utils_Schema') ? \CRM_Utils_Schema::class : SchemaBackport::class;
+
$fields = [];
foreach ($xml->field as $fieldXml) {
if (isset($fieldXml->drop)) {
continue;
}
$name = self::toString('name', $fieldXml);
- $typeAttributes = \CRM_Utils_Schema::getTypeAttributes($fieldXml);
+ $typeAttributes = $utilSchem::getTypeAttributes($fieldXml);
if ($typeAttributes['crmType'] == 'CRM_Utils_Type::T_BOOLEAN') {
$typeAttributes['sqlType'] = 'boolean';
}
$fields[$name] = [
- 'title' => self::toString('title', $fieldXml) ?: \CRM_Utils_Schema::composeTitle($name),
+ 'title' => self::toString('title', $fieldXml) ?: $utilSchem::composeTitle($name),
'sql_type' => $typeAttributes['sqlType'],
'input_type' => ((string) $fieldXml->html->type) ?: NULL,
];
@@ -268,9 +282,9 @@ private static function getFieldsFromXml($xml, $thisTables): array {
$fields[$name]['serialize'] = 'CRM_Core_DAO::SERIALIZE_' . $fieldXml->serialize;
}
if (!empty($fieldXml->permission)) {
- $fields[$name]['permission'] = \CRM_Utils_Schema::getFieldPermission($fieldXml);
+ $fields[$name]['permission'] = $utilSchem::getFieldPermission($fieldXml);
}
- $usage = \CRM_Utils_Schema::getFieldUsage($fieldXml);
+ $usage = $utilSchem::getFieldUsage($fieldXml);
$usage = array_keys(array_filter($usage));
if ($usage) {
$fields[$name]['usage'] = $usage;
diff --git a/src/CRM/CivixBundle/Command/UpgradeCommand.php b/src/CRM/CivixBundle/Command/UpgradeCommand.php
index 6438f022..eb416e4c 100644
--- a/src/CRM/CivixBundle/Command/UpgradeCommand.php
+++ b/src/CRM/CivixBundle/Command/UpgradeCommand.php
@@ -1,9 +1,7 @@
cleanEmptyHooks();
$gen->cleanEmptyLines();
$gen->reconcileMixins();
+ $gen->updateModuleCivixPhp();
/**
* @var \CRM\CivixBundle\Builder\Info $info
@@ -96,10 +95,6 @@ protected function executeGenericUpgrade(): void {
[$ctx, $info] = $this->loadCtxInfo();
$basedir = new Path(\CRM\CivixBundle\Application::findExtDir());
- $module = new Module(Civix::templating());
- $module->loadInit($ctx);
- $module->save($ctx, \Civix::output());
-
if ($ctx['namespace']) {
$phpFile = $basedir->string(Naming::createClassFile($ctx['namespace'], 'Upgrader', 'Base.php'));
if (file_exists($phpFile)) {
diff --git a/src/CRM/CivixBundle/Generator.php b/src/CRM/CivixBundle/Generator.php
index 9e220360..62318f5a 100644
--- a/src/CRM/CivixBundle/Generator.php
+++ b/src/CRM/CivixBundle/Generator.php
@@ -4,11 +4,16 @@
use Civix;
use CRM\CivixBundle\Builder\Info;
use CRM\CivixBundle\Builder\Mixins;
+use CRM\CivixBundle\Builder\Module;
use CRM\CivixBundle\Builder\PhpData;
+use CRM\CivixBundle\Builder\PHPUnitGenerateInitFiles;
use CRM\CivixBundle\Command\Mgd;
use CRM\CivixBundle\Utils\Files;
+use CRM\CivixBundle\Utils\MixinLibraries;
use CRM\CivixBundle\Utils\Naming;
use CRM\CivixBundle\Utils\Path;
+use PhpArrayDocument\Parser;
+use PhpArrayDocument\Printer;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
@@ -48,6 +53,11 @@ class Generator {
*/
public $infoXml;
+ /**
+ * @var \CRM\CivixBundle\Utils\MixinLibraries
+ */
+ public $mixinLibraries;
+
/**
* @param \CRM\CivixBundle\Utils\Path $baseDir
* The folder that contains the extension.
@@ -57,6 +67,7 @@ public function __construct(Path $baseDir) {
$this->output = \Civix::output();
$this->io = \Civix::io();
$this->baseDir = $baseDir;
+ $this->mixinLibraries = new MixinLibraries($baseDir->path('mixin/lib'), \Civix::appDir('lib'));
$this->reloadInfo();
}
@@ -80,6 +91,19 @@ public function updateModulePhp(callable $function): void {
}
}
+ /**
+ * Re-generate the 'module.civix.php' boilerplate.
+ */
+ public function updateModuleCivixPhp(): void {
+ $ctx = $this->createDefaultCtx();
+ $info = new Info($this->baseDir->string('info.xml'));
+ $info->load($ctx);
+
+ $module = new Module(Civix::templating());
+ $module->loadInit($ctx);
+ $module->save($ctx, \Civix::output());
+ }
+
/**
* Apply a filter to the `info.xml` file.
*
@@ -117,6 +141,22 @@ public function updateMixins(callable $function): void {
$this->infoXml->save($ctx, $this->output);
}
+ /**
+ * Apply a filter to the "mixin/lib" (Mixin Libraries).
+ *
+ * @param callable $function
+ * signature: `function(MixinLibraries $mixinLibraries): void`
+ */
+ public function updateMixinLibraries(callable $function): void {
+ $function($this->mixinLibraries);
+ if (\Civix::checker()->hasMixinLibrary() && !\Civix::checker()->coreHasPathload()) {
+ $this->copyFile(Civix::appDir('lib/pathload-0.php'), Civix::extDir('mixin/lib/pathload-0.php'));
+ }
+ else {
+ $this->removeFile(Civix::extDir('mixin/lib/pathload-0.php'));
+ }
+ }
+
/**
* Update a PHP-style data-file. (If the file is new, create it.)
*
@@ -139,6 +179,27 @@ public function updatePhpData($path, callable $filter): void {
$phpData->save($ctx, $this->output);
}
+ /**
+ * Update a PHP-style data-file. (If the file is new, create it.)
+ *
+ * Ex: updatePhpArrayDocument('foobar.mgd.php', fn(PhpArrayDocument $doc) => $doc->setInnerComment("Hello world"))
+ *
+ * TIP: updatePhpData() and updatePhpArrayDocument() fill a similar niche.
+ * - updatePhpArrayDocument reveals and preserves more metadata.
+ * - updatePhpData has a simpler API.
+ *
+ * @param $path
+ * @param callable $filter
+ */
+ public function updatePhpArrayDocument($path, callable $filter): void {
+ $file = Path::for($path)->string();
+ $oldCode = file_get_contents($file);
+ $doc = (new Parser())->parse($oldCode);
+ $filter($doc);
+ $newCode = (new Printer())->print($doc);
+ $this->writeTextFile($file, $newCode, TRUE);
+ }
+
/**
* Update a managed-entity data-file. (If the file is new, create it.)
*
@@ -311,6 +372,54 @@ public function writeTextFile($path, string $content, $overwrite) {
}
}
+ /**
+ * @param string $class
+ * Ex: 'CRM_Foo_DAO_Bar'
+ * @param string $tableName
+ * Ex: 'civicrm_foo_bar'
+ * @param string $overwrite
+ * Ex: 'overwrite' or 'ask' (per checkOverwrite())
+ * @return void
+ */
+ public function addDaoClass(string $class, string $tableName, string $overwrite): void {
+ $namespace = Naming::coerceNamespace($this->infoXml->getNamespace(), 'CRM');
+
+ $this->addClass($class, 'entity-dao.php.php', [
+ 'tableName' => $tableName,
+ 'daoBaseClass' => Naming::createClassName($namespace, 'DAO', 'Base'),
+ 'classRenaming' => FALSE,
+ ], $overwrite);
+ }
+
+ /**
+ * Create or update an exact copy of a file.
+ *
+ * If the file is the same, do nothing.
+ *
+ * @param string $src
+ * @param string $dest
+ */
+ public function copyFile(string $src, string $dest) {
+ if (!Files::isIdenticalFile($src, $dest)) {
+ $relPath = Files::relativize($dest, getcwd());
+ $this->output->writeln("Write " . $relPath);
+ copy($src, $dest);
+ }
+ }
+
+ /**
+ * Remove a file (if it exists).
+ *
+ * @param string $file
+ */
+ public function removeFile(string $file) {
+ if (file_exists($file)) {
+ $relPath = Files::relativize($file, getcwd());
+ $this->output->writeln("Remove " . $relPath);
+ unlink($file);
+ }
+ }
+
/**
* Update the content of a series of text files.
*
@@ -576,6 +685,18 @@ public function addMixins(array $mixinConstraints): void {
});
}
+ /**
+ * Add skeletal initialization files for PHPUnit.
+ *
+ * @return void
+ */
+ public function addPhpunit(): void {
+ $ctx = $this->createDefaultCtx();
+ $phpUnitInitFiles = new PHPUnitGenerateInitFiles();
+ $phpUnitInitFiles->initPhpunitXml($this->baseDir->string('phpunit.xml.dist'), $ctx, Civix::output());
+ $phpUnitInitFiles->initPhpunitBootstrap($this->baseDir->string('tests', 'phpunit', 'bootstrap.php'), $ctx, Civix::output());
+ }
+
/**
* Add a class file. The class-name and file-name are relative to your configured .
*
@@ -596,25 +717,23 @@ public function addMixins(array $mixinConstraints): void {
* - classNameFull (e.g. "Civi\Foo\Bar")
* - classNamespace (e.g. "Civi\Foo")
* - classNamespaceDecl (e.g. "namespace Civi\Foo;")
+ * - classRenaming (bool; whether developer should be allowed to change the class name)
* - useE (e.g. 'use CRM_Myextension_ExtensionUtil as E;')
+ * @param string $overwrite
+ * Whether to overwrite existing files. (See options in checkOverwrite().)
* @return void
*/
- public function addClass(string $className, string $template, array $tplData = []): void {
- $tplData = array_merge($this->createClassVars($className), $tplData);
+ public function addClass(string $className, string $template, array $tplData = [], string $overwrite = 'ask'): void {
+ $tplData['classRenaming'] = $tplData['classRenaming'] ?? TRUE;
+ $tplData = array_merge($this->createClassVars($className, $tplData['classRenaming']), $tplData);
$classFile = $tplData['classFile'];
$className = $tplData['className'];
- if ('keep' === $this->checkOverwrite($classFile, 'ask')) {
+ if ('keep' === $this->checkOverwrite($classFile, $overwrite)) {
return;
}
- if (file_exists($classFile)) {
- $forced = $this->input->hasOption('force') && $this->input->getOption('force');
- if (!$forced && !$this->io->confirm("Class $className already exists. Overwrite?")) {
- return;
- }
- }
- $this->io->note("Write " . Files::relativize($classFile, getcwd()));
+ $this->io->writeln(sprintf("Write %s", Files::relativize($classFile, getcwd())));
$rendered = Civix::templating()->render($template, $tplData);
Path::for(dirname($classFile))->mkdir();
file_put_contents($classFile, $rendered);
@@ -622,12 +741,13 @@ public function addClass(string $className, string $template, array $tplData = [
/**
* @param string $className
- * @param string $layout
+ * @param bool $classRenaming
+ * Whether developer should be allowed to change the class name
* @return array
* @internal
*/
- public function createClassVars($className, string $layout = 'auto'): array {
- if ($this->input->isInteractive()) {
+ public function createClassVars($className, bool $classRenaming = TRUE): array {
+ if ($classRenaming && $this->input->isInteractive()) {
$className = $this->io->ask('Class name', $className);
}
$classFile = preg_replace(';[_/\\\];', '/', $className) . '.php';
@@ -655,6 +775,25 @@ public function createClassVars($className, string $layout = 'auto'): array {
return $tplData;
}
+ /**
+ * Add an "upgrader" class ("CRM_MyExtension_Upgrader")
+ *
+ * @param string $overwrite
+ */
+ public function addUpgrader(string $overwrite = 'ask'): void {
+ // TODO: Re-test comprehensively to ensure that "Civi\Foo\Upgrader" is valid/workable. Drop coercion.
+ $namespace = Naming::coerceNamespace($this->infoXml->getNamespace(), 'CRM');
+ $className = Naming::createClassName($namespace, 'Upgrader');
+ $this->addClass($className, 'upgrader.php.php', ['classRenaming' => FALSE], $overwrite);
+
+ $this->updateInfo(function($info) {
+ $info->get()->upgrader = sprintf('CiviMix\\Schema\\%s\\AutomaticUpgrader', Naming::createCamelName($info->getFile()));
+ // tag only exists in 5.38+.
+ $info->raiseCompatibilityMinimum('5.38');
+ });
+ $this->updateModuleCivixPhp();
+ }
+
// -------------------------------------------------
// These are some helper utilities.
@@ -683,11 +822,18 @@ public function checkOverwrite($path, $mode): string {
return 'write';
}
+ if ($mode === 'ask' && !$this->input->isInteractive()) {
+ $mode = 'if-forced';
+ }
+ if ($mode === 'if-forced') {
+ $mode = $this->input->hasOption('force') && $this->input->getOption('force') ? 'overwrite' : 'keep';
+ }
+
if ($mode === 'overwrite' || $mode === TRUE) {
return 'write';
}
if ($mode === 'keep'|| $mode === FALSE) {
- $this->io->warning("Skip $file: file already exists");
+ $this->io->writeln("Skip " . Files::relativize($file) . ": file already exists");
return 'keep';
}
if ($mode === 'abort') {
@@ -695,10 +841,10 @@ public function checkOverwrite($path, $mode): string {
}
if ($mode === 'ask' && $this->input->isInteractive()) {
$relPath = Files::relativize($file, $this->baseDir->string());
- $action = mb_strtolower($this->io->choice("File $relPath already exists. What should we do?", [
- 'o' => '[O]verwrite the file',
- 'k' => '[K]eep the current file',
- 'a' => '[A]bort the process',
+ $action = mb_strtolower($this->io->choice("File \"$relPath\" already exists. What should we do?", [
+ 'o' => 'Overwrite the file',
+ 'k' => 'Keep the current file',
+ 'a' => 'Abort the process',
]));
if ($action === 'o') {
return 'write';
@@ -710,12 +856,6 @@ public function checkOverwrite($path, $mode): string {
throw new \RuntimeException("File $relPath already exists. Operation aborted");
}
}
- if ($mode === 'ask' || $mode === 'if-forced') {
- if ($this->input->hasOption('force') && $this->input->getOption('force')) {
- $this->io->note("Overwrite $file in --force mode");
- return 'write';
- }
- }
throw new \RuntimeException("Invalid argument checkOverwrite(...$mode)");
}
diff --git a/src/CRM/CivixBundle/Resources/views/Code/entity-dao.php.php b/src/CRM/CivixBundle/Resources/views/Code/entity-dao.php.php
new file mode 100644
index 00000000..4274d8cf
--- /dev/null
+++ b/src/CRM/CivixBundle/Resources/views/Code/entity-dao.php.php
@@ -0,0 +1,23 @@
+
+
+/**
+ * DAOs provide an OOP-style facade for reading and writing database records.
+ *
+ * DAOs are a primary source for metadata in several versions of CiviCRM (<5.75)
+ * and are required for some subsystems (such as APIv3).
+ *
+ * This stub provides compatibility. It is not intended to be modified in a
+ * substantive way. However, you may add comments and annotations.
+ */
+class extends {
+
+ // Required by some versions of CiviCRM.
+ public static $_tableName = ;
+
+}
diff --git a/src/CRM/CivixBundle/Resources/views/Code/entity-schema.xml.php b/src/CRM/CivixBundle/Resources/views/Code/entity-schema.xml.php
deleted file mode 100644
index b6c358f9..00000000
--- a/src/CRM/CivixBundle/Resources/views/Code/entity-schema.xml.php
+++ /dev/null
@@ -1,38 +0,0 @@
-'."\n";
-?>
-
-
-
-
-
- FIXME
- true
-
-
- id
- int unsigned
- true
- Unique ID
-
- Number
-
-
-
- id
- true
-
-
-
- contact_id
- int unsigned
- FK to Contact
-
-
- contact_id
-
- id
- CASCADE
-
-
-
diff --git a/src/CRM/CivixBundle/Resources/views/Code/module.civix.php.php b/src/CRM/CivixBundle/Resources/views/Code/module.civix.php.php
index dc91609b..9b3dc1cd 100644
--- a/src/CRM/CivixBundle/Resources/views/Code/module.civix.php.php
+++ b/src/CRM/CivixBundle/Resources/views/Code/module.civix.php.php
@@ -82,10 +82,56 @@ public static function findClass($suffix) {
return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix);
}
+hasUpgrader()) { ?> /**
+ * @return \CiviMix\Schema\SchemaHelperInterface
+ */
+ public static function schema() {
+ if (!isset($GLOBALS['CiviMixSchema'])) {
+ pathload()->loadPackage('civimix-schema@5', TRUE);
+ }
+ return $GLOBALS['CiviMixSchema']->getHelper(static::LONG_NAME);
+ }
+
+
}
use _ExtensionUtil as E;
+hasMixinLibrary() && !\Civix::checker()->coreHasPathload()) { ?>
+($GLOBALS['_PathLoad'][0] ?? require __DIR__ . '/mixin/lib/pathload-0.php');
+
+hasMixinLibrary()) { ?>
+pathload()->addSearchDir(__DIR__ . '/mixin/lib');
+
+hasSchemaPhp() || \Civix::checker()->hasMixinLibrary('civimix-schema@5')) { ?>
+spl_autoload_register('__civix_class_loader', TRUE, TRUE);
+
+function __civix_class_loader($class) {
+
+
+ if ($class === ) {
+ if (version_compare(CRM_Utils_System::version(), '5.74.beta', '>=')) {
+ class_alias('CRM_Core_DAO_Base', );
+ // ^^ Materialize concrete names -- encourage IDE's to pick up on this association.
+ }
+ else {
+ $realClass = ;
+ class_alias($realClass, $class);
+ // ^^ Abstract names -- discourage IDE's from picking up on this association.
+ }
+ return;
+ }
+
+ // This allows us to tap-in to the installation process (without incurring real file-reads on typical requests).
+ if (strpos($class, ) === 0) {
+ // civimix-schema@5 is designed for backported use in download/activation workflows,
+ // where new revisions may become dynamically available.
+ pathload()->loadPackage('civimix-schema@5', TRUE);
+ CiviMix\Schema\loadClass($class);
+ }
+}
+
+
function __civix_mixin_polyfill() {
if (!class_exists('CRM_Extension_MixInfo')) {
diff --git a/src/CRM/CivixBundle/Resources/views/Code/upgrader.php.php b/src/CRM/CivixBundle/Resources/views/Code/upgrader.php.php
index e619ab1b..6adac401 100644
--- a/src/CRM/CivixBundle/Resources/views/Code/upgrader.php.php
+++ b/src/CRM/CivixBundle/Resources/views/Code/upgrader.php.php
@@ -1,14 +1,14 @@
-
-use _ExtensionUtil as E;
-
/**
* Collection of upgrade steps.
*/
-class _Upgrader extends {
+class extends \CRM_Extension_Upgrader_Base {
// By convention, functions that look like "function upgrade_NNNN()" are
// upgrade tasks. They are executed in order (like Drupal's hook_update_N).
diff --git a/src/CRM/CivixBundle/Test/SubProcessCommandTester.php b/src/CRM/CivixBundle/Test/SubProcessCommandTester.php
index fbb6b23e..216726da 100644
--- a/src/CRM/CivixBundle/Test/SubProcessCommandTester.php
+++ b/src/CRM/CivixBundle/Test/SubProcessCommandTester.php
@@ -21,6 +21,11 @@ class SubProcessCommandTester implements CommandTester {
*/
protected $statusCode;
+ /**
+ * @var string|null
+ */
+ protected $commandLine;
+
/**
* @param array $baseCommand
*/
@@ -59,6 +64,8 @@ public function execute(array $input, array $options = []) {
$buffer = fopen('php://memory', 'w+');
$p = new Process($command);
+ $this->commandLine = $p->getCommandLine();
+
$p->run(function ($type, $data) use ($buffer) {
// Default policy - combine STDOUT and STDIN into one continuous stream.
fwrite($buffer, $data);
@@ -85,4 +92,8 @@ public function getStatusCode(): int {
return $this->statusCode;
}
+ public function getCommandLine(): string {
+ return $this->commandLine;
+ }
+
}
diff --git a/src/CRM/CivixBundle/Utils/MixinLibraries.php b/src/CRM/CivixBundle/Utils/MixinLibraries.php
new file mode 100644
index 00000000..0c298656
--- /dev/null
+++ b/src/CRM/CivixBundle/Utils/MixinLibraries.php
@@ -0,0 +1,123 @@
+activeDir = Path::for($activeDir);
+ $this->availableDir = Path::for($availableDir);
+ $this->refresh();
+ }
+
+ /**
+ * Add $majorName to the mixin/lib/ folder.
+ *
+ * If a current/newer version already exists, this is a null-op.
+ *
+ * @param string $majorName
+ */
+ public function add(string $majorName): void {
+
+ /** @var \CRM\CivixBundle\Utils\PathloadPackage $avail */
+ $avail = $this->available[$majorName] ?? NULL;
+ /** @var \CRM\CivixBundle\Utils\PathloadPackage $active */
+ $active = $this->active[$majorName] ?? NULL;
+
+ if (!$avail) {
+ throw new \RuntimeException("Cannot enable unknown library ($majorName)");
+ }
+ if ($active) {
+ if (version_compare($active->version, $avail->version, '>=')) {
+ return;
+ }
+ else {
+ $this->remove($majorName);
+ }
+ }
+
+ $newFile = $this->activeDir->string(basename($avail->file));
+ \Civix::output()->writeln("Write " . Files::relativize($newFile));
+ $this->activeDir->mkdir();
+ copy($avail->file, $newFile);
+ $this->refresh();
+ }
+
+ /**
+ * Delete $majorName from the mixin/lib/ folder.
+ *
+ * @param string $majorName
+ */
+ public function remove(string $majorName): void {
+ /** @var \CRM\CivixBundle\Utils\PathloadPackage $active */
+ $active = $this->active[$majorName] ?? NULL;
+ if ($active) {
+ \Civix::output()->writeln("Remove " . Files::relativize($active->file));
+ unlink($active->file);
+ }
+ $this->refresh();
+ }
+
+ public function toggle(string $majorName, bool $active): void {
+ if ($active) {
+ $this->add($majorName);
+ }
+ else {
+ $this->remove($majorName);
+ }
+ }
+
+ public function refresh(): void {
+ $this->active = static::scan($this->activeDir);
+ $this->available = static::scan($this->availableDir);
+ }
+
+ /**
+ * @param string $majorName
+ * Either major-name or a wildcard.
+ * Ex: 'civimix-schema@5' or '*'
+ * @return bool
+ */
+ public function hasActive(string $majorName = '*'): bool {
+ return $majorName === '*' ? !empty($this->active) : isset($this->active[$majorName]);
+ }
+
+ protected static function scan(Path $libDir): array {
+ if (!is_dir($libDir->string())) {
+ return [];
+ }
+
+ $files = Path::for($libDir)->search('glob:*@*');
+ $packages = array_map([PathloadPackage::class, 'create'], $files);
+ $result = [];
+ foreach ($packages as $package) {
+ /** @var \CRM\CivixBundle\Utils\PathloadPackage $package */
+ $result[$package->majorName] = $package;
+ }
+ return $result;
+ }
+
+}
diff --git a/src/CRM/CivixBundle/Utils/PathloadPackage.php b/src/CRM/CivixBundle/Utils/PathloadPackage.php
new file mode 100644
index 00000000..0ac05368
--- /dev/null
+++ b/src/CRM/CivixBundle/Utils/PathloadPackage.php
@@ -0,0 +1,88 @@
+majorName, $self->name, $self->version] = static::parseExpr($base);
+ $self->file = $file;
+ $self->type = $type;
+ return $self;
+ }
+
+ /**
+ * @var string
+ * Ex: '/var/www/app-1/lib/cloud-file-io@1.2.3.phar'
+ */
+ public $file;
+
+ /**
+ * @var string
+ * Ex: 'cloud-file-io'
+ */
+ public $name;
+
+ /**
+ * @var string
+ * Ex: 'cloud-file-io@1'
+ */
+ public $majorName;
+
+ /**
+ * @var string
+ * Ex: '1.2.3'
+ */
+ public $version;
+
+ /**
+ * @var string
+ * Ex: 'php' or 'phar' or 'dir'
+ */
+ public $type;
+
+}
diff --git a/src/CRM/CivixBundle/Utils/SchemaBackport.php b/src/CRM/CivixBundle/Utils/SchemaBackport.php
new file mode 100644
index 00000000..d0f11116
--- /dev/null
+++ b/src/CRM/CivixBundle/Utils/SchemaBackport.php
@@ -0,0 +1,349 @@
+$key)) {
+ return (string) $xml->$key;
+ }
+ return NULL;
+ }
+
+ public static function toBool(string $key, SimpleXMLElement $xml): ?bool {
+ if (isset($xml->$key)) {
+ $value = strtolower((string) $xml->$key);
+ return $value === 'true' || $value === '1';
+ }
+ return NULL;
+ }
+
+ /**
+ * Get some attributes related to html type
+ *
+ * Extracted during refactor, still a bit messy.
+ *
+ * @param \SimpleXMLElement $fieldXML
+ * @return array
+ */
+ public static function getTypeAttributes(SimpleXMLElement $fieldXML) {
+ $type = (string) $fieldXML->type;
+ $field = [];
+ switch ($type) {
+ case 'varchar':
+ case 'char':
+ $field['length'] = (int) $fieldXML->length;
+ $field['sqlType'] = "$type({$field['length']})";
+ $field['crmType'] = 'CRM_Utils_Type::T_STRING';
+ $field['size'] = self::getSize($fieldXML);
+ break;
+
+ case 'text':
+ $field['sqlType'] = $type;
+ $field['crmType'] = 'CRM_Utils_Type::T_' . strtoupper($type);
+ // CRM-13497 see fixme below
+ $field['rows'] = isset($fieldXML->html) ? self::toString('rows', $fieldXML->html) : NULL;
+ $field['cols'] = isset($fieldXML->html) ? self::toString('cols', $fieldXML->html) : NULL;
+ break;
+
+ case 'datetime':
+ $field['sqlType'] = $type;
+ $field['crmType'] = 'CRM_Utils_Type::T_DATE + CRM_Utils_Type::T_TIME';
+ break;
+
+ case 'boolean':
+ // need this case since some versions of mysql do not have boolean as a valid column type and hence it
+ // is changed to tinyint. hopefully after 2 yrs this case can be removed.
+ $field['sqlType'] = 'tinyint';
+ $field['crmType'] = 'CRM_Utils_Type::T_' . strtoupper($type);
+ break;
+
+ case 'decimal':
+ $length = $fieldXML->length ?: '20,2';
+ $field['sqlType'] = 'decimal(' . $length . ')';
+ $field['crmType'] = self::toString('crmType', $fieldXML) ?: 'CRM_Utils_Type::T_MONEY';
+ $field['precision'] = $length;
+ break;
+
+ case 'float':
+ $field['sqlType'] = 'double';
+ $field['crmType'] = 'CRM_Utils_Type::T_FLOAT';
+ break;
+
+ default:
+ $field['sqlType'] = $type;
+ if ($type === 'int unsigned' || $type === 'tinyint') {
+ $field['crmType'] = 'CRM_Utils_Type::T_INT';
+ }
+ else {
+ $field['crmType'] = self::toString('crmType', $fieldXML) ?: 'CRM_Utils_Type::T_' . strtoupper($type);
+ }
+ break;
+ }
+ // Get value of crmType constant(s)
+ $field['crmTypeValue'] = 0;
+ $crmTypes = explode('+', $field['crmType']);
+ foreach ($crmTypes as $crmType) {
+ $field['crmTypeValue'] += constant(trim($crmType));
+ }
+ return $field;
+ }
+
+ /**
+ * Sets the size property of a textfield.
+ *
+ * @param \SimpleXMLElement $fieldXML
+ *
+ * @return string
+ */
+ public static function getSize(SimpleXMLElement $fieldXML): string {
+ // Extract from tag if supplied
+ if (!empty($fieldXML->html) && !empty($fieldXML->html->size)) {
+ return (string) $fieldXML->html->size;
+ }
+ return self::getDefaultSize(self::toString('length', $fieldXML));
+ }
+
+ public static function getDefaultSize($length) {
+ // Infer from tag if 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';
+ }
+
+ public 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));
+ }
+ }
+
+ /**
+ * Fallback used when field in schema xml is missing a title.
+ *
+ * TODO: Trigger a deprecation notice when this happens.
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ public static function composeTitle(string $name): string {
+ $substitutions = [
+ 'is_active' => 'Enabled',
+ ];
+ if (isset($substitutions[$name])) {
+ return $substitutions[$name];
+ }
+ $names = explode('_', strtolower($name));
+ $allCaps = ['im', 'id'];
+ foreach ($names as $i => $str) {
+ if (in_array($str, $allCaps, TRUE)) {
+ $names[$i] = strtoupper($str);
+ }
+ else {
+ $names[$i] = ucfirst(trim($str));
+ }
+ }
+ return trim(implode(' ', $names));
+ }
+
+ /**
+ * Get the 'usage' property for a field.
+ *
+ * @param \SimpleXMLElement $fieldXML
+ * @return array
+ */
+ public static function getFieldUsage(SimpleXMLElement $fieldXML): array {
+ $import = self::toBool('import', $fieldXML) ?? FALSE;
+ $export = self::toBool('export', $fieldXML);
+ if (!isset($fieldXML->usage)) {
+ $usage = [
+ 'import' => $import,
+ 'export' => $export ?? $import,
+ ];
+ }
+ else {
+ $usage = [];
+ foreach ($fieldXML->usage->children() as $usedFor => $isUsed) {
+ $usage[$usedFor] = self::toBool($usedFor, $fieldXML->usage);
+ }
+ $import = $usage['import'] ?? $import;
+ }
+ // Ensure all keys are populated. Import is the historical de-facto default.
+ $usage = array_merge(array_fill_keys(['import', 'export', 'duplicate_matching'], $import), $usage);
+ // Usage for tokens has not historically been in the metadata so we can default to FALSE.
+ // historically hard-coded lists have been used.
+ $usage['token'] = $usage['token'] ?? FALSE;
+ return $usage;
+ }
+
+ public static function getFieldHtml(SimpleXMLElement $fieldXML): ?array {
+ $html = NULL;
+ if (!empty($fieldXML->html)) {
+ $html = [];
+ $validOptions = [
+ 'type',
+ 'formatType',
+ 'label',
+ 'controlField',
+ 'min',
+ 'max',
+ /* Fixme: CRM-13497 these could also be moved
+ 'rows',
+ 'cols',
+ 'size', */
+ ];
+ foreach ($validOptions as $htmlOption) {
+ if (isset($fieldXML->html->$htmlOption) && $fieldXML->html->$htmlOption !== '') {
+ $html[$htmlOption] = self::toString($htmlOption, $fieldXML->html);
+ }
+ }
+ if (isset($fieldXML->html->filter)) {
+ $html['filter'] = (array) $fieldXML->html->filter;
+ }
+ }
+ return $html;
+ }
+
+ public static function getFieldPseudoconstant(SimpleXMLElement $fieldXML): ?array {
+ $pseudoconstant = NULL;
+ if (!empty($fieldXML->pseudoconstant)) {
+ //ok this is a bit long-winded but it gets there & is consistent with above approach
+ $pseudoconstant = [];
+ $validOptions = [
+ // Fields can specify EITHER optionGroupName OR table, not both
+ // (since declaring optionGroupName means we are using the civicrm_option_value table)
+ 'optionGroupName',
+ 'table',
+ // If table is specified, keyColumn and labelColumn are also required
+ 'keyColumn',
+ 'labelColumn',
+ // Non-translated machine name for programmatic lookup. Defaults to 'name' if that column exists
+ 'nameColumn',
+ // Column to fetch in "abbreviate" context
+ 'abbrColumn',
+ // Supported by APIv4 suffixes
+ 'colorColumn',
+ 'iconColumn',
+ // Where clause snippet (will be joined to the rest of the query with AND operator)
+ 'condition',
+ // callback function incase of static arrays
+ 'callback',
+ // Path to options edit form
+ 'optionEditPath',
+ // Should options for this field be prefetched (for presenting on forms).
+ // The default is TRUE, but adding FALSE helps when there could be many options
+ 'prefetch',
+ ];
+ foreach ($validOptions as $pseudoOption) {
+ if (!empty($fieldXML->pseudoconstant->$pseudoOption)) {
+ $pseudoconstant[$pseudoOption] = self::toString($pseudoOption, $fieldXML->pseudoconstant);
+ }
+ }
+ if (!isset($pseudoconstant['optionEditPath']) && !empty($pseudoconstant['optionGroupName'])) {
+ $pseudoconstant['optionEditPath'] = 'civicrm/admin/options/' . $pseudoconstant['optionGroupName'];
+ }
+ // Set suffixes if explicitly declared
+ if (!empty($fieldXML->pseudoconstant->suffixes)) {
+ $pseudoconstant['suffixes'] = explode(',', self::toString('suffixes', $fieldXML->pseudoconstant));
+ }
+ // For now, fields that have option lists that are not in the db can simply
+ // declare an empty pseudoconstant tag and we'll add this placeholder.
+ // That field's BAO::buildOptions fn will need to be responsible for generating the option list
+ if (empty($pseudoconstant)) {
+ $pseudoconstant = 'not in database';
+ }
+ }
+ return $pseudoconstant;
+ }
+
+ public static function getFieldPermission(SimpleXMLElement $fieldXML): ?array {
+ $permission = NULL;
+ if (isset($fieldXML->permission)) {
+ $permission = trim(self::toString('permission', $fieldXML));
+ $permission = $permission ? array_filter(array_map('trim', explode(',', $permission))) : [];
+ if (isset($fieldXML->permission->or)) {
+ $permission[] = array_filter(array_map('trim', explode(',', $fieldXML->permission->or)));
+ }
+ }
+ return $permission;
+ }
+
+ /**
+ * In multilingual context popup, we need extra information to create appropriate widget
+ *
+ * @param \SimpleXMLElement $fieldXML
+ * @return array|string[]|null
+ */
+ public static function getFieldWidget(SimpleXMLElement $fieldXML): ?array {
+ $widget = NULL;
+ if ($fieldXML->localizable) {
+ if (isset($fieldXML->html)) {
+ $widget = (array) $fieldXML->html;
+ }
+ else {
+ // default
+ $widget = ['type' => 'Text'];
+ }
+ if (isset($fieldXML->required)) {
+ $widget['required'] = self::toString('required', $fieldXML);
+ }
+ }
+ return $widget;
+ }
+
+}
diff --git a/src/Civix.php b/src/Civix.php
index fb625ff2..6dfd74e3 100644
--- a/src/Civix.php
+++ b/src/Civix.php
@@ -217,6 +217,16 @@ public static function generator($extDir = NULL): \CRM\CivixBundle\Generator {
return self::$cache[__FUNCTION__][$cacheKey];
}
+ /**
+ * Get the checker for analyzing extension usage/design/requirements.
+ *
+ * @param string|Path|null $extDir
+ * @return \CRM\CivixBundle\Checker
+ */
+ public static function checker($extDir = NULL): \CRM\CivixBundle\Checker {
+ return new \CRM\CivixBundle\Checker(static::generator($extDir));
+ }
+
/**
* @return \CRM\CivixBundle\UpgradeList
*/
@@ -232,6 +242,9 @@ public static function reset(): void {
if (isset(static::$cache['boot'])) {
$new['boot'] = static::$cache['boot'];
}
+ if (isset(static::$cache['ioStack'])) {
+ $new['ioStack'] = static::$cache['ioStack'];
+ }
static::$cache = $new;
static::$extDir = NULL;
}
diff --git a/tests/e2e/AddEntityTest.php b/tests/e2e/AddEntityTest.php
index 238608eb..723fcdd1 100644
--- a/tests/e2e/AddEntityTest.php
+++ b/tests/e2e/AddEntityTest.php
@@ -2,6 +2,10 @@
namespace E2E;
+use PhpArrayDocument\ArrayNode;
+use PhpArrayDocument\PhpArrayDocument;
+use PhpArrayDocument\ScalarNode;
+
class AddEntityTest extends \PHPUnit\Framework\TestCase {
use CivixProjectTestTrait;
@@ -11,26 +15,26 @@ class AddEntityTest extends \PHPUnit\Framework\TestCase {
private $entityFiles = [
'CRM/CivixAddentity/DAO/Bread.php',
'CRM/CivixAddentity/BAO/Bread.php',
- 'xml/schema/CRM/CivixAddentity/Bread.xml',
- 'xml/schema/CRM/CivixAddentity/Bread.entityType.php',
+ 'schema/Bread.entityType.php',
'CRM/CivixAddentity/DAO/Sandwich.php',
'CRM/CivixAddentity/BAO/Sandwich.php',
- 'xml/schema/CRM/CivixAddentity/Sandwich.xml',
- 'xml/schema/CRM/CivixAddentity/Sandwich.entityType.php',
+ 'schema/Sandwich.entityType.php',
'CRM/CivixAddentity/DAO/Flour.php',
'CRM/CivixAddentity/BAO/Flour.php',
- 'xml/schema/CRM/CivixAddentity/Flour.xml',
- 'xml/schema/CRM/CivixAddentity/Flour.entityType.php',
+ 'schema/Flour.entityType.php',
];
public function setUp(): void {
chdir(static::getWorkspacePath());
static::cleanDir(static::getKey());
- $this->civixGenerateModule(static::getKey());
+ $this->civixGenerateModule(static::getKey(), [
+ '--compatibility' => '5.69',
+ ]);
chdir(static::getKey());
+ \Civix::ioStack()->push(...$this->createInputOutput());
$this->assertFileGlobs([
'info.xml' => 1,
'civix_addentity.php' => 1,
@@ -38,45 +42,43 @@ public function setUp(): void {
]);
}
+ protected function tearDown(): void {
+ parent::tearDown();
+ \Civix::ioStack()->reset();
+ }
+
public function testAddEntity(): void {
$this->assertFileGlobs([
'sql/auto_install.sql' => 0,
]);
$this->assertFileGlobs(array_fill_keys($this->entityFiles, 0));
+ $this->civixGenerateUpgrader();
$this->civixGenerateEntity('Bread');
$this->civixGenerateEntity('Sandwich');
$this->civixGenerateEntity('Meal');
$this->civixGenerateEntity('Flour');
- $this->civixGenerateEntityBoilerplate();
- $this->addExampleFK($this->getExtPath('xml/schema/CRM/CivixAddentity/Bread.xml'), 'flour', 'civicrm_flour');
- $this->addExampleFK($this->getExtPath('xml/schema/CRM/CivixAddentity/Sandwich.xml'), 'bread', 'civicrm_bread');
- $this->addExampleFK($this->getExtPath('xml/schema/CRM/CivixAddentity/Meal.xml'), 'sandwich', 'civicrm_sandwich');
+ $this->addExampleFK($this->getExtPath('schema/Bread.entityType.php'), 'flour', 'Flour', 'civicrm_flour');
+ $this->addExampleFK($this->getExtPath('schema/Sandwich.entityType.php'), 'bread', 'Bread', 'civicrm_bread');
+ $this->addExampleFK($this->getExtPath('schema/Meal.entityType.php'), 'sandwich', 'Sandwich', 'civicrm_sandwich');
// add FK referencing its own table
- $this->addExampleFK($this->getExtPath('xml/schema/CRM/CivixAddentity/Meal.xml'), 'next_meal', 'civicrm_meal');
- $this->civixGenerateEntityBoilerplate();
+ $this->addExampleFK($this->getExtPath('schema/Meal.entityType.php'), 'next_meal', 'Meal', 'civicrm_meal');
$this->assertFileGlobs([
- 'sql/auto_install.sql' => 1,
+ // No longer use static files
+ 'sql/auto_install.sql' => 0,
]);
$this->assertFileGlobs(array_fill_keys($this->entityFiles, 1));
- $install = $this->grepLines(';CREATE TABLE;', $this->getExtPath('sql/auto_install.sql'));
- $uninstall = $this->grepLines(';DROP TABLE;', $this->getExtPath('sql/auto_uninstall.sql'));
-
- $this->assertEquals([
- 'CREATE TABLE `civicrm_flour` (',
- 'CREATE TABLE `civicrm_bread` (',
- 'CREATE TABLE `civicrm_sandwich` (',
- 'CREATE TABLE `civicrm_meal` (',
- ], $install);
-
- $this->assertEquals([
- 'DROP TABLE IF EXISTS `civicrm_meal`;',
- 'DROP TABLE IF EXISTS `civicrm_sandwich`;',
- 'DROP TABLE IF EXISTS `civicrm_bread`;',
- 'DROP TABLE IF EXISTS `civicrm_flour`;',
- ], $uninstall);
+ // FIXME: Perhaps call `cv ev 'E::schema()->generateInstallSql()` and restore the old assertions
+ $this->assertEquals('CiviMix\\Schema\\CivixAddentity\\AutomaticUpgrader', trim($this->civixInfoGet('upgrader')->getDisplay()));
+ $civixPhpFile = $this->getExtPath('civix_addentity.civix.php');
+ $content = file_get_contents($civixPhpFile);
+ $this->assertStringSequence([
+ '($GLOBALS[\'_PathLoad\'][0] ?? require __DIR__ . \'/mixin/lib/pathload-0.php\');',
+ 'pathload()->addSearchDir(__DIR__ . \'/mixin/lib\');',
+ ' pathload()->loadPackage(\'civimix-schema@5\', TRUE);',
+ ], $content);
}
private function grepLines(string $pattern, string $file): array {
@@ -85,34 +87,25 @@ private function grepLines(string $pattern, string $file): array {
return array_values(preg_grep($pattern, $lines));
}
- private function addExampleFK(string $xmlFile, string $field, string $foreignTable) {
- $newXmlTpl = '
- %%FIELD%%
- %%FIELD%% ID
- int unsigned
- FK to %%TABLE%% ID
-
-
-
- 2.0
-
-
- %%FIELD%%
-
- id
- 2.0
- CASCADE
- ';
- $newXml = strtr($newXmlTpl, [
- '%%FIELD%%' => $field,
- '%%TABLE%%' => $foreignTable,
- ]);
-
- $tail = '';
-
- $raw = file_get_contents($xmlFile);
- $raw = str_replace($tail, "{$newXml}\n{$tail}", $raw);
- file_put_contents($xmlFile, $raw);
+ private function addExampleFK(string $schemaFile, string $fieldName, string $foreignEntity, string $foreignTable) {
+ \Civix::generator()->updatePhpArrayDocument($schemaFile, function (PhpArrayDocument $doc) use ($fieldName, $foreignEntity, $foreignTable) {
+ $field = ArrayNode::create()->importData([
+ 'title' => ScalarNode::create("$fieldName ID")->setFactory('E::ts'),
+ 'sql_type' => 'int unsigned',
+ 'input_type' => 'EntityRef',
+ 'description' => ScalarNode::create("FK to $foreignTable ID")->setFactory('E::ts'),
+ 'add' => '2.0',
+ 'input_attrs' => [
+ 'label' => ScalarNode::create("$foreignTable")->setFactory('E::ts'), /* weird */
+ ],
+ 'entity_reference' => [
+ 'entity' => $foreignEntity,
+ 'key' => 'id',
+ 'on_delete' => 'CASCADE',
+ ],
+ ]);
+ $doc->getRoot()['getFields'][$fieldName] = $field;
+ });
}
}
diff --git a/tests/e2e/CivixProjectTestTrait.php b/tests/e2e/CivixProjectTestTrait.php
index c388f7f7..2c274683 100644
--- a/tests/e2e/CivixProjectTestTrait.php
+++ b/tests/e2e/CivixProjectTestTrait.php
@@ -105,9 +105,7 @@ public function civixGenerateModule(string $key, array $options = []): CommandTe
'key' => $key,
'--enable' => 'false',
]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to generate module (%s):\n%s", $key, $tester->getDisplay(TRUE)));
- }
+ $this->assertTesterOk($tester, 'Failed to generate module');
return $tester;
}
@@ -117,36 +115,28 @@ public function civixGeneratePage(string $className, string $webPath): CommandTe
'' => $className,
'' => $webPath,
]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to generate module (%s)", static::getKey()));
- }
+ $this->assertTesterOk($tester, 'Failed to generate page');
return $tester;
}
public function civixGenerateEntity(string $entity, array $options = []): CommandTester {
$tester = static::civix('generate:entity');
$tester->execute(['' => $entity] + $options);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to generate entity (%s)", static::getKey()));
- }
- return $tester;
- }
-
- public function civixGenerateEntityBoilerplate(): CommandTester {
- $tester = static::civix('generate:entity-boilerplate');
- $tester->execute([]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to generate entity boilerplate (%s)", static::getKey()));
- }
+ $this->assertTesterOk($tester, 'Failed to generate entity');
return $tester;
}
public function civixGenerateService(string $name, array $options = []): CommandTester {
$tester = static::civix('generate:service');
$tester->execute($options + ['name' => $name]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to generate service (%s)", $name));
- }
+ $this->assertTesterOk($tester, 'Failed to generate service');
+ return $tester;
+ }
+
+ public function civixGenerateUpgrader(array $options = []): CommandTester {
+ $tester = static::civix('generate:upgrader');
+ $tester->execute($options);
+ $this->assertTesterOk($tester, 'Failed to generate upgrader');
return $tester;
}
@@ -160,9 +150,7 @@ public function civixInfoGet(string $xpath): CommandTester {
$tester->execute([
'--xpath' => $xpath,
]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to get \"%s\"", $xpath));
- }
+ $this->assertTesterOk($tester, sprintf("Failed to get \"%s\"", $xpath));
return $tester;
}
@@ -178,9 +166,7 @@ public function civixInfoSet(string $xpath, string $value): CommandTester {
'--xpath' => $xpath,
'--to' => $value,
]);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to set \"%s\" to \"%s\"", $xpath, $value));
- }
+ $this->assertTesterOk($tester, sprintf("Failed to set \"%s\" to \"%s\"", $xpath, $value));
return $tester;
}
@@ -195,9 +181,7 @@ public function civixInfoSet(string $xpath, string $value): CommandTester {
public function civixMixin(array $options): CommandTester {
$tester = static::civix('mixin');
$tester->execute($options);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to call \"civix mixin\" with options: %s", json_encode($options)));
- }
+ $this->assertTesterOk($tester, sprintf("Failed to call \"civix mixin\" with options: %s", json_encode($options)));
return $tester;
}
@@ -210,9 +194,7 @@ public function civixMixin(array $options): CommandTester {
public function civixUpgrade(array $options = []): CommandTester {
$tester = static::civix('upgrade');
$tester->execute($options);
- if ($tester->getStatusCode() !== 0) {
- throw new \RuntimeException(sprintf("Failed to run upgrade (%s)", static::getKey()));
- }
+ $this->assertTesterOk($tester, sprintf("Failed to run upgrade (%s)", static::getKey()));
return $tester;
}
@@ -386,4 +368,12 @@ protected function createInputOutput(array $argv = NULL): array {
return [$input, $output];
}
+ protected function assertTesterOk(CommandTester $tester, string $message = NULL) {
+ if ($tester->getStatusCode() !== 0) {
+ $message = $message ?: 'Failed to run command';
+ $command = is_callable([$tester, 'getCommandLine']) ? $tester->getCommandLine() : "(unknown)";
+ throw new \RuntimeException(sprintf("(%s) %s\nCOMMMAND: %s\nOUTPUT:\n%s", static::getKey(), $message, $command, $tester->getDisplay(TRUE)));
+ }
+ }
+
}
diff --git a/tests/e2e/IdempotentUpgradeTest.php b/tests/e2e/IdempotentUpgradeTest.php
index 922df6f6..ec20046e 100644
--- a/tests/e2e/IdempotentUpgradeTest.php
+++ b/tests/e2e/IdempotentUpgradeTest.php
@@ -25,8 +25,8 @@ public function setUp(): void {
public function testBasicUpgrade(): void {
// Make an example
$this->civixGeneratePage('MyPage', 'civicrm/thirty');
+ $this->civixGenerateUpgrader(); /* TODO: Make this implicit with generate:entity */
$this->civixGenerateEntity('MyEntity');
- $this->civixGenerateEntityBoilerplate();
$start = $this->getExtSnapshot();
// Do the upgrade
@@ -49,8 +49,8 @@ public function testBasicUpgrade(): void {
public function testResetVersion0(): void {
// Make an example
$this->civixGeneratePage('MyPage', 'civicrm/thirty');
+ $this->civixGenerateUpgrader(); /* TODO: Make this implicit with generate:entity */
$this->civixGenerateEntity('MyEntity');
- $this->civixGenerateEntityBoilerplate();
$start = $this->getExtSnapshot();
// Do the upgrade
@@ -74,8 +74,8 @@ public function testResetVersion0(): void {
public function testResetVersion2201(): void {
// Make an example
$this->civixGeneratePage('MyPage', 'civicrm/thirty');
+ $this->civixGenerateUpgrader(); /* TODO: Make this implicit with generate:entity */
$this->civixGenerateEntity('MyEntity');
- $this->civixGenerateEntityBoilerplate();
$start = $this->getExtSnapshot();
// Do the upgrade
diff --git a/upgrades/24.09.0.up.php b/upgrades/24.09.0.up.php
new file mode 100644
index 00000000..1e6bd7f2
--- /dev/null
+++ b/upgrades/24.09.0.up.php
@@ -0,0 +1,25 @@
+coreHasPathload()) {
+ $steps[] = 'Create mixin/lib/pathload-0.php';
+ }
+ if (!Civix::checker()->coreProvidesLibrary('civimix-schema@5')) {
+ $steps[] = 'Create mixin/lib/' . basename($gen->mixinLibraries->available['civimix-schema@5']->file);
+ }
+
+ if (\Civix::checker()->hasUpgrader() && $steps) {
+ \Civix::io()->note([
+ "This update adds a new helper, E::schema(), which requires the library civimix-schema@5. To enable support for older versions of CiviCRM (<5.73), the update will:\n\n" . Formatting::ol("%s\n", $steps),
+ ]);
+ }
+
+};
diff --git a/upgrades/24.09.1.up.php b/upgrades/24.09.1.up.php
new file mode 100644
index 00000000..bd810861
--- /dev/null
+++ b/upgrades/24.09.1.up.php
@@ -0,0 +1,92 @@
+search('glob:sql/auto_*.sql');
+ $relSqlFiles = array_map([\CRM\CivixBundle\Utils\Files::class, 'relativize'], $sqlFiles);
+
+ $oldClass = (string) $gen->infoXml->get()->upgrader;
+ $newClass = sprintf('CiviMix\\Schema\\%s\\AutomaticUpgrader', Naming::createCamelName($gen->infoXml->getFile()));
+ $delegateClass = Naming::createClassName($gen->infoXml->getNamespace(), 'Upgrader');
+
+ $steps = [];
+ $xmlSchemaFiles = civix::extDir()->search('find:xml/schema/*.xml');
+ if (!empty($xmlSchemaFiles)) {
+ $steps[] = "Convert xml/schema/*.xml to schema/*.entityType.php";
+ $steps[] = "Regenerate CRM/*/DAO/*.php";
+ $steps[] = "Update mixin entity-types-php@1 to entity-types-php@2";
+ }
+
+ if ($oldClass && $oldClass !== $newClass) {
+ $steps[] = "Update info.xml to use $newClass (which delegates to $delegateClass)";
+ }
+ else {
+ $steps[] = "Update info.xml to use $newClass";
+ }
+ if (!empty($relSqlFiles)) {
+ $steps[] = 'Delete ' . implode(' and ', $relSqlFiles);
+ }
+
+ if (empty($steps)) {
+ return;
+ }
+
+ $warnings = [
+ "Your target environment is CiviCRM v5.44 or earlier.",
+ "The SQL files include custom/manual statements.",
+ ];
+ if ($oldClass) {
+ // $warnings[] = "$delegateClass class has non-standard revision tracking (such as Step-NNN class-files).";
+ $warnings[] = "The class $delegateClass overrides any internal plumbing (e.g. setCurrentRevision(), appendTask(), or getRevisions())";
+ }
+ if ($oldClass && $oldClass !== $delegateClass) {
+ $warnings[] = "The old upgrader ($oldClass) does not match the expected name ($delegateClass).";
+ }
+
+ $notes = [
+ "This update converts data-storage from Entity Framework v1 (EFv1) to Entity Framework v2 (EFv2).",
+ "EFv2 stores schema as *.php. It has simpler workflows and less boilerplate. SQL is generated during installation. (More details: https://github.com/totten/civix/wiki/Entity-Templates)",
+ // "For a full comparison, see: https://github.com/totten/civix/wiki/Entity-Templates",
+ "The upgrader will make the following changes:\n\n" . Formatting::ol("%s\n", $steps),
+ "This should work for many extensions, but it should be tested. You may encounter issues if any of these scenarios apply:\n\n" . Formatting::ol("%s\n", $warnings),
+ "You may skip this update. However, going forward, civix will only support EFv2. You will be responsible for maintaining any boilerplate for EFv1.",
+ ];
+
+ Civix::io()->title("Entity Framework v1 => v2");
+ Civix::io()->note($notes);
+
+ $actions = [
+ 'y' => 'Yes, update to Entity Framework v2',
+ 'n' => 'No, stay on Entity Framework v1',
+ 'a' => 'Abort',
+ ];
+ $action = Civix::io()->choice("Should we apply the update?", $actions, 'y');
+ if ($action === 'a') {
+ throw new \RuntimeException('User stopped upgrade');
+ }
+ if ($action === 'n') {
+ return;
+ }
+
+ // OK go!
+
+ // The logic to toggle 'pathload' and `civimix-schema@5` is actually
+ // a general/recurring update in CRM\CivixBundle\Builder\Module::save().
+ // But it only applies if the $newClass has been set.
+
+ if (!empty($oldClass)) {
+ $gen->updateInfo(function(\CRM\CivixBundle\Builder\Info $info) use ($newClass) {
+ $info->get()->upgrader = $newClass;
+ });
+ }
+ foreach ($sqlFiles as $file) {
+ $gen->removeFile($file);
+ }
+
+ Civix::boot(['output' => Civix::output()]);
+ \CRM\CivixBundle\Command\ConvertEntityCommand::convertEntities($xmlSchemaFiles, FALSE);
+
+};