class-source.php
| 1 | <?php |
| 2 | |
| 3 | namespace SearchRegex\Source; |
| 4 | |
| 5 | use SearchRegex\Sql; |
| 6 | use SearchRegex\Schema; |
| 7 | use SearchRegex\Search; |
| 8 | use SearchRegex\Filter; |
| 9 | use SearchRegex\Context; |
| 10 | |
| 11 | /** |
| 12 | * Represents a source of data that can be searched. Typically maps directly to a database table |
| 13 | * |
| 14 | * @phpstan-import-type MetaItem from Has_Meta |
| 15 | * @phpstan-import-type TermItem from Has_Terms |
| 16 | * @phpstan-type PostActions array{edit?: string, view?: string} |
| 17 | * @phpstan-type RowColumn array{column: string, value?: mixed, items?: list<MetaItem|TermItem>} |
| 18 | */ |
| 19 | abstract class Source { |
| 20 | const AUTOCOMPLETE_LIMIT = 50; |
| 21 | |
| 22 | /** |
| 23 | * Search filters |
| 24 | * |
| 25 | * @var array<Filter\Filter> |
| 26 | */ |
| 27 | protected array $filters; |
| 28 | |
| 29 | /** |
| 30 | * The source type |
| 31 | */ |
| 32 | protected string $source_type; |
| 33 | |
| 34 | /** |
| 35 | * The source type name |
| 36 | */ |
| 37 | protected string $source_name; |
| 38 | |
| 39 | /** |
| 40 | * Create a Source\Source object |
| 41 | * |
| 42 | * @param array<string, mixed> $handler Source handler information - an array of `name`, `class`, `label`, and `type`. |
| 43 | * @param array<Filter\Filter> $filters Array of Filter\Filter objects for this source. |
| 44 | */ |
| 45 | public function __construct( array $handler, array $filters ) { |
| 46 | $this->filters = $filters; |
| 47 | $this->source_type = $handler['name'] ?? 'unknown'; |
| 48 | $this->source_name = $handler['label'] ?? $this->source_type; |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Return the source type |
| 53 | * |
| 54 | * @param array<string, mixed> $row Database row, used in some sources to determine the type. |
| 55 | * @return string Source type |
| 56 | */ |
| 57 | public function get_type( array $row = [] ) { |
| 58 | return $this->source_type; |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * Return true if the source matches the type, false otherwise |
| 63 | * |
| 64 | * @param string $type Source type. |
| 65 | * @return bool |
| 66 | */ |
| 67 | public function is_type( $type ) { |
| 68 | return $this->source_type === $type; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Return the source name |
| 73 | * |
| 74 | * @param array<string, mixed> $row Database row, used in some sources to determine the type. |
| 75 | * @return string A user viewable source name |
| 76 | */ |
| 77 | public function get_name( array $row = [] ) { |
| 78 | return $this->source_name; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Return the associated Filter\Filter items |
| 83 | * |
| 84 | * @return list<Filter\Filter> Filter\Filter objects |
| 85 | */ |
| 86 | public function get_search_filters() { |
| 87 | return array_values( $this->filters ); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Return an array of additional columns to return in a search. These aren't searched, and can be used by the source. |
| 92 | * |
| 93 | * @return list<Sql\Select\Select> The array of column names |
| 94 | */ |
| 95 | public function get_info_columns() { |
| 96 | return [ |
| 97 | new Sql\Select\Select( Sql\Value::table( $this->get_table_name() ), Sql\Value::column( $this->get_title_column() ) ), |
| 98 | ]; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Return an the table's ID column name |
| 103 | * |
| 104 | * @return string The table's ID column name |
| 105 | */ |
| 106 | abstract public function get_table_id(); |
| 107 | |
| 108 | /** |
| 109 | * Return the column name used as a visible title for the source. For example, a post would have `post_title` |
| 110 | * |
| 111 | * @return string Column used for the title |
| 112 | */ |
| 113 | abstract public function get_title_column(); |
| 114 | |
| 115 | /** |
| 116 | * Return the table name |
| 117 | * |
| 118 | * @return string The table name for the source |
| 119 | */ |
| 120 | abstract public function get_table_name(); |
| 121 | |
| 122 | /** |
| 123 | * Return a visible label for the column. This is shown to the user and should be more descriptive than the column name itself |
| 124 | * |
| 125 | * @param string $column Column name. |
| 126 | * @return string Column label |
| 127 | */ |
| 128 | public function get_column_label( $column ) { |
| 129 | foreach ( $this->get_schema()['columns'] as $schema_column ) { |
| 130 | if ( $schema_column['column'] === $column ) { |
| 131 | return $schema_column['title']; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | return $column; |
| 136 | } |
| 137 | |
| 138 | /** |
| 139 | * Get an array of actions for a given row |
| 140 | * |
| 141 | * @param Search\Result $result The Search\Result object containing the row from the source. |
| 142 | * @return PostActions |
| 143 | */ |
| 144 | public function get_actions( Search\Result $result ) { |
| 145 | return []; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Get the total number of matches for this search |
| 150 | * |
| 151 | * @param list<Filter\Filter> $filters Search string. |
| 152 | * @return array{matches: int, rows: int}|\WP_Error The number of matches as an array of 'matches' and 'rows', or \WP_Error on error |
| 153 | */ |
| 154 | public function get_global_match_total( $filters ) { |
| 155 | $query = Filter\Filter::get_as_query( $filters, $this ); |
| 156 | $query->add_from( new Sql\From( Sql\Value::column( $this->get_table_name() ) ) ); |
| 157 | |
| 158 | $sql = new Sql\Builder(); |
| 159 | |
| 160 | /** @var \WP_Error|object{match_total: string, match_rows: string} $result */ |
| 161 | $result = $sql->get_result( $query, new Sql\Modifier\Select_Count_Id( Sql\Value::table( $this->get_table_name() ), Sql\Value::column( $this->get_table_id() ) ) ); |
| 162 | if ( $result instanceof \WP_Error ) { |
| 163 | return $result; |
| 164 | } |
| 165 | |
| 166 | return [ |
| 167 | 'matches' => isset( $result->match_total ) ? intval( $result->match_total, 10 ) : 0, |
| 168 | 'rows' => intval( $result->match_rows, 10 ), |
| 169 | ]; |
| 170 | } |
| 171 | |
| 172 | /** |
| 173 | * Get total number of rows for this source |
| 174 | * |
| 175 | * @return int|\WP_Error The number of rows, or \WP_Error on error |
| 176 | */ |
| 177 | public function get_total_rows() { |
| 178 | $sql = new Sql\Builder(); |
| 179 | |
| 180 | $query = new Sql\Query(); |
| 181 | $query->add_select( new Sql\Select\Select( Sql\Value::table( $this->get_table_name() ), Sql\Value::safe_raw( 'COUNT(*)' ) ) ); |
| 182 | $query->add_from( new Sql\From( Sql\Value::column( $this->get_table_name() ) ) ); |
| 183 | |
| 184 | return $sql->get_count( $query ); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Get a single row from the source |
| 189 | * |
| 190 | * @param int $row_id The row ID. |
| 191 | * @return list<array<string, mixed>>|\WP_Error The database row, or \WP_Error on error |
| 192 | */ |
| 193 | public function get_row( $row_id ) { |
| 194 | $builder = new Sql\Builder(); |
| 195 | |
| 196 | // Create query |
| 197 | $query = new Sql\Query(); |
| 198 | $query->add_selects( $this->get_query_selects() ); |
| 199 | $query->add_from( new Sql\From( Sql\Value::column( $this->get_table_name() ) ) ); |
| 200 | |
| 201 | // Add the filters except the where |
| 202 | $query->add_query_except_where( Filter\Filter::get_as_query( $this->filters, $this ) ); |
| 203 | |
| 204 | // Add our row ID |
| 205 | $query->add_where( new Sql\Where\Where_Integer( new Sql\Select\Select( Sql\Value::table( $this->get_table_name() ), Sql\Value::column( $this->get_table_id() ) ), 'equals', $row_id ) ); |
| 206 | |
| 207 | return $builder->get_search( $query ); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Get columns for a single row |
| 212 | * |
| 213 | * @param int $row_id The row ID. |
| 214 | * @return list<RowColumn>|\WP_Error |
| 215 | */ |
| 216 | public function get_row_columns( $row_id ) { |
| 217 | global $wpdb; |
| 218 | |
| 219 | $columns = array_filter( |
| 220 | $this->get_schema()['columns'], function ( $column ) { |
| 221 | if ( isset( $column['join'] ) ) { |
| 222 | return false; |
| 223 | } |
| 224 | |
| 225 | if ( isset( $column['modify'] ) && $column['modify'] === false ) { |
| 226 | return false; |
| 227 | } |
| 228 | |
| 229 | return true; |
| 230 | } |
| 231 | ); |
| 232 | |
| 233 | $columns = array_map( |
| 234 | fn( $column ) => $column['column'], |
| 235 | $columns |
| 236 | ); |
| 237 | |
| 238 | // Known query |
| 239 | // phpcs:ignore |
| 240 | $row = $wpdb->get_row( $wpdb->prepare( "SELECT " . implode( ',', $columns ) . " FROM {$this->get_table_name()} WHERE {$this->get_table_id()}=%d", $row_id ), ARRAY_A ); |
| 241 | if ( $row === null ) { |
| 242 | return new \WP_Error( 'searchregex_database', 'No row for ' . (string) $row_id, 401 ); |
| 243 | } |
| 244 | |
| 245 | // Convert it, then get other stuff |
| 246 | $row_columns = []; |
| 247 | foreach ( $row as $column => $value ) { |
| 248 | $row_columns[] = [ |
| 249 | 'column' => $column, |
| 250 | 'value' => $value, |
| 251 | ]; |
| 252 | } |
| 253 | |
| 254 | return $row_columns; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Get a set of matching rows |
| 259 | * |
| 260 | * @param int $offset The row offset. |
| 261 | * @param int $limit The number of rows to return. |
| 262 | * @return list<array<string, string>>|\WP_Error The database rows, or \WP_Error on error |
| 263 | */ |
| 264 | public function get_matched_rows( $offset, $limit ) { |
| 265 | $builder = new Sql\Builder(); |
| 266 | |
| 267 | // Create query |
| 268 | $query = new Sql\Query(); |
| 269 | $query->add_selects( $this->get_query_selects() ); |
| 270 | $query->add_from( new Sql\From( Sql\Value::column( $this->get_table_name() ) ) ); |
| 271 | $query->set_paging( $offset, $limit ); |
| 272 | $query->set_order( $this->get_table_name() . '.' . $this->get_table_id() ); |
| 273 | |
| 274 | // Add filters |
| 275 | $query->add_query( Filter\Filter::get_as_query( $this->filters, $this ) ); |
| 276 | |
| 277 | return $builder->get_search( $query ); |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Can we replace this column? |
| 282 | * |
| 283 | * @param string $column Column name. |
| 284 | * @return bool |
| 285 | */ |
| 286 | private function can_replace_column( $column ) { |
| 287 | foreach ( $this->get_schema()['columns'] as $column_schema ) { |
| 288 | if ( $column_schema['column'] === $column ) { |
| 289 | return ! isset( $column_schema['modify'] ) || $column_schema['modify']; |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | return false; |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Get array of columns to change |
| 298 | * |
| 299 | * @param array<string, array{change: list<Context\Type\Replace>}> $updates Array of updates. |
| 300 | * @return array<string, string|null|integer> Array of column name => replacement |
| 301 | */ |
| 302 | protected function get_columns_to_change( array $updates ) { |
| 303 | $columns = []; |
| 304 | |
| 305 | foreach ( $updates as $column => $update ) { |
| 306 | foreach ( $update['change'] as $change ) { |
| 307 | if ( $change->get_type() === Context\Type\Replace::TYPE_REPLACE && $this->can_replace_column( $column ) ) { |
| 308 | $columns[ $column ] = $change->get_replacement(); |
| 309 | } |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | return $columns; |
| 314 | } |
| 315 | |
| 316 | /** |
| 317 | * Save a replacement to the database |
| 318 | * |
| 319 | * @param int $row_id The row ID to save. |
| 320 | * @param array<string, mixed> $changes The value to save to the column in the row. |
| 321 | * @return bool|\WP_Error True on success, or \WP_Error on error |
| 322 | */ |
| 323 | abstract public function save( $row_id, array $changes ); |
| 324 | |
| 325 | /** |
| 326 | * Delete a row from the source |
| 327 | * |
| 328 | * @param int $row_id The row ID. |
| 329 | * @return bool|\WP_Error true on success, or \WP_Error on error |
| 330 | */ |
| 331 | abstract public function delete_row( $row_id ); |
| 332 | |
| 333 | /** |
| 334 | * Returns database columns in SQL format |
| 335 | * |
| 336 | * @internal |
| 337 | * @return Sql\Select\Select[] SQL string |
| 338 | */ |
| 339 | protected function get_query_selects() { |
| 340 | return [ |
| 341 | // Table ID column |
| 342 | new Sql\Select\Select( Sql\Value::table( $this->get_table_name() ), Sql\Value::column( $this->get_table_id() ) ), |
| 343 | // Any extra 'info' columns |
| 344 | ...$this->get_info_columns(), |
| 345 | ]; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Get source filters |
| 350 | * |
| 351 | * @return list<Filter\Filter> |
| 352 | */ |
| 353 | public function get_filters() { |
| 354 | return array_values( $this->filters ); |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * Get schema for a source |
| 359 | * |
| 360 | * @return array<string, mixed> |
| 361 | */ |
| 362 | public function get_schema_for_source() { |
| 363 | return apply_filters( 'searchregex_schema_item', $this->get_schema() ); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Get the columns in the order they are defined in the schema, suitable for ordering results |
| 368 | * |
| 369 | * @return array<string, int> |
| 370 | */ |
| 371 | public function get_schema_order() { |
| 372 | $schema = $this->get_schema_for_source(); |
| 373 | $values = range( 0, count( $schema['columns'] ) - 1 ); |
| 374 | $keys = array_map( |
| 375 | fn( $column ) => $column['column'], $schema['columns'] |
| 376 | ); |
| 377 | |
| 378 | $result = array_combine( $keys, $values ); |
| 379 | if ( $result === false ) { |
| 380 | return []; |
| 381 | } |
| 382 | |
| 383 | return $result; |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Internal function to get schema, which is then filtered by `get_schema_for_source` |
| 388 | * |
| 389 | * @return array<string, mixed> |
| 390 | */ |
| 391 | abstract public function get_schema(); |
| 392 | |
| 393 | /** |
| 394 | * Get the schema as a Schema\Source object |
| 395 | * |
| 396 | * @return Schema\Source |
| 397 | */ |
| 398 | public function get_schema_item() { |
| 399 | return new Schema\Source( $this->get_schema() ); |
| 400 | } |
| 401 | |
| 402 | /** |
| 403 | * Get any preloadable data for the given filter |
| 404 | * |
| 405 | * @param array<string, mixed> $schema Schema. |
| 406 | * @param Filter\Type\Filter_Type $filter Filter. |
| 407 | * @return list<array{label: string, value: string}> |
| 408 | */ |
| 409 | public function get_filter_preload( $schema, $filter ) { |
| 410 | return []; |
| 411 | } |
| 412 | |
| 413 | /** |
| 414 | * Perform autocompletion on a column and a value |
| 415 | * |
| 416 | * @param array<string, mixed> $column Column. |
| 417 | * @param string $value Value. |
| 418 | * @return list<object{id: string|int, value: string}> |
| 419 | */ |
| 420 | abstract public function autocomplete( array $column, $value ); |
| 421 | |
| 422 | /** |
| 423 | * Does this source have any advanced filters? |
| 424 | * |
| 425 | * @return bool |
| 426 | */ |
| 427 | public function has_advanced_filter() { |
| 428 | foreach ( $this->filters as $filter ) { |
| 429 | if ( $filter->is_advanced() ) { |
| 430 | return true; |
| 431 | } |
| 432 | } |
| 433 | |
| 434 | return false; |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * Try and convert the column value into a text label. For example, user ID to user name |
| 439 | * |
| 440 | * @param Schema\Column $schema Schema. |
| 441 | * @param string $value Column value. |
| 442 | * @return string Column label, or column value. |
| 443 | */ |
| 444 | public function convert_result_value( Schema\Column $schema, $value ) { |
| 445 | if ( $schema->get_options() ) { |
| 446 | foreach ( $schema->get_options() as $option ) { |
| 447 | if ( $option['value'] === $value || intval( $option['value'], 10 ) === $value ) { |
| 448 | return $option['label']; |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | if ( $schema->get_source() ) { |
| 454 | $convert = new Convert_Values(); |
| 455 | |
| 456 | return $convert->convert( $schema, $value ); |
| 457 | } |
| 458 | |
| 459 | return $value; |
| 460 | } |
| 461 | |
| 462 | /** |
| 463 | * Helper function to log actions if WP_DEBUG is enabled |
| 464 | * |
| 465 | * @param string $title Log title. |
| 466 | * @param mixed $update Log data. |
| 467 | * @return void |
| 468 | */ |
| 469 | protected function log_save( $title, $update ) { |
| 470 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { |
| 471 | // @phpstan-ignore disallowed.function, disallowed.function |
| 472 | error_log( $title . ': ' . print_r( $update, true ) ); // phpcs:ignore |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 |