delete()` convention; neither outcome is an error. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use RuntimeException; /** * Persistence port for `affilun_products` rows. * * See the file-level docblock for the contract's scope, the record * type it exchanges, and its insert-vs-update / not-found / delete * semantics. */ interface ProductRepository { /** * Look up a paginated, filtered, sorted slice of stored rows. * * Backs the REST list endpoint described in * `specifications/rest-api-spec.md` §5.3 and §8.2. The query VO * carries every supported axis (pagination, filters, sort) with * closed-set tokens already validated, so implementations may * interpolate sort tokens directly into SQL without re-validating * and may treat null filters as "do not constrain this axis". * * Implementations MUST compute the total matching row count * AFTER filtering and BEFORE pagination, so the returned * {@see ProductListResult} carries the value `total_pages` is * derived from in §4.2. * * @param ProductListQuery $query Validated list-query inputs. * * @return ProductListResult Page slice plus total-row count. * * @throws RuntimeException When the underlying driver reports an * error. */ public function findPaginated( ProductListQuery $query ): ProductListResult; /** * Persist the given record. * * Implementations decide insert vs. update based on whether the * supplied record carries an id: * * - Null id: insert. The implementation assigns the auto- * increment primary key and returns a new record carrying * the assigned id. The returned record reflects any other * column defaults the database applied (for example * timestamps normalized to the storage precision). * * - Non-null id: update. The implementation writes every column * on the record to the matching row. The returned record is * the canonical post-save shape; callers MUST use the returned * record (not the input) for any subsequent operation so * `updated_at` and any implementation-normalized values are * not silently dropped. * * A save that targets a non-null id which no longer exists in the * database MUST raise a {@see RuntimeException} rather than * silently inserting a new row with a caller-controlled id — the * application's update-after-delete race should be a visible * error, not a data-integrity surprise. * * @param ProductRecord $record Record to persist. * * @return ProductRecord The post-save record, always carrying a * non-null id. * * @throws RuntimeException When the underlying driver reports an * error, when a unique-constraint * violation occurs, or when an update * targets a row that no longer exists. */ public function save( ProductRecord $record ): ProductRecord; /** * Look up a record by its auto-increment primary key. * * Returns null when no row matches. Non-positive ids are a * programming error and implementations MAY raise * {@see \InvalidArgumentException}; callers that only have an * optional id in hand should filter before calling. * * @param int $id Primary key to look up. * * @throws RuntimeException When the underlying driver reports an * error. */ public function findById( int $id ): ?ProductRecord; /** * Bulk lookup primitive used by Phase-10 grid rendering to avoid * N+1 query patterns. Implementations MUST resolve every supplied * id in a single round-trip — typically a `SELECT ... WHERE id IN * (...)` query — rather than fanning out one * {@see self::findById()} call per id. * * Input handling: * * - Empty input list returns an empty list with no DB round- * trip; this is a fast path for callers that gathered an * empty id set upstream. * - Duplicate ids in the input MUST be deduplicated before the * query is issued so the IN-clause stays minimal and the * returned list never carries the same record twice. * - Non-positive ids in the input list are a programming error * and implementations MUST raise * {@see \InvalidArgumentException} so the offending call * surfaces loudly rather than being silently dropped. * * Output handling: * * - Returned records appear in the same order as the * deduplicated input ids — implementations build a * `id => record` lookup from the result set and then re-walk * the input order to pick present entries, so callers can * zip the returned list against their requested-id sequence * without an additional sort. * - "Not found" is silent: ids with no matching row are simply * omitted from the returned list. Callers that need to * reconcile present-vs-requested compare the returned record * ids against the requested id set themselves. * * @param list $ids Positive ids to look up. Empty list * allowed; duplicates are deduplicated. * * @return list Found records in deduplicated input * order; missing ids omitted. * * @throws \InvalidArgumentException When `$ids` contains a * non-positive id. * @throws RuntimeException When the underlying driver * reports an error. */ public function findByIds( array $ids ): array; /** * Look up a record by the `(provider, provider_item_id)` unique * constraint from §4.3. * * Returns null when no row matches either component. Empty * strings for either argument are a programming error and * implementations MAY raise {@see \InvalidArgumentException}. * * @param string $provider Provider identifier (for example * `amazon`, `rakuten`, `yahoo`). * @param string $providerItemId Provider-side item identifier. * * @throws RuntimeException When the underlying driver reports an * error. */ public function findByProviderItem( string $provider, string $providerItemId ): ?ProductRecord; /** * Delete the row identified by the given id. * * Idempotent: returns true when a row was removed, false when no * row matched. Neither outcome is an error. * * Non-positive ids are a programming error and implementations * MAY raise {@see \InvalidArgumentException}. * * @param int $id Primary key of the row to delete. * * @throws RuntimeException When the underlying driver reports an * error. */ public function delete( int $id ): bool; /** * Total number of rows currently stored in `affilun_products`. * * Used by diagnostics and integration tests to assert on the * pipeline outcome without enumerating rows. Implementations * SHOULD back this with a `SELECT COUNT(*)` query rather than * materializing every row. * * @throws RuntimeException When the underlying driver reports an * error. */ public function count(): int; }