Skip to content

Commit

Permalink
Additional fixes to Clone Command (CO-479)
Browse files Browse the repository at this point in the history
  • Loading branch information
Benn Oshrin committed Feb 18, 2026
1 parent 0fdbe49 commit 37bfc2d
Show file tree
Hide file tree
Showing 31 changed files with 303 additions and 140 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ class ApiSourceRecord extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable-related'];
}
3 changes: 2 additions & 1 deletion app/plugins/CoreAssigner/config/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"format_assigner_sequences_i1": { "columns": [ "format_assigner_id", "affix" ], "unique": true },
"format_assigner_sequences_i2": { "needed": false, "columns": [ "format_assigner_id" ] }
},
"changelog": false
"changelog": false,
"clone_relation": true
},
"sql_assigners": {
"columns": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ class FormatAssignerSequence extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable-related'];
}
9 changes: 3 additions & 6 deletions app/plugins/CoreServer/config/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@
},
"indexes": {
"oauth2_servers_i1": { "columns": [ "server_id" ] }
},
"clone_relation": true
}
},
"smtp_servers": {
"columns": {
Expand All @@ -86,8 +85,7 @@
},
"indexes": {
"smtp_servers_i1": { "columns": [ "server_id" ] }
},
"clone_relation": true
}
},
"sql_servers": {
"columns": {
Expand All @@ -102,8 +100,7 @@
},
"indexes": {
"sql_servers_i1": { "columns": [ "server_id" ]}
},
"clone_relation": true
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,6 @@ class MatchServerAttribute extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable-related'];
}
3 changes: 3 additions & 0 deletions app/resources/locales/en_US/field.po
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ msgstr "Order"
msgid "organization"
msgstr "Organization"

msgid "originalid"
msgstr "Original Record ID"

msgid "parameters"
msgstr "Parameters"

