Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ final class DeliveryOperationsAdminQueryRequestDTO implements \JsonSerializable
- **Retry Bounds**: `attemptNoMin` and `attemptNoMax` must be unsigned (>= 0). If both are set, `attemptNoMin <= attemptNoMax`.
- **Text Search**: `errorMessageLike` uses safe exact contains search with escaping (`LIKE :error_message_like ESCAPE '\\'`). Escaping must natively escape `\`, `%`, and `_`. Case insensitivity depends on the table's collation.
- **Null-State Tri-State**: `nullStateFilters` accepts an associative array mapping exactly to these allowed property names: `actorType`, `actorId`, `targetType`, `targetId`, `scheduledAt`, `completedAt`, `correlationId`, `requestId`, `provider`, `providerMessageId`, `errorCode`, `errorMessage`. Values must be strictly `bool` (`true` -> `IS NULL`, `false` -> `IS NOT NULL`). An exception is thrown for invalid properties or non-bool values. Duplicate semantic conditions (e.g., both equality and null-state) naturally translate into SQL `WHERE column = X AND column IS NULL`, producing 0 rows as intended.
- **Metadata JSON Search**: `metadataFilters` must be an exact associative array mapping `['stringPath' => scalarValue]`. No arbitrary JSON path evaluation. Maximum 5 filters per request. The path format must strictly enforce leading `$.`, exactly 1 to 5 non-empty ASCII `[A-Za-z0-9_]+` segments, dot separators only, no repeated dots, and maximum total length 64 bytes/characters. Allowed scalar value types: `string|int|float|bool|null`. Values must be successfully encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. A JSON `null` filter explicitly searches for `CAST(NULL AS JSON)` value stored in the document, whereas missing a path returns NULL which requires `JSON_CONTAINS_PATH(metadata, 'one', :path) = 1` checks to differentiate. Null vs missing must be handled correctly in SQL.
- **Metadata JSON Search**: `metadataFilters` must be an exact associative array mapping `['stringPath' => scalarValue]`. No arbitrary JSON path evaluation. Maximum 5 filters per request. The path format must strictly enforce leading `$.`, exactly 1 to 5 non-empty ASCII `[A-Za-z0-9_]+` segments, dot separators only, no repeated dots, and maximum total length 64 bytes/characters. Allowed scalar value types: `string|int|float|bool|null`. Values must be successfully encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. A JSON `null` filter explicitly searches for `'null' JSON literal` value stored in the document, whereas missing a path returns NULL which requires `JSON_CONTAINS_PATH(metadata, 'one', :path) = 1` checks to differentiate. Null vs missing must be handled correctly in SQL.

### 3.5 Result DTO
`DeliveryOperationsAdminPageResultDTO`
Expand Down Expand Up @@ -500,7 +500,7 @@ For `errorMessageLike`, the parameter value must be escaped using:
This matches the explicit `ESCAPE '\\'` SQL clause.

### Metadata Paths
Path format strictly matches `/^\$\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+){0,4}$/`. Callers must supply the leading `$.`. Maximum 5 keys. Maximum path length 64 bytes/characters. Deterministic placeholders (e.g., `meta_path_0`, `meta_val_0`). Parameter values encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. JSON-null searches natively match `CAST('null' AS JSON)` if the path exists, enforced via `JSON_CONTAINS_PATH`.
Path format strictly matches `/^\$\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+){0,4}$/`. Callers must supply the leading `$.`. Maximum 5 keys. Maximum path length 64 bytes/characters. Deterministic placeholders (e.g., `meta_path_exists_N`, `meta_value_N`). Parameter values encoded via `json_encode(..., JSON_THROW_ON_ERROR)`. JSON-null searches natively match `the 'null' JSON literal` if the path exists, enforced via `JSON_CONTAINS_PATH`.

### SQL Semantics
- **Empty filters:** generates empty `whereSql` (no `WHERE` string added to data/count SQL).
Expand Down
10 changes: 5 additions & 5 deletions docs/roadmap/ADMIN_QUERY_API_ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Admin Query API Roadmap

**Status:** Phase 4 Complete / DeliveryOperations Runtime Implemented / Pending Release
**Status:** Phase 4 Active / DeliveryOperations Runtime Implemented / Pending Release

## 1. Scope Boundary

Expand Down Expand Up @@ -93,9 +93,9 @@ Implement the Admin Query API for domains that never received the incorrect post

These are new post-v1.0 features, not corrections to the first-release Runtime.

- **Status:** Phase 4 is active. DiagnosticsTelemetry Runtime is complete and merged.
- **Status:** Phase 4 is active. DiagnosticsTelemetry Runtime is complete and merged. DeliveryOperations Runtime Implemented.
- `DiagnosticsTelemetry`: [Owner Approved / Runtime Implemented / Complete](../architecture/ADMIN_QUERY_DIAGNOSTICS_TELEMETRY_BLUEPRINT.md)
- `DeliveryOperations`: Owner Approved / Runtime Implemented / Complete
- `DeliveryOperations`: Owner Approved / Runtime Implemented / Pending Merge

### Phase 5 — Reporting and Dashboard Summary Contracts

Expand Down Expand Up @@ -140,7 +140,7 @@ Every implementation phase must prove all of the following:

## 6. Current Gate

- Phase 4 is complete;
- DeliveryOperations Runtime is complete;
- Phase 4 is active;
- DeliveryOperations Runtime is complete / Pending Merge;
- reporting/dashboard does not start until Admin pagination is released and verified for all six domains;
- no release/tag authorized.
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ public function map(array $row): DeliveryOperationsViewDTO
if (isset($row['metadata']) && is_string($row['metadata']) && $row['metadata'] !== '') {
try {
$decoded = json_decode($row['metadata'], true, 512, JSON_THROW_ON_ERROR);
if (is_array($decoded) && array_is_list($decoded) === false || $decoded === []) {
/** @var array<string, mixed> $decoded */
$metadata = $decoded;
if (is_array($decoded)) {
$allKeysStrings = true;
foreach (array_keys($decoded) as $decodedKey) {
if (!is_string($decodedKey)) {
$allKeysStrings = false;
break;
}
}
if ($allKeysStrings) {
/** @var array<string, mixed> $decoded */
$metadata = $decoded;
}
}
} catch (JsonException) {
// Mapping failure or corruption -> fallback to null.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,18 +308,43 @@ public function testItPreservesCallerOwnedTransaction(): void
$this->assertSame(0, $res2->filtered);
}

public function testItTranslatesRealPdoExceptionToStorageException(): void
public function testItTranslatesRealMappingFailureToStorageException(): void
{
$this->pdo->exec('DROP TABLE maa_event_logging_delivery_operations');
$this->insertLog(eventId: 'e1');

$this->pdo->exec("RENAME TABLE maa_event_logging_delivery_operations TO maa_event_logging_delivery_operations_real");
$this->pdo->exec("CREATE VIEW maa_event_logging_delivery_operations AS SELECT id, event_id, channel, operation_type, actor_type, actor_id, target_type, target_id, status, attempt_no, scheduled_at, completed_at, correlation_id, request_id, provider, provider_message_id, error_code, error_message, metadata, 'invalid-date' as occurred_at FROM maa_event_logging_delivery_operations_real");

try {
$this->repository->paginate(new DeliveryOperationsAdminQueryRequestDTO());
$this->fail('Expected DeliveryOperationsStorageException');
} catch (DeliveryOperationsStorageException $e) {
$this->assertStringContainsString('Failed to map DeliveryOperations row', $e->getMessage());
} finally {
$this->pdo->exec("DROP VIEW IF EXISTS maa_event_logging_delivery_operations");
$this->pdo->exec("RENAME TABLE maa_event_logging_delivery_operations_real TO maa_event_logging_delivery_operations");
}
}

public function testItTranslatesRealPdoExceptionToStorageExceptionAndPreservesTransaction(): void
{
$this->pdo->beginTransaction();

$this->pdo->exec('SET @@session.max_join_size=1');

$this->insertLog(eventId: 'e1');
$this->insertLog(eventId: 'e2');

try {
$this->repository->paginate(new DeliveryOperationsAdminQueryRequestDTO());
$this->fail('Expected DeliveryOperationsStorageException');
} catch (DeliveryOperationsStorageException $e) {
$this->assertStringContainsString('Failed to query DeliveryOperations records:', $e->getMessage());
$this->assertInstanceOf(\PDOException::class, $e->getPrevious());
$this->assertStringContainsString('Failed to query DeliveryOperations records', $e->getMessage());
} finally {
$this->setUp(); // Re-create schema for subsequent tests if necessary
$this->pdo->exec('SET @@session.max_join_size=DEFAULT');
}

$this->assertTrue($this->pdo->inTransaction());
$this->pdo->rollBack();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,112 @@ public function testDtoPreservesPrimitiveSignatureAndDefaults(): void

// Assert all but limit are nullable
foreach ($params as $param) {
$type = $param->getType();
$this->assertInstanceOf(\ReflectionNamedType::class, $type);
if ($param->getName() === 'limit') {
$this->assertFalse($param->getType()->allowsNull());
$this->assertFalse($type->allowsNull());
$this->assertSame('int', $type->getName());
$this->assertTrue($param->isDefaultValueAvailable());
$this->assertSame(50, $param->getDefaultValue());
} else {
$this->assertTrue($param->getType()->allowsNull());
$this->assertTrue($type->allowsNull());
$this->assertTrue($param->isDefaultValueAvailable());
$this->assertNull($param->getDefaultValue());

if (str_ends_with($param->getName(), 'At') || in_array($param->getName(), ['after', 'before'])) {
$this->assertSame(DateTimeImmutable::class, $type->getName());
} elseif (in_array($param->getName(), ['actorId', 'targetId', 'cursorId'])) {
$this->assertSame('int', $type->getName());
} else {
$this->assertSame('string', $type->getName());
}
}
}
}

public function testPrimitiveRepositoryNeverCallsTransactionMethods(): void
{
$this->pdo->expects($this->never())->method('beginTransaction');
$this->pdo->expects($this->never())->method('commit');
$this->pdo->expects($this->never())->method('rollBack');

$this->pdo->expects($this->once())
->method('prepare')
->willReturn($this->statement);

$this->statement->expects($this->once())
->method('execute');

$this->statement->expects($this->once())
->method('fetchAll')
->willReturn([]);

$this->repository->find(new DeliveryOperationsQueryDTO());
}

public function testPrimitiveRepositoryNeverCallsTransactionMethodsOnFailure(): void
{
$this->pdo->expects($this->never())->method('beginTransaction');
$this->pdo->expects($this->never())->method('commit');
$this->pdo->expects($this->never())->method('rollBack');

$this->pdo->expects($this->once())
->method('prepare')
->willThrowException(new PDOException('PDO failure'));

$this->expectException(DeliveryOperationsStorageException::class);
$this->repository->find(new DeliveryOperationsQueryDTO());
}

public function testPrimitiveRepositoryContractBoundariesArePreserved(): void
{
$interfaces = class_implements(DeliveryOperationsQueryMysqlRepository::class);
$this->assertContains(\Maatify\EventLogging\DeliveryOperations\Contract\DeliveryOperationsQueryInterface::class, $interfaces);
$this->assertTrue((new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->isFinal());

$constructor = (new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->getConstructor();
$this->assertNotNull($constructor);
$this->assertCount(1, $constructor->getParameters());
$param = $constructor->getParameters()[0];
$this->assertSame('pdo', $param->getName());
$type = $param->getType();
$this->assertInstanceOf(\ReflectionNamedType::class, $type);
$this->assertSame(PDO::class, $type->getName());

$findMethod = (new \ReflectionClass(DeliveryOperationsQueryMysqlRepository::class))->getMethod('find');
$this->assertTrue($findMethod->isPublic());
$this->assertCount(1, $findMethod->getParameters());
$queryParam = $findMethod->getParameters()[0];
$this->assertSame('query', $queryParam->getName());
$queryType = $queryParam->getType();
$this->assertInstanceOf(\ReflectionNamedType::class, $queryType);
$this->assertSame(DeliveryOperationsQueryDTO::class, $queryType->getName());

$returnType = $findMethod->getReturnType();
$this->assertInstanceOf(\ReflectionNamedType::class, $returnType);
$this->assertSame('array', $returnType->getName());
}

public function testPrimitiveRepositoryBoundariesAndSchemaArePreservedUnchanged(): void
{
// Prove primitive read API is strictly separated from writer boundaries
$this->assertTrue(interface_exists(\Maatify\EventLogging\DeliveryOperations\Contract\DeliveryOperationsLoggerInterface::class));
$this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Infrastructure\Mysql\DeliveryOperationsLoggerMysqlRepository::class));
$this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsRecorder::class));
$this->assertTrue(class_exists(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsDefaultPolicy::class));

// Schema verification
$schemaPath = __DIR__ . '/../../../../../src/DeliveryOperations/Database/schema.maa_event_logging_delivery_operations.sql';
$this->assertFileExists($schemaPath);
$schema = file_get_contents($schemaPath);
$this->assertIsString($schema);
$this->assertStringContainsString('maa_event_logging_delivery_operations', $schema);
$this->assertStringContainsString('INDEX idx_delivery_ops_actor_time (actor_type, actor_id, occurred_at)', $schema);

// Fail-open boundary verification (Recorder does not throw StorageException)
$recorderClass = new \ReflectionClass(\Maatify\EventLogging\DeliveryOperations\Recorder\DeliveryOperationsRecorder::class);
$docComment = $recorderClass->getMethod('record')->getDocComment();
$docString = is_string($docComment) ? $docComment : '';
$this->assertFalse(str_contains($docString, '@throws ' . DeliveryOperationsStorageException::class));
}
}
Loading