PluginProbe
Search Regex / 3.4.4
Search Regex v3.4.4
3.4.4 3.4.3 trunk 1.4.12 1.4.13 1.4.14 1.4.15 1.4.16 2.0 2.0.1 2.1 2.2 2.2.1 2.3 2.3.1 2.3.2 2.3.3 2.4 2.4.1 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 All 38 releases
search-regex / includes / source / class-source.php
class-source.php
476 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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