Allows to generate dependent fixture
Hi,
I want to create consistent data when baking fixtures. But using bake without records from database create foreign keys with non existent values. And using bake with database records only fetch data without considering dependant records (belongsTo associations).
So I wrote my proper Fixture Sheel Task class inherit from FixtureTask. The main goal is to store all baking data before writing fixture files in order to get associated models if I'm using --records option and --dependencies option.
When I fetch database records, I add belongsTo records and store It in an object variable. I wrote all files only after getting datas from all models and associated models.
Finally, for one baking file, I can get multiple files written. For my use, it's more efficient because :
- I don't have to modify each fixture file to make exixting foreign Keys throw hundred of files
- I can export a small part of my production data to generate consistent testing data (I have faking function to anonymize production data).
Do you think it's a good approach and could be implemented ? I'm using CakePHP 3.9.
Here is my code
<?php
namespace App\Shell\Task;
use Cake\Console\Shell;
use Cake\Database\Exception;
use Cake\Datasource\ConnectionManager;
use Cake\ORM\TableRegistry;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
/**
* Fixture shell task.
*/
class FixtureTask extends \Bake\Shell\Task\FixtureTask
{
/**
* Store data to each table to bake
* @var array [<tableName> => [<table vars for baking fixture>]]
*/
protected $_stackedData = [];
/**
* Manage the available sub-commands along with their arguments and help
*
* @see http://book.cakephp.org/3.0/en/console-and-shells.html#configuring-options-and-generating-help
* @return \Cake\Console\ConsoleOptionParser
*/
public function getOptionParser()
{
$parser = parent::getOptionParser();
$parser->addOption('dependencies', [
'help' => 'Generate fixture files and records for model dependencies (relations belongsTo).' .
' Used with --records to fetch records from database.',
'short' => 'd',
'boolean' => true,
]);
return $parser;
}
/**
* Execution method always used for tasks
* Handles dispatching to interactive, named, or all processes.
*
* @param string|null $name The name of the fixture to bake.
* @return null|bool
*/
public function main($name = null)
{
\Bake\Shell\Task\BakeTask::main();
$name = $this->_getName($name);
if (empty($name)) {
$this->out('Choose a fixture to bake from the following:');
foreach ($this->Model->listUnskipped() as $table) {
$this->out('- ' . $this->_camelize($table));
}
return true;
}
$table = null;
if (isset($this->params['table'])) {
$table = $this->params['table'];
}
$model = $this->_camelize($name);
$this->_stackModel($model, $table);
$this->_bakeStakedModels();
}
/**
* Bake All the Fixtures at once. Will only bake fixtures for models that exist.
*
* @return void
*/
public function all()
{
$tables = $this->Model->listUnskipped();
foreach ($tables as $table) {
$name = $this->_getName($table);
$model = $this->_camelize($name);
$this->_stackModel($model);
}
$this->_bakeStakedModels();
}
/**
* Interact with the user to get a custom SQL condition and use that to extract data
* to build a fixture.
* Stack associated data if dependencies is set
*
* @param string $modelName name of the model to take records from.
* @param string|null $useTable Name of table to use.
* @return array Array of records.
*/
protected function _getRecordsFromTable($modelName, $useTable = null)
{
$recordCount = (isset($this->params['count']) ? $this->params['count'] : 10);
$conditions = (isset($this->params['conditions']) ? $this->params['conditions'] : '1=1');
if (TableRegistry::getTableLocator()->exists($modelName)) {
$model = TableRegistry::getTableLocator()->get($modelName);
} else {
$model = TableRegistry::getTableLocator()->get($modelName, [
'table' => $useTable,
'connection' => ConnectionManager::get($this->connection),
]);
}
$records = $model->find('all')
->where($conditions)
->limit($recordCount)
->enableHydration(false);
$associations = [];
if ($this->params['dependencies']) {
foreach ($model->associations() as $assoc) {
if ($assoc->type() === \Cake\ORM\Association::MANY_TO_ONE) {
$records->contain($assoc->getAlias());
$associations[] = $assoc;
$this->out(sprintf('- Adding %s dependency for %s', $assoc->getTable(), $model->getTable()), 1, Shell::QUIET);
}
}
}
$records = $records->toArray();
if (!empty($associations)) {
foreach ($associations as $assoc) {
$property = $assoc->getProperty();
$assocRecords = $this->_extractDataFromRecords($records, $assoc->getBindingKey(), $property);
$records = Hash::remove($records, '{n}.' . $property);
// Get the target classname
$target = namespaceSplit(get_class($assoc->getTarget()));
$className = substr(end($target), 0, -5);
// Generate structure data for associated table
$table = $assoc->getTable();
$import = null;
if (!empty($importBits) && $this->connection !== 'default') {
$importBits = [
"'connection' => '{$this->connection}'"
];
$import = sprintf("[%s]", implode(', ', $importBits));
}
$schema = $this->_generateSchema($this->readSchema($className, $table));
$this->_stackData($className, ['records' => $assocRecords, 'table' => $table, 'schema' => $schema, 'import' => $import]);
}
}
return $this->_extractDataFromRecords($records, $model->getPrimaryKey());
}
/**
* Transform an array of records as an associated array :
* - key : join of primarey key values
* - values : values of each record or subset of record if property is set
*
* @param array $records Records to extract from
* @param array|string $pk primarey key used to set the associative array key
* @param string|null $property used to get a subset data from records according to property name
* @return array Array of records
*/
protected function _extractDataFromRecords($records, $pk, $property = null)
{
if (empty($records)) {
return $records;
}
if (!empty($property)) {
$records = Hash::extract($records, '{n}.' . $property);
$records = Hash::filter($records);
}
$pk = (array)$pk;
$extract = $formatKey = [];
foreach ($pk as $key) {
$extract[] = '{n}.' . $key;
$formatKey[] = '%s';
}
$formatKey = join('.', $formatKey);
$keyPath = array_merge([$formatKey], $extract);
return Hash::combine($records, $keyPath, '{n}');
}
/**
* Create all fixtures stored in stacked data
* @return void
*/
protected function _bakeStakedModels()
{
foreach ($this->_stackedData as $model => $stacked) {
if (isset($stacked['records'])) {
$stacked['records'] = $this->_makeRecordString(array_values($stacked['records']));
}
$this->generateFixtureFile($model, $stacked);
}
}
/**
* Assembles and store data to bake a Fixture file
*
* @param string $model Name of model to bake.
* @param string|null $useTable Name of table to use.
* @return void
* @throws \RuntimeException
*/
protected function _stackModel($model, $useTable = null)
{
$table = $schema = $import = $modelImport = $records = null;
$this->out("\n" . sprintf('Assemble data for %s...', $model), 1, Shell::QUIET);
if (!$useTable) {
$useTable = Inflector::tableize($model);
} elseif ($useTable !== Inflector::tableize($model)) {
$table = $useTable;
}
$importBits = [];
if (!empty($this->params['schema'])) {
$modelImport = true;
$importBits[] = "'table' => '{$useTable}'";
}
if (!empty($importBits) && $this->connection !== 'default') {
$importBits[] = "'connection' => '{$this->connection}'";
}
if (!empty($importBits)) {
$import = sprintf("[%s]", implode(', ', $importBits));
}
try {
$data = $this->readSchema($model, $useTable);
} catch (Exception $e) {
TableRegistry::getTableLocator()->remove($model);
$useTable = Inflector::underscore($model);
$table = $useTable;
$data = $this->readSchema($model, $useTable);
}
if ($modelImport === null) {
$schema = $this->_generateSchema($data);
}
if (empty($this->params['records'])) {
$recordCount = 1;
if (isset($this->params['count'])) {
$recordCount = $this->params['count'];
}
$records = $this->_generateRecords($data, $recordCount);
}
if (!empty($this->params['records'])) {
$records = $this->_getRecordsFromTable($model, $useTable);
}
$this->_stackData($model, compact('records', 'table', 'schema', 'import'));
}
/**
* Store Model data to the stack
* @param string $model Name of model to bake.
* @param array $data Data to store
* @return void
*/
protected function _stackData($model, $data)
{
if (!array_key_exists($model, $this->_stackedData)) {
$this->_stackedData[$model] = $data;
} else {
$this->_stackedData[$model] = Hash::merge($this->_stackedData[$model], $data);
}
}
}
Best Regards