db = $db ?? $GLOBALS['wpdb']; } /** * Returns the table this repository reads and writes. */ abstract protected function table(): string; /** * Returns the writable columns and their placeholder formats. * * Anything absent from this map is discarded before a write, so callers can * pass request data without smuggling unexpected columns into a query. * * @return array Formats keyed by column name. */ abstract protected function formats(): array; /** * Filters a data set down to the writable columns. * * @param array $data Raw column data. * * @return array{0: array, 1: array} Data and matching formats. */ protected function prepare_columns( array $data ): array { $formats = $this->formats(); $columns = array(); $types = array(); foreach ( $formats as $column => $format ) { if ( ! array_key_exists( $column, $data ) ) { continue; } $columns[ $column ] = $data[ $column ]; $types[] = $format; } return array( $columns, $types ); } /** * Starts a query against this repository's table. */ protected function query_builder(): QueryBuilder { return new QueryBuilder( $this->table() ); } /** * Runs a built query and returns the raw rows. * * `wpdb::prepare()` warns when a statement carries no placeholders, so a * query without conditions runs unprepared. That branch has no bound values * by definition, which is what makes it safe. * * @param QueryBuilder $query Query to run. * @param string $columns Optional. Columns to select. Default '*'. * * @return array> The rows. */ protected function select( QueryBuilder $query, string $columns = '*' ): array { $sql = $query->select_sql( $columns ); $params = $query->select_params(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Identifiers are whitelisted by the builder, values are prepared. $rows = array() === $params ? $this->db->get_results( $sql, ARRAY_A ) : $this->db->get_results( $this->db->prepare( $sql, $params ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return is_array( $rows ) ? $rows : array(); } /** * Counts the rows a built query matches. * * @param QueryBuilder $query Query to count. */ protected function count_rows( QueryBuilder $query ): int { $sql = $query->count_sql(); $params = $query->count_params(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- See select(). $total = array() === $params ? $this->db->get_var( $sql ) : $this->db->get_var( $this->db->prepare( $sql, $params ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return (int) $total; } /** * Turns rows into entities. * * @param array> $rows Rows to hydrate. * @param callable $factory Callable receiving one row and returning an entity. * * @return array The entities. */ protected function hydrate( array $rows, callable $factory ): array { return array_map( $factory, $rows ); } /** * Reads a single row by ID. * * @param int $id Row ID. * * @return array|null The row, or null when it does not exist. */ protected function find_row( int $id ): ?array { if ( $id <= 0 ) { return null; } $rows = $this->select( $this->query_builder()->where_equals( 'id', $id, '%d' )->paginate( 1, 1 ) ); return $rows[0] ?? null; } /** * Reads a single row by a column value. * * @param string $column Column name. Must come from a whitelist. * @param mixed $value Value to match. * @param string $format Optional. Placeholder, `%s` or `%d`. Default '%s'. * * @return array|null The row, or null when it does not exist. */ protected function find_row_by( string $column, mixed $value, string $format = '%s' ): ?array { if ( null === $value || '' === $value ) { return null; } $rows = $this->select( $this->query_builder()->where_equals( $column, $value, $format )->paginate( 1, 1 ) ); return $rows[0] ?? null; } /** * Determines whether a row exists. * * @param int $id Row ID. * * @return bool True when the row exists. */ protected function row_exists( int $id ): bool { if ( $id <= 0 ) { return false; } return $this->count_rows( $this->query_builder()->where_equals( 'id', $id, '%d' ) ) > 0; } /** * Inserts a row, keeping only the writable columns. * * @param array $data Column data. Unknown keys are ignored. * * @return int The new row ID, or 0 on failure. */ protected function insert_row( array $data ): int { list( $columns, $formats ) = $this->prepare_columns( $data ); if ( array() === $columns ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Repository write. $inserted = $this->db->insert( $this->table(), $columns, $formats ); if ( false === $inserted ) { $this->report_error( 'insert' ); return 0; } return (int) $this->db->insert_id; } /** * Updates a row, keeping only the writable columns. * * @param int $id Row ID. * @param array $data Column data. Unknown keys are ignored. * * @return bool True when the write succeeded. */ protected function update_row( int $id, array $data ): bool { if ( $id <= 0 ) { return false; } list( $columns, $formats ) = $this->prepare_columns( $data ); if ( array() === $columns ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Repository write. $updated = $this->db->update( $this->table(), $columns, array( 'id' => $id ), $formats, array( '%d' ) ); if ( false === $updated ) { $this->report_error( 'update' ); return false; } return true; } /** * Announces a failed write. * * The repositories answer their callers with `false` or `0`, which says that * something did not happen but not why. The reason is sitting in `last_error` * for exactly as long as it takes the next query to replace it, so it is read * here and handed to whoever is listening — the logger, in practice. * * @param string $operation Which write failed. */ protected function report_error( string $operation ): void { $error = trim( (string) $this->db->last_error ); if ( '' === $error ) { return; } /** * Fires when a repository write is refused by the database. * * @since 1.0.0 * * @param string $error Message reported by the database. * @param string $operation Which write failed. * @param string $table Table being written to. */ do_action( 'formpilot_database_error', $error, $operation, $this->table() ); } /** * Deletes a row by ID. * * @param int $id Row ID. * * @return bool True when a row was removed. */ protected function delete_row( int $id ): bool { if ( $id <= 0 ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Repository write. $deleted = $this->db->delete( $this->table(), array( 'id' => $id ), array( '%d' ) ); if ( false === $deleted ) { $this->report_error( 'delete' ); return false; } return (bool) $deleted; } /** * Normalises the pagination arguments of a query. * * The upper bound is enforced here rather than only in the REST argument * schema, because the repositories are also reached from the admin screens, * WP-CLI and any add-on. A caller asking for a million rows per page gets the * ceiling, not a query that reads the whole table into memory. * * @param array $args Query arguments. * * @return array{0: int, 1: int} Rows per page and page number. */ protected function pagination( array $args ): array { /** * Filters the largest page size a repository query will honour. * * @since 1.0.0 * * @param int $max Maximum rows per page. Default 500. * @param string $table Table being read. */ $max = (int) apply_filters( 'formpilot_max_per_page', self::MAX_PER_PAGE, $this->table() ); $max = max( 1, $max ); return array( min( $max, max( 1, (int) ( $args['per_page'] ?? 20 ) ) ), max( 1, (int) ( $args['page'] ?? 1 ) ), ); } /** * Prepares a search term for a LIKE comparison. * * Returns an empty string when there is nothing to search for, which the * builder reads as "add no clause". * * @param mixed $term Raw search term. * * @return string The wildcard wrapped, escaped term, or an empty string. */ protected function search_term( mixed $term ): string { $term = is_scalar( $term ) ? trim( (string) $term ) : ''; return '' === $term ? '' : $this->like( $term ); } /** * Runs a callback inside a database transaction. * * Multi statement writes such as deleting a form and its children are all * or nothing: without this, a failure halfway leaves submissions pointing at * a form that no longer exists. Storage engines without transaction support * ignore the statements, so the callback still runs and the behaviour simply * degrades to what it was before. * * @param callable $callback Work to run. Returning false rolls the work back. * * @return mixed Whatever the callback returned, or false when it threw. */ protected function transaction( callable $callback ): mixed { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control. $this->db->query( 'START TRANSACTION' ); try { $result = $callback(); } catch ( \Throwable $exception ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control. $this->db->query( 'ROLLBACK' ); /* * Rolling back silently made a failed cascade indistinguishable from * "nothing matched". The exception is the only account of what went * wrong, so it is announced on the same channel as a refused write * before the caller is told false. */ do_action( 'formpilot_database_error', sprintf( '%s: %s', $exception::class, $exception->getMessage() ), 'transaction', $this->table() ); return false; } if ( false === $result ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control. $this->db->query( 'ROLLBACK' ); return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control. $this->db->query( 'COMMIT' ); return $result; } /** * Returns the current site time in MySQL format. */ protected function now(): string { return current_time( 'mysql' ); } /** * Escapes a value for use inside a LIKE comparison. * * @param string $value Raw search term. */ protected function like( string $value ): string { return '%' . $this->db->esc_like( $value ) . '%'; } }