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 -
civicrm_contact
- 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%% - %%TABLE%%
- 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); + +};