*/ private array $records = array(); /** * Next auto-increment id to allocate on insert. * * Starts at 1 and advances monotonically with every successful * insert. Mirrors the MySQL `AUTO_INCREMENT` convention: ids are * never reused, even after a delete. */ private int $nextId = 1; /** * Construct the repository, optionally seeded with a set of * already-persisted records. * * Seeding is primarily useful for tests that need to exercise * "existing environment" code paths without driving a sequence of * {@see self::save()} calls first. Seed records MUST carry a * non-null id; their ids are honored verbatim, and the repository's * auto-increment cursor advances past the highest seeded id so * subsequent inserts cannot collide with a seeded row. * * @param list $seed Records to preload. * * @throws InvalidArgumentException When a seed record has no id or * when two seeds share an id. */ public function __construct( array $seed = array() ) { foreach ( $seed as $record ) { $id = $record->id(); if ( $id === null ) { $message = 'InMemoryLogRepository seed records must carry a non-null id.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( array_key_exists( $id, $this->records ) ) { $message = sprintf( 'InMemoryLogRepository seed contains duplicate id %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->records[ $id ] = $record; if ( $id >= $this->nextId ) { $this->nextId = $id + 1; } } } /** * Persist the record and return its post-insert form. * * See {@see LogRepository::save()} for the full contract. This * implementation enforces the insert-only rule by raising * {@see \RuntimeException} when the supplied record already * carries an id; the rationale is documented at length in the * contract's file-level docblock (append-only schema, retention * via purge job). * * On insert, the next auto-increment id is allocated and the * record is stored under it. The returned record carries the * newly-allocated id without mutating the input. * * @param LogRecord $record Record to persist. Must carry a null id. * * @throws RuntimeException When the supplied record already * carries a non-null id. */ public function save( LogRecord $record ): LogRecord { $id = $record->id(); if ( $id !== null ) { $message = sprintf( 'InMemoryLogRepository::save() is insert-only; ' . 'refusing to persist a record that already carries id %d. ' . 'affilun_logs is append-only per database-schema.md §9.2 / §9.4.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } $assigned = $this->nextId; ++$this->nextId; $persisted = $record->withId( $assigned ); $this->records[ $assigned ] = $persisted; return $persisted; } /** * Look up a record by its primary key. * * Returns null when no row matches; raises * {@see InvalidArgumentException} for non-positive ids so a * programming error at the call site surfaces loudly instead of * silently missing. * * @param int $id Primary key to look up. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function findById( int $id ): ?LogRecord { if ( $id <= 0 ) { $message = sprintf( 'InMemoryLogRepository::findById() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $this->records[ $id ] ?? null; } /** * Remove the row identified by the given id. * * Idempotent: returns true when a row was removed, false when no * row matched. Non-positive ids raise * {@see InvalidArgumentException} so a programming error at the * call site surfaces loudly instead of silently missing. * * Single-row only; bulk purge belongs with the `cleanup_logs` * scheduled job (§8.4 / §9.4) and is intentionally not part of * the {@see LogRepository} contract. * * @param int $id Primary key of the row to delete. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function delete( int $id ): bool { if ( $id <= 0 ) { $message = sprintf( 'InMemoryLogRepository::delete() requires a positive id, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( $id, $this->records ) ) { return false; } unset( $this->records[ $id ] ); return true; } /** * Total number of rows currently stored. */ public function count(): int { return count( $this->records ); } /** * Return every stored record, ordered by ascending primary key. * * Exposed purely as a convenience for test assertions that want * to enumerate the repository contents without driving a * paginated listing. Production code MUST NOT rely on this method * because it has no counterpart on the {@see LogRepository} * contract and the Wpdb-backed implementation does not offer it. * * @return list */ public function all(): array { $ordered = $this->records; ksort( $ordered, SORT_NUMERIC ); return array_values( $ordered ); } /** * Next auto-increment id the repository would allocate on the * following insert. * * Exposed purely as a convenience for test assertions that want to * probe the internal cursor without issuing a dummy insert. Like * {@see self::all()}, production code MUST NOT rely on this. */ public function nextId(): int { return $this->nextId; } /** * Look up a paginated, filtered slice of stored rows. * * See {@see LogRepository::findPaginated()} for the contract. * Filtering is applied per non-null axis on `$query`; the * remaining rows are sorted by descending id (a proxy for * `created_at DESC, id DESC` since this double allocates ids * monotonically) and sliced to the requested page. * * @param LogListQuery $query Validated list-query inputs. */ public function findPaginated( LogListQuery $query ): LogListResult { $matching = array(); foreach ( $this->records as $record ) { if ( ! $this->matchesQuery( $record, $query ) ) { continue; } $matching[] = $record; } usort( $matching, static function ( LogRecord $a, LogRecord $b ): int { return ( $b->id() ?? 0 ) <=> ( $a->id() ?? 0 ); } ); $total = count( $matching ); $offset = $query->offset(); $perPage = $query->perPage(); $slice = array_slice( $matching, $offset, $perPage ); /** @var list $slice */ return new LogListResult( $slice, $total, $query->page(), $perPage ); } /** * Group every stored row by PSR-3 level and return the per-level * counts. * * See {@see LogRepository::countByLevel()} for the contract; in * particular, levels with zero occurrences are omitted so callers * default-fill against {@see LogListQuery::VALID_LEVELS}. * * @return array */ public function countByLevel(): array { $counts = array(); foreach ( $this->records as $record ) { $level = $record->level(); $counts[ $level ] = ( $counts[ $level ] ?? 0 ) + 1; } return $counts; } /** * Return the most recent log entries, newest-first, capped at * `$limit`. * * See {@see LogRepository::findRecent()} for the contract. The * ordering proxy mirrors {@see self::findPaginated()} (descending * id since this double allocates ids monotonically). * * @param int $limit Maximum number of entries to return (1..100). * * @return list * * @throws InvalidArgumentException When `$limit` is out of range. */ public function findRecent( int $limit ): array { if ( $limit < 1 || $limit > LogListQuery::MAX_PER_PAGE ) { $message = sprintf( 'InMemoryLogRepository::findRecent() requires limit between 1 and %d, got %d.', LogListQuery::MAX_PER_PAGE, $limit ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $ordered = array_values( $this->records ); usort( $ordered, static function ( LogRecord $a, LogRecord $b ): int { return ( $b->id() ?? 0 ) <=> ( $a->id() ?? 0 ); } ); return array_slice( $ordered, 0, $limit ); } /** * Whether `$record` satisfies every non-null filter axis on * `$query`. * * @param LogRecord $record Record under test. * @param LogListQuery $query Filter axes. */ private function matchesQuery( LogRecord $record, LogListQuery $query ): bool { if ( $query->level() !== null && $record->level() !== $query->level() ) { return false; } if ( $query->channel() !== null && $record->channel() !== $query->channel() ) { return false; } if ( $query->relatedProductId() !== null && $record->relatedProductId() !== $query->relatedProductId() ) { return false; } if ( $query->relatedJobId() !== null && $record->relatedJobId() !== $query->relatedJobId() ) { return false; } return true; } }