From d93aa61dd6a1571092043fed2a65478814e29bb1 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sat, 22 Nov 2025 07:51:05 -0500 Subject: [PATCH] Additional fix for CloneCommand (CFM-127) --- app/composer.json | 6 +- app/src/Command/CloneCommand.php | 152 +++++++++++++++------ app/src/Lib/Traits/PluggableModelTrait.php | 22 ++- app/src/Lib/Traits/TableMetaTrait.php | 15 +- app/src/Lib/Util/TableUtilities.php | 74 +++++++++- 5 files changed, 219 insertions(+), 50 deletions(-) diff --git a/app/composer.json b/app/composer.json index b5a21792a..b1d1e02a1 100644 --- a/app/composer.json +++ b/app/composer.json @@ -1,9 +1,9 @@ { "name": "cakephp/app", - "description": "CakePHP skeleton app", - "homepage": "https://cakephp.org", + "description": "COmanage Registry PE", + "homepage": "https://incommon.org/software/comanage/", "type": "project", - "license": "MIT", + "license": "Apache 2", "require": { "php": ">=8.1", "ext-intl": "*", diff --git a/app/src/Command/CloneCommand.php b/app/src/Command/CloneCommand.php index 98a57a934..f47ef5880 100644 --- a/app/src/Command/CloneCommand.php +++ b/app/src/Command/CloneCommand.php @@ -139,6 +139,22 @@ public function execute(Arguments $args, ConsoleIo $io) { // Note the command line flag is "target" but we use the connection name // "remote" to clarify that it's a different datasource. + // In order for the remote datasource to work correctly, all the stars must be + // correctly aligned. In particular, the related model assocations will be rekeyed + // below from (eg) ["FooWidgets" => ["FooWidgetRecords"]] to + // ["RemoteFooWidgets" => ["RemoteFooWidgetRecords"]]. This then implies that the + // entity values must also be rekeyed ($entity->foo_widgets becomes + // $entity->remote_foo_widgets) and (importantly) that the TableLocator can resolve + // "RemoteFooWidgets" to a Table with alias "RemoteFooWidgets" but class + // "FooWidgetPlugin.FooWidgets". Most of this is handled below, but the plugin + // resolution is handled in PluggableModelTrait. + + // When debugging this, keep in mind Cake will autocreate tables that it can't find + // Table files for ($TableLocator->allowFallbackClass). (As of Registry v5.2.0 we + // can't simply turn that off since a bunch of other stuff breaks; CFM-405.) Telltale + // signs include entities whose path is \Cake\ORM\Entity rather than + // \FooWidget\Model\Entity\FooWidget (and Tables with similarly generic classpaths.) + $targetDS = 'remote'; $SqlServer = TableRegistry::getTableLocator()->get('CoreServer.SqlServers'); @@ -427,39 +443,81 @@ protected function cloneEntity( $targetRelated = $related; if(!empty($targetRelated)) { - // We need to convert $related to use the same prefix that getTableWithDataSource uses. - // While we're here, rekey $copy as well. - if($targetDataSource != 'default') { + // We need to convert $related to use the same prefix that getTableWithDataSource uses. + // We create our own anonymous function here rather than use array_map to prefix + // the array entries because array_may doesn't quite work correctly with Cake's + // complicated relations notation. (We don't use normalizeAssocationArray because we + // want to keep $related in the same form as it was originally specified.) + $prefix = \Cake\Utility\Inflector::camelize($targetDataSource); - $fn = function(string $s) use ($prefix): string { - return $prefix . $s; - }; + $fn = function($related, $prefix) use (&$fn) { + $ret = []; - $targetRelated = array_map($fn, $related); + foreach($related as $k => $v) { + if(is_int($k)) { + // [0 = 'Foo'] - // This is similar to what we do for related models, below + $ret[] = $prefix.$v; + } elseif(is_array($v)) { + // ['Foo' => ['Bar']] - foreach($related as $r) { - // eg: http_servers - $pluralSource = Inflector::underscore($r); - // eg: remote_http_servers - $pluralTarget = $targetDataSource . "_" . $pluralSource; + $ret[$prefix.$k] = $fn($v, $prefix); + } else { + // ['Foo' => 'Bar] - if(!empty($copy[$pluralSource])) { - $copy[$pluralTarget] = $copy[$pluralSource]; - unset($copy[$pluralSource]); + $ret[$prefix.$k] = [$prefix.$v]; + } } - $singularSource = Inflector::singularize($pluralSource); - $singularTarget = Inflector::singularize($pluralTarget); + return $ret; + }; + + $targetRelated = $fn($related, $prefix); + + // While we're here, rekey $copy as well. This is similar to what we do + // for related models, below, but here we operate on an array and below + // we operate on an entity. + + $fn2 = function($copy, $related, $targetDataSource) use (&$fn2) { + // Because $related is passed in normalized, we can expect it to always + // be in $model => [ $associated ] notation. + + foreach($related as $rm => $ra) { + // We need to check both singular (hasOne) and plural (hasMany) + + // eg: http_servers + $pluralSource = Inflector::underscore($rm); + // eg: remote_http_servers + $pluralTarget = $targetDataSource . "_" . $pluralSource; + + if(!empty($copy[$pluralSource])) { + $copy[$pluralTarget] = $copy[$pluralSource]; + unset($copy[$pluralSource]); + + if(!empty($ra)) { + $copy[$pluralTarget] = $fn2($copy[$pluralTarget], $ra, $targetDataSource); + } + } + + $singularSource = Inflector::singularize($pluralSource); + $singularTarget = Inflector::singularize($pluralTarget); + + if(!empty($copy[$singularSource])) { + $copy[$singularTarget] = $copy[$singularSource]; + unset($copy[$singularSource]); - if(!empty($copy[$singularSource])) { - $copy[$singularTarget] = $copy[$singularSource]; - unset($copy[$singularSource]); + if(!empty($ra)) { + $copy[$singularTarget] = $fn2($copy[$singularTarget], $ra, $targetDataSource); + } + } } - } + + return $copy; + }; + + $copy = $fn2($copy, TableUtilities::normalizeAssociationArray($related), $targetDataSource); } $query = $query->contain($targetRelated); @@ -510,27 +568,45 @@ protected function cloneEntity( if($targetDataSource != 'default' && !empty($related)) { // We need to rekey the related models, and we need to check both singular (hasOne) - // and plural (hasMany). + // and plural (hasMany). This is similar to $fn2 above, but we operate on an entity + // rather than an array here. - foreach($related as $r) { - // eg: http_servers - $pluralSource = Inflector::underscore($r); - // eg: remote_http_servers - $pluralTarget = $targetDataSource . "_" . $pluralSource; + $fn3 = function($clone, $related, $targetDataSource) use(&$fn2) { + // Because $related is passed in normalized, we can expect it to always + // be in $model => [ $associated ] notation. - if(!empty($clone->$pluralSource)) { - $clone->$pluralTarget = $clone->$pluralSource; - unset($clone->$pluralSource); - } + foreach($related as $rm => $ra) { + // eg: http_servers + $pluralSource = Inflector::underscore($rm); + // eg: remote_http_servers + $pluralTarget = $targetDataSource . "_" . $pluralSource; + + if(!empty($clone->$pluralSource)) { + $clone->$pluralTarget = $clone->$pluralSource; + unset($clone->$pluralSource); - $singularSource = Inflector::singularize($pluralSource); - $singularTarget = Inflector::singularize($pluralTarget); + if(!empty($ra)) { + $clone->$pluralTarget = $fn3($clone->pluralTarget, $ra, $targetDataSource); + } + } + + $singularSource = Inflector::singularize($pluralSource); + $singularTarget = Inflector::singularize($pluralTarget); + + if(!empty($clone->$singularSource)) { + $clone->$singularTarget = $clone->$singularSource; + unset($clone->$singularSource); - if(!empty($clone->$singularSource)) { - $clone->$singularTarget = $clone->$singularSource; - unset($clone->$singularSource); + if(!empty($ra)) { + $clone->$singularTarget = $fn3($clone->singularTarget, $ra, $targetDataSource); + } + } } - } + + return $clone; + }; + + $clone = $fn3($clone, TableUtilities::normalizeAssociationArray($related), $targetDataSource); } // Since we're managing the entity, we can skip the UUID duplication check diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php index ebbb7537f..31702e151 100644 --- a/app/src/Lib/Traits/PluggableModelTrait.php +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -116,6 +116,27 @@ public function checkCloneDependencies( 'status' => SuspendableStatusEnum::Active ]) ->firstOrFail(); + + // While we're here, we instantiate RemoteModel aliases for each of the plugin + // relations (recursively) so Cake's TableLocator can find the correctly instantiated + // model (attached to the remote data source) at save(). (For further discussion, see + // the comments in CloneCommand::execute().) We don't actually need the Table + // objects here, we just want to make sure the TableRegistry is correctly set up. + + $related = TableUtilities::normalizeAssociationArray($this->getCloneRelations()); + + $fn = function($related, $targetDataSource, $pluginName) use (&$fn) { + foreach($related as $rm => $ra) { + TableUtilities::getTableWithDataSource( + tableName: $pluginName.".".$rm, + connectionName: $targetDataSource + ); + + $fn($ra, $targetDataSource, $pluginName); + } + }; + + $fn($related, $targetDataSource, $pluginName); } } @@ -261,7 +282,6 @@ protected function setPluginRelations() { connectionName: $datasource ); - $models = $Plugins->getActivePluginModels($this->getPluggableModelType()); foreach($models as $plugin) { diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index 741155217..9c5a1327c 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -307,6 +307,13 @@ public function fixCloneForeignKeys( foreach($OriginalTable->associations()->getByType(['hasOne', 'hasMany']) as $rassn) { // We use the property name to find the sub-entity $property = $rassn->getProperty(); + $tproperty = $property; + + if($dataSource != 'default') { + // The target property is prefixed with the datasource name + + $tproperty = $dataSource . "_" . $property; + } if(!empty($original->$property)) { // We have a non-empty related entity, eg $server->match_sever @@ -320,7 +327,7 @@ public function fixCloneForeignKeys( foreach($original->$property as $rorig) { // Walk the clones until we find a match - foreach($clone->$property as $rclone) { + foreach($clone->$tproperty as $rclone) { // $rclone might be an array or it might be an entity. When Cake marshals // an array into an entity, it sometimes leaves subrelations as arrays // apparently at least in some cases those provided by plugins since it @@ -357,13 +364,13 @@ public function fixCloneForeignKeys( } } - $clone->$property = $fixed; + $clone->$tproperty = $fixed; } else { // hasOne - $clone->$property = $this->fixCloneForeignKeys( + $clone->$tproperty = $this->fixCloneForeignKeys( $original->$property, - $clone->$property, + $clone->$tproperty, $targetCoId, $dataSource ); diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index 63e0a4f52..6c831eab5 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -62,23 +62,89 @@ public static function getTableWithDataSource( } // Start with the prefix (eg: "Remote") - $modelName = Inflector::camelize($connectionName); + $prefix = Inflector::camelize($connectionName); + $modelName = $prefix; + $pluginName = null; if(str_contains($tableName, '.')) { - // now (eg) SqlServers + // (eg) SqlServers $modelName .= StringUtilities::PluginModel($tableName); + $pluginName = StringUtilities::PluginPlugin($tableName); } else { - // eg, "People" or "CoreServer.SqlServers" + // eg, "People" $modelName .= $tableName; } + // In order to prevent infinite recursion, first see if we have the requested table already + $Locator = TableRegistry::getTableLocator(); + + if($Locator->exists($modelName)) { + return $Locator->get($modelName); + } + $mergedOptions = $options; $mergedOptions['alias'] = $modelName; $mergedOptions['className'] = $tableName; $mergedOptions['connectionName'] = $connectionName; - return self::getTableFromRegistry($modelName, $mergedOptions); + $m = self::getTableFromRegistry($modelName, $mergedOptions); + + // Relabel associations with $prefix. Some will already be correctly set up, in particular + // dynamic plugin assocations created via PluggableModelTrait::setPluginRelations, so we + // check for and skip those. (We're actually doing something similar to that code, here.) + + $assns = $m->associations(); + + foreach($assns as $a) { + // Each association will have the requested table ($tableName) as its source side, + // so we just need to check that the target is also using $connectionName. + + $target = $a->getTarget(); + + if($target->getConnection()->configName() != $connectionName) { + // Association type: BelongsTo, HasMany, HasOne; we lowercase the initial letter + // to match the Table function name. + $r = new \ReflectionClass($a); + $aType = Inflector::variable($r->getShortName()); + + // The (new) prefixed alias (eg: RemoteIdentifiers) + $targetAlias = $prefix . $target->getAlias(); + + // The class name we are trying to create. We need to handle plugins here. + // If $pluginName is set, we'll assume HasMany and HasOne relations are within + // the same plugin. + $className = $target->getAlias(); + + if($pluginName && ($aType == 'hasMany' || $aType == 'hasOne')) { + $className = $pluginName . "." . $className; + } + + // We create a new association for the requested connection name. + // Cake doesn't provide a mechanism to drop the old association, so we just ignore it. + + // Don't recurse here! + $aTargetTable = self::getTableFromRegistry( + alias: $targetAlias, + options: [ + 'alias' => $targetAlias, + 'className' => $className, + 'connectionName' => $connectionName + ] + ); + + if(!$aTargetTable->hasAssociation($targetAlias)) { + $m->$aType($targetAlias) + ->setClassName($target->getAlias()) + ->setForeignKey(StringUtilities::tableToForeignKey($target)) + ->setCascadeCallbacks(true) + ->setTarget($aTargetTable); + // Unlike PluggableTrait we don't setDependent(), it's not clear if we need to... + } + } + } + + return $m; } /**