Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/Command/ExampleSetInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ private function check(array $plan, array $adopt, OutputInterface $output): int
$output->writeln($type.': '.$count.' checked');
}

foreach ($result['checkedChanges'] as $type => $count) {
$output->writeln($type.': '.$count.' change(s) checked');
}

if ($result['refusals'] !== []) {
$this->printRefusals(refusals: $result['refusals'], output: $output);
$output->writeln('<error>'.count($result['refusals']).' refusal(s). No account, group or object of the set was written.</error>');
Expand Down
13 changes: 9 additions & 4 deletions lib/Db/MagicMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
use RuntimeException;
use OCA\OpenRegister\Db\MagicMapper\MagicBulkHandler;
use OCA\OpenRegister\Db\MagicMapper\MagicFacetHandler;
use OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumn;
use OCA\OpenRegister\Db\MagicMapper\MagicMetadataColumnHealer;
use OCA\OpenRegister\Db\MagicMapper\MagicOrganizationHandler;
use OCA\OpenRegister\Db\MagicMapper\MagicRbacHandler;
Expand Down Expand Up @@ -9646,6 +9647,8 @@ public function getAllRegisterSchemaPairs(): array {
* @param array $row The database row
*
* @return ObjectEntity|null The ObjectEntity or null if conversion fails
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
private function rowToObjectEntity(array $row): ?ObjectEntity {
// Check if we have the minimum required fields.
Expand Down Expand Up @@ -9689,9 +9692,11 @@ private function rowToObjectEntity(array $row): ?ObjectEntity {
// path produces (buildTableColumnsFromSchema), keyed by
// property name, so collision-resolved columns round-trip
// back to their original property instead of being lost.
$schemaProperties = ($schema->getProperties() ?? []);
foreach ($this->buildTableColumnsFromSchema(schema: $schema) as $propertyName => $columnDef) {
// Skip metadata columns (handled separately above).
if (str_starts_with($propertyName, '_') === true) {
// Skip metadata columns (handled separately above); a schema
// property whose name starts with an underscore stays.
if (str_starts_with($propertyName, '_') === true && isset($schemaProperties[$propertyName]) === false) {
continue;
}

Expand All @@ -9718,8 +9723,8 @@ private function rowToObjectEntity(array $row): ?ObjectEntity {
// Build the object data from non-metadata columns.
$objectData = [];
foreach ($row as $column => $value) {
// Skip metadata columns (those starting with _).
if (str_starts_with($column, '_') === true) {
// Skip metadata columns; a property column with the prefix is read.
if ((new MagicMetadataColumn())->field(column: $column, columnToPropertyMap: $columnToPropertyMap) !== null) {
continue;
}

Expand Down
7 changes: 5 additions & 2 deletions lib/Db/MagicMapper/MagicBulkHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,13 @@ private function getFullTableName(string $tableName): string {
* @psalm-return list<non-empty-array<string, false|int|mixed|null|string>>
*
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Bulk preparation requires handling multiple data type scenarios
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
private function prepareObjectsForDynamicTable(array $objects, Register $register, Schema $schema): array {
$prepared = [];
$now = new DateTime();
$columnRoles = new MagicMetadataColumn();

foreach ($objects as $object) {
$preparedObject = [];
Expand Down Expand Up @@ -211,8 +214,7 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe
// Properties can be at top level OR in 'object' key (structured format).
$propertySource = $object['object'] ?? $object;
foreach ($propertySource as $propertyName => $value) {
// Skip metadata (already handled) and @self.
if ($propertyName === '@self' || str_starts_with($propertyName, '_') === true) {
if ($columnRoles->skippedOnWrite(key: $propertyName, schemaProperties: ($schema->getProperties() ?? [])) === true) {
continue;
}

Expand Down Expand Up @@ -240,6 +242,7 @@ private function prepareObjectsForDynamicTable(array $objects, Register $registe
return $prepared;
}//end prepareObjectsForDynamicTable()


/**
* Map the six display-metadata columns onto a prepared row.
*
Expand Down
109 changes: 109 additions & 0 deletions lib/Db/MagicMapper/MagicMetadataColumn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

/**
* MagicMetadataColumn: which underscore-prefixed magic-table columns are
* object metadata and which are schema properties.
*
* Metadata columns carry a `_` prefix (`_uuid`, `_schema_version`). A schema
* may also declare a property whose name starts with an underscore (`_meta`),
* and that property's column carries the same prefix. A reader that took every
* `_` column for metadata either dropped the property or, calling the setter
* of an attribute ObjectEntity does not have, failed the whole row.
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V.
*
* @category Db
* @package OCA\OpenRegister\Db\MagicMapper
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @link https://OpenRegister.app
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/

declare(strict_types=1);

namespace OCA\OpenRegister\Db\MagicMapper;

use OCA\OpenRegister\Db\ObjectEntity;

/**
* Classify a magic-table column as metadata or as a schema property.
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
final class MagicMetadataColumn {

private const PREFIX = '_';

/**
* The metadata field a column carries, or null when it carries a property.
*
* A column without the prefix is a property. A prefixed column the schema
* maps to one of its properties is that property, unless ObjectEntity has
* an attribute of the same name: the metadata column then wins, because
* the table holds one column of that name and the metadata is what it was
* made for.
*
* @param string $column The column name.
* @param array<string, string> $columnToPropertyMap The schema's column-to-property map.
*
* @return string|null The metadata field without its prefix (`schema_version`), or null.
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
public function field(string $column, array $columnToPropertyMap): ?string {
if (str_starts_with($column, self::PREFIX) === false) {
return null;
}

$field = substr($column, strlen(self::PREFIX));
if (isset($columnToPropertyMap[$column]) === true && $this->attribute(field: $field) === null) {
return null;
}

return $field;
}//end field()

/**
* Whether a key of an object being written is metadata or `@self`, not a property column.
*
* A key starting with the prefix is metadata unless the schema declares it as a property.
*
* @param string $key The object key.
* @param array<string, mixed> $schemaProperties The schema's properties.
*
* @return bool
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
public function skippedOnWrite(string $key, array $schemaProperties): bool {
if ($key === '@self') {
return true;
}

return str_starts_with($key, self::PREFIX) === true && isset($schemaProperties[$key]) === false;
}//end skippedOnWrite()

/**
* The ObjectEntity attribute a metadata field sets, or null when it has none.
*
* @param string $field The metadata field (`uuid`, `schema_version`).
*
* @return string|null The attribute name (`uuid`, `schemaVersion`), or null.
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
public function attribute(string $field): ?string {
$attribute = lcfirst(str_replace('_', '', ucwords($field, '_')));
if ($attribute === '' || property_exists(ObjectEntity::class, $attribute) === false) {
return null;
}

return $attribute;
}//end attribute()
}//end class
15 changes: 12 additions & 3 deletions lib/Db/MagicMapper/MagicSearchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@ class MagicSearchHandler {
*/
private SearchTermSqlCompiler $termCompiler;

/**
* Tells metadata columns from underscore-named property columns.
*
* @var MagicMetadataColumn
*/
private MagicMetadataColumn $columnRoles;

/**
* Constructor for MagicSearchHandler
*
Expand Down Expand Up @@ -234,6 +241,7 @@ public function __construct(
) {
$this->termParser = new SearchTermParser();
$this->termCompiler = new SearchTermSqlCompiler();
$this->columnRoles = new MagicMetadataColumn();
}//end __construct()

/**
Expand Down Expand Up @@ -4065,6 +4073,8 @@ private function getSchemaColumnMaps(Schema $schema): array {
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.NPathComplexity) Row to entity conversion requires many field mappings
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
private function convertRowToObjectEntity(
array $row,
Expand All @@ -4090,9 +4100,8 @@ private function convertRowToObjectEntity(
$columnToPropertyMap = $schemaMaps['columns'];

foreach ($row as $column => $value) {
if (str_starts_with($column, '_') === true) {
// Metadata column - remove prefix and map to ObjectEntity.
$metadataField = substr($column, 1);
$metadataField = $this->columnRoles->field(column: $column, columnToPropertyMap: $columnToPropertyMap);
if ($metadataField !== null) {
$metadataData[$metadataField] = $value;
continue;
}
Expand Down
62 changes: 48 additions & 14 deletions lib/Db/MagicMapper/MagicStatisticsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ class MagicStatisticsHandler {
*/
private const METADATA_PREFIX = '_';

/**
* Metadata fields already noted as skipped in this process, by field.
*
* @var array<string, true>
*/
private static array $skippedFields = [];

/**
* Tells metadata columns from underscore-named property columns.
*
* @var MagicMetadataColumn
*/
private MagicMetadataColumn $columnRoles;

/**
* Callable for counting objects in a register-schema table.
*
Expand Down Expand Up @@ -119,6 +133,7 @@ public function __construct(
private readonly DateTimeNormalizer $dateTimeNormalizer,
private readonly SchemaTypeConverter $schemaTypeConverter,
) {
$this->columnRoles = new MagicMetadataColumn();
}//end __construct()

/**
Expand Down Expand Up @@ -579,6 +594,8 @@ public function getSchemaChartData(?int $registerId = null, ?int $schemaId = nul
* @SuppressWarnings(PHPMD.CyclomaticComplexity) Row to entity conversion requires many field mappings
* @SuppressWarnings(PHPMD.NPathComplexity) Row to entity conversion requires many field mappings
* @SuppressWarnings(PHPMD.ExcessiveMethodLength) Complete field mapping requires comprehensive handling
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
public function convertRowToObjectEntity(array $row, Register $_register, Schema $_schema): ?ObjectEntity {
try {
Expand Down Expand Up @@ -610,9 +627,8 @@ public function convertRowToObjectEntity(array $row, Register $_register, Schema
$objectData = [];

foreach ($row as $columnName => $value) {
if (str_starts_with($columnName, self::METADATA_PREFIX) === true) {
// This is a metadata field.
$metadataField = substr($columnName, strlen(self::METADATA_PREFIX));
$metadataField = $this->columnRoles->field(column: $columnName, columnToPropertyMap: $columnToPropertyMap);
if ($metadataField !== null) {

// Handle datetime fields.
if (in_array(
Expand Down Expand Up @@ -713,21 +729,16 @@ public function convertRowToObjectEntity(array $row, Register $_register, Schema
continue;
}

$method = 'set' . ucfirst($field);
// Use is_callable() instead of method_exists() to support magic methods.
// Entity base class uses __call() for property setters.
if (is_callable([$objectEntity, $method]) === false) {
$this->logger->warning(
message: '[MagicStatisticsHandler] Method is not callable for metadata field',
context: ['file' => __FILE__, 'line' => __LINE__, 'field' => $field, 'method' => $method]
);
// Entity setters are magic (__call), so is_callable() is true for
// any name; ask whether ObjectEntity has the attribute instead.
$attribute = $this->columnRoles->attribute(field: $field);
if ($attribute === null) {
$this->skipUnknownMetadataField(field: $field);
continue;
}

$method = 'set' . ucfirst($attribute);
$objectEntity->$method($value);
// Debug critical fields.
if (in_array($field, ['id', 'uuid', 'owner'], true) === true) {
}
}//end foreach

// Set object data.
Expand Down Expand Up @@ -762,6 +773,29 @@ public function convertRowToObjectEntity(array $row, Register $_register, Schema
}//end try
}//end convertRowToObjectEntity()

/**
* Note, once per field and process, a metadata column ObjectEntity has no attribute for.
*
* Such a column is skipped, not an error: the row is still read.
*
* @param string $field The metadata field without its prefix.
*
* @return void
*
* @spec openspec/changes/underscore-properties-and-config-paths/specs/magic-table-reads/spec.md#requirement-a-property-whose-name-starts-with-an-underscore-is-read-as-a-property-req-mtr-001
*/
private function skipUnknownMetadataField(string $field): void {
if (isset(self::$skippedFields[$field]) === true) {
return;
}

self::$skippedFields[$field] = true;
$this->logger->debug(
message: '[MagicStatisticsHandler] Skipped a metadata column ObjectEntity has no attribute for',
context: ['file' => __FILE__, 'line' => __LINE__, 'field' => $field]
);
}//end skipUnknownMetadataField()

/**
* Sanitize column name for safe database usage
*
Expand Down
Loading
Loading