Skip to content

Commit

Permalink
Additional fix for CloneCommand (CFM-127)
Browse files Browse the repository at this point in the history
  • Loading branch information
Benn Oshrin committed Nov 22, 2025
1 parent cc40e6a commit d93aa61
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 50 deletions.
6 changes: 3 additions & 3 deletions app/composer.json
Original file line number Diff line number Diff line change
@@ -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": "*",
Expand Down
152 changes: 114 additions & 38 deletions app/src/Command/CloneCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion app/src/Lib/Traits/PluggableModelTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -261,7 +282,6 @@ protected function setPluginRelations() {
connectionName: $datasource
);


$models = $Plugins->getActivePluginModels($this->getPluggableModelType());

foreach($models as $plugin) {
Expand Down
15 changes: 11 additions & 4 deletions app/src/Lib/Traits/TableMetaTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
);
Expand Down
74 changes: 70 additions & 4 deletions app/src/Lib/Util/TableUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down

0 comments on commit d93aa61

Please sign in to comment.