Expand Down
79 changes: 71 additions & 8 deletions app/src/Command/CloneCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -474,11 +474,25 @@ protected function cloneEntity(

if(!empty($hasOne)) {
if($targetDataSource != 'default') {
// Prefix the hasOne relations (we're assuming only one level for now,
// so no recursion) with the data source label.

foreach($hasOne as $h) {
// Prefix the hasOne relations (we're assuming only one level for now,
// so no recursion) with the data source label.

$targetHasOne[] = $prefix.$h;

// Also, while we're here prefix the property, if set.
// (eg: $copy['api_source'] -> $copy['remote_api_source'])
// (This could arguably be done in filterMetadataForCopy, but as a general
// rule we seem to be managing datasource-specific prefixing here in
// CloneCommand.)

$property = Inflector::singularize(Inflector::underscore($h));
$targetProperty = $targetDataSource . "_" . $property;

if(array_key_exists($property, $copy)) {
$copy[$targetProperty] = $copy[$property];
unset($copy[$property]);
}
}
} else {
$targetHasOne = $hasOne;
Expand Down Expand Up @@ -657,9 +671,8 @@ protected function cloneEntity(
$this->io->out($sourceIterator->count() . " records in source to sync");

foreach($sourceIterator as $srcent) {

$targetent = $TargetRelatedTable->find()
->where(['original_id' => $srcent->id])
->where(['originalid' => $srcent->id])
// There should be at most one
->first();

Expand Down Expand Up @@ -734,8 +747,8 @@ protected function cloneEntity(
// correct it, but this is clearer)
$targetent->$parent_key = $cloneId;

// Insert original_id, _after_ fixing the foreign keys
$targetent->original_id = $srcent->id;
// Insert originalid, _after_ fixing the foreign keys
$targetent->originalid = $srcent->id;

$TargetRelatedTable->saveOrFail($targetent);

Expand Down Expand Up @@ -810,7 +823,20 @@ protected function cloneEntity(
}
}

$tcxn->commit();
// Perform any table specific follow up tasks. Unlike checkCloneDependencies
// this call may perform work.

if(method_exists($TargetTable, "postClone")) {
try {
$TargetTable->postClone($clone, $targetDataSource);
}
catch(\Exception $e) {
// We catch the Exception to provide context
throw new \Exception($clone->uuid . ": postClone failed: " . $e->getMessage());
}
}

$tcxn->commit();
}
catch(\Exception $e) {
$this->io->err($e->getMessage());
Expand All @@ -819,6 +845,43 @@ protected function cloneEntity(
$tcxn->rollback();
}
}

if(method_exists($Table, "getCloneSuccessors")) {
// Now clone any successor entities

$uuids = $Table->getCloneSuccessors($original);

// We accept both UUIDs and PaginatedSqlIterators in the array. We have to accept
// multiple PaginatedSqlIterators because each one can only handle a single model.
// Failure to clone a successor entity does _not_ fail the primary clone action,
// and does not prevent other successors from being cloned.

$this->io->out("== Processing Successor Entities ==");

foreach($uuids as $uuid) {
if(is_string($uuid)) {
// Simple UUID
try {
$this->cloneByUuid($uuid, $sourceCoId, $targetCoId, $targetDataSource);
}
catch(\Exception $e) {
$this->io->err($uuid . ": " . $e->getMessage());
}
} else {
// PaginatedSqlIterator
foreach($uuid as $entity) {
try {
$this->cloneByUuid($entity->uuid, $sourceCoId, $targetCoId, $targetDataSource);
}
catch(\Exception $e) {
$this->io->err($uuid . ": " . $e->getMessage());
}
}
}
}

$this->io->out("== Finished Processing Successor Entities ==");
}
}

/**
Expand Down
62 changes: 13 additions & 49 deletions app/src/Lib/Traits/PluggableModelTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,29 +123,21 @@ public function checkCloneDependencies(
// 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->getCloneHasMany());

$fn = function($related, $targetDataSource, $pluginName) use (&$fn) {
foreach($related as $rm => $ra) {
TableUtilities::getTableWithDataSource(
tableName: $pluginName.".".$rm,
connectionName: $targetDataSource
);

$fn($ra, $targetDataSource, $pluginName);
}
};

// getCloneRelations() will return all possible relations for all plugins for the
// current model (eg if $original is ExternalIdentitySource, we'll get back an
// array of all EIS plugins), but we only want to instantiate related models
// for $plugin.
// We now only need to handle hasOne relations because hasMany relations are
// handled by iteration

$related = TableUtilities::normalizeAssociationArray($this->getCloneHasOne());

$pluginModel = StringUtilities::pluginPlugin($original->plugin);
if(array_key_exists($original->plugin, $related)) {
// getCloneRelations() will return all possible relations for all plugins for the
// current model (eg if $original is ExternalIdentitySource, we'll get back an
// array of all EIS plugins), but we only want to instantiate the active model

if(!empty($related[$pluginModel])) {
$fn($related[$pluginModel], $targetDataSource, $pluginName);
TableUtilities::getTableWithDataSource(
tableName: $original->plugin,
connectionName: $targetDataSource
);
}
}
}
Expand Down Expand Up @@ -188,40 +180,12 @@ public function getCloneHasOne(): array {
$ret = [];

foreach($this->_pluginModels as $entryPoint) {
$ret[] = $entryPoint; //StringUtilities::pluginModel($entryPoint);
$ret[] = $entryPoint;
}

return $ret;
}

/**
* Get the set of entities that are to be cloned after $original.
*
* The returned array may include both UUIDs (strings) and PaginatedSqlIterators,
* where the Iterator returns only clonable entities.
*
* @since COmanage Registry v5.2.0
* @param EntityInterface $original Current entity being cloned
* @return array Array of UUIDs and/or PaginatedSqlIterators
*/

public function getCloneSuccessors(
\Cake\Datasource\EntityInterface $original
): array {
// We don't really know whether we need to use PaginatedSqlIterator for every
// hasMany relation (without adding annotations of some form), and indeed in most
// cases we probably don't need it (smaller deployments, models with only a few
// related entities), but for the cases where we need it we really need it
// (ApiSourceRecords, EnvSourceIdentities) so we always use it. (The overhead
// for smoller data sets should be marginal.)

// Because PaginatedSqlIterators only operate over a single table, we need to
// return one per hasMany relation.
$ret = [];

return $ret;
}

/**
* Determine the plugin type used by this Pluggable Model. This is the lowercased
* singular prefix of the Pluggable Model Table name. eg: For "ReportsTable" the
Expand Down
9 changes: 5 additions & 4 deletions app/src/Lib/Util/SchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,11 @@ protected function processSchema(
// Add metadata fields for Clonable Related Models. Eventually this setting
// should probably be the default, and disabled when not needed (like changelog).

// original_id is _not_ given a foreign key constraint because the Original
// Object may be in a different database.
$table->addColumn("original_id", "integer", ['notnull' => false]);
$table->addIndex(["original_id"], $tablePrefix.$tName."_icb", [], []);
// We call this "originalid" and not "original_id" because it's not a proper
// foreign key, since minimally it crosses a CO (which would violate AR-GMR-2)
// and maybe crosses CMPs (in which case it's a foreign key to another database).
$table->addColumn("originalid", "integer", ['notnull' => false]);
$table->addIndex(["originalid"], $tablePrefix.$tName."_icb", [], []);
}

// (For Registry) If MVEA models are specified, emit the appropriate
Expand Down
2 changes: 2 additions & 0 deletions app/src/Model/Entity/ApiUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class ApiUser extends Entity {
// can set api_key. (AR-ApiUser-4)
'api_key' => false
];

public array $_supportedMetadata = ['clonable'];

/**
* Hash (bcrypt) an API Key on save.
Expand Down
2 changes: 2 additions & 0 deletions app/src/Model/Entity/Cou.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class Cou extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
2 changes: 2 additions & 0 deletions app/src/Model/Entity/ExternalIdentitySource.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class ExternalIdentitySource extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
3 changes: 3 additions & 0 deletions app/src/Model/Entity/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class Group extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];

/**
* Determine if this entity record can be deleted.
Expand Down Expand Up @@ -73,6 +75,7 @@ public function isAllMembers(): bool {
*/

public function isAutomatic(): bool {
// If this list is updated, CousTable::getCloneSuccessors will need to be updated as well
return in_array($this->group_type, [GroupTypeEnum::ActiveMembers, GroupTypeEnum::AllMembers]);
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/Model/Entity/IdentifierAssignment.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class IdentifierAssignment extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
2 changes: 2 additions & 0 deletions app/src/Model/Entity/Pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class Pipeline extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
2 changes: 2 additions & 0 deletions app/src/Model/Entity/ProvisioningTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class ProvisioningTarget extends Entity {
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];

/**
* Determine if this entity is Active.
*
Expand Down
2 changes: 2 additions & 0 deletions app/src/Model/Entity/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class Server extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
2 changes: 2 additions & 0 deletions app/src/Model/Entity/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ class Type extends Entity {
'id' => false,
'slug' => false,
];

public array $_supportedMetadata = ['clonable'];
}
Loading

0 comments on commit 37bfc2d

Please sign in to comment.