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/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/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(); + } +}