diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3ad2b2965..748ac52ba 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -25,11 +25,6 @@ parameters: count: 4 path: src/Propel/Common/Config/PropelConfiguration.php - - - message: "#^Access to an undefined property Propel\\\\Generator\\\\Model\\\\Table\\:\\:\\$isArchiveTable\\.$#" - count: 1 - path: src/Propel/Generator/Behavior/Archivable/ArchivableBehavior.php - - message: "#^Call to an undefined method Propel\\\\Generator\\\\Config\\\\GeneratorConfigInterface\\:\\:get\\(\\)\\.$#" count: 1 @@ -40,6 +35,11 @@ parameters: count: 1 path: src/Propel/Generator/Behavior/ConcreteInheritance/ConcreteInheritanceBehavior.php + - + message: "#^Parameter \\#1 \\$unique of method Propel\\\\Generator\\\\Model\\\\Table\\:\\:addUnique\\(\\) expects array\\|Propel\\\\Generator\\\\Model\\\\Unique, Propel\\\\Generator\\\\Model\\\\Index given\\.$#" + count: 1 + path: src/Propel/Generator/Behavior/SyncedTable/TableSyncer.php + - message: "#^Access to an undefined property Propel\\\\Generator\\\\Model\\\\Table\\:\\:\\$isVersionTable\\.$#" count: 1 @@ -195,19 +195,9 @@ parameters: count: 1 path: src/Propel/Generator/Manager/AbstractManager.php - - - message: "#^Strict comparison using \\=\\=\\= between int and null will always evaluate to false\\.$#" - count: 1 - path: src/Propel/Generator/Model/Domain.php - - - - message: "#^Strict comparison using \\=\\=\\= between array\\ and null will always evaluate to false\\.$#" - count: 1 - path: src/Propel/Generator/Model/Table.php - - message: "#^Call to an undefined method Propel\\\\Generator\\\\Config\\\\GeneratorConfigInterface\\:\\:get\\(\\)\\.$#" - count: 2 + count: 1 path: src/Propel/Generator/Platform/MysqlPlatform.php - @@ -380,11 +370,6 @@ parameters: count: 1 path: src/Propel/Runtime/Collection/ObjectCombinationCollection.php - - - message: "#^Return type \\(Propel\\\\Runtime\\\\Collection\\\\OnDemandIterator\\) of method Propel\\\\Runtime\\\\Collection\\\\OnDemandCollection\\:\\:getIterator\\(\\) should be compatible with return type \\(Propel\\\\Runtime\\\\Collection\\\\CollectionIterator\\) of method Propel\\\\Runtime\\\\Collection\\\\Collection\\:\\:getIterator\\(\\)$#" - count: 1 - path: src/Propel/Runtime/Collection/OnDemandCollection.php - - message: "#^Call to an undefined method Propel\\\\Runtime\\\\Connection\\\\ConnectionWrapper\\:\\:getProfiler\\(\\)\\.$#" count: 3 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b213fa1c3..4a44622d3 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -11,7 +11,7 @@ - + $versionTable->isVersionTable diff --git a/src/Propel/Generator/Behavior/Archivable/ArchivableBehavior.php b/src/Propel/Generator/Behavior/Archivable/ArchivableBehavior.php index c34548e00..8159c73c8 100644 --- a/src/Propel/Generator/Behavior/Archivable/ArchivableBehavior.php +++ b/src/Propel/Generator/Behavior/Archivable/ArchivableBehavior.php @@ -8,49 +8,39 @@ namespace Propel\Generator\Behavior\Archivable; +use Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior; use Propel\Generator\Builder\Om\AbstractOMBuilder; use Propel\Generator\Exception\InvalidArgumentException; -use Propel\Generator\Exception\SchemaException; -use Propel\Generator\Model\Behavior; use Propel\Generator\Model\Column; -use Propel\Generator\Model\ForeignKey; -use Propel\Generator\Model\Index; use Propel\Generator\Model\Table; -use Propel\Generator\Platform\PgsqlPlatform; -use Propel\Generator\Platform\PlatformInterface; -use Propel\Generator\Platform\SqlitePlatform; /** * Keeps tracks of an ActiveRecord object, even after deletion * * @author Francois Zaninotto */ -class ArchivableBehavior extends Behavior +class ArchivableBehavior extends SyncedTableBehavior { /** - * Default parameters value + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::DEFAULT_SYNCED_TABLE_SUFFIX * - * @var array + * @var string DEFAULT_SYNCED_TABLE_SUFFIX */ - protected $parameters = [ - 'archive_table' => '', - 'archive_phpname' => null, - 'archive_class' => '', - 'sync' => 'false', - 'inherit_foreign_key_relations' => 'false', - 'inherit_foreign_key_constraints' => 'false', - 'foreign_keys' => null, - 'log_archived_at' => 'true', - 'archived_at_column' => 'archived_at', - 'archive_on_insert' => 'false', - 'archive_on_update' => 'false', - 'archive_on_delete' => 'true', - ]; + protected const DEFAULT_SYNCED_TABLE_SUFFIX = '_archive'; /** - * @var \Propel\Generator\Model\Table|null + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::PARAMETER_KEY_SYNCED_TABLE + * + * @var string */ - protected $archiveTable; + public const PARAMETER_KEY_SYNCED_TABLE = 'archive_table'; + + /** + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::PARAMETER_KEY_SYNCED_PHPNAME + * + * @var string + */ + public const PARAMETER_KEY_SYNCED_PHPNAME = 'archive_phpname'; /** * @var \Propel\Generator\Behavior\Archivable\ArchivableBehaviorObjectBuilderModifier|null @@ -63,18 +53,29 @@ class ArchivableBehavior extends Behavior protected $queryBuilderModifier; /** - * @return void + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::getDefaultParameters() + * + * @return array */ - public function modifyDatabase(): void + protected function getDefaultParameters(): array { - foreach ($this->getDatabase()->getTables() as $table) { - if ($table->hasBehavior($this->getId())) { - // don't add the same behavior twice - continue; - } - $b = clone $this; - $table->addBehavior($b); - } + return [ + static::PARAMETER_KEY_SYNCED_TABLE => '', + static::PARAMETER_KEY_SYNCED_PHPNAME => null, + 'archive_class' => '', + static::PARAMETER_KEY_SYNC => 'false', + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_RELATIONS => 'false', + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS => 'false', + static::PARAMETER_KEY_FOREIGN_KEYS => null, + static::PARAMETER_KEY_SYNC_INDEXES => 'true', + static::PARAMETER_KEY_SYNC_UNIQUE_AS => null, + static::PARAMETER_KEY_EMPTY_ACCESSOR_COLUMNS => 'true', + 'log_archived_at' => 'true', + 'archived_at_column' => 'archived_at', + 'archive_on_insert' => 'false', + 'archive_on_update' => 'false', + 'archive_on_delete' => 'true', + ]; } /** @@ -84,275 +85,40 @@ public function modifyDatabase(): void */ public function modifyTable(): void { - if ($this->getParameter('archive_class') && $this->getParameter('archive_table')) { + if ($this->getParameter('archive_class') && $this->getParameter(static::PARAMETER_KEY_SYNCED_TABLE)) { throw new InvalidArgumentException('Please set only one of the two parameters "archive_class" and "archive_table".'); } if (!$this->getParameter('archive_class')) { - $this->addArchiveTable(); - } - } - - /** - * @return string - */ - protected function getArchiveTableName(): string - { - return $this->getParameter('archive_table') ?: ($this->getTable()->getOriginCommonName() . '_archive'); - } - - /** - * @return void - */ - protected function addArchiveTable(): void - { - $table = $this->getTable(); - $database = $table->getDatabase(); - $archiveTableName = $this->getArchiveTableName(); - - $archiveTableExistsInSchema = $database->hasTable($archiveTableName); - - $this->archiveTable = $archiveTableExistsInSchema ? - $database->getTable($archiveTableName) : - $this->createArchiveTable(); - - if ($archiveTableExistsInSchema && !$this->parameterHasValue('sync', 'true')) { - return; - } - - $this->syncTables(); - } - - /** - * @return \Propel\Generator\Model\Table - */ - protected function createArchiveTable(): Table - { - $sourceTable = $this->getTable(); - $database = $sourceTable->getDatabase(); - - // create the version table - return $database->addTable([ - 'name' => $this->getArchiveTableName(), - 'phpName' => $this->getParameter('archive_phpname'), - 'package' => $sourceTable->getPackage(), - 'schema' => $sourceTable->getSchema(), - 'namespace' => $sourceTable->getNamespace() ? '\\' . $sourceTable->getNamespace() : null, - 'identifierQuoting' => $sourceTable->isIdentifierQuotingEnabled(), - ]); - } - - /** - * @return \Propel\Generator\Model\Table - */ - protected function syncTables(): Table - { - $archiveTable = $this->getArchiveTable(); - $sourceTable = $this->getTable(); - - $columns = $sourceTable->getColumns(); - $this->syncColumns($archiveTable, $columns); - - $this->addArchivedAtColumn($archiveTable); - - $foreignKeys = $this->getParameter('foreign_keys'); - if ($foreignKeys) { - foreach ($foreignKeys as $fkData) { - $this->createForeignKeyFromParameters($archiveTable, $fkData); - } - } - - $inheritFkRelations = $this->parameterHasValue('inherit_foreign_key_relations', 'true'); - $inheritFkConstraints = $this->parameterHasValue('inherit_foreign_key_constraints', 'true'); - if ($inheritFkRelations || $inheritFkConstraints) { - $foreignKeys = $sourceTable->getForeignKeys(); - $this->syncForeignKeys($archiveTable, $foreignKeys, $inheritFkConstraints); - } - - $indexes = $sourceTable->getIndices(); - $platform = $sourceTable->getDatabase()->getPlatform(); - $renameIndexes = $this->isDistinctiveIndexNameRequired($platform); - $this->syncIndexes($archiveTable, $indexes, $renameIndexes); - - $uniqueIndexes = $sourceTable->getUnices(); - $this->syncUniqueIndexes($archiveTable, $uniqueIndexes); - - $behaviors = $sourceTable->getDatabase()->getBehaviors(); - $this->reapplyBehaviors($behaviors); - - return $archiveTable; - } - - /** - * @param \Propel\Generator\Model\Table $archiveTable - * @param array<\Propel\Generator\Model\Column> $columns - * - * @return void - */ - protected function syncColumns(Table $archiveTable, array $columns) - { - foreach ($columns as $sourceColumn) { - if ($archiveTable->hasColumn($sourceColumn)) { - continue; - } - $archiveColumn = clone $sourceColumn; - $archiveColumn->clearReferrers(); - $archiveColumn->setAutoIncrement(false); - $archiveTable->addColumn($archiveColumn); + parent::modifyTable(); } } /** - * @param \Propel\Generator\Model\Table $archiveTable + * @param \Propel\Generator\Model\Table $syncedTable + * @param bool $tableExistsInSchema * * @return void */ - protected function addArchivedAtColumn(Table $archiveTable) + public function addTableElements(Table $syncedTable, $tableExistsInSchema): void { - if (!$this->parameterHasValue('log_archived_at', 'true')) { - return; - } - $columnName = $this->getParameter('archived_at_column'); - if ($archiveTable->hasColumn($columnName)) { - return; - } - $archiveTable->addColumn([ - 'name' => $columnName, - 'type' => 'TIMESTAMP', - ]); + parent::addTableElements($syncedTable, $tableExistsInSchema); + $this->addCustomColumnsToSyncedTable($syncedTable); } /** - * @param \Propel\Generator\Model\Table $archiveTable - * @param array<\Propel\Generator\Model\ForeignKey> $foreignKeys - * @param bool $inheritConstraints + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::addCustomColumnsToSyncedTable() * - * @return void - */ - protected function syncForeignKeys(Table $archiveTable, array $foreignKeys, bool $inheritConstraints) - { - foreach ($foreignKeys as $foreignKey) { - if ($archiveTable->containsForeignKeyWithSameName($foreignKey)) { - continue; - } - $copiedForeignKey = clone $foreignKey; - $copiedForeignKey->setSkipSql(!$inheritConstraints); - $archiveTable->addForeignKey($copiedForeignKey); - } - } - - /** - * @param \Propel\Generator\Model\Table $archiveTable - * @param array<\Propel\Generator\Model\Index> $indexes - * @param bool $rename + * @param \Propel\Generator\Model\Table $syncedTable * * @return void */ - protected function syncIndexes(Table $archiveTable, array $indexes, bool $rename) + protected function addCustomColumnsToSyncedTable(Table $syncedTable) { - foreach ($indexes as $index) { - $copiedIndex = clone $index; - if ($rename) { - // by removing the name, Propel will generate a unique name based on table and columns - $copiedIndex->setName(null); - } - if ($archiveTable->hasIndex($index->getName())) { - continue; - } - $archiveTable->addIndex($copiedIndex); + if ($this->parameterHasValue('log_archived_at', 'true')) { + $this->addColumnFromParameterIfNotExists($syncedTable, 'archived_at_column', ['type' => 'TIMESTAMP']); } } - /** - * Create regular indexes from unique indexes on the given archive table. - * - * The archive table cannot use unique indexes, as even unique data on the - * source table can be archived several times. - * - * @param \Propel\Generator\Model\Table $archiveTable - * @param array<\Propel\Generator\Model\Unique> $uniqueIndexes - * - * @return void - */ - protected function syncUniqueIndexes(Table $archiveTable, array $uniqueIndexes) - { - foreach ($uniqueIndexes as $unique) { - $index = new Index(); - $index->setTable($archiveTable); - foreach ($unique->getColumns() as $columnName) { - $columnDef = [ - 'name' => $columnName, - 'size' => $unique->getColumnSize($columnName), - ]; - $index->addColumn($columnDef); - } - - if ($archiveTable->hasIndex($index->getName())) { - continue; - } - $archiveTable->addIndex($index); - } - } - - /** - * @param array $behaviors - * - * @return void - */ - protected function reapplyBehaviors(array $behaviors) - { - foreach ($behaviors as $behavior) { - if ($behavior instanceof ArchivableBehavior) { - continue; - } - $behavior->modifyDatabase(); - } - } - - /** - * @psalm-param array{name?: string, localColumn: string, foreignTable: string, foreignColumn: string, relationOnly?: string} $fkParameterData - * - * @param \Propel\Generator\Model\Table $table - * @param array $fkParameterData - * - * @throws \Propel\Generator\Exception\SchemaException - * - * @return void - */ - protected function createForeignKeyFromParameters(Table $table, array $fkParameterData): void - { - if ( - empty($fkParameterData['localColumn']) || - empty($fkParameterData['foreignColumn']) - ) { - $tableName = $this->table->getName(); - - throw new SchemaException("Table `$tableName`: Archivable behavior misses foreign key parameters. Please supply `localColumn`, `foreignTable` and `foreignColumn` for every entry"); - } - - $fk = new ForeignKey($fkParameterData['name'] ?? null); - $fk->addReference($fkParameterData['localColumn'], $fkParameterData['foreignColumn']); - $table->addForeignKey($fk); - $fk->loadMapping($fkParameterData); - } - - /** - * @param \Propel\Generator\Platform\PlatformInterface|null $platform - * - * @return bool - */ - protected function isDistinctiveIndexNameRequired(?PlatformInterface $platform): bool - { - return $platform instanceof PgsqlPlatform || $platform instanceof SqlitePlatform; - } - - /** - * @return \Propel\Generator\Model\Table|null - */ - public function getArchiveTable(): ?Table - { - return $this->archiveTable; - } - /** * @param \Propel\Generator\Builder\Om\AbstractOMBuilder $builder * @@ -364,7 +130,7 @@ public function getArchiveTablePhpName(AbstractOMBuilder $builder): string return $this->getParameter('archive_class'); } - $archiveTable = $this->getArchiveTable(); + $archiveTable = $this->getSyncedTable(); $tableStub = $builder->getNewStubObjectBuilder($archiveTable); return $builder->getClassNameFromBuilder($tableStub); @@ -381,7 +147,7 @@ public function getArchiveTableQueryName(AbstractOMBuilder $builder): string return $this->getParameter('archive_class') . 'Query'; } - return $builder->getClassNameFromBuilder($builder->getNewStubQueryBuilder($this->getArchiveTable())); + return $builder->getClassNameFromBuilder($builder->getNewStubQueryBuilder($this->getSyncedTable())); } /** @@ -389,7 +155,7 @@ public function getArchiveTableQueryName(AbstractOMBuilder $builder): string */ public function hasArchiveClass(): bool { - return $this->getParameter('archive_class') ? true : false; + return (bool)$this->getParameter('archive_class'); } /** @@ -397,8 +163,8 @@ public function hasArchiveClass(): bool */ public function getArchivedAtColumn(): ?Column { - if ($this->getArchiveTable() && $this->getParameter('log_archived_at') === 'true') { - return $this->getArchiveTable()->getColumn($this->getParameter('archived_at_column')); + if ($this->getSyncedTable() && $this->getParameter('log_archived_at') === 'true') { + return $this->getSyncedTable()->getColumn($this->getParameter('archived_at_column')); } return null; diff --git a/src/Propel/Generator/Behavior/Auditable/AuditableBehavior.php b/src/Propel/Generator/Behavior/Auditable/AuditableBehavior.php new file mode 100644 index 000000000..62feb7dad --- /dev/null +++ b/src/Propel/Generator/Behavior/Auditable/AuditableBehavior.php @@ -0,0 +1,155 @@ +omitOnSkipSql() && $this->table->isSkipSql()) ? new stdClass() : new AuditableObjectModifier($this); + } + + /** + * @return void + */ + public function modifyTable(): void + { + parent::modifyTable(); + $this->addAggregationColumn($this->table); + } + + /** + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + public function addAggregationColumn(Table $table): void + { + $columnName = $this->aggregationColumnNameOnSource(); + if (!$columnName) { + return; + } + TableSyncer::addColumnIfNotExists($table, $columnName, [ + 'type' => 'INTEGER', + 'defaultValue' => 0, + 'defaultExpression' => 0, + 'required' => true, + ]); + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param bool $tableExistsInSchema + * + * @return void + */ + public function addTableElements(Table $syncedTable, bool $tableExistsInSchema): void + { + parent::addTableElements($syncedTable, $tableExistsInSchema); + $auditedAtColumn = TableSyncer::addColumnIfNotExists($syncedTable, $this->getAuditedAtFieldName(), [ + 'type' => 'TIMESTAMP', + 'defaultExpr' => 'CURRENT_TIMESTAMP', + ]); + $auditEventColumn = TableSyncer::addColumnIfNotExists($syncedTable, $this->getAuditEventFieldName(), [ + 'type' => PropelTypes::ENUM, + 'valueSet' => 'insert, update, delete, pre-audit', + 'required' => true, + ]); + $internalChangedValuesColumn = TableSyncer::addColumnIfNotExists($syncedTable, $this->getInternalChangedValuesFieldName(), [ + 'type' => $this->getChangedValuesFieldType(), + 'size' => $this->getChangedValuesFieldSize(), + ]); + + $fk = $this->findSyncedRelation($syncedTable->getForeignKeys()); + $internalChangedValuesColumnPhpName = $internalChangedValuesColumn->getPhpName(); + + InsertCodeBehavior::addToTable($this, $syncedTable, [ + 'preInsert' => '$this->' . $auditedAtColumn->getName() . ' ??= new DateTime();', + 'objectMethods' => fn (ObjectBuilder $builder) => $this->renderLocalTemplate('auditObjectMethods', [ + 'internalChangedValuesColumnPhpName' => $internalChangedValuesColumnPhpName, + 'restoredChangedValuesColumnPhpName' => $this->getChangedValuesFieldPhpName(), + 'auditObjectName' => $builder->getObjectClassName(), + 'auditIdColumnName' => $this->addPkAs(), + 'auditedAtColumnName' => $auditedAtColumn->getName(), + 'syncedPkColumns' => $this->getSyncedPrimaryKeyColumns($syncedTable), + 'relationToSourceName' => $builder->getFKPhpNameAffix($fk, false), + 'auditEventColumnPhpName' => $auditEventColumn->getPhpName(), + 'queryClassName' => $builder->getQueryClassName(), + ]), + 'objectFilter' => fn (string $script) => $this->removeInternalFieldFromToArrayCode($script, $internalChangedValuesColumnPhpName), + ]); + } + + /** + * @see \Propel\Generator\Builder\Om\ObjectBuilder::addToArray() + * + * @param string $script + * @param string $columnPhpName + * + * @return string + */ + protected function removeInternalFieldFromToArrayCode(string $script, string $columnPhpName): string + { + $pattern = '/^\s+\$keys\[\d+\] => \$this->get' . $columnPhpName . '\(\),\n/m';// remove line "$keys[5] => $this->getInternalChangedValues()," + + return preg_replace($pattern, '', $script, 1); + } + + /** + * @return array + */ + public function selectAuditedFields(): array + { + $ignoredFields = $this->getIgnoredFieldNames(); + $omitedFields = $this->getOmitValueFields(); + $omitedTypes = $this->getOmitValueFieldTypes(); + $auditedFields = []; + + foreach ($this->table->getColumns() as $column) { + $fieldName = $column->getName(); + if (in_array($fieldName, $ignoredFields)) { + continue; + } + $isOmited = (in_array($fieldName, $omitedFields) || in_array($column->getType(), $omitedTypes)); + $auditedFields[] = [ + 'column' => $column, + 'isOmited' => $isOmited, + ]; + } + + return $auditedFields; + } + + /** + * @see Propel\Generator\Model\Behavior\Behavior::renderTemplate() + * + * @param string $filename + * @param array $vars + * + * @return string + */ + public function renderLocalTemplate(string $filename, array $vars = []): string + { + $templatePath = $this->getDirname() . '/templates/'; + + return $this->renderTemplate($filename, $vars, $templatePath); + } +} diff --git a/src/Propel/Generator/Behavior/Auditable/AuditableBehaviorDeclaration.php b/src/Propel/Generator/Behavior/Auditable/AuditableBehaviorDeclaration.php new file mode 100644 index 000000000..8655db061 --- /dev/null +++ b/src/Propel/Generator/Behavior/Auditable/AuditableBehaviorDeclaration.php @@ -0,0 +1,297 @@ + 'audit_id', + static::PARAMETER_KEY_SYNC_PK_ONLY => 'true', + static::PARAMETER_KEY_COLUMN_PREFIX => 'true', + ]; + } + + /** + * @throws \Propel\Generator\Behavior\SyncedTable\SyncedTableException + * + * @return void + */ + public function validateParameters(): void + { + $disallowedParameters = [ + SyncedTableBehaviorDeclaration::PARAMETER_KEY_EMPTY_ACCESSOR_COLUMNS, + SyncedTableBehaviorDeclaration::PARAMETER_KEY_IGNORE_COLUMNS, + SyncedTableBehaviorDeclaration::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS, + SyncedTableBehaviorDeclaration::PARAMETER_KEY_INHERIT_FOREIGN_KEY_RELATIONS, + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNC_INDEXES, + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNC_UNIQUE_AS, + ]; + + foreach ($disallowedParameters as $disallowedParameter) { + if (array_key_exists($disallowedParameter, $this->parameters)) { + throw new SyncedTableException($this, "Use of parameter '$disallowedParameter' is not allowed."); + } + } + + parent::validateParameters(); + $this->checkColumnsInParameterExistInTable(static::PARAMETER_KEY_IGNORE_FIELDS, true); + $this->checkColumnsInParameterExistInTable(static::PARAMETER_KEY_OMIT_VALUE_FIELDS, true); + $this->checkColumnsInParameterExistInTable(static::PARAMETER_KEY_AUDITED_COLUMNS_ON_INSERT, true); + if ($this->isCascadeDelete() && is_array(parent::getRelationAttributes())) { + $format = "Cannot combine parameter '%s' with array input for relation ('%s') - set onDelete behavior in array."; + $msg = sprintf($format, static::PARAMETER_KEY_CASCADE_DELETE, static::PARAMETER_KEY_RELATION); + + throw new SyncedTableException($this, $msg); + } + } + + /** + * @return string + */ + public function getAuditedAtFieldName(): string + { + return $this->getParameter(static::PARAMETER_KEY_AUDITED_AT_FIELD_NAME, 'audited_at'); + } + + /** + * @return string + */ + public function getAuditEventFieldName(): string + { + return $this->getParameter(static::PARAMETER_KEY_AUDIT_EVENT_FIELD_NAME, 'audit_event'); + } + + /** + * @return string + */ + public function getChangedValuesFieldName(): string + { + return $this->getParameter(static::PARAMETER_KEY_CHANGED_VALUES_FIELD_NAME, 'changed_values'); + } + + /** + * @return string + */ + public function getChangedValuesFieldPhpName(): string + { + return Column::generatePhpName($this->getChangedValuesFieldName()); + } + + /** + * @return string + */ + public function getInternalChangedValuesFieldName(): string + { + return 'internal_' . $this->getChangedValuesFieldName(); + } + + /** + * @return string + */ + public function getChangedValuesFieldType(): string + { + return $this->getParameter(static::PARAMETER_KEY_CHANGED_VALUES_FIELD_TYPE, PropelTypes::JSON); + } + + /** + * @return int|null + */ + public function getChangedValuesFieldSize(): ?int + { + return $this->getParameterInt(static::PARAMETER_KEY_CHANGED_VALUES_FIELD_SIZE); + } + + /** + * @return array + */ + public function getIgnoredFieldNames(): array + { + $val = $this->getParameterCsv(static::PARAMETER_KEY_IGNORE_FIELDS); + if ($this->aggregationColumnNameOnSource()) { + $val[] = $this->aggregationColumnNameOnSource(); + } + + return $val; + } + + /** + * @return array + */ + public function getOmitValueFields(): array + { + return $this->getParameterCsv(static::PARAMETER_KEY_OMIT_VALUE_FIELDS); + } + + /** + * @return array + */ + public function getOmitValueFieldTypes(): array + { + return $this->getParameterCsv(static::PARAMETER_KEY_OMIT_VALUE_TYPES, ['BLOB, CLOB']); + } + + /** + * @return string + */ + public function getOmitValue(): string + { + return $this->getParameter(static::PARAMETER_KEY_OMIT_VALUE, 'changed'); + } + + /** + * @return array + */ + public function getAuditedColumnsOnInsert(): array + { + return $this->getParameterCsv(static::PARAMETER_KEY_AUDITED_COLUMNS_ON_INSERT); + } + + /** + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehaviorDeclaration::getRelationAttributes() + * + * @return array|null + */ + public function getRelationAttributes(): ?array + { + $parentRelation = parent::getRelationAttributes(); + if (is_array($parentRelation)) { + return $parentRelation; + } + + return $this->isCascadeDelete() + ? ['onDelete' => 'cascade'] + : ['skipSql' => 'true']; + } + + /** + * @return bool + */ + protected function isCascadeDelete(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_CASCADE_DELETE, false); + } + + /** + * @return bool + */ + public function relationCascadesDelete(): bool + { + $attributes = $this->getRelationAttributes(); + + return $attributes && !empty($attributes['onDelete']) && $attributes['onDelete'] === 'cascade'; + } + + /** + * @return string|null + */ + public function aggregationColumnNameOnSource(): ?string + { + $val = $this->getParameter(static::PARAMETER_KEY_ADD_AGGREGATION_TO_SOURCE); + if (!$val) { + return null; + } + + return in_array(strtolower($val), ['true', '1']) ? 'number_of_audits' : $val; + } +} diff --git a/src/Propel/Generator/Behavior/Auditable/AuditableObjectModifier.php b/src/Propel/Generator/Behavior/Auditable/AuditableObjectModifier.php new file mode 100644 index 000000000..07df34f8f --- /dev/null +++ b/src/Propel/Generator/Behavior/Auditable/AuditableObjectModifier.php @@ -0,0 +1,253 @@ +behavior = $behavior; + } + + /** + * @see \Propel\Generator\Model\Behavior::objectFilter() + * + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function objectAttributes(ObjectBuilder $objectBuilder) + { + return " +/** + * The data row this object was hydrated from. + * + * @var array|null + */ +protected \${$this->dataRowAttributeName} = null; +"; + + // end auditable behavior + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function objectMethods(ObjectBuilder $objectBuilder): string + { + $table = $this->behavior->getSyncedTable(); + $fk = $this->behavior->findSyncedRelation($objectBuilder->getTable()->getReferrers()); + $getPhpNameForFieldName = fn (string $fieldName) => $table->getColumn($fieldName)->getPhpName(); + + return $this->behavior->renderLocalTemplate('sourceObjectMethods', [ + 'objectBuilder' => $objectBuilder, + 'fk' => $fk, + 'dataRowAttributeName' => $this->dataRowAttributeName, + 'table' => $table, + + 'auditedFields' => $this->behavior->selectAuditedFields(), + 'omitValue' => $this->behavior->getOmitValue(), + + 'internalChangedValuesColumnPhpName' => $getPhpNameForFieldName($this->behavior->getInternalChangedValuesFieldName()), + 'auditEventColumnPhpName' => $getPhpNameForFieldName($this->behavior->getAuditEventFieldName()), + ]); + } + + /** + * @see \Propel\Generator\Model\Behavior::objectFilter() + * + * @param string $script + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return void + */ + public function objectFilter(string &$script, ObjectBuilder $objectBuilder) + { + $script = $this->updateHydrateCode($script, $objectBuilder); + } + + /** + * @see \Propel\Generator\Builder\Om\ObjectBuilder::addHydrate() + * + * @param string $script + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + protected function updateHydrateCode(string $script, ObjectBuilder $objectBuilder): string + { + $tableMapClassName = $objectBuilder->getTableMapClassName(); + $pattern = '/^\s*(public function hydrate\(.*\v\s*\{\n)/m'; // hydate() function header up until newline after '{' + $code = <<< EOT + // auditable behavior + try { + \$this->{$this->dataRowAttributeName} = (\$indexType === TableMap::TYPE_NUM) + ? array_slice(\$row, \$startcol) + : array_map(fn(\$fn) => \$row[\$fn], {$tableMapClassName}::getFieldNames(\$indexType)); + } catch (Exception \$e) { + throw new PropelException('Error extracting data row with numeric keys from input row to hydrate().', 0, \$e); + } + + +EOT; + + return preg_replace_callback($pattern, fn (array $match) => "{$match[0]}{$code}", $script, 1); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function preUpdate(ObjectBuilder $objectBuilder) + { + $table = $this->behavior->getSyncedTable(); + $auditObjectClass = $table->getPhpName(); + $fk = $this->behavior->findSyncedRelation($objectBuilder->getTable()->getReferrers()); + $relationName = $objectBuilder->getRefFKPhpNameAffix($fk, false); + $incrementStatement = $this->getIncrementAggregationColumnStatement("\n "); + + return <<create{$auditObjectClass}('update'); +if (\$ret && \$audit) { + \$this->add{$relationName}(\$audit);{$incrementStatement} +} +EOT; + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function preInsert(ObjectBuilder $objectBuilder) + { + $incrementStatement = $this->getIncrementAggregationColumnStatement(); + + return $incrementStatement ? '$ret && ' . $incrementStatement : ''; + } + + /** + * @param string $indent + * + * @return string + */ + protected function getIncrementAggregationColumnStatement(string $indent = ''): string + { + $columnName = $this->behavior->aggregationColumnNameOnSource(); + if (!$columnName) { + return ''; + } + $phpColumnName = $this->behavior->getTable()->getColumn($columnName)->getPhpName(); + + return <<set{$phpColumnName}((\$this->$columnName ?? 0) + 1); +EOT; + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postSave(ObjectBuilder $objectBuilder) + { + return $this->getUpdateDataRowValueStatement(); + } + + /** + * @return string + */ + protected function getUpdateDataRowValueStatement(): string + { + return <<update{$this->dataRowAttributeName}(); +EOT; + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postInsert(ObjectBuilder $objectBuilder) + { + $columns = $this->behavior->getAuditedColumnsOnInsert(); + $columnsExpression = ($columns) + ? "[\n '" . implode("',\n '", $columns) . "'\n]" + : 'null'; + + return implode("\n", [ + $this->getUpdateDataRowValueStatement(), // necessary for other postInsert + $this->buildCreateAuditStatements('insert', $objectBuilder, $columnsExpression, true, true, true), + ]); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postDelete(ObjectBuilder $objectBuilder) + { + if ($this->behavior->relationCascadesDelete()) { + return ''; + } + $columnsExpression = $objectBuilder->getTableMapClassName() . '::getFieldNames(TableMap::TYPE_COLNAME)'; + + return implode("\n", [ + $this->buildCreateAuditStatements('delete', $objectBuilder, $columnsExpression, true, true, true), + $this->getIncrementAggregationColumnStatement(), + ]); + } + + /** + * @param string $auditEvent 'insert', 'update' or 'delete' + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * @param string $columnsExpression + * @param bool $saveManually + * @param bool $useObjectValues + * @param bool $forceCreate + * + * @return string + */ + public function buildCreateAuditStatements( + string $auditEvent, + ObjectBuilder $objectBuilder, + string $columnsExpression, + bool $saveManually, + bool $useObjectValues = false, + bool $forceCreate = false + ): string { + $forceSaveExpression = var_export($saveManually, true); + $useObjectValuesExpression = var_export($useObjectValues, true); + $forceCreateExpression = var_export($forceCreate, true); + + return <<addNewAudit('$auditEvent', $columnsExpression, $forceSaveExpression, $useObjectValuesExpression, $forceCreateExpression); +EOT; + } +} diff --git a/src/Propel/Generator/Behavior/Auditable/templates/auditObjectMethods.php b/src/Propel/Generator/Behavior/Auditable/templates/auditObjectMethods.php new file mode 100644 index 000000000..1a9109e42 --- /dev/null +++ b/src/Propel/Generator/Behavior/Auditable/templates/auditObjectMethods.php @@ -0,0 +1,154 @@ + $syncedPkColumns + * @var string $relationToSourceName + * @var string $queryClassName + */ +?> +/** + * @param $audit1 + * @param $audit2 + * + * @return int + */ +protected function compareSourcePk( $audit1, $audit2): int +{ +isNumericType() ? "%s - %s" : 'strcomp(%s, %s)'; + $columnAccessor = 'get' . $pkColumn->getPhpName().'()'; + $comparisons[] = sprintf($comparatorPattern, "\$audit2->$columnAccessor", "\$audit1->$columnAccessor"); + } + $compareStatement = implode("\n ?:", $comparisons); +?> + return ; +} + +/** + * @param array<> $audits + * + * @return array>> + */ +protected static function groupAuditsBySource(array $audits): array +{ + $groups = []; + foreach ($audits as $audit) { + '$audit->' . $col->getName(), $syncedPkColumns); + $keyGetter = count($keyAccessors) === 1 ? $keyAccessors[0] : ('md5(json_encode(' . implode(', ', $keyAccessors) . '))'); +?> + $key = ; + $groups[$key] ?? ($groups[$key] = []); + $groups[$key][] = $audit; + } + + return $groups; +} + +/** + * @param ConnectionInterface $con (optional) The ConnectionInterface connection to use. + * + * @return array + */ +public function get(ConnectionInterface $con = null): array +{ + if (!$this->hasVirtualColumn('')) { + "->filterBy{$column->getPhpName()}(\$this->{$column->getName()})", $syncedPkColumns)); +?> + $auditGroup = ::create()->find($con)->toArrayCopy(); + $this->restoreAudits($auditGroup); + } + + return $this->getVirtualColumn(''); +} + +/** + * Restores changed values of the given audit objects. + * + * Can process audits of different source objects. + * + * For correct results, the list has to include, for each given audit, all + * existing later audits. + * + * @param array<> $listOfAudits + * + * @return array>> The processed input object, + * grouped by source object id. + */ +public static function restoreAudits(array $listOfAudits): array +{ + $groups = static::groupAuditsBySource($listOfAudits); + $auditRows = []; + foreach ($groups as $key => $audits) { + if (!$audits) { + continue; + } + usort($audits, fn( $audit1, $audit2) => (!$audit1-> ? 1 : (!$audit2-> ? -1 : (int) $audit2->->format('Uu') - (int) $audit1->->format('Uu')))); + + $sourceObject = $audits[0]->get(); + if (!$sourceObject && $audits[0]->get() !== 'delete') { + throw new \RuntimeException('Cannot retrieve current values of audited object - looks like it was deleted without writing an audit log? AuditId: '.$audits[0]->); + } + $laterRow = $sourceObject ? $sourceObject->getAuditedColumnsCurrentValues() : $audits[0]->get(); + + $groupRows = []; + foreach ($audits as $audit) { + $audit->setVirtualColumn('', $audit->resolveEarlierSourceValues($laterRow)); + $groupRows[] = $audit; + $laterRow = array_merge($laterRow, $audit->get()); + } + if ($groupRows && $groupRows[count($groupRows) - 1]->get() !== 'insert') { + $groupRows[] = static::createPreAuditEntry($laterRow); + } + $auditRows[$key] = $groupRows; + } + + return $auditRows; +} + +/** + * Resolve values actually changed in audit. + * + * Override for custom output. + * + * @param array $laterValues + * + * @return array + */ +protected function resolveEarlierSourceValues(array $laterValues): array +{ + $overriddenValues = $this->get(); + + return ($this->get() === 'insert') + ? array_merge($overriddenValues ?? [], $laterValues) + : array_intersect_key($laterValues, $overriddenValues ); +} + +/** + * + * @param array $values + * + * @return + + */ +protected static function createPreAuditEntry(array $values): + +{ + $audit = new (); + $audit->set('pre-audit'); + $audit->setVirtualColumn('', $values); + + return $audit; +} diff --git a/src/Propel/Generator/Behavior/Auditable/templates/sourceObjectMethods.php b/src/Propel/Generator/Behavior/Auditable/templates/sourceObjectMethods.php new file mode 100644 index 000000000..efbccc452 --- /dev/null +++ b/src/Propel/Generator/Behavior/Auditable/templates/sourceObjectMethods.php @@ -0,0 +1,167 @@ + $auditedFields + * @var \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * @var \Propel\Generator\Model\ForeignKey $fk + * @var \Propel\Generator\Model\Table $table + * @var string $dataRowAttributeName + * @var string $auditEventColumnPhpName + * @var string $internalChangedValuesColumnPhpName + * @var string $omitValue + * + */ + + $auditObjectImportedClass = $objectBuilder->getClassNameFromTable($table); +?> + +/** + * Set row values to current data. + */ +protected function update(): void +{ + $this-> = $this->toArray(TableMap::TYPE_NUM, false); +} + +/** + * @param string $auditEvent 'insert', 'update' or 'delete' + * @param array|null $selectedColumns Optional parameter to override audited columns. + * @param bool $forceSave Immediately save the audit after creation. + * @param bool $useCurrent Use current data instead of row values. + * @param bool $forceCreate Create audit even if there are no changes. + * + * @return void + */ +protected function addNewAudit( + string $auditEvent, + ?array $selectedColumns = null, + bool $forceSave = false, + bool $useCurrent = false, + bool $forceCreate = false +): void +{ + $audit = $this->creategetPhpName() ?>($auditEvent, $selectedColumns, $useCurrent, $forceCreate); + if (!$audit) { + return; + } + $this->addgetRefFKPhpNameAffix($fk, false) ?>($audit); + if ($forceSave){ + $audit->save(); + } +} + +/** + * Create an audit for modified or specified columns. + * + * @param string $auditEvent 'insert', 'update' or 'delete' + * @param array|null $selectedColumns Optional parameter to override audited columns. + * @param bool $useCurrent Use current data instead of row values. + * @param bool $forceCreate Create audit even if there are no changes. + * + * @return getPhpName() ?>|null + */ +protected function creategetPhpName() ?>( + string $auditEvent, + ?array $selectedColumns = null, + bool $useCurrent = false, + bool $forceCreate = false +): ?getPhpName() ?> + +{ + $auditData = $this->buildAuditChanges($selectedColumns, $useCurrent); + if (!$auditData && !$forceCreate) { + return null; + } + $audit = new getClassNameFromTable($table) ?>(); + $audit->set($auditEvent); + $audit->set($auditData); + + return $audit; +} + +/** + * @param string $auditEvent 'insert', 'update' or 'delete' + * Build list of changed column names and their old value. + * + * @param array|null $selectedColumns Optional parameter to override audited columns. + * @param bool $useCurrent Use current data instead of row values. + * + * @return array + */ +protected function buildAuditChanges(?array $selectedColumns = null, $useCurrent = false): array +{ + if (!$useCurrent && $this-> === null){ + throw new \RuntimeException('Trying to create audit without row values.'); + } + + $overwrittenValues = []; + $columnKeys = $selectedColumns ?: array_keys($this->modifiedColumns); + $values = $useCurrent ? $this->toArray(TableMap::TYPE_NUM, false) : $this->; + foreach ($columnKeys as $qualifiedColumnName) { + switch ($qualifiedColumnName) { +getPosition()-1 ; + $valueGetter = $fieldData['isOmited'] ? "'$omitValue'" : "\$values[$fieldIndex]"; +?> + + case getFQConstantName() ?>: + $overwrittenValues['getName() ?>'] = $this->getAuditFieldValue($qualifiedColumnName, ); + + break; + + } + } + + return $overwrittenValues; +} + +/** + * @return array + */ +public function getAuditedColumnsCurrentValues(): array +{ + $auditedColumns = getTableMapClassName() ?>::getFieldNames(TableMap::TYPE_COLNAME); + + return $this->buildAuditChanges($auditedColumns, true); +} + +/** + * Determines column values in audits. Can be overridden to set custom values. + * + * @param string $qualifiedColumnName The column name as stored in the TableMap const (i.e. BookTableMap::COL_ID). + * @param mixed $defaultAuditValue The audit value according to configuration. + * + * @return mixed + */ +protected function getAuditFieldValue(string $qualifiedColumnName, $defaultAuditValue) +{ + return $defaultAuditValue; +} + +/** + * Load the complet audit with restored change values. + * + * @param ConnectionInterface|null $con + * + * @return ObjectCollection The audit objects ordered by audit + * date in descending order (latest change first). + */ +public function restoreAudit(?ConnectionInterface $con = null): ObjectCollection +{ + if (!$this->hasVirtualColumn('RestoredAudit')) { + $auditObjects = $this->getgetRefFKPhpNameAffix($fk, true) ?>($con); + $auditGroups = getPhpName() ?>::restoreAudits($auditObjects->getArrayCopy()); + $restoredAudit = $auditGroups ? reset($auditGroups) : $auditGroups; + $auditObjects->exchangeArray($restoredAudit); // fixes order + $this->setVirtualColumn('RestoredAudit', $auditObjects); + } + + return $this->getVirtualColumn('RestoredAudit'); +} + +// end auditable behavior diff --git a/src/Propel/Generator/Behavior/ConfigLoad/ConfigLoadBehavior.php b/src/Propel/Generator/Behavior/ConfigLoad/ConfigLoadBehavior.php new file mode 100644 index 000000000..6cc6643d0 --- /dev/null +++ b/src/Propel/Generator/Behavior/ConfigLoad/ConfigLoadBehavior.php @@ -0,0 +1,92 @@ +getAttribute(static::ATTRIBUTE_KEY_REF); + } + + /** + * @param \Propel\Generator\Model\Database|\Propel\Generator\Model\Table $behaviorable + * + * @return void + */ + protected function apply($behaviorable): void + { + $this->validateAttributes(); + $this->createBehavior($behaviorable); + } + + /** + * @param \Propel\Generator\Model\Database|\Propel\Generator\Model\Table $behaviorable + * + * @return void + */ + public function createBehavior($behaviorable): void + { + $configuration = ConfigurationStore::getInstance()->loadPreconfiguration($this->getKey()); + $fullAttributes = array_merge([], $configuration->getBehaviorAttributes(), $this->getAuxilaryAttributes()); + $fullParams = array_merge([], $configuration->getParameters(), $this->parameters); + $behavior = $behaviorable->addBehavior($fullAttributes); + $behavior->setParameters(array_merge($behavior->getParameters(), $fullParams)); + } + + /** + * @return array + */ + protected function getAuxilaryAttributes(): array + { + $ownAttributes = [ + static::ATTRIBUTE_KEY_REF => 1, + static::ATTRIBUTE_KEY_MULTIPLE => 1, + 'name' => 1, + 'id' => 1, + ]; + + $attributes = array_diff_key($this->attributes, $ownAttributes); + + if ($this->getAttribute(static::ATTRIBUTE_KEY_MULTIPLE, false)) { + $attributes['id'] = $this->getKey() . '_' . uniqid(); + } + + return $attributes; + } + + /** + * @throws \Propel\Generator\Exception\SchemaException + * + * @return void + */ + protected function validateAttributes(): void + { + if (!$this->getAttribute(static::ATTRIBUTE_KEY_REF)) { + throw new SchemaException(sprintf("%s behavior: required parameter '%s' is missing.", $this->getName(), static::ATTRIBUTE_KEY_REF)); + } + } +} diff --git a/src/Propel/Generator/Behavior/ConfigStore/ConfigOperationBehavior.php b/src/Propel/Generator/Behavior/ConfigStore/ConfigOperationBehavior.php new file mode 100644 index 000000000..b7a3b7815 --- /dev/null +++ b/src/Propel/Generator/Behavior/ConfigStore/ConfigOperationBehavior.php @@ -0,0 +1,53 @@ +apply($this->database); + } + + /** + * @return void + */ + public function modifyTable(): void + { + $this->apply($this->table); + } + + /** + * @param \Propel\Generator\Model\Database|\Propel\Generator\Model\Table $behaviorable + * + * @return void + */ + abstract protected function apply($behaviorable): void; +} diff --git a/src/Propel/Generator/Behavior/ConfigStore/ConfigStoreBehavior.php b/src/Propel/Generator/Behavior/ConfigStore/ConfigStoreBehavior.php new file mode 100644 index 000000000..98f7dfe3d --- /dev/null +++ b/src/Propel/Generator/Behavior/ConfigStore/ConfigStoreBehavior.php @@ -0,0 +1,109 @@ +wasApplied) { + return; + } + $this->wasApplied = true; + $this->validateAttributes(); + $this->storeConfiguration(); + } + + /** + * @return string + */ + protected function getKey(): string + { + return $this->getAttribute('id'); + } + + /** + * @return void + */ + protected function storeConfiguration(): void + { + $attributes = $this->getAuxilaryAttributes(); + $attributes['name'] = $this->getAttribute(static::ATTRIBUTE_KEY_BEHAVIOR); + ConfigurationStore::getInstance()->storePreconfiguration($this->getKey(), $attributes, $this->parameters); + } + + /** + * @return array + */ + protected function getAuxilaryAttributes(): array + { + $ownAttributes = [ + static::ATTRIBUTE_KEY_BEHAVIOR => 1, + 'name' => 1, + 'id' => 1, + ]; + + return array_diff_key($this->attributes, $ownAttributes); + } + + /** + * @throws \Propel\Generator\Exception\SchemaException + * + * @return void + */ + protected function validateAttributes(): void + { + if (!$this->getAttribute(static::ATTRIBUTE_KEY_BEHAVIOR)) { + throw new SchemaException(sprintf("%s behavior: required parameter '%s' is missing.", $this->getName(), static::ATTRIBUTE_KEY_BEHAVIOR)); + } + + if (!$this->getAttribute('id')) { + throw new SchemaException(sprintf("%s behavior: required parameter 'id' is missing.", $this->getName())); + } + + if (ConfigurationStore::getInstance()->hasPreconfiguration($this->getKey())) { + $format = "%s behavior for '%s': key '%s' is already in use."; + $message = sprintf($format, $this->getName(), $this->getAttribute(static::ATTRIBUTE_KEY_BEHAVIOR), $this->getKey()); + + throw new SchemaException($message); + } + } +} diff --git a/src/Propel/Generator/Behavior/ConfigStore/ConfigurationItem.php b/src/Propel/Generator/Behavior/ConfigStore/ConfigurationItem.php new file mode 100644 index 000000000..a830f1dd2 --- /dev/null +++ b/src/Propel/Generator/Behavior/ConfigStore/ConfigurationItem.php @@ -0,0 +1,48 @@ +behaviorAttributes = $behaviorAttributes; + $this->parameters = $parameters; + } + + /** + * @return array + */ + public function getBehaviorAttributes(): array + { + return $this->behaviorAttributes; + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } +} diff --git a/src/Propel/Generator/Behavior/ConfigStore/ConfigurationStore.php b/src/Propel/Generator/Behavior/ConfigStore/ConfigurationStore.php new file mode 100644 index 000000000..5d264d3b1 --- /dev/null +++ b/src/Propel/Generator/Behavior/ConfigStore/ConfigurationStore.php @@ -0,0 +1,80 @@ + + */ + private static $preconfigurations = []; + + /** + * @return \Propel\Generator\Behavior\ConfigStore\ConfigurationStore|self + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * @param string $key + * @param array $behaviorAttributes + * @param array $params + * + * @throws \Propel\Generator\Exception\SchemaException + * + * @return void + */ + public function storePreconfiguration(string $key, array $behaviorAttributes, array $params): void + { + if ($this->hasPreconfiguration($key)) { + throw new SchemaException("preconfigure behavior: $key '%s' is already in use."); + } + + self::$preconfigurations[$key] = new ConfigurationItem($behaviorAttributes, $params); + } + + /** + * @param string $key + * + * @throws \Propel\Generator\Exception\SchemaException + * + * @return \Propel\Generator\Behavior\ConfigStore\ConfigurationItem + */ + public function loadPreconfiguration(string $key): ConfigurationItem + { + if (!array_key_exists($key, self::$preconfigurations)) { + throw new SchemaException("preconfigure behavior: No preconfigured behavior with key '$key'."); + } + + return self::$preconfigurations[$key]; + } + + /** + * @param string $key + * + * @return bool + */ + public function hasPreconfiguration(string $key): bool + { + return array_key_exists($key, self::$preconfigurations); + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehavior.php b/src/Propel/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehavior.php new file mode 100644 index 000000000..a985826a1 --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehavior.php @@ -0,0 +1,114 @@ +buildCodeForHooks($columnNames); + $behavior->setup($insertingBehavior, $table, $codeForHooks); + + return $behavior; + } + + /** + * @param array $columnNames + * + * @return array + */ + protected function buildCodeForHooks(array $columnNames): array + { + $accessorNames = $this->buildAccessorNames($columnNames); + + return [ + 'objectAttributes' => $this->buildObjectAttributes($accessorNames), + 'objectCall' => $this->buildObjectCall(), + ]; + } + + /** + * @param array $columnNames + * + * @return array + */ + protected function buildAccessorNames(array $columnNames): array + { + $accessors = []; + foreach ($columnNames as $columnName) { + $phpName = (new Column($columnName))->getPhpName(); + array_push($accessors, 'get' . $phpName, 'set' . $phpName); + } + + return $accessors; + } + + /** + * @param array $accessorNames + * + * @return string + */ + public function buildObjectAttributes(array $accessorNames): string + { + if (!$accessorNames) { + return ''; + } + $nameToArrayKeyFun = fn (string $name) => " '$name' => 1,"; + $namesAsArrayKeys = implode("\n", array_map($nameToArrayKeyFun, $accessorNames)); + + return <<__parentCall(\$name, \$params); + } catch(BadMethodCallException \$e){ + return null; + } + } + +EOT; + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehavior.php b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehavior.php new file mode 100644 index 000000000..964fe8ed8 --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehavior.php @@ -0,0 +1,248 @@ +syncedTable; + } + + /** + * @return string + */ + public function getDefaultTableSuffix(): string + { + return static::DEFAULT_SYNCED_TABLE_SUFFIX; + } + + /** + * @return void + */ + protected function setupObject(): void + { + parent::setupObject(); + $this->setParameterDefaults(); + } + + /** + * @return void + */ + protected function setParameterDefaults(): void + { + $params = $this->getParameters(); + $defaultParams = $this->getDefaultParameters(); + $this->setParameters(array_merge($defaultParams, $params)); + } + + /** + * @return string + */ + public function resolveSyncedTableName(): string + { + return $this->getSyncedTableName() + ?: $this->getTable()->getOriginCommonName() . $this->getDefaultSyncedTableSuffix(); + } + + /** + * @see \Propel\Generator\Model\Behavior::modifyDatabase() + * + * @return void + */ + public function modifyDatabase(): void + { + foreach ($this->getDatabase()->getTables() as $table) { + $this->addBehaviorToTable($table); + } + } + + /** + * Note overridden by inheriting classes. + * + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function addBehaviorToTable(Table $table): void + { + if ($table->hasBehavior($this->getId())) { + // don't add the same behavior twice + return; + } + $b = clone $this; + $table->addBehavior($b); + } + + /** + * @see \Propel\Generator\Model\Behavior::modifyTable() + * + * @return void + */ + public function modifyTable(): void + { + if ($this->omitOnSkipSql() && $this->table->isSkipSql()) { + return; + } + $this->validateParameters(); + $this->syncedTable = TableSyncer::getSyncedTable($this, $this->getTable()); + $this->addEmptyAccessorsToTable($this->syncedTable); + } + + /** + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function addEmptyAccessorsToTable(Table $table): void + { + $emptyAccessorColumnNames = $this->getEmptyAccessorColumnNames(); + if (!$emptyAccessorColumnNames) { + return; + } + EmptyColumnAccessorsBehavior::addEmptyAccessors($this, $table, $emptyAccessorColumnNames); + } + + /** + * @throws \Propel\Generator\Behavior\SyncedTable\SyncedTableException + * + * @return void + */ + public function validateParameters(): void + { + foreach ($this->getForeignKeys() as $fkData) { + if (empty($fkData['localColumn']) || empty($fkData['foreignTable']) || empty($fkData['foreignColumn'])) { + throw new SyncedTableException($this, 'Missing foreign key parameters - please supply `localColumn`, `foreignTable` and `foreignColumn` for every entry'); + } + } + } + + /** + * Manual add elements to the synced table. + * + * Allows extending classes to setup custom element. Happens somewhat + * between table setup for backward compatibility. + * + * @param \Propel\Generator\Model\Table $syncedTable + * @param bool $tableExistsInSchema + * + * @return void + */ + public function addTableElements(Table $syncedTable, bool $tableExistsInSchema): void + { + // base implementation does nothing + } + + /** + * @param \Propel\Generator\Model\Table $table + * @param string $parameterWithColumnName + * @param array $columnDefinition + * + * @return void + */ + protected function addColumnFromParameterIfNotExists(Table $table, string $parameterWithColumnName, array $columnDefinition): void + { + $columnName = $this->getParameter($parameterWithColumnName); + TableSyncer::addColumnIfNotExists($table, $columnName, $columnDefinition); + } + + /** + * @param string $parameterName + * @param bool $canBeBoolean + * @param \Propel\Generator\Model\Table|null $table + * + * @throws \Propel\Generator\Behavior\SyncedTable\SyncedTableException + * + * @return void + */ + protected function checkColumnsInParameterExistInTable(string $parameterName, bool $canBeBoolean = false, ?Table $table = null): void + { + $table ??= $this->getTable(); + + if ( + empty($this->parameters[$parameterName]) || + ($canBeBoolean && in_array(strtolower($this->parameters[$parameterName]), ['true', 'false', 0, 1])) + ) { + return; + } + $columnNames = $this->getParameterCsv($parameterName); + foreach ($columnNames as $columnName) { + if ($table->hasColumn($columnName)) { + continue; + } + + throw new SyncedTableException($this, "Column '$columnName' in parameter '$parameterName' does not exist in table"); + } + } + + /** + * @return string + */ + public function getColumnPrefix(): string + { + $val = $this->useColumnPrefix(); + if ($val === true) { + return $this->table->getName() . '_'; + } + + return is_string($val) ? $val : ''; + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * + * @throws \Propel\Generator\Behavior\SyncedTable\SyncedTableException + * + * @return array<\Propel\Generator\Model\Column> + */ + protected function getSyncedPrimaryKeyColumns(Table $syncedTable): array + { + $prefix = $this->getColumnPrefix(); + $pkColumns = []; + foreach ($this->table->getPrimaryKey() as $sourcePkColumn) { + $syncedPkColumnName = $prefix . $sourcePkColumn->getName(); + $syncedPkColumn = $syncedTable->getColumn($syncedPkColumnName); + if (!$syncedPkColumn) { + throw new SyncedTableException($this, "Cannot find synced PK column '{$syncedPkColumnName}' for source column '{$sourcePkColumn->getName()}'"); + } + $pkColumns[] = $syncedPkColumn; + } + + return $pkColumns; + } + + /** + * @param array<\Propel\Generator\Model\ForeignKey> $foreignKeys + * + * @return \Propel\Generator\Model\ForeignKey|null + */ + public function findSyncedRelation(array $foreignKeys): ?ForeignKey + { + return TableSyncer::findSyncedRelation($this, $foreignKeys); + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehaviorDeclaration.php b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehaviorDeclaration.php new file mode 100644 index 000000000..de0b8c658 --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableBehaviorDeclaration.php @@ -0,0 +1,377 @@ + '', + static::PARAMETER_KEY_SYNCED_PHPNAME => null, + static::PARAMETER_KEY_ADD_PK => null, + static::PARAMETER_KEY_SYNC => 'true', + static::PARAMETER_KEY_FOREIGN_KEYS => null, + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_RELATIONS => 'false', + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS => 'false', + static::PARAMETER_KEY_SYNC_INDEXES => 'false', + static::PARAMETER_KEY_SYNC_UNIQUE_AS => null, + static::PARAMETER_KEY_RELATION => null, + static::PARAMETER_KEY_IGNORE_COLUMNS => null, + static::PARAMETER_KEY_EMPTY_ACCESSOR_COLUMNS => null, + static::PARAMETER_KEY_SYNC_PK_ONLY => 'false', + ]; + } + + /** + * @return string|null + */ + public function getSyncedTableName(): ?string + { + return $this->getParameter(static::PARAMETER_KEY_SYNCED_TABLE); + } + + /** + * @return array + */ + public function getTableAttributes(): array + { + $val = $this->parameters[static::PARAMETER_KEY_TABLE_ATTRIBUTES] ?? null; + + return $val ? reset($val) : []; + } + + /** + * @return string|null + */ + public function getSyncedTablePhpName(): ?string + { + return $this->getParameter(static::PARAMETER_KEY_SYNCED_PHPNAME); + } + + /** + * @return string|null + */ + public function addPkAs(): ?string + { + $val = $this->getParameterTrueOrValue(static::PARAMETER_KEY_ADD_PK, false); + + return $val === true ? 'id' : $val; + } + + /** + * @return array|null + */ + public function getColmns(): ?array + { + return $this->parameters[static::PARAMETER_KEY_COLUMNS] ?? []; + } + + /** + * @return array + */ + public function getForeignKeys(): array + { + return $this->parameters[static::PARAMETER_KEY_FOREIGN_KEYS] ?? []; + } + + /** + * @return bool + */ + public function isSync(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_SYNC, false); + } + + /** + * @return string|bool + */ + public function useColumnPrefix() + { + return $this->getParameterTrueOrValue(static::PARAMETER_KEY_COLUMN_PREFIX, false); + } + + /** + * @return bool + */ + public function isSyncIndexes(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_SYNC_INDEXES, false); + } + + /** + * @return string|null + */ + public function getSyncUniqueIndexAs(): ?string + { + return $this->getParameter(static::PARAMETER_KEY_SYNC_UNIQUE_AS); + } + + /** + * @return array|null + */ + public function getRelationAttributes(): ?array + { + /** @var array|string|null $val */ + $val = $this->parameters[static::PARAMETER_KEY_RELATION] ?? null; + if (is_array($val)) { + return $this->unwrapParameterList($val); + } + if (is_string($val)) { + $val = strtolower($val); + } + if (in_array($val, [null, false, 'false', 0, '0'], true)) { + return null; + } + $attributes = []; + if ($val === 'skipsql') { + $attributes['skipSql'] = 'true'; + } + + return $attributes; + } + + /** + * @return bool + */ + public function isInheritForeignKeyRelations(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_RELATIONS, false); + } + + /** + * @return bool + */ + public function isInheritForeignKeyConstraints(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS, false); + } + + /** + * @return array + */ + public function getIgnoredColumnNames(): array + { + return $this->getParameterCsv(static::PARAMETER_KEY_IGNORE_COLUMNS, []); + } + + /** + * @return array|null + */ + public function getEmptyAccessorColumnNames(): ?array + { + $val = $this->getParameterTrueOrCsv(static::PARAMETER_KEY_EMPTY_ACCESSOR_COLUMNS); + + return ($val === true) ? $this->getIgnoredColumnNames() : $val; + } + + /** + * @return bool + */ + public function isSyncPkOnly(): bool + { + return $this->getParameterBool(static::PARAMETER_KEY_SYNC_PK_ONLY, false); + } + + /** + * @return string + */ + public function onSkipSql(): string + { + $val = strtolower($this->getParameter(static::PARAMETER_KEY_ON_SKIP_SQL, 'omit')); + + return in_array($val, ['ignore', 'inherit', 'omit']) ? $val : 'omit'; + } + + /** + * @return bool + */ + public function inheritSkipSql(): bool + { + return $this->onSkipSql() === 'inherit'; + } + + /** + * @return bool + */ + public function omitOnSkipSql(): bool + { + return $this->onSkipSql() === 'omit'; + } + + /** + * @throws \Propel\Generator\Behavior\SyncedTable\SyncedTableException + * + * @return array|null + */ + public function getTableInheritance() + { + $val = $this->parameters[static::PARAMETER_KEY_INHERIT_FROM_TABLE] ?? null; + if ($val === null) { + return null; + } + if (is_string($val)) { + return ['source_table' => $val]; + } + + $val = $this->unwrapParameterList($val); + if (empty($val['source_table'])) { + $format = 'Array input to parameter "%s" requires a table name in '; + $msg = sprintf($format, static::PARAMETER_KEY_INHERIT_FROM_TABLE, 'source_table'); + + throw new SyncedTableException($this, $msg); + } + + return $val; + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/SyncedTableException.php b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableException.php new file mode 100644 index 000000000..0d8606920 --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/SyncedTableException.php @@ -0,0 +1,29 @@ +getName()}' on table '{$behavior->getTable()->getName()}': "; + parent::__construct($messageHead . $message, $code, $previous); + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncer.php b/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncer.php new file mode 100644 index 000000000..03a110ea3 --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncer.php @@ -0,0 +1,574 @@ +config = $config; + } + + /** + * @param \Propel\Generator\Behavior\SyncedTable\TableSyncer\TableSyncerConfigInterface $config + * @param \Propel\Generator\Model\Table $sourceTable + * + * @return \Propel\Generator\Model\Table + */ + public static function getSyncedTable(TableSyncerConfigInterface $config, Table $sourceTable): Table + { + return (new self($config))->buildSyncedTable($sourceTable); + } + + /** + * @param \Propel\Generator\Model\Table $sourceTable + * + * @return \Propel\Generator\Model\Table + */ + protected function buildSyncedTable(Table $sourceTable): Table + { + $database = $sourceTable->getDatabase(); + $syncedTableName = $this->config->resolveSyncedTableName(); + + $tableExistsInSchema = $database->hasTable($syncedTableName); + + $syncedTable = $tableExistsInSchema ? + $database->getTable($syncedTableName) : + $this->createSyncedTable($sourceTable); + + $this->resolveInheritance($syncedTable); + + if (!$tableExistsInSchema || $this->config->isSync()) { + $this->syncTables($sourceTable, $syncedTable); + } else { + $this->addCustomElements($syncedTable, true); + } + + return $syncedTable; + } + + /** + * @param \Propel\Generator\Model\Table $sourceTable + * + * @return \Propel\Generator\Model\Table + */ + protected function createSyncedTable(Table $sourceTable): Table + { + $database = $sourceTable->getDatabase(); + + $tableAttributes = $this->config->getTableAttributes(); + $defaultAttributes = [ + 'name' => $this->config->resolveSyncedTableName(), + 'phpName' => $this->config->getSyncedTablePhpName(), + 'package' => $sourceTable->getPackage(), + 'schema' => $sourceTable->getSchema(), + 'namespace' => $sourceTable->getNamespace() ? '\\' . $sourceTable->getNamespace() : null, + 'identifierQuoting' => $sourceTable->isIdentifierQuotingEnabled(), + ]; + + if ($this->config->inheritSkipSql()) { + $defaultAttributes['skipSql'] = $sourceTable->isSkipSql(); + } + + return $database->addTable(array_merge($defaultAttributes, $tableAttributes)); + } + + /** + * @param \Propel\Generator\Model\Table $targetTable Table to copy to. + * + * @throws \Propel\Generator\Exception\SchemaException + * + * @return void + */ + protected function resolveInheritance(Table $targetTable): void + { + $inheritance = $this->config->getTableInheritance(); + if (!$inheritance) { + return; + } + $sourceTableName = $inheritance['source_table']; + $sourceTable = $targetTable->getDatabase()->getTable($sourceTableName); + if (!$sourceTable) { + throw new SchemaException("Cannot find source table '$sourceTableName'"); + } + $behavior = new SyncedTableBehavior(); + $behavior->setId('sync_to_table_' . $targetTable->getName()); + $behavior->setTable($sourceTable); + $defaultParameters = [ + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNCED_TABLE => $targetTable->getName(), + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNC => 'true', + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNC_INDEXES => 'true', + SyncedTableBehaviorDeclaration::PARAMETER_KEY_SYNC_UNIQUE_AS => 'unique', + SyncedTableBehaviorDeclaration::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS => 'true', + SyncedTableBehaviorDeclaration::PARAMETER_KEY_ON_SKIP_SQL => 'ignore', + ]; + $parameters = array_merge($defaultParameters, $inheritance); + $behavior->setParameters($parameters); + $behavior->modifyTable(); + InsertCodeBehavior::addToTable($behavior, $targetTable, ['parentClass' => $sourceTable]); + } + + /** + * @param \Propel\Generator\Model\Table $sourceTable + * @param \Propel\Generator\Model\Table $syncedTable + * + * @return \Propel\Generator\Model\Table + */ + protected function syncTables(Table $sourceTable, Table $syncedTable): Table + { + $columns = $sourceTable->getColumns(); + $ignoreColumnNames = $this->resolveIgnoredColumnNames($sourceTable); + $this->syncColumns($syncedTable, $columns, $ignoreColumnNames); + + $relationAttributes = $this->config->getRelationAttributes(); + if ($relationAttributes !== null) { + $this->addForeignKeyRelationToSyncedTable($syncedTable, $sourceTable, $relationAttributes); + } + + $this->addCustomElements($syncedTable, false); + + $inheritFkRelations = $this->config->isInheritForeignKeyRelations(); + $inheritFkConstraints = $this->config->isInheritForeignKeyConstraints(); + if ($inheritFkRelations || $inheritFkConstraints) { + $foreignKeys = $sourceTable->getForeignKeys(); + $this->syncForeignKeys($syncedTable, $foreignKeys, $inheritFkConstraints, $ignoreColumnNames); + } + + if ($this->config->isSyncIndexes()) { + $indexes = $sourceTable->getIndices(); + $platform = $sourceTable->getDatabase()->getPlatform(); + $renameIndexes = $this->isDistinctiveIndexNameRequired($platform); + $this->syncIndexes($syncedTable, $indexes, $renameIndexes, $ignoreColumnNames); + } + + $syncUniqueAs = $this->config->getSyncUniqueIndexAs(); + if ($syncUniqueAs) { + $asIndex = $syncUniqueAs !== 'unique'; + $uniqueIndexes = $sourceTable->getUnices(); + $this->syncUniqueIndexes($asIndex, $syncedTable, $uniqueIndexes, $ignoreColumnNames); + } + + $this->reapplyTableBehaviors($sourceTable); + + return $syncedTable; + } + + /** + * @param \Propel\Generator\Model\Table $sourceTable + * + * @return array + */ + protected function resolveIgnoredColumnNames(Table $sourceTable): array + { + $ignoreColumnNames = $this->config->getIgnoredColumnNames(); + if (!$this->config->isSyncPkOnly()) { + return $ignoreColumnNames; + } + $nonPkColumns = array_filter($sourceTable->getColumns(), fn (Column $column) => !$column->isPrimaryKey()); + $nonPkColumnNames = array_map(fn (Column $column) => $column->getName(), $nonPkColumns); + + return array_unique(array_merge($ignoreColumnNames, $nonPkColumnNames)); + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param bool $tableExistsInSchema + * + * @return void + */ + protected function addCustomElements(Table $syncedTable, bool $tableExistsInSchema) + { + $this->addPkColumn($syncedTable); + $this->addColumnsFromParameter($syncedTable); + $this->addCustomForeignKeysToSyncedTable($syncedTable); + $this->config->addTableElements($syncedTable, $tableExistsInSchema); + } + + /** + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function addColumnsFromParameter(Table $table): void + { + $columnData = $this->config->getColmns(); + if (!$columnData) { + return; + } + array_map([$table, 'addColumn'], $columnData); + } + + /** + * Allows inheriting classes to add columns. + * + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function addPkColumn(Table $table) + { + $idColumnName = $this->config->addPkAs(); + if (!$idColumnName) { + return; + } + static::addColumnIfNotExists($table, $idColumnName, [ + 'type' => 'INTEGER', + 'required' => 'true', + 'primaryKey' => 'true', + 'autoIncrement' => 'true', + ]); + foreach ($table->getPrimaryKey() as $pkColumn) { + if ($pkColumn->getName() === $idColumnName) { + continue; + } + $pkColumn->setPrimaryKey(false); + } + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param \Propel\Generator\Model\Table $sourceTable + * @param array $relationAttributes + * + * @throws \Propel\Generator\Exception\SchemaException If columns are not found. + * + * @return void + */ + protected function addForeignKeyRelationToSyncedTable(Table $syncedTable, Table $sourceTable, array $relationAttributes): void + { + $fk = new ForeignKey(); + $syncedTable->addForeignKey($fk); + $defaultAttributes = [ + 'foreignTable' => $sourceTable->getOriginCommonName(), + 'foreignSchema' => $sourceTable->getSchema(), + static::ATTRIBUTE_KEY_SYNCED_THROUGH => $this->config->getId(), // allows to retrieve relation + ]; + $fullAttributes = array_merge($defaultAttributes, $relationAttributes); + + $fk->loadMapping($fullAttributes); + + foreach ($sourceTable->getPrimaryKey() as $sourceColumn) { + $syncedColumnName = $this->getPrefixedColumnName($sourceColumn->getName()); + $syncedColumn = $syncedTable->getColumn($syncedColumnName); + if (!$syncedColumn) { + throw new SchemaException('Synced table behavior cannot create relation: primary key column of source table is missing on synced table: ' . $syncedColumnName); + } + $fk->addReference($syncedColumn, $sourceColumn); + } + } + + /** + * @param \Propel\Generator\Behavior\SyncedTable\TableSyncer\TableSyncerConfigInterface $config + * @param array<\Propel\Generator\Model\ForeignKey> $foreignKeys + * + * @throws \RuntimeException If more than one relation is found. + * + * @return \Propel\Generator\Model\ForeignKey|null + */ + public static function findSyncedRelation(TableSyncerConfigInterface $config, array $foreignKeys): ?ForeignKey + { + $filter = fn (ForeignKey $fk) => $fk->getAttribute(static::ATTRIBUTE_KEY_SYNCED_THROUGH) === $config->getId(); + $matches = array_filter($foreignKeys, $filter); + if (count($matches) > 1) { + throw new RuntimeException('More than one relation identified through '); + } + + return reset($matches) ?: null; + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * + * @return void + */ + protected function addCustomForeignKeysToSyncedTable(Table $syncedTable) + { + $foreignKeys = $this->config->getForeignKeys(); + foreach ($foreignKeys as $fkData) { + $this->createForeignKeyFromParameters($syncedTable, $fkData); + } + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param array<\Propel\Generator\Model\Column> $columns + * @param array $ignoreColumnNames + * + * @return void + */ + protected function syncColumns(Table $syncedTable, array $columns, array $ignoreColumnNames) + { + foreach ($columns as $sourceColumn) { + $syncedColumnName = $this->getPrefixedColumnName($sourceColumn->getName()); + if (in_array($sourceColumn->getName(), $ignoreColumnNames) || $syncedTable->hasColumn($syncedColumnName)) { + continue; + } + $syncedColumn = clone $sourceColumn; + $syncedColumn->setName($syncedColumnName); + $syncedColumn->setPhpName(null); + $syncedColumn->clearReferrers(); + $syncedColumn->clearInheritanceList(); + $syncedColumn->setAutoIncrement(false); + $syncedTable->addColumn($syncedColumn); + } + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param array<\Propel\Generator\Model\ForeignKey> $foreignKeys + * @param bool $inheritConstraints + * @param array $ignoreColumnNames + * + * @return void + */ + protected function syncForeignKeys(Table $syncedTable, array $foreignKeys, bool $inheritConstraints, array $ignoreColumnNames) + { + foreach ($foreignKeys as $originalForeignKey) { + if ( + $syncedTable->containsForeignKeyWithSameName($originalForeignKey) + || array_intersect($originalForeignKey->getLocalColumns(), $ignoreColumnNames) + ) { + continue; + } + $syncedForeignKey = clone $originalForeignKey; + $syncedForeignKey->setSkipSql(!$inheritConstraints); + $this->prefixForeignKeyColumnNames($syncedForeignKey); + $syncedTable->addForeignKey($syncedForeignKey); + } + } + + /** + * @param \Propel\Generator\Model\ForeignKey $fk + * + * @return void + */ + protected function prefixForeignKeyColumnNames(ForeignKey $fk): void + { + if (!$this->config->getColumnPrefix()) { + return; + } + $mapping = $fk->getColumnObjectsMapping(); + $fk->clearReferences(); + foreach ($mapping as $def) { + $fk->addReference([ + 'local' => $this->getPrefixedColumnName($def['local']->getName()), + 'foreign' => $def['foreign'] ? $def['foreign']->getName() : null, + 'value' => $def['value'], + ]); + } + } + + /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param array<\Propel\Generator\Model\Index> $indexes + * @param bool $rename + * @param array $ignoreColumnNames + * + * @return void + */ + protected function syncIndexes(Table $syncedTable, array $indexes, bool $rename, array $ignoreColumnNames) + { + foreach ($indexes as $originalIndex) { + $index = clone $originalIndex; + + if (!$this->removeColumnsFromIndex($index, $ignoreColumnNames)) { + continue; + } + + if ($rename) { + // by removing the name, Propel will generate a unique name based on table and columns + $index->setName(null); + } + + $this->prefixIndexColumnNames($index, $syncedTable); + + if ($syncedTable->hasIndex($index->getName())) { + continue; + } + $syncedTable->addIndex($index); + } + } + + /** + * @param \Propel\Generator\Model\Index $index + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function prefixIndexColumnNames(Index $index, Table $table): void + { + if (!$this->config->getColumnPrefix()) { + return; + } + $updatedColumnNames = array_map([$this, 'getPrefixedColumnName'], $index->getColumns()); + $columns = array_map([$table, 'getColumn'], $updatedColumnNames); + $index->setColumns($columns); + } + + /** + * @param \Propel\Generator\Model\Index $index + * @param array $ignoreColumnNames + * + * @return \Propel\Generator\Model\Index|null Returns null if the index has no remaining columns. + */ + protected function removeColumnsFromIndex(Index $index, array $ignoreColumnNames): ?Index + { + $ignoredColumnsInIndex = array_intersect($index->getColumns(), $ignoreColumnNames); + if (!$ignoredColumnsInIndex) { + return $index; + } + if (count($ignoredColumnsInIndex) === count($index->getColumns())) { + return null; + } + $indexColumns = array_filter($index->getColumnObjects(), fn (Column $col) => !in_array($col->getName(), $ignoredColumnsInIndex)); + $index->setColumns($indexColumns); + + return $index; + } + + /** + * Create regular indexes from unique indexes on the given synced table. + * + * The synced table cannot use unique indexes, as even unique data on the + * source table can be syncedd several times. + * + * @param bool $asIndex + * @param \Propel\Generator\Model\Table $syncedTable + * @param array<\Propel\Generator\Model\Unique> $uniqueIndexes + * @param array $ignoreColumnNames + * + * @return void + */ + protected function syncUniqueIndexes(bool $asIndex, Table $syncedTable, array $uniqueIndexes, array $ignoreColumnNames) + { + $indexClass = $asIndex ? Index::class : Unique::class; + foreach ($uniqueIndexes as $unique) { + if (array_intersect($unique->getColumns(), $ignoreColumnNames)) { + continue; + } + $index = new $indexClass(); + $index->setTable($syncedTable); + foreach ($unique->getColumns() as $columnName) { + $columnDef = [ + 'name' => $this->getPrefixedColumnName($columnName), + 'size' => $unique->getColumnSize($columnName), + ]; + $index->addColumn($columnDef); + } + + $existingIndexes = $asIndex ? $syncedTable->getIndices() : $syncedTable->getUnices(); + $existingIndexNames = array_map(fn ($index) => $index->getName(), $existingIndexes); + if (in_array($index->getName(), $existingIndexNames)) { + continue; + } + $index instanceof Unique ? $syncedTable->addUnique($index) : $syncedTable->addIndex($index); + } + } + + /** + * @param \Propel\Generator\Model\Table $table + * + * @return void + */ + protected function reapplyTableBehaviors(Table $table) + { + $behaviors = $table->getDatabase()->getBehaviors(); + foreach ($behaviors as $behavior) { + if ($behavior instanceof SyncedTableBehavior) { + continue; + } + $behavior->modifyDatabase(); + } + } + + /** + * @psalm-param array{name?: string, localColumn: string, foreignTable: string, foreignColumn: string, relationOnly?: string} $fkData + * + * @param \Propel\Generator\Model\Table $syncedTable + * @param array $fkData + * + * @return void + */ + protected function createForeignKeyFromParameters(Table $syncedTable, array $fkData): void + { + $fk = new ForeignKey($fkData['name'] ?? null); + $fk->addReference($fkData['localColumn'], $fkData['foreignColumn']); + $syncedTable->addForeignKey($fk); + $fk->loadMapping($fkData); + } + + /** + * @param \Propel\Generator\Model\Table $table + * @param string $columnName + * @param array $columnDefinition + * + * @return \Propel\Generator\Model\Column + */ + public static function addColumnIfNotExists(Table $table, string $columnName, array $columnDefinition): Column + { + if ($table->hasColumn($columnName)) { + return $table->getColumn($columnName); + } + $columnDefinitionWithName = array_merge(['name' => $columnName], $columnDefinition); + + return $table->addColumn($columnDefinitionWithName); + } + + /** + * @param \Propel\Generator\Platform\PlatformInterface|null $platform + * + * @return bool + */ + protected function isDistinctiveIndexNameRequired(?PlatformInterface $platform): bool + { + return $platform instanceof PgsqlPlatform || $platform instanceof SqlitePlatform; + } + + /** + * @param string $columnName + * + * @return string + */ + protected function getPrefixedColumnName(string $columnName): string + { + return $this->config->getColumnPrefix() . $columnName; + } +} diff --git a/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncerConfigInterface.php b/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncerConfigInterface.php new file mode 100644 index 000000000..db87e345b --- /dev/null +++ b/src/Propel/Generator/Behavior/SyncedTable/TableSyncer/TableSyncerConfigInterface.php @@ -0,0 +1,122 @@ +parameters[$name] ?? null; + + return is_string($val) ? trim($val) : $defaultValue; // means empty space (' ') cannot be a value, seems ok. + } + + /** + * @param string $parameterName + * @param bool|null $defaultValue + * + * @return bool|null + */ + public function getParameterBool(string $parameterName, ?bool $defaultValue = null): ?bool + { + $val = $this->getParameter($parameterName); + + return !$val ? $defaultValue : in_array(strtolower($val), ['true', '1']); + } + + /** + * @param string $parameterName + * @param int|null $defaultValue + * + * @throws \Propel\Generator\Exception\SchemaException + * + * @return int|null + */ + public function getParameterInt(string $parameterName, ?int $defaultValue = null): ?int + { + $val = $this->getParameter($parameterName); + if ($val === null) { + return $defaultValue; + } + if (!is_numeric($val)) { + throw new SchemaException("Parameter $parameterName should be numeric, but is '$val'"); + } + + return (int)$val; + } + + /** + * @param string $parameterName + * @param array|null $defaultValue + * @param callable|null $mapper + * + * @return array|null + */ + public function getParameterCsv(string $parameterName, ?array $defaultValue = [], ?callable $mapper = null): ?array + { + $valString = $this->getParameter($parameterName); + if (!$valString) { + return $defaultValue; + } + $valList = $this->explodeCsv($valString); + + return $mapper ? array_map($mapper, $valList) : $valList; + } + + /** + * @param string $parameterName + * @param mixed $defaultValue + * + * @return mixed|true + */ + public function getParameterTrueOrValue(string $parameterName, $defaultValue = null) + { + $val = $this->parameters[$parameterName] ?? null; + $isTrue = is_string($val) && $this->getParameterBool($parameterName); + + return $isTrue ?: ($this->parameters[$parameterName] ?? $defaultValue); + } + + /** + * @param string $parameterName + * @param array|null $defaultValue + * @param callable|null $mapper + * + * @return array|true|null + */ + public function getParameterTrueOrCsv(string $parameterName, ?array $defaultValue = null, $mapper = null) + { + $val = $this->getParameterBool($parameterName); + + return $val ?: $this->getParameterCsv($parameterName, $defaultValue, $mapper); + } + + /** + * @param string $stringValue + * + * @return array|null + */ + protected function explodeCsv(string $stringValue): ?array + { + $stringValue = trim($stringValue); + + return trim($stringValue) ? array_map('trim', explode(',', $stringValue)) : null; + } + + /** + * Unwraps an array created by ``. + * + * @psalm-param [array]|array $parameterListOrList + * + * @param array $parameterListOrList + * + * @return array + */ + protected function unwrapParameterList(array $parameterListOrList): array + { + $firstElement = reset($parameterListOrList); + $assumeParameterList = count($parameterListOrList) === 1 && is_array($firstElement); + + return $assumeParameterList ? $firstElement : $parameterListOrList; + } +} diff --git a/src/Propel/Generator/Behavior/Util/InsertCodeBehavior.php b/src/Propel/Generator/Behavior/Util/InsertCodeBehavior.php new file mode 100644 index 000000000..899ccf8ae --- /dev/null +++ b/src/Propel/Generator/Behavior/Util/InsertCodeBehavior.php @@ -0,0 +1,265 @@ + + */ + protected $codeForHooks = []; + + /** + * Add this behavior to a table. + * + * @param \Propel\Generator\Model\Behavior $insertingBehavior + * @param \Propel\Generator\Model\Table $table + * @param array $codeForHooks + * + * @return \Propel\Generator\Behavior\Util\InsertCodeBehavior|self + */ + public static function addToTable(Behavior $insertingBehavior, Table $table, array $codeForHooks): self + { + $behavior = new self(); + $behavior->setup($insertingBehavior, $table, $codeForHooks); + + return $behavior; + } + + /** + * @param \Propel\Generator\Model\Behavior $insertingBehavior + * @param \Propel\Generator\Model\Table $table + * @param array $codeForHooks + * + * @return void + */ + public function setup(Behavior $insertingBehavior, Table $table, array $codeForHooks) + { + $id = "insert_code_from_{$insertingBehavior->getName()}_behavior_on_table_{$insertingBehavior->getTable()->getName()}"; + $this->setId($id); + $this->setDatabase($table->getDatabase()); + $this->setTable($table); + $this->codeForHooks = $codeForHooks; + $table->addBehavior($this); + } + + /** + * @see \Propel\Generator\Model\Behavior::allowMultiple() + * + * @return bool + */ + public function allowMultiple(): bool + { + return true; + } + + // object builder hooks + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function objectAttributes(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('objectAttributes', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function objectMethods(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('objectMethods', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function objectCall(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('objectCall', $objectBuilder); + } + + /** + * @see \Propel\Generator\Model\Behavior::objectFilter() + * + * @param string $script + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function objectFilter(string &$script, ObjectBuilder $objectBuilder) + { + $fun = $this->codeForHooks['objectFilter'] ?? null; + if (!$fun) { + return; + } + if (!is_callable($fun)) { + throw new InvalidArgumentException("Value in 'objectFilter' has to be callable."); + } + + $script = $fun($script, $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function preInsert(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('preInsert', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postInsert(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('postInsert', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function preUpdate(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('preUpdate', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postUpdate(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('postUpdate', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function preDelete(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('preDelete', $objectBuilder); + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + public function postDelete(ObjectBuilder $objectBuilder): string + { + return $this->resolveCode('postDelete', $objectBuilder); + } + + /** + * @param string $hook + * @param \Propel\Generator\Builder\Om\ObjectBuilder $objectBuilder + * + * @return string + */ + protected function resolveCode(string $hook, ObjectBuilder $objectBuilder): string + { + $code = $this->codeForHooks[$hook] ?? null; + if (!$code) { + return ''; + } + + return is_callable($code) ? $code($objectBuilder) : $code; + } + + // parentClass + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder|\Propel\Generator\Builder\Om\QueryBuilder $builder + * + * @return string|null + */ + public function parentClass($builder): ?string + { + $parentTable = $this->resolveTableFromHookContent($builder, 'parentClass'); + if (!$parentTable) { + return null; + } + $stubBuilder = $this->buildStubBuilder($builder, $parentTable); + + return $stubBuilder ? $builder->declareClassFromBuilder($stubBuilder, true) : null; + } + + /** + * @param \Propel\Generator\Builder\Om\AbstractOMBuilder $builder + * @param string $hook + * + * @throws \InvalidArgumentException If the table cannot be resolved + * + * @return \Propel\Generator\Model\Table|null + */ + protected function resolveTableFromHookContent(AbstractOMBuilder $builder, string $hook): ?Table + { + $parentTable = $this->codeForHooks[$hook] ?? null; + if (is_string($parentTable)) { + $resolvedTable = $builder->getDatabase()->getTable($parentTable); + if (!$resolvedTable) { + throw new InvalidArgumentException("Could not find table in '$hook': '$parentTable'"); + } + + return $resolvedTable; + } + if ($parentTable && !($parentTable instanceof Table)) { + throw new InvalidArgumentException("Value in '$hook' has to be an instance of Table"); + } + + return $parentTable; + } + + /** + * @param \Propel\Generator\Builder\Om\ObjectBuilder|\Propel\Generator\Builder\Om\QueryBuilder $builder + * @param \Propel\Generator\Model\Table $table + * + * @return \Propel\Generator\Builder\Om\AbstractOMBuilder|null + */ + protected function buildStubBuilder($builder, Table $table): ?AbstractOMBuilder + { + switch (get_class($builder)) { + case ObjectBuilder::class: + return $builder->getNewStubObjectBuilder($table); + case QueryBuilder::class: + return $builder->getNewStubQueryBuilder($table); + default: + return null; + } + } +} diff --git a/src/Propel/Generator/Behavior/Versionable/VersionableBehavior.php b/src/Propel/Generator/Behavior/Versionable/VersionableBehavior.php index 62b9af2b3..aed4e862f 100644 --- a/src/Propel/Generator/Behavior/Versionable/VersionableBehavior.php +++ b/src/Propel/Generator/Behavior/Versionable/VersionableBehavior.php @@ -8,7 +8,8 @@ namespace Propel\Generator\Behavior\Versionable; -use Propel\Generator\Model\Behavior; +use Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior; +use Propel\Generator\Behavior\SyncedTable\TableSyncer\TableSyncer; use Propel\Generator\Model\Column; use Propel\Generator\Model\ForeignKey; use Propel\Generator\Model\Table; @@ -18,24 +19,33 @@ * * @author Francois Zaninotto */ -class VersionableBehavior extends Behavior +class VersionableBehavior extends SyncedTableBehavior { /** - * Default parameters value + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::DEFAULT_SYNCED_TABLE_SUFFIX * - * @var array + * @var string DEFAULT_SYNCED_TABLE_SUFFIX */ - protected $parameters = [ - 'version_column' => 'version', - 'version_table' => '', - 'log_created_at' => 'false', - 'log_created_by' => 'false', - 'log_comment' => 'false', - 'version_created_at_column' => 'version_created_at', - 'version_created_by_column' => 'version_created_by', - 'version_comment_column' => 'version_comment', - 'indices' => 'false', - ]; + protected const DEFAULT_SYNCED_TABLE_SUFFIX = '_version'; + + /** + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::PARAMETER_KEY_SYNCED_TABLE + * + * @var string + */ + public const PARAMETER_KEY_SYNCED_TABLE = 'version_table'; + + /** + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::PARAMETER_KEY_SYNCED_PHPNAME + * + * @var string + */ + public const PARAMETER_KEY_SYNCED_PHPNAME = 'version_phpname'; + + /** + * @var string + */ + public const PARAMETER_KEY_SYNC_INDEXES = 'indices'; /** * @var \Propel\Generator\Model\Table @@ -58,73 +68,101 @@ class VersionableBehavior extends Behavior protected $tableModificationOrder = 80; /** + * @see \Propel\Generator\Behavior\SyncedTable\SyncedTableBehavior::getDefaultParameters() + * + * @return array + */ + protected function getDefaultParameters(): array + { + return [ + 'version_column' => 'version', + static::PARAMETER_KEY_SYNCED_TABLE => '', + static::PARAMETER_KEY_SYNCED_PHPNAME => null, + static::PARAMETER_KEY_SYNC => 'false', + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_RELATIONS => 'false', + static::PARAMETER_KEY_INHERIT_FOREIGN_KEY_CONSTRAINTS => 'false', + static::PARAMETER_KEY_FOREIGN_KEYS => null, + static::PARAMETER_KEY_SYNC_INDEXES => 'false', + static::PARAMETER_KEY_SYNC_UNIQUE_AS => null, + static::PARAMETER_KEY_RELATION => [['onDelete' => 'cascade']], + static::PARAMETER_KEY_ON_SKIP_SQL => 'inherit', + 'log_created_at' => 'false', + 'log_created_by' => 'false', + 'log_comment' => 'false', + 'version_created_at_column' => 'version_created_at', + 'version_created_by_column' => 'version_created_by', + 'version_comment_column' => 'version_comment', + ]; + } + + /** + * @return \Propel\Generator\Model\Table + */ + public function getVersionTable(): Table + { + return $this->syncedTable; + } + + /** + * @param \Propel\Generator\Model\Table $table + * * @return void */ - public function modifyDatabase(): void + protected function addBehaviorToTable(Table $table): void { - foreach ($this->getDatabase()->getTables() as $table) { - if ($table->hasBehavior($this->getId())) { - // don't add the same behavior twice - continue; - } - if (property_exists($table, 'isVersionTable')) { - // don't add the behavior to version tables - continue; - } - $b = clone $this; - $table->addBehavior($b); + if (property_exists($table, 'isVersionTable')) { + // don't add the behavior to version tables + return; } + parent::addBehaviorToTable($table); } /** - * @return void + * @return string|null */ - public function modifyTable(): void + public function getSyncedTablePhpName(): ?string { - $this->addVersionColumn(); - $this->addLogColumns(); - $this->addVersionTable(); - $this->addForeignKeyVersionColumns(); + // required for BC + return parent::getSyncedTablePhpName() ?? $this->getTable()->getPhpName() . 'Version'; } /** * @return void */ - protected function addVersionColumn(): void + public function modifyTable(): void { - $table = $this->getTable(); - // add the version column - if (!$table->hasColumn($this->getParameter('version_column'))) { - $table->addColumn([ - 'name' => $this->getParameter('version_column'), - 'type' => 'INTEGER', - 'default' => 0, - ]); - } + $this->addColumnsToSourceTable(); + parent::modifyTable(); + $this->addForeignKeyVersionColumns(); } /** * @return void */ - protected function addLogColumns(): void + protected function addColumnsToSourceTable(): void { $table = $this->getTable(); - if ($this->getParameter('log_created_at') === 'true' && !$table->hasColumn($this->getParameter('version_created_at_column'))) { - $table->addColumn([ - 'name' => $this->getParameter('version_created_at_column'), + + $this->addColumnFromParameterIfNotExists($table, 'version_column', [ + 'type' => 'INTEGER', + 'default' => 0, + ]); + + if ($this->getParameter('log_created_at') === 'true') { + $this->addColumnFromParameterIfNotExists($table, 'version_created_at_column', [ 'type' => 'TIMESTAMP', ]); } - if ($this->getParameter('log_created_by') === 'true' && !$table->hasColumn($this->getParameter('version_created_by_column'))) { - $table->addColumn([ - 'name' => $this->getParameter('version_created_by_column'), + + if ($this->getParameter('log_created_by') === 'true') { + $this->addColumnFromParameterIfNotExists($table, 'version_created_by_column', [ 'type' => 'VARCHAR', 'size' => 100, ]); } - if ($this->getParameter('log_comment') === 'true' && !$table->hasColumn($this->getParameter('version_comment_column'))) { - $table->addColumn([ - 'name' => $this->getParameter('version_comment_column'), + + if ($this->getParameter('log_comment') === 'true') { + $this->addColumnFromParameterIfNotExists($table, 'version_comment_column', [ 'type' => 'VARCHAR', 'size' => 255, ]); @@ -132,68 +170,24 @@ protected function addLogColumns(): void } /** + * @param \Propel\Generator\Model\Table $syncedTable + * @param bool $tableExistsInSchema + * * @return void */ - protected function addVersionTable(): void + public function addTableElements(Table $syncedTable, bool $tableExistsInSchema): void { - $table = $this->getTable(); - $database = $table->getDatabase(); - $versionTableName = $this->getParameter('version_table') ?: ($table->getOriginCommonName() . '_version'); - if (!$database->hasTable($versionTableName)) { - // create the version table - $versionTable = $database->addTable([ - 'name' => $versionTableName, - 'phpName' => $this->getVersionTablePhpName(), - 'package' => $table->getPackage(), - 'schema' => $table->getSchema(), - 'namespace' => $table->getNamespace() ? '\\' . $table->getNamespace() : null, - 'skipSql' => $table->isSkipSql(), - 'identifierQuoting' => $table->isIdentifierQuotingEnabled(), - ]); - $versionTable->isVersionTable = true; - // every behavior adding a table should re-execute database behaviors - foreach ($database->getBehaviors() as $behavior) { - $behavior->modifyDatabase(); - } - // copy all the columns - foreach ($table->getColumns() as $column) { - $columnInVersionTable = clone $column; - $columnInVersionTable->clearInheritanceList(); - if ($columnInVersionTable->hasReferrers()) { - $columnInVersionTable->clearReferrers(); - } - if ($columnInVersionTable->isAutoincrement()) { - $columnInVersionTable->setAutoIncrement(false); - } - $versionTable->addColumn($columnInVersionTable); - } - // create the foreign key - $fk = new ForeignKey(); - $fk->setForeignTableCommonName($table->getCommonName()); - $fk->setForeignSchemaName($table->getSchema()); - $fk->setOnDelete('CASCADE'); - $fk->setOnUpdate(null); - $tablePKs = $table->getPrimaryKey(); - foreach ($versionTable->getPrimaryKey() as $key => $column) { - $fk->addReference($column, $tablePKs[$key]); - } - $versionTable->addForeignKey($fk); - - if ($this->getParameter('indices') === 'true') { - foreach ($table->getIndices() as $index) { - $index = clone $index; - $versionTable->addIndex($index); - } - } + parent::addTableElements($syncedTable, $tableExistsInSchema); - // add the version column to the primary key - $versionColumn = $versionTable->getColumn($this->getParameter('version_column')); - $versionColumn->setNotNull(true); - $versionColumn->setPrimaryKey(true); - $this->versionTable = $versionTable; - } else { - $this->versionTable = $database->getTable($versionTableName); + if ($tableExistsInSchema) { + return; } + $syncedTable->isVersionTable = true; + + // add the version column to the primary key + $versionColumn = $syncedTable->getColumn($this->getParameter('version_column')); + $versionColumn->setNotNull(true); + $versionColumn->setPrimaryKey(true); } /** @@ -201,54 +195,29 @@ protected function addVersionTable(): void */ public function addForeignKeyVersionColumns(): void { - $versionTable = $this->versionTable; + $versionTable = $this->syncedTable; foreach ($this->getVersionableFks() as $fk) { $fkVersionColumnName = $fk->getLocalColumnName() . '_version'; - if (!$versionTable->hasColumn($fkVersionColumnName)) { - $versionTable->addColumn([ - 'name' => $fkVersionColumnName, - 'type' => 'INTEGER', - 'default' => 0, - ]); - } + TableSyncer::addColumnIfNotExists($versionTable, $fkVersionColumnName, [ + 'type' => 'INTEGER', + 'default' => 0, + ]); } foreach ($this->getVersionableReferrers() as $fk) { $fkTableName = $fk->getTable()->getName(); $fkIdsColumnName = $fkTableName . '_ids'; - if (!$versionTable->hasColumn($fkIdsColumnName)) { - $versionTable->addColumn([ - 'name' => $fkIdsColumnName, - 'type' => 'ARRAY', - ]); - } + TableSyncer::addColumnIfNotExists($versionTable, $fkIdsColumnName, [ + 'type' => 'ARRAY', + ]); $fkVersionsColumnName = $fkTableName . '_versions'; - if (!$versionTable->hasColumn($fkVersionsColumnName)) { - $versionTable->addColumn([ - 'name' => $fkVersionsColumnName, - 'type' => 'ARRAY', - ]); - } + TableSyncer::addColumnIfNotExists($versionTable, $fkVersionsColumnName, [ + 'type' => 'ARRAY', + ]); } } - /** - * @return \Propel\Generator\Model\Table - */ - public function getVersionTable(): Table - { - return $this->versionTable; - } - - /** - * @return string - */ - public function getVersionTablePhpName(): string - { - return $this->getTable()->getPhpName() . 'Version'; - } - /** * @return list<\Propel\Generator\Model\ForeignKey> */ @@ -297,7 +266,7 @@ public function getReferrerIdsColumn(ForeignKey $fk): ?Column $fkTableName = $fk->getTable()->getName(); $fkIdsColumnName = $fkTableName . '_ids'; - return $this->versionTable->getColumn($fkIdsColumnName); + return $this->syncedTable->getColumn($fkIdsColumnName); } /** @@ -310,7 +279,7 @@ public function getReferrerVersionsColumn(ForeignKey $fk): ?Column $fkTableName = $fk->getTable()->getName(); $fkIdsColumnName = $fkTableName . '_versions'; - return $this->versionTable->getColumn($fkIdsColumnName); + return $this->syncedTable->getColumn($fkIdsColumnName); } /** diff --git a/src/Propel/Generator/Builder/Om/ObjectBuilder.php b/src/Propel/Generator/Builder/Om/ObjectBuilder.php index 5eaf4b3b4..7b7b0f5d5 100644 --- a/src/Propel/Generator/Builder/Om/ObjectBuilder.php +++ b/src/Propel/Generator/Builder/Om/ObjectBuilder.php @@ -289,7 +289,7 @@ abstract class " . $this->getUnqualifiedClassName() . $parentClass . ' implement * Specifies the methods that are added as part of the basic OM class. * This can be overridden by subclasses that wish to add more methods. * - * @see ObjectBuilder::addClassBody() + * @see \Propel\Generator\Builder\Om\AbstractOMBuilder::addClassBody() * * @param string $script * diff --git a/src/Propel/Generator/Model/Behavior.php b/src/Propel/Generator/Model/Behavior.php index 0859dc0ab..5b6b60a51 100644 --- a/src/Propel/Generator/Model/Behavior.php +++ b/src/Propel/Generator/Model/Behavior.php @@ -275,7 +275,7 @@ public function getParameter(string $name) */ public function parameterHasValue(string $paramName, $value): bool { - return $this->parameters[$paramName] === $value; + return ($this->parameters[$paramName] ?? null) === $value; } /** diff --git a/src/Propel/Generator/Model/Index.php b/src/Propel/Generator/Model/Index.php index 1a749c5af..ca60f0a02 100644 --- a/src/Propel/Generator/Model/Index.php +++ b/src/Propel/Generator/Model/Index.php @@ -227,6 +227,7 @@ public function setColumns(array $columns): void { $this->columns = []; $this->columnsSize = []; + $this->columnObjects = []; foreach ($columns as $column) { $this->addColumn($column); } diff --git a/tests/Fixtures/bookstore/behavior-auditable-schema.xml b/tests/Fixtures/bookstore/behavior-auditable-schema.xml new file mode 100644 index 000000000..b99b8ae3c --- /dev/null +++ b/tests/Fixtures/bookstore/behavior-auditable-schema.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
diff --git a/tests/Propel/Tests/Generator/Behavior/Archivable/ArchivableBehaviorSyncTest.php b/tests/Propel/Tests/Generator/Behavior/Archivable/ArchivableBehaviorSyncTest.php deleted file mode 100644 index 107823423..000000000 --- a/tests/Propel/Tests/Generator/Behavior/Archivable/ArchivableBehaviorSyncTest.php +++ /dev/null @@ -1,362 +0,0 @@ - - - - ', - // archive table input columns - '', - // auxiliary schema data - '', - // archive output columns - ' - - - - ', - ], [ - // description - 'Cannot override columns declared on archive table', - //additional behavior parameters - '', - // source table columns: column with size 8 - '', - // archive table input columns: column with size 999 - '', - // auxiliary schema data - '', - // archive output columns - '', - ], [ - // description - 'Should sync index', - //additional behavior parameters - '', - // source table columns: column with index - ' - - - - - ', - // archive table input columns - '', - // auxiliary schema data - '', - // archive output columns - ' - - - - - ', - ], [ - // description - 'Should sync fk column without relation', - //additional behavior parameters - '', - // source table columns: column with fk - ' - - - - - ', - // archive table input columns - '', - // auxiliary schema data - ' - - -
- ', - // archive output columns - '', - ], [ - // description - 'Should sync fk column with relation through parameter', - //additional behavior parameters - '', - // source table columns: column with fk - ' - - - - - ', - // archive table input columns - '', - // auxiliary schema data - ' - - -
- ', - // archive output columns - ' - - - - - ', - ], [ - // description - 'Behavior can override synced FKs', - //additional behavior parameters: inherit fks but override relation "LeName" - ' - - - - - - - - - - ', - // source table columns: column with fk - ' - - - - - ', - // archive table input columns - '', - // auxiliary schema data - ' - - -
- - -
- ', - // archive output columns - ' - - - - - ', - ], [ - // description - 'Behavior cannot override FKs declared on archive table', - //additional behavior parameters: declare fk - ' - - - - - - - - - ', - // source table columns - '', - // archive table input columns: fk conflicting with behavior fk - ' - - - - - ', - // auxiliary schema data - ' - - -
- - -
- ', - // archive output columns: expect exception - EngineException::class, - ], - ]; - } - - /** - * @dataProvider syncTestDataProvider - * - * @param string $message - * @param string $behaviorAdditions - * @param string $sourceTableContentTags - * @param string $archiveTableInputTags - * @param string $auxiliaryTables - * @param string $archiveTableOutputTags - * - * @return void - */ - public function testSync( - string $message, - string $behaviorAdditions, - string $sourceTableContentTags, - string $archiveTableInputTags, - string $auxiliaryTables, - string $archiveTableOutputTags - ) { - // source table: some columns - // archive table: empty - $schema = << - - - - - - $behaviorAdditions - - - $sourceTableContentTags - -
- - $auxiliaryTables - - $archiveTableInputTags
- -EOT; - - // archive table: all columns plus archived_at - $expected = << - - $archiveTableOutputTags -
- - $auxiliaryTables - -EOT; - - if (class_exists($archiveTableOutputTags) && is_subclass_of($archiveTableOutputTags, Exception::class)) { - $this->expectException($archiveTableOutputTags); - } - $this->assertSchemaTableMatches($expected, $schema, 'archive_table', $message); - } - - /** - * @param string $expectedTableXml - * @param string $schema - * @param string $tableName - * @param string|null $message - * - * @return void - */ - protected function assertSchemaTableMatches(string $expectedTableXml, string $schema, string $tableName, ?string $message = null) - { - $expectedSchema = $this->buildSchema($expectedTableXml); - $expectedTable = $expectedSchema->getTable($tableName); - - $actualSchema = $this->buildSchema($schema); - $actualTable = $actualSchema->getTable($tableName); - - $diff = TableComparator::computeDiff($actualTable, $expectedTable); - if ($diff !== false) { - $message = $this->buildTestMessage($message, $diff, $expectedSchema, $actualSchema); - $this->fail($message); - } - $this->expectNotToPerformAssertions(); - } - - /** - * @param string $schema - * - * @return \Propel\Generator\Model\Database - */ - protected function buildSchema(string $schema): Database - { - $builder = new QuickBuilder(); - $builder->setSchema($schema); - - return $builder->getDatabase(); - } - - /** - * @param string $inputMessage - * @param \Propel\Generator\Model\Diff\TableDiff $diff - * @param \Propel\Generator\Model\Database $expectedSchema - * @param \Propel\Generator\Model\Database $actualSchema - * - * @return string - */ - protected function buildTestMessage(string $inputMessage, TableDiff $diff, Database $expectedSchema, Database $actualSchema) - { - $inputMessage ??= ''; - $platform = new MysqlPlatform(); - $sql = $platform->getModifyTableDDL($diff); - - return <<markTestSkipped(); $schema = << @@ -241,7 +240,7 @@ public function testMissingFkParametersThrowsException(string $description, stri $builder = new QuickBuilder(); $builder->setSchema($schema); - $this->expectException(SchemaException::class); + $this->expectException(SyncedTableException::class); $builder->getDatabase(); } diff --git a/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorDeclarationTest.php b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorDeclarationTest.php new file mode 100644 index 000000000..0ed9dc2b3 --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorDeclarationTest.php @@ -0,0 +1,171 @@ + + + + + +
+ +EOT; + $builder = new QuickBuilder(); + $builder->setSchema($schemaXml); + + $database = $builder->getDatabase(); + $table = $database->getTable('source_table_audit'); + $this->assertNotNull($table, 'Audit table should have been created with expected name'); + $fieldNames = array_map(fn(Column $col) => $col->getName(), $table->getColumns()); + $expectedFieldNames = ['source_table_int_col', 'audit_id', 'audited_at', 'audit_event', 'internal_changed_values']; + $this->assertEquals($expectedFieldNames, $fieldNames); + } + + /** + * @return void + */ + public function testTableParameters(): void + { + $schemaXml = << + + + + + + + + + + + + + + + + + +
+ + EOT; + $builder = new QuickBuilder(); + $builder->setSchema($schemaXml); + + $database = $builder->getDatabase(); + $table = $database->getTable('le_source_table_audit'); + $this->assertNotNull($table, 'Audit table should have been created with expected name'); + + $fieldNames = array_map(fn(Column $col) => $col->getName(), $table->getColumns()); + $expectedFieldNames = ['source_int_col', 'le_id', 'le_at', 'audit_event', 'internal_le_changes']; + $this->assertEquals($expectedFieldNames, $fieldNames); + + $changedValuesDomain = $table->getColumn('internal_le_changes')->getDomain(); + $this->assertEquals(PropelTypes::CHAR, $changedValuesDomain->getType()); + $this->assertEquals(2, $changedValuesDomain->getSize()); + + $relation = $table->getForeignKeys()[0]; + $this->assertNotNull($relation); + $this->assertEquals('le_value', $relation->getAttribute('le_attribute')); + } + + /** + * @return void + */ + public function testSelectAuditedColumns(): void + { + $schemaXml = << + + + + + + + + + + + + +
+ +EOT; + $builder = new QuickBuilder(); + $builder->setSchema($schemaXml); + + $database = $builder->getDatabase(); + $table = $database->getTable('table'); + $behavior = $table->getBehavior('auditable'); + + $selected = $this->callMethod($behavior, 'selectAuditedFields'); + + $this->assertIsArray($selected); + + $expected = [ + ['column' => $table->getColumn('int_col'), 'isOmitted' => false], + ['column' => $table->getColumn('password'), 'isOmitted' => true], + ['column' => $table->getColumn('blob_col'), 'isOmitted' => true], + ]; + + $this->assertEqualsCanonicalizing($expected, $selected); + } + + + /** + * @return void + */ + public function testCannotCombineCascadeParamterWithRelationArray(): void + { + $schemaXml = << + + + + + + + + +
+ +EOT; + $builder = new QuickBuilder(); + $builder->setSchema($schemaXml); + + $this->expectException(SyncedTableException::class); + $this->expectExceptionMessage("Cannot combine parameter 'cascade_delete' with array input for relation ('relation') - set onDelete behavior in array."); + $builder->getDatabase(); + + } +} diff --git a/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorTest.php b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorTest.php new file mode 100644 index 000000000..b46712537 --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableBehaviorTest.php @@ -0,0 +1,197 @@ +setInternalChangedValues(['key' => 'val']); + + $this->assertArrayNotHasKey('InternalChangedValues', $audit->toArray()); + } + + /** + * @return void + */ + public function testDefaultAudits(): void + { + $auditableOperation = 0; + $source = AuditableObjectDataBuilder::createInitialSourceWithDefaultAudit(); + $this->assertDefaultAuditMatchesSource($source, $auditableOperation++, 'insert', []); + + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update1'); + $this->assertDefaultAuditMatchesSource($source, $auditableOperation++, 'update', [ + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + 'regular_column' => 'initial regular value', + ]); + + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update2', ['omited_column', 'omited_type_column']); + $this->assertDefaultAuditMatchesSource($source, $auditableOperation++, 'update', [ + 'regular_column' => 'update1 regular value', + ]); + + $source->delete(); + $this->assertDefaultAuditMatchesSource($source, $auditableOperation++, 'delete', [ + 'id' => $source->getId(), + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + 'regular_column' => 'update2 regular value', + ]); + } + + /** + * @return void + */ + public function testComplexAudits(): void + { + $auditableOperation = 0; + $source = AuditableObjectDataBuilder::createInitialSourceWithComplexAudit(); + $this->assertComplexAuditMatchesSource($source, $auditableOperation++, 'insert', []); + + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update1'); + $this->assertComplexAuditMatchesSource($source, $auditableOperation++, 'update', [ + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + 'regular_column' => 'initial regular value', + ]); + + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update2', ['omited_column', 'omited_type_column']); + $this->assertComplexAuditMatchesSource($source, $auditableOperation++, 'update', [ + 'regular_column' => 'update1 regular value', + ]); + + $source->delete(); + $this->assertComplexAuditMatchesSource($source, $auditableOperation++, 'delete', [ + 'id' => $source->getId(), + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + 'regular_column' => 'update2 regular value', + ]); + } + + /** + * @return void + */ + public function testRestoreAudit(): void + { + $source = AuditableObjectDataBuilder::createInitialSourceWithComplexAudit(); + $sourceId = $source->getId(); + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update1'); + AuditableObjectDataBuilder::updateDefaultColumns($source, 'update2', ['omited_column', 'omited_type_column']); + $source->delete(); + $audit = $source->restoreAudit(); + $changedValues = array_map(fn($audit) => $audit->getLeChanges(), $audit->getArrayCopy()); + + $expectedChanges = [ + [ + 'id' => $sourceId, + 'regular_column' => 'update2 regular value', + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + ], + [ + 'regular_column' => 'update2 regular value', + ], + [ + 'regular_column' => 'update1 regular value', + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + ], + [ + 'id' => $sourceId, + 'regular_column' => 'initial regular value', + 'omited_column' => 'got changed', + 'omited_type_column' => 'got changed', + ] + ]; + + $this->assertSame($expectedChanges, $changedValues); + } + + /** + * @param SourceWithDefaultAudit $source + * @param int $auditNumber + * @param string $event + * @param array $changedValues + * + * @return void + */ + public function assertDefaultAuditMatchesSource(SourceWithDefaultAudit $source, int $auditNumber, string $event, array $changedValues): void + { + $audits = $source->getSourceWithDefaultAuditAudits(); + $this->assertCount($auditNumber + 1, $audits); + $audit = $audits[$auditNumber]; + $audit->reload(); + $expectedExport = [ + 'SourceWithDefaultAuditId' => $source->getId(), + 'AuditId' => $audit->getAuditId(), + 'AuditedAt' => $audit->getAuditedAt('Y-m-d H:i:s.u'), + 'AuditEvent' => $event, + ]; + $this->assertSame($expectedExport, $audit->toArray()); + $this->assertNotNull($audit->getAuditedAt(), 'Audit date should not be null'); + + $this->assertEqualsCanonicalizing($changedValues, $audit->getInternalChangedValues()); + } + + /** + * @param SourceWithComplexAudit $source + * @param int $auditNumber + * @param string $event + * @param array $changedValues + * + * @return void + */ + public function assertComplexAuditMatchesSource(SourceWithComplexAudit $source, int $auditNumber, string $event, array $changedValues): void + { + if ($event !== 'delete') { + $source->reload(); + } + $this->assertEquals($auditNumber + 1, $source->getNumberOfAudits()); + + $audits = $source->getLeAuditCompliquesRelatedByLeSourceId(); + $this->assertCount($auditNumber + 1, $audits); + $audit = $audits[$auditNumber]; + $audit->reload(); + + $expectedExport = [ + 'LeSourceId' => $source->getId(), + 'LeId' => $audit->getLeId(), + 'FkToSourceId' => null, + 'LeAt' => $audit->getLeAt('Y-m-d H:i:s.u'), + 'LeWhen' => $event, + ]; + $this->assertSame($expectedExport, $audit->toArray()); + $this->assertInstanceOf(\DateTime::class, $audit->getLeAt(), 'Audit date should not be null'); + $this->assertEqualsCanonicalizing($changedValues, $audit->getInternalLeChanges()); + } +} diff --git a/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableObjectDataBuilder.php b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableObjectDataBuilder.php new file mode 100644 index 000000000..a0ec8aeaa --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/Auditable/AuditableObjectDataBuilder.php @@ -0,0 +1,67 @@ +setRegularColumn($updateId . ' regular value'); + in_array('ignored_column', $skipColumns) || $source->setIgnoredColumn($updateId . ' ignored value'); + in_array('omited_column', $skipColumns) || $source->setOmitedColumn($updateId . ' omited value'); + in_array('omited_type_column', $skipColumns) || $source->setOmitedTypeColumn(fopen('data://text/plain,' . $updateId . ' omited type', 'r')); + $source->save(); + } +} diff --git a/tests/Propel/Tests/Generator/Behavior/ConfigStore/ConfigOperationBehaviorTest.php b/tests/Propel/Tests/Generator/Behavior/ConfigStore/ConfigOperationBehaviorTest.php new file mode 100644 index 000000000..f7d9ce65c --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/ConfigStore/ConfigOperationBehaviorTest.php @@ -0,0 +1,211 @@ +setStoredConfigurations(); + } + + /** + * @return void + */ + public function testStoreConfiguration(): void + { + $schemaXml = << + + + + +EOT; + $this->buildSchemaXml($schemaXml); + + $expected = [ + 'foo_conf' => new ConfigurationItem( + ['name' => 'foo', 'additional' => 'any'], + ['param1' => 'value1'] + ) + ]; + $preconfigurations = $this->getStoredConfigurations(); + + $this->assertEquals($expected, $preconfigurations); + } + + /** + * @return void + */ + public function testLoadBehavior(): void + { + $schemaXml = << + + + + + + + + + +
+ +EOT; + $table = $this->buildSchemaXml($schemaXml)->getTable('table'); + $behavior = $table->getBehavior('auto_add_pk'); + $this->assertNotNull($behavior, 'Should have created behavior'); + + $expectedAttributes = [ + 'name' => 'auto_add_pk', + 'store_attribute' => 'storeAttribute value', + 'load_attribute' => 'loadAttribute value', + 'override_attribute' => 'overridden attribute value' + ]; + $this->assertEqualsCanonicalizing($expectedAttributes, $behavior->getAttributes(), 'Behavior should inherit attributes'); + + $expectedParameters = array_merge( + (new AutoAddPkBehavior())->getParameters(), // default parameters + [ + 'param1' => 'value1', + 'param2' => 'value2', + 'override parameter' => 'overridden parameter value', + ] + ); + $this->assertEqualsCanonicalizing($expectedParameters, $behavior->getParameters(), 'Behavior should inherit parameters'); + + $this->assertNotNull($table->getColumn('id'), 'Should have applied behavior'); + } + + /** + * @return void + */ + public function testStoreCannotOmitId(): void + { + $schemaXml = << + + +EOT; + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('config_store behavior: required parameter \'id\' is missing.'); + $this->buildSchemaXml($schemaXml); + } + + /** + * @return void + */ + public function testCannotStoreSameRefTwice(): void + { + $schemaXml = << + + + +
+ +EOT; + $this->expectException(SchemaException::class); + $this->expectExceptionMessage('config_store behavior for \'foo\': key \'foo1\' is already in use.'); + $this->buildSchemaXml($schemaXml); + } + + /** + * @return void + */ + public function testLoadMultipleBehaviors(): void + { + $aggregateParams = ' + + + '; + $schemaXml = << + $aggregateParams + $aggregateParams + + + + +
+ +EOT; + $database = $this->buildSchemaXml($schemaXml); + $behaviors = $database->getTable('table')->getBehaviors(); + $loadedBehaviors = array_filter($behaviors, fn($key) => preg_match('/^aggregate[12]_[0-9a-f]{13}$/', $key), ARRAY_FILTER_USE_KEY); + $this->assertCount(3, $loadedBehaviors); + } + + /** + * @return array + */ + protected function getStoredConfigurations(): array + { + $class = new \ReflectionClass(ConfigurationStore::class); + + return $class->getStaticPropertyValue('preconfigurations'); + } + + /** + * @param array + * + * @return void + */ + protected function setStoredConfigurations(array $configurations = []): void + { + $class = new \ReflectionClass(ConfigurationStore::class); + + $class->setStaticPropertyValue('preconfigurations', $configurations); + } + + /** + * @param string $schemaXml + * + * @return Database + */ + public function buildSchemaXml(string $schemaXml): Database + { + $builder = new QuickBuilder(); + $builder->setSchema($schemaXml); + + return $builder->getDatabase(); + } +} diff --git a/tests/Propel/Tests/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehaviorTest.php b/tests/Propel/Tests/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehaviorTest.php new file mode 100644 index 000000000..f0d5ef0df --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/SyncedTable/EmptyColumnAccessorsBehaviorTest.php @@ -0,0 +1,38 @@ +callMethod($behavior, 'buildAccessorNames', [['a_column', 'le_column']]); + $expected = ['getAColumn', 'setAColumn', 'getLeColumn', 'setLeColumn']; + + $this->assertEqualsCanonicalizing($expected, $accessors); + } +} diff --git a/tests/Propel/Tests/Generator/Behavior/SyncedTable/SyncedTableBehaviorTest.php b/tests/Propel/Tests/Generator/Behavior/SyncedTable/SyncedTableBehaviorTest.php new file mode 100644 index 000000000..5f7e4373c --- /dev/null +++ b/tests/Propel/Tests/Generator/Behavior/SyncedTable/SyncedTableBehaviorTest.php @@ -0,0 +1,1027 @@ + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + ', + ], + [ + // description + 'Cannot override columns declared on synced table', + //additional behavior parameters + '', + // source table columns: column with size 8 + '', + // synced table input columns: column with size 999 + '', + // auxiliary schema data + '', + // synced output columns + '', + ], + [ + // description + 'Should sync index if requested', + //additional behavior parameters + '', + // source table columns: column with index + ' + + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Syncing index can be enabled', + //additional behavior parameters + '', + // source table columns: column with index + ' + + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Should sync fk column without relation', + //additional behavior parameters + '', + // source table columns: column with fk + ' + + + + + ', + // synced table input columns + '', + // auxiliary schema data + ' + + +
+ ', + // synced output columns + '', + ], + [ + // description + 'Should sync fk column with relation through parameter', + //additional behavior parameters + '', + // source table columns: column with fk + ' + + + + + ', + // synced table input columns + '', + // auxiliary schema data + ' + + +
+ ', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Behavior can override synced FKs', + //additional behavior parameters: inherit fks but override relation "LeName" + ' + + + + + + + + + + ', + // source table columns: column with fk + ' + + + + + ', + // synced table input columns + '', + // auxiliary schema data + ' + + +
+ + +
+ ', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Behavior cannot override FKs declared on synced table', + //additional behavior parameters: declare fk + ' + + + + + + + + + ', + // source table columns + '', + // synced table input columns: fk conflicting with behavior fk + ' + + + + + ', + // auxiliary schema data + ' + + +
+ + +
+ ', + // synced output columns: expect exception + EngineException::class, + ], + [ + // description + 'Behavior does not sync unique indexes by default', + //additional behavior parameters + '', + // source table columns: column with uniques + ' + + + + + + + + + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + ', + ], + [ + // description + 'Behavior syncs unique indexes as regular indexes if requested', + //additional behavior parameters + '', + // source table columns: column with uniques + ' + + + + + + + + + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + + + + + + + ', + ], + [ + // description + 'Behavior syncs unique indexes as unique indexes if requested', + //additional behavior parameters + '', + // source table columns: column with uniques + ' + + + + + + + + + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + + + + + + + ', + ], + [ + // description + 'Behavior can add pk', + //additional behavior parameters + '', + // source table columns: column with index + ' + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + ', + ], + [ + // description + 'Behavior can add renamed pk', + //additional behavior parameters + '', + // source table columns: column with index + '', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + ', + ], + [ + // description + 'Behavior can add FK by parameter', + //additional behavior parameters + ' + + ', + // source table columns: column with index + '', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Behavior can add FK by array', + //additional behavior parameters + ' + + + + + + ', + // source table columns: column with index + '', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + ', + ], + [ + // description + 'Behavior can add cascading FK when changing id', + //additional behavior parameters + ' + + + ', + // source table columns: column with index + '', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + + + + ', + ], + [ + // description + 'Behavior ignores marked columns', + //additional behavior parameters + ' + + + + + ', + // source table columns + ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ', + // synced table input columns + '', + // auxiliary schema data + ' + + + +
+ ', + // synced output columns + ' + + + + + + + + + + + ', + ], + [ + // description + 'Behavior can sync only PKs', + //additional behavior parameters + ' + + ', + // source table columns + ' + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + ', + ], + [ + // description + 'Behavior can sync reduced PKs', + //additional behavior parameters + ' + + + ', + // source table columns + ' + + + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + ', + ], + [ + // description + 'Behavior can add columns', + //additional behavior parameters + ' + + + + + + + + + ', + // source table columns + ' + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + + ', + ], + [ + // description + 'Behavior prefixes synced columns', + //additional behavior parameters + ' + + + + + + ', + // source table columns + ' + + + + + + + + + + + + + + + ', + // synced table input columns + '', + // auxiliary schema data + ' + + + +
+ ', + // synced output columns + ' + + + + + + + + + + + + + + + + + + ', + ], + [ + // description + 'Behavior prefixes synced columns with table name by default', + //additional behavior parameters + ' + + ', + // source table columns + ' + + ', + // synced table input columns + '', + // auxiliary schema data + '', + // synced output columns + ' + + ', + ], + [ + // description + 'Behavior can inherit from other table by table name', + //additional behavior parameters + ' + + ', + // source table columns + '', + // synced table input columns + ' + + ', + // auxiliary schema data + ' + + +
+ ', + // synced output columns + ' + + + ', + ], + [ + // description + 'Behavior can inherit from other table by array', + //additional behavior parameters + ' + + + + + + ', + // source table columns + '', + // synced table input columns + ' + + ', + // auxiliary schema data + ' + + +
+ ', + // synced output columns + ' + + + ', + ], + [ + // description + 'Inheritance overrides defaults', + //additional behavior parameters + ' + + ', + // source table columns + ' + + + ', + // synced table input columns + ' + ', + // auxiliary schema data + ' + + +
+ ', + // synced output columns + ' + + + ', + ], + ]; + } + + /** + * @dataProvider syncTestDataProvider + * + * @param string $message + * @param string $behaviorAdditions + * @param string $sourceTableContentTags + * @param string $syncedTableInputTags + * @param string $auxiliaryTables + * @param string $syncedTableOutputTags + * + * @return void + */ + public function testSync( + string $message, + string $behaviorAdditions, + string $sourceTableContentTags, + string $syncedTableInputTags, + string $auxiliaryTables, + string $syncedTableOutputTags + ) { + // source table: some columns + // synced table: empty + $inputSchemaXml = << + + + + $behaviorAdditions + + + $sourceTableContentTags + +
+ + $auxiliaryTables + + $syncedTableInputTags
+ +EOT; + + // synced table: all columns + $expectedTableXml = << + + $sourceTableContentTags +
+ + + $syncedTableOutputTags +
+ + $auxiliaryTables + +EOT; + + if (class_exists($syncedTableOutputTags) && is_subclass_of($syncedTableOutputTags, Exception::class)) { + $this->expectException($syncedTableOutputTags); + } + $this->assertSchemaTableMatches($expectedTableXml, $inputSchemaXml, 'synced_table', $message); + } + + /** + * @return void + */ + public function testNoSyncByDefaultIfParentSkipsSql(): void + { + $inputSchemaXml = << + + +
+ + + + + +
+ +EOT; + $db = $this->buildSchema($inputSchemaXml); + $this->assertcount(2, $db->getTables()); + $this->assertNull($db->getTable('source_table1_synced')); + $this->assertNull($db->getTable('source_table2_synced')); + } + + /** + * @return void + */ + public function testInheritSkipsSql(): void + { + $inputSchemaXml = << + + + + +
+ + + + +
+ +EOT; + $db = $this->buildSchema($inputSchemaXml); + + $syncedTable1 = $db->getTable('source_table1_synced'); + $this->assertNotNull($syncedTable1, 'Should create synced table 1'); + $this->assertTrue($syncedTable1->isSkipSql(), 'Should inherit skipSql="true"'); + + $syncedTable2 = $db->getTable('source_table2_synced'); + $this->assertNotNull($syncedTable2, 'Should create synced table 2'); + $this->assertFalse($syncedTable2->isSkipSql(), 'Should inherit skipSql="false"'); + } + + /** + * @return void + */ + public function testIgnoreSkipsSql(): void + { + $inputSchemaXml = << + + + + +
+ +EOT; + $db = $this->buildSchema($inputSchemaXml); + + $syncedTable = $db->getTable('source_table_synced'); + $this->assertNotNull($syncedTable, 'Should create synced table'); + $this->assertFalse($syncedTable->isSkipSql(), 'Should ignore skipSql="true"'); + } + + /** + * @return void + */ + public function testSetTableAttributes(): void + { + $inputSchemaXml = << + + + + + + + + +
+ +EOT; + $db = $this->buildSchema($inputSchemaXml); + + $syncedTable = $db->getTable('source_table_synced'); + $this->assertArrayHasKey('foo', $syncedTable->getAttributes()); + $this->assertSame('bar', $syncedTable->getAttribute('foo')); + } + + /** + * @return void + */ + public function testCreatFkRelationWithoutConstraint(): void + { + $inputSchemaXml = << + + + + +
+ +EOT; + $db = $this->buildSchema($inputSchemaXml); + + $syncedTable = $db->getTable('source_table_synced'); + $fks = $syncedTable->getForeignKeys(); + $this->assertCount(1, $fks); + $relation = reset($fks); + $this->assertTrue($relation->isSkipSql()); + } + + + /** + * @param string $expectedTableXml + * @param string $schema + * @param string $tableName + * @param string|null $message + * + * @return void + */ + protected function assertSchemaTableMatches(string $expectedTableXml, string $inputSchemaXml, string $tableName, ?string $message = null) + { + $expectedDb = $this->buildSchema($expectedTableXml); + $expectedTable = $expectedDb->getTable($tableName); + + $actualDb = $this->buildSchema($inputSchemaXml); + $actualTable = $actualDb->getTable($tableName); + + $diff = TableComparator::computeDiff($actualTable, $expectedTable); + if ($diff !== false) { + $message = $this->buildTestMessage($message, $diff, $expectedDb, $actualDb); + $this->fail($message); + } + $this->expectNotToPerformAssertions(); + } + + /** + * @param string $schema + * + * @return \Propel\Generator\Model\Database + */ + protected function buildSchema(string $schema): Database + { + $builder = new QuickBuilder(); + $builder->setSchema($schema); + + return $builder->getDatabase(); + } + + /** + * @param string $inputMessage + * @param \Propel\Generator\Model\Diff\TableDiff $diff + * @param \Propel\Generator\Model\Database $expectedDb + * @param \Propel\Generator\Model\Database $actualDb + * + * @return string + */ + protected function buildTestMessage(string $inputMessage, TableDiff $diff, Database $expectedDb, Database $actualDb) + { + $inputMessage ??= ''; + $platform = new MysqlPlatform(); + $sql = $platform->getModifyTableDDL($diff); + + return <<