From 5a4adb260e496bc96aa7eb5b3454764a42ddffc1 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Mon, 30 Aug 2021 21:24:35 -0500 Subject: [PATCH 01/34] PR/115 - Recommendations from phpstan. (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix return types in Query * Docs: improvents recommended by phpstan-wordpress. Props szepeviktor. Co-authored-by: Viktor Szépe --- composer.json | 7 ++- src/Database/Base.php | 6 +-- src/Database/Column.php | 12 ++--- src/Database/Queries/Date.php | 40 ++++++++--------- src/Database/Query.php | 82 ++++++++++++++++++----------------- src/Database/Row.php | 2 +- src/Database/Table.php | 2 +- 7 files changed, 79 insertions(+), 72 deletions(-) diff --git a/composer.json b/composer.json index 04716c1..1a1cb21 100644 --- a/composer.json +++ b/composer.json @@ -3,10 +3,13 @@ "description": "A collection of PHP classes and functions that aims to provide an ORM-like experience and interface to WordPress database tables.", "type": "library", "license": "GPL-2.0-only", - "require": {}, "autoload": { "psr-4": { "BerlinDB\\": "src/" } + }, + "require-dev": { + "szepeviktor/phpstan-wordpress": "^0.7.7", + "phpstan/extension-installer": "^1.1" } -} \ No newline at end of file +} diff --git a/src/Database/Base.php b/src/Database/Base.php index 495b111..d5aaa7a 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -162,7 +162,7 @@ protected function apply_prefix( $string = '', $sep = '_' ) { * @since 1.0.0 * * @param string $string - * @param string $sep + * @param non-empty-string $sep * @return string */ protected function first_letters( $string = '', $sep = '_' ) { @@ -213,7 +213,7 @@ protected function first_letters( $string = '', $sep = '_' ) { * * @param string $name The name of the database table * - * @return string Sanitized database table name + * @return mixed Sanitized database table name on success, False on error */ protected function sanitize_table_name( $name = '' ) { @@ -280,7 +280,7 @@ protected function set_vars( $args = array() ) { * * @since 1.0.0 * - * @return \wpdb Database interface, or False if not set + * @return bool|\wpdb Database interface, or False if not set */ protected function get_db() { diff --git a/src/Database/Column.php b/src/Database/Column.php index dafe835..15edff4 100644 --- a/src/Database/Column.php +++ b/src/Database/Column.php @@ -52,7 +52,7 @@ class Column extends Base { * See: https://dev.mysql.com/doc/en/storage-requirements.html * * @since 1.0.0 - * @var string + * @var mixed */ public $length = false; @@ -299,7 +299,7 @@ class Column extends Base { * Use in conjunction with a database index for speedy queries. * * @since 1.0.0 - * @var string + * @var bool */ public $cache_key = false; @@ -374,7 +374,7 @@ class Column extends Base { * * @since 1.0.0 * - * @param string|array $args { + * @param array|string $args { * Optional. Array or query string of order query parameters. Default empty. * * @type string $name Name of database column @@ -664,8 +664,8 @@ private function sanitize_relationships( $relationships = array() ) { * Sanitize the default value * * @since 1.0.0 - * @param string $default - * @return string|null + * @param int|string|null $default + * @return int|string|null */ private function sanitize_default( $default = '' ) { @@ -811,7 +811,7 @@ public function validate_decimal( $value = 0, $decimals = 9 ) { : 1; // Only numbers and period - $value = preg_replace( '/[^0-9\.]/', '', (string) $value ); + $value = (float) preg_replace( '/[^0-9\.]/', '', (string) $value ); // Format to number of decimals, and cast as float $formatted = number_format( $value, $decimals, '.', '' ); diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php index db976c3..0098c8f 100644 --- a/src/Database/Queries/Date.php +++ b/src/Database/Queries/Date.php @@ -65,7 +65,7 @@ class Date extends Base { * The value comparison operator. Can be changed via the query arguments. * * @since 1.0.0 - * @var array + * @var string */ public $compare = '='; @@ -73,7 +73,7 @@ class Date extends Base { * The start of week operator. Can be changed via the query arguments. * * @since 1.1.0 - * @var array + * @var int */ public $start_of_week = 0; @@ -179,7 +179,7 @@ class Date extends Base { * @type array ...$0 { * Optional. An array of first-order clause parameters, or another fully-formed date query. * - * @type string|array $before { + * @type array|string $before { * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string, * or array of 'year', 'month', 'day' values. * @@ -189,7 +189,7 @@ class Date extends Base { * @type string $day Optional when passing array.The day of the month. * Default (string:empty)|(array:1). Accepts numbers 1-31. * } - * @type string|array $after { + * @type array|string $after { * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string, * or array of 'year', 'month', 'day' values. * @@ -360,7 +360,7 @@ protected function is_first_order_clause( $query = array() ) { * * @param array $query A date query or a date subquery. * - * @return string The current unix timestamp. + * @return int The current unix timestamp. */ public function get_now( $query = array() ) { @@ -435,7 +435,7 @@ public function get_relation( $query = array() ) { * * @param array $query A date query or a date subquery. * - * @return string The comparison operator. + * @return int The comparison operator. */ public function get_start_of_week( $query = array() ) { @@ -502,7 +502,7 @@ public function validate_date_values( $date_query = array() ) { $_year = $date_query['year']; } - $max_days_of_year = gmdate( 'z', gmmktime( 0, 0, 0, 12, 31, $_year ) ) + 1; + $max_days_of_year = (int) gmdate( 'z', gmmktime( 0, 0, 0, 12, 31, $_year ) ) + 1; // Otherwise we use the max of 366 (leap-year) } else { @@ -643,7 +643,7 @@ public function validate_column( $column = '' ) { * * @since 1.0.0 * - * @return string MySQL WHERE clauses. + * @return array MySQL WHERE clauses. */ public function get_sql() { $sql = $this->get_sql_clauses(); @@ -656,7 +656,7 @@ public function get_sql() { * @param string $sql Clauses of the date query. * @param Date $this The Date query instance. */ - return apply_filters( 'get_date_sql', $sql, $this ); + return (array) apply_filters( 'get_date_sql', $sql, $this ); } /** @@ -681,7 +681,7 @@ protected function get_sql_clauses() { $sql['where'] = ' AND ' . $sql['where']; } - return apply_filters( 'get_date_sql_clauses', $sql, $this ); + return (array) apply_filters( 'get_date_sql_clauses', $sql, $this ); } /** @@ -773,7 +773,7 @@ protected function get_sql_for_query( $query = array(), $depth = 0 ) { } // Filter and return - return apply_filters( 'get_date_sql_for_query', $sql, $query, $depth, $this ); + return (array) apply_filters( 'get_date_sql_for_query', $sql, $query, $depth, $this ); } /** @@ -893,9 +893,9 @@ protected function get_sql_for_clause( $query = array(), $parent_query = array() * @since 1.0.0 * * @param string $compare The compare operator to use - * @param string|array $value The value + * @param array|int|string $value The value * - * @return string|false|int The value to be used in SQL or false on error. + * @return string|bool|int The value to be used in SQL or false on error. */ public function build_numeric_value( $compare = '=', $value = null ) { @@ -952,7 +952,7 @@ public function build_numeric_value( $compare = '=', $value = null ) { * @since 1.0.0 * * @param string $compare The compare operator to use - * @param string|array $value The value + * @param array|string $value The value * * @return string|false|int The value to be used in SQL or false on error. */ @@ -1013,12 +1013,12 @@ public function build_value( $compare = '=', $value = null ) { * * @since 1.0.0 * - * @param string|array $datetime An array of parameters or a strtotime() string - * @param bool $default_to_max Whether to round up incomplete dates. Supported by values - * of $datetime that are arrays, or string values that are a - * subset of MySQL date format ('Y', 'Y-m', 'Y-m-d', 'Y-m-d H:i'). - * Default: false. - * @param string|int $now The current unix timestamp. + * @param array|int|string $datetime An array of parameters or a strtotime() string + * @param bool $default_to_max Whether to round up incomplete dates. Supported by values + * of $datetime that are arrays, or string values that are a + * subset of MySQL date format ('Y', 'Y-m', 'Y-m-d', 'Y-m-d H:i'). + * Default: false. + * @param string|int $now The current unix timestamp. * * @return string|false A MySQL format date/time or false on failure */ diff --git a/src/Database/Query.php b/src/Database/Query.php index 8775c64..16dd4c7 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -31,18 +31,18 @@ * @property string $item_name_plural * @property string $item_shape * @property string $cache_group - * @property int $last_changed + * @property string $last_changed * @property array $columns * @property array $query_clauses * @property array $request_clauses - * @property Queries\Meta $meta_query - * @property Queries\Date $date_query - * @property Queries\Compare $compare_query + * @property null|Queries\Meta $meta_query + * @property null|Queries\Date $date_query + * @property null|Queries\Compare $compare_query * @property array $query_vars * @property array $query_var_originals * @property array $query_var_defaults * @property string $query_var_default_value - * @property array $items + * @property array|int $items * @property int $found_items * @property int $max_num_pages * @property string $request @@ -133,9 +133,9 @@ class Query extends Base { * The last updated time. * * @since 1.0.0 - * @var int + * @var string */ - protected $last_changed = 0; + protected $last_changed = ''; /** Columns ***************************************************************/ @@ -183,25 +183,25 @@ class Query extends Base { * Meta query container. * * @since 1.0.0 - * @var object|Queries\Meta + * @var null|object|Queries\Meta */ - protected $meta_query = false; + protected $meta_query = null; /** * Date query container. * * @since 1.0.0 - * @var object|Queries\Date + * @var null|object|Queries\Date */ - protected $date_query = false; + protected $date_query = null; /** * Compare query container. * * @since 1.0.0 - * @var object|Queries\Compare + * @var null|object|Queries\Compare */ - protected $compare_query = false; + protected $compare_query = null; /** Query Variables *******************************************************/ @@ -254,7 +254,7 @@ class Query extends Base { * List of items located by the query. * * @since 1.0.0 - * @var array + * @var array|int */ public $items = array(); @@ -289,7 +289,7 @@ class Query extends Base { * * @since 1.0.0 * - * @param string|array $query { + * @param array|string $query { * Optional. Array or query string of item query parameters. * Default empty. * @@ -304,7 +304,7 @@ class Query extends Base { * Default 0. * @type bool $no_found_rows Whether to disable the `SQL_CALC_FOUND_ROWS` query. * Default true. - * @type string|array $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. + * @type array|string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. * Default '', to primary column ID. * @type string $item How to item retrieved items. Accepts 'ASC', 'DESC'. * Default 'DESC'. @@ -341,7 +341,7 @@ public function __construct( $query = array() ) { * * @since 1.0.0 * - * @param string|array $query Array or URL query string of parameters. + * @param array|string $query Array or URL query string of parameters. * @return array|int List of items, or number of items when 'count' is passed as a query var. */ public function query( $query = array() ) { @@ -594,11 +594,11 @@ private function set_items( $item_ids = array() ) { * * @since 1.0.0 * - * @param array $item_ids Optional array of item IDs + * @param mixed $item_ids Optional array of item IDs */ private function set_found_items( $item_ids = array() ) { - // Items were not found + // Bail if items are empty if ( empty( $item_ids ) ) { return; } @@ -796,10 +796,10 @@ private function get_column_by( $args = array() ) { * * @since 1.0.0 * - * @param array $args Arguments to filter columns by. - * @param string $operator Optional. The logical operation to perform. - * @param string $field Optional. A field from the object to place - * instead of the entire object. Default false. + * @param array $args Arguments to filter columns by. + * @param string $operator Optional. The logical operation to perform. + * @param bool|string $field Optional. A field from the object to place + * instead of the entire object. Default false. * @return array Array of column. */ private function get_columns( $args = array(), $operator = 'and', $field = false ) { @@ -819,7 +819,7 @@ private function get_columns( $args = array(), $operator = 'and', $field = false * @since 1.0.0 * * @param string $column_name Name of database column - * @param string $column_value Value to query for + * @param mixed $column_value Value to query for * @return object|false False if empty/error, Object if successful */ private function get_item_raw( $column_name = '', $column_value = '' ) { @@ -906,7 +906,7 @@ private function get_items() { // Pagination if ( ! empty( $this->found_items ) && ! empty( $this->query_vars['number'] ) ) { - $this->max_num_pages = ceil( $this->found_items / $this->query_vars['number'] ); + $this->max_num_pages = (int) ceil( $this->found_items / $this->query_vars['number'] ); } // Cast to int if not grouping counts @@ -926,8 +926,8 @@ private function get_items() { * * @since 1.0.0 * - * @return int|array A single count of item IDs if a count query. An array - * of item IDs if a full query. + * @return mixed An array of item IDs if a full query. A single count of + * item IDs if a count query. */ private function get_item_ids() { @@ -976,8 +976,8 @@ private function get_item_ids() { * * @since 1.0.0 * - * @param array $pieces A compacted array of item query clauses. - * @param Query &$this Current instance passed by reference. + * @param array $query A compacted array of item query clauses. + * @param Query &$this Current instance passed by reference. */ $clauses = (array) apply_filters_ref_array( $this->apply_prefix( "{$this->item_name_plural}_query_clauses" ), array( $query, &$this ) ); @@ -1124,7 +1124,7 @@ private function get_search_sql( $string = '', $columns = array() ) { * * @see Query::__construct() * - * @param string|array $query Array or string of Query arguments. + * @param array|string $query Array or string of Query arguments. */ private function parse_query( $query = array() ) { @@ -1289,7 +1289,7 @@ private function parse_where() { * * @param array $search_columns Array of column names to be searched. * @param string $search Text being searched. - * @param object $this The current Query instance. + * @param Query $this The current Query instance. */ $search_columns = (array) apply_filters( $this->apply_prefix( "{$this->item_name_plural}_search_columns" ), $search_columns, $this->query_vars['search'], $this ); @@ -1474,7 +1474,7 @@ private function parse_groupby( $groupby = '', $alias = true ) { * @since 1.0.0 * * @param string $orderby Field for the items to be ordered by. - * @return string|false Value to used in the ORDER clause. False otherwise. + * @return string Value to used in the ORDER clause. */ private function parse_orderby( $orderby = '' ) { @@ -1767,7 +1767,7 @@ public function get_item_by( $column_name = '', $column_value = '' ) { * @since 1.0.0 * * @param array $data - * @return bool + * @return bool|int */ public function add_item( $data = array() ) { @@ -2227,7 +2227,6 @@ private function default_item() { * @param array $new_data * @param array $old_data * @param int $item_id - * @return array */ private function transition_item( $new_data = array(), $old_data = array(), $item_id = 0 ) { @@ -2300,7 +2299,7 @@ private function transition_item( $new_data = array(), $old_data = array(), $ite * @param int $item_id * @param string $meta_key * @param string $meta_value - * @param string $unique + * @param bool $unique * @return int|false The meta ID on success, false on failure. */ protected function add_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $unique = false ) { @@ -2398,7 +2397,7 @@ protected function update_item_meta( $item_id = 0, $meta_key = '', $meta_value = * @param int $item_id * @param string $meta_key * @param string $meta_value - * @param string $delete_all + * @param bool $delete_all * @return bool True on successful delete, false on failure. */ protected function delete_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $delete_all = false ) { @@ -2715,6 +2714,8 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { $singular = rtrim( $this->table_name, 's' ); // sic update_meta_cache( $singular, $item_ids ); } + + return true; } /** @@ -2728,7 +2729,8 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { * * @since 1.0.0 * - * @param array $items + * @param int|object|array $items Primary ID if int. Row if object. Array + * of objects if array. */ private function update_item_cache( $items = array() ) { @@ -2823,6 +2825,8 @@ private function clean_item_cache( $items = array() ) { // Update last changed $this->update_last_changed_cache(); + + return true; } /** @@ -2896,7 +2900,7 @@ private function get_non_cached_ids( $item_ids = array(), $group = '' ) { $id = $this->shape_item_id( $id ); // Add to return value if not cached - if ( false === $this->cache_get( $id, $group ) ) { + if ( false === $this->cache_get( (string) $id, $group ) ) { $retval[] = $id; } } @@ -3137,7 +3141,7 @@ public function get_results( $cols = array(), $where_cols = array(), $limit = 25 // Maybe set an offset if ( ! empty( $offset ) ) { $values = explode( ',', $offset ); - $values = array_filter( $values, 'intval' ); + $values = array_map( 'intval', array_filter( $values ) ); $offset = implode( ',', $values ); $query .= " OFFSET {$offset} "; } diff --git a/src/Database/Row.php b/src/Database/Row.php index 8a981e6..8e33c58 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -33,7 +33,7 @@ class Row extends Base { * * @since 1.0.0 * - * @param mixed Null by default, Array/Object if not + * @param mixed $item Null by default, Array/Object if not */ public function __construct( $item = null ) { if ( ! empty( $item ) ) { diff --git a/src/Database/Table.php b/src/Database/Table.php index 9560ddf..cc83e96 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -355,7 +355,7 @@ public function exists() { * * @since 1.2.0 * - * @return array + * @return mixed Array on success, False on failure */ public function columns() { From aeb83f62fb1be7b7ac2437d0a35b0eec03665706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Sz=C3=A9pe?= Date: Tue, 31 Aug 2021 18:51:10 +0200 Subject: [PATCH 02/34] Make booleans much better (#120) --- src/Database/Base.php | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index d5aaa7a..df34c9e 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -80,14 +80,10 @@ public function __isset( $key = '' ) { // Return property if exists if ( method_exists( $this, $method ) ) { return true; - - // Return get method results if exists - } elseif ( property_exists( $this, $key ) ) { - return true; } - // Return false if not exists - return false; + // Return get method results if exists + return property_exists( $this, $key ); } /** @@ -240,13 +236,10 @@ protected function sanitize_table_name( $name = '' ) { // Remove trailing underscores $clean = trim( $single, '_' ); - // Bail if table name was garbaged - if ( empty( $clean ) ) { - return false; - } - - // Return the cleaned table name - return $clean; + // Bail if table name was garbaged or return the cleaned table name + return empty( $clean ) + ? false + : $clean; } /** @@ -338,6 +331,6 @@ protected function is_success( $result = false ) { } // Return the result - return (bool) $retval; + return $retval; } } From d5fd2714d02941da68ac53eefeedd0c9223c23c6 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Tue, 31 Aug 2021 14:03:26 -0500 Subject: [PATCH 03/34] Add composer.lock. --- composer.lock | 315 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 composer.lock diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..e7495a3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,315 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ff2a6025b5680b5b700a3016c03a5341", + "packages": [], + "packages-dev": [ + { + "name": "php-stubs/wordpress-stubs", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/php-stubs/wordpress-stubs.git", + "reference": "794e6eedfd5f2a334d581214c007fc398be588fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/794e6eedfd5f2a334d581214c007fc398be588fe", + "reference": "794e6eedfd5f2a334d581214c007fc398be588fe", + "shasum": "" + }, + "replace": { + "giacocorsiglia/wordpress-stubs": "*" + }, + "require-dev": { + "giacocorsiglia/stubs-generator": "^0.5.0", + "php": "~7.1" + }, + "suggest": { + "paragonie/sodium_compat": "Pure PHP implementation of libsodium", + "symfony/polyfill-php73": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "szepeviktor/phpstan-wordpress": "WordPress extensions for PHPStan" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress function and class declaration stubs for static analysis.", + "homepage": "https://github.com/php-stubs/wordpress-stubs", + "keywords": [ + "PHPStan", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/php-stubs/wordpress-stubs/issues", + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v5.8.0" + }, + "time": "2021-07-21T02:34:37+00:00" + }, + { + "name": "phpstan/extension-installer", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "66c7adc9dfa38b6b5838a9fb728b68a7d8348051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/66c7adc9dfa38b6b5838a9fb728b68a7d8348051", + "reference": "66c7adc9dfa38b6b5838a9fb728b68a7d8348051", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0", + "php": "^7.1 || ^8.0", + "phpstan/phpstan": ">=0.11.6" + }, + "require-dev": { + "composer/composer": "^1.8", + "phing/phing": "^2.16.3", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.1.0" + }, + "time": "2020-12-13T13:06:13+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "0.12.96", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "a98bdc51318f20fcae8c953d266f81a70254917f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a98bdc51318f20fcae8c953d266f81a70254917f", + "reference": "a98bdc51318f20fcae8c953d266f81a70254917f", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.12-dev" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "support": { + "issues": "https://github.com/phpstan/phpstan/issues", + "source": "https://github.com/phpstan/phpstan/tree/0.12.96" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpstan", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2021-08-21T11:55:13+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.23.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", + "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, + { + "name": "szepeviktor/phpstan-wordpress", + "version": "v0.7.7", + "source": { + "type": "git", + "url": "https://github.com/szepeviktor/phpstan-wordpress.git", + "reference": "bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72", + "reference": "bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-stubs/wordpress-stubs": "^4.7 || ^5.0", + "phpstan/phpstan": "^0.12.26", + "symfony/polyfill-php73": "^1.12.0" + }, + "require-dev": { + "composer/composer": "^1.10.22", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7", + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpstan/phpstan-strict-rules": "^0.12", + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "SzepeViktor\\PHPStan\\WordPress\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "WordPress extensions for PHPStan", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues", + "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v0.7.7" + }, + "funding": [ + { + "url": "https://www.paypal.me/szepeviktor", + "type": "custom" + } + ], + "time": "2021-07-14T09:19:15+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.1.0" +} From 5a1375d27c7432500842dfa4d741dce833d87203 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Thu, 9 Sep 2021 11:59:29 -0500 Subject: [PATCH 04/34] Query: change the parameter order of private method transition_item() This change ensures that, going forward, this methods parameter signature better matches the other _item() methods. This should be /relatively/ safe to do thanks to it being private. I've looked at all of the projects I'm aware of that use Berlin, and they are all uneffected. --- src/Database/Query.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 4045838..3d0232c 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1851,7 +1851,7 @@ public function add_item( $data = array() ) { $this->update_item_cache( $item_id ); // Transition item data - $this->transition_item( $save, array(), $item_id ); + $this->transition_item( $item_id, $save, array() ); // Return result return $item_id; @@ -1978,7 +1978,7 @@ public function update_item( $item_id = 0, $data = array() ) { $this->update_item_cache( $item_id ); // Transition item data - $this->transition_item( $save, $item, $item_id ); + $this->transition_item( $item_id, $save, $item ); // Return result return $result; @@ -2224,11 +2224,11 @@ private function default_item() { * * @since 1.0.0 * + * @param int $item_id * @param array $new_data * @param array $old_data - * @param int $item_id */ - private function transition_item( $new_data = array(), $old_data = array(), $item_id = 0 ) { + private function transition_item( $item_id = 0, $new_data = array(), $old_data = array() ) { // Look for transition columns $columns = $this->get_columns( array( 'transition' => true ), 'and', 'name' ); From 15f8a3afb410953af912d4a49ab9c8e08bc3420d Mon Sep 17 00:00:00 2001 From: Robin Cornett Date: Fri, 29 Apr 2022 14:57:22 -0400 Subject: [PATCH 05/34] Create dynamic hook after an item is deleted (#133) #132 --- src/Database/Query.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Database/Query.php b/src/Database/Query.php index 3d0232c..0b55ad6 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -2035,6 +2035,16 @@ public function delete_item( $item_id = 0 ) { $this->delete_all_item_meta( $item_id ); $this->clean_item_cache( $item ); + /** + * Fires after an object has been deleted. + * + * @since 2.1.0 + * + * @param int $item_id The ID of the item that was deleted. + * @param bool $result Whether the item was successfully deleted. + */ + do_action( $this->apply_prefix( "{$this->item_name}_deleted" ), $item_id, $result ); + // Return result return $result; } From 3be9949b12b9c4b9a58fbe12d375e951080c874e Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Fri, 29 Apr 2022 14:10:23 -0500 Subject: [PATCH 06/34] Update composer deps. --- composer.json | 5 +++++ composer.lock | 51 +++++++++++++++++++++++++++------------------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index 72d6217..d533a8f 100644 --- a/composer.json +++ b/composer.json @@ -11,5 +11,10 @@ "require-dev": { "szepeviktor/phpstan-wordpress": "^0.7.7", "phpstan/extension-installer": "^1.1" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/composer.lock b/composer.lock index fce370d..0066250 100644 --- a/composer.lock +++ b/composer.lock @@ -9,24 +9,27 @@ "packages-dev": [ { "name": "php-stubs/wordpress-stubs", - "version": "v5.8.0", + "version": "v5.9.3", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "794e6eedfd5f2a334d581214c007fc398be588fe" + "reference": "18d56875e5078a50b8ea4bc4b20b735ca61edeee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/794e6eedfd5f2a334d581214c007fc398be588fe", - "reference": "794e6eedfd5f2a334d581214c007fc398be588fe", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/18d56875e5078a50b8ea4bc4b20b735ca61edeee", + "reference": "18d56875e5078a50b8ea4bc4b20b735ca61edeee", "shasum": "" }, "replace": { "giacocorsiglia/wordpress-stubs": "*" }, "require-dev": { - "giacocorsiglia/stubs-generator": "^0.5.0", - "php": "~7.1" + "nikic/php-parser": "< 4.12.0", + "php": "~7.3 || ~8.0", + "php-stubs/generator": "^0.8.1", + "phpdocumentor/reflection-docblock": "^5.3", + "phpstan/phpstan": "^1.2" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -47,9 +50,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v5.8.0" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v5.9.3" }, - "time": "2021-07-21T02:34:37+00:00" + "time": "2022-04-06T15:33:59+00:00" }, { "name": "phpstan/extension-installer", @@ -98,16 +101,16 @@ }, { "name": "phpstan/phpstan", - "version": "0.12.96", + "version": "0.12.99", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "a98bdc51318f20fcae8c953d266f81a70254917f" + "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a98bdc51318f20fcae8c953d266f81a70254917f", - "reference": "a98bdc51318f20fcae8c953d266f81a70254917f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4d40f1d759942f523be267a1bab6884f46ca3f7", + "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7", "shasum": "" }, "require": { @@ -138,7 +141,7 @@ "description": "PHPStan - PHP Static Analysis Tool", "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/0.12.96" + "source": "https://github.com/phpstan/phpstan/tree/0.12.99" }, "funding": [ { @@ -158,20 +161,20 @@ "type": "tidelift" } ], - "time": "2021-08-21T11:55:13+00:00" + "time": "2021-09-12T20:09:55+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.23.0", + "version": "v1.25.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" + "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/cc5db0e22b3cb4111010e48785a97f670b350ca5", + "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5", "shasum": "" }, "require": { @@ -188,12 +191,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -221,7 +224,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.25.0" }, "funding": [ { @@ -237,7 +240,7 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2021-06-05T21:20:04+00:00" }, { "name": "szepeviktor/phpstan-wordpress", @@ -311,5 +314,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } From 5670fb595d709a82ac4f5016b041147844ac9d27 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Thu, 26 May 2022 10:46:52 -0500 Subject: [PATCH 07/34] Table: add support for table comment. This refactors the Table::create() SQL generator to explode an array of parts, which should make it easier to make more edits to later. --- src/Database/Table.php | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index 236ddfc..970c0f1 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -120,6 +120,17 @@ abstract class Table extends Base { */ protected $charset_collation = ''; + /** + * Typically empty; probably ignore. + * + * By default, tables do not have comments. This is unused by any other + * relative code, but you can include less than 1024 characters here. + * + * @since 2.1.0 + * @var string + */ + protected $comment = ''; + /** * Key => value array of versions => methods. * @@ -412,8 +423,26 @@ public function create() { return false; } + // Bail if schema not initialized (tables need at least 1 column) + if ( empty( $this->schema ) ) { + return false; + } + + // Required parts + $sql = array( + 'CREATE TABLE', + $this->table_name, + "( {$this->schema} )", + $this->charset_collation, + ); + + // Maybe append comment + if ( ! empty( $this->comment ) ) { + $sql[] = "COMMENT='{$this->comment}'"; + } + // Query statement - $query = "CREATE TABLE {$this->table_name} ( {$this->schema} ) {$this->charset_collation}"; + $query = implode( ' ', array_filter( $sql ) ); $result = $db->query( $query ); // Was the table created? From 9bd5c4e243c0e4328a7bf3233038e136d0ecbc3c Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Mon, 27 Jun 2022 15:20:54 -0500 Subject: [PATCH 08/34] WIP - Issue/137 (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Column: improvements to $pattern * Always a string (no false) * Add link to PHP docs * Update description to remove "string replace" * Set UUID to %s * Base: prevent double prefixing in apply_prefix() * Column: correct inline doc * Column: rename private references from pattern to format. * Autoloader: minor code cleanup * Column: various improvements: * Tons of inline & block docs * Smarter default class values * Column::args are saved during parse_args() for later reuse * Prefer get_object_vars() over another array of args * Add "extra" support to special_args() * Add is_ methods for some other types * Add is_extra() method for comparing extra values * Add sanitize_extra() for allowing specific values * Improve fallback support in sanitize_pattern() * Improve fallback support in sanitize_validation() * Remove function_exists check for gmdate() from validate_datetime() * Legitimize validate_numeric() and use where appropriate * Improve get_create_string() with support for binary, more types, null, etc... * Base: introduce sanitize_column_name() * Allows upper-case letters in table and column names. * Swaps out sanitize_key() usage for a custom preg_replace: '/^[a-zA-Z0-9_\-]+$/' * Table: use Base::sanitize_column_name() * Also fix return value inline comment in count() * Column: introduce validate() and validate_int() * validate() centralizes column value validation into the most logical location, and validate_int() allows for falling back to $default in a way that intval obviously could not. * Base: update regex. * Base: add stash_args() method * Also avoid errors in apply_prefix() if not a string. * Column: use stash_args() * Also bail early if no arguments to parse. * Schema: add support for indexes. * Move filters into methods and their own section * Improve docs, and add missing docs * Default values for item_names to prevent fatals * Add setup() method and move set_ methods out of __construct() and into it * Minimize touches to this->columns for future Schema/Structure work * Remove assumptions that primary column must be an int that uses absint/intval - see #124 * Add some todo's for MySQL 8 improvements * Improve support for CURRENT_TIMESTAMP in relevant columns * Add get_columns_field_by() to retrieve a single field from a matching array of values to a single key - primarily used for getting an array of column patterns when querying, to return an array of formats for sprintf() * Improve readability of do_action_ref_array() calls * Clean-up get_item_ids() * Rename parse_where() to parse_query_vars() * Add parse_where() and parse_join() – likely get renamed in the future * Use wp_parse_list() instead of wp_parse_id_list() - likely needs its own handler * Prevent fatals from return values of get_search_sql() * Abstract repeated code into new get_in_sql() method to escape/prepare/format IN (%s) SQL * Introduce parse_query_var() and use it in place of repeated query_vars[] touches - attempts to internally parse comma separated strings (might remove) * Introduce undocumented $column->by check to allow a column to not be queried directly by its name * Refactor parse_query_vars() to improve its internal patterns, for future abstraction * Fallback in parse_fields() and parse_groupby() to prevent fatal errors * Introduce parse_single_orderby, parse_limits, parse_join, and parse_where - refactor parse_orderby * Some minor clean-up to shape_items() * Bail early in get_item_fields() to avoid trying to filter empty fields * Introduce validate_item_field() to call $column->validate(), and use it inside shape_item_id() and more. This centralizes validation and ensures they always return the same results. * Update add_item() and update_item() to skip database if $save fails validation * Update copy_item() to shape the item ID, as it is not done inside of get_item_raw() * Update delete_item() to match other item changes above * All _item() functions use get_columns_field_by() to get patterns to send into wpdb queries for proper formatting (including delete_all_item_meta) - see #137 * Update validate_item() to use validate_item_field() * Use shape_item_id() in update_item_cache() - also use is_scalar() in place of is_numeric() when making assumptions about the shape of the primary column * Stop shaping the ID inside of get_non_cached_ids(), as item IDs are (or should be) previously shaped * Gut get_results() and make it use the query() method - this needs more work * Query: Introduce filter_search_columns() * Switch it to using apply_filters_ref_array() - minor back-compat break * Add direct Schema support * Get columns directly from Schema * Deprecate $columns var * Introduce set_query_clause_defaults() for allowing the query & request clauses to be updated easier * Add keys to query & request clauses * Refactor the way that counts & searches are parsed * Always include columns when count & groupby are used together * Pass query_vars into more parse_ methods to further abstract their usages for future un-privating * Override query_vars in parse_query() when counting * Introduce parse_count() * Rename parse_where/join to _clauses() suffix * Pass arguments into default_item(), and use array_combine() * Swap some var orders in prime_item_caches() * All: update @copyright and README --- README.md | 66 +- autoloader.php | 26 +- src/Database/Base.php | 99 +- src/Database/Column.php | 918 ++++++++++++---- src/Database/Queries/Compare.php | 2 +- src/Database/Queries/Date.php | 2 +- src/Database/Queries/Meta.php | 2 +- src/Database/Query.php | 1766 +++++++++++++++++++----------- src/Database/Row.php | 2 +- src/Database/Schema.php | 285 ++++- src/Database/Table.php | 14 +- 11 files changed, 2228 insertions(+), 954 deletions(-) diff --git a/README.md b/README.md index 69e3b35..b43f163 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,69 @@ # BerlinDB -BerlinDB is a collection of PHP classes and functions that aims to provide an ORM-like experience and interface to WordPress database tables. - -This repository contains all of the code that is required to be included in your WordPress project. +BerlinDB is a collection of PHP classes and methods provides an ORM-like experience & interface to database tables in WordPress. The most common use-case for BerlinDB is a WordPress Plugin that needs to create custom database tables, but more advanced uses are possible, including managing and interfacing with the WordPress Core database tables themselves. -Future repositories in this organization will contain examples, extensions, drop-ins, unit tests, and more. +## Mission + +The primary mission of BerlinDB is to democratize data storage. + +### Phase 1 +Reduce the overall labor required to perform routine & repetitive database interactions. + +### Phase 2 +Achieve platform agnosticism through smart abstractions and interoperability layers. + +### Phase 3 +Generate the custom code that is necessary from any existing database table structure. ----- +### Phase 4 +Automate database table structure changes for a seamless upgrade/rollback experience. -The name of this project comes from WordCamp Europe 2019, where it was originally announced as an unnamed library. Thank you to Peter Wilson for the idea to pay homage to such a wonderful audience. +### Phase 5 +Manage all database connections to directly support reads, writes, clones, splitting, and sharding. ----- +## Name -The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning multiple continents, native languages, and years of conceptual development: +The name of this project comes from [WordCamp Europe 2019](https://europe.wordcamp.org/2019/) – which took place in the beautiful & historic capital city of Berlin, Germany – where it was originally exhibited & announced as an unnamed utility being used by the Sandhills Development engineering team. +Peter Wilson recommended naming it "Berlin" to commemorate everyone in attendance for its unveiling. + +## Story + +The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning multiple continents, native languages, and years of conceptual development & iteration: + +* BuddyPress (inspired by) +* WordPress Multisite (inspired by) * Easy Digital Downloads (3.0 and higher) * Sugar Calendar (2.0 and higher) * Restrict Content Pro (3.1 and higher) -* WordPress Multisite (inspired by) -* BuddyPress (inspired by) -These projects all require custom database tables to acheive their goals (and to meet the expecations that their users have in them) to perform and scale flawlessly in a highly available WordPress based web application. +The above projects use custom database tables to perform reliably and scale effortlessly in highly available WordPress based web applications. + +## Contribution + +Interested in contributing? See the [contributing guide](/CONTRIBUTING.md). + +## Support + +Have a question? [Open a new issue](https://github.com/berlindb/core/issues/new) and someone will try to help. + +## License + +MIT. Please enjoy this code freely & openly, [as is](/LICENSE). + +If you are using BerlinDB in a commercial product, please consider [becoming a sponsor](https://github.com/sponsors/jjj?frequency=recurring&sponsor=jjj). + +## Created By -Each of these projects originally implemented their own bespoke approaches to database management, resulting in a massive amount of code duplication, rework, and eventual fragmentation of approaches and ideas. +- [@JJJ](https://twitter.com/JJJ) - https://jjj.blog -This project helps avoid those issues by (somewhat magically) limiting how much code you need to write to accomplish the same repetitive database related tasks. +## Credits ----- +This organization is currently managed by Triple J Software, Inc.. -This organization was created by (and is managed by) Sandhills Development, LLC, where we aim to craft superior experiences through ingenuity, with deep commitment to (and appreciation for) the human element. +Special thanks to: +- Sandhills Development, LLC +- Awesome Motive, Inc. +- All of JJJ's generous sponsors 💛 \ No newline at end of file diff --git a/autoloader.php b/autoloader.php index ce30769..59a17eb 100644 --- a/autoloader.php +++ b/autoloader.php @@ -1,30 +1,31 @@ prefix ) - ? "{$this->prefix}{$sep}{$string}" - : $string; + + // Bail if not a string + if ( ! is_string( $string ) ) { + return ''; + } + + // Trim spaces off the ends + $retval = trim( $string ); + + // Bail if no prefix + if ( empty( $this->prefix ) ) { + return $retval; + } + + // Setup new prefix + $new_prefix = $this->prefix . $sep; + + // Bail if already prefixed + if ( 0 === strpos( $string, $new_prefix ) ) { + return $retval; + } + + // Setup prefixed string + $retval = $new_prefix . $retval; + + // Return the result + return $retval; } /** @@ -157,8 +182,8 @@ protected function apply_prefix( $string = '', $sep = '_' ) { * * @since 1.0.0 * - * @param string $string - * @param non-empty-string $sep + * @param string $string Default empty string. + * @param string $sep Default "_". * @return string */ protected function first_letters( $string = '', $sep = '_' ) { @@ -177,7 +202,7 @@ protected function first_letters( $string = '', $sep = '_' ) { // Only non-accented table names (avoid truncation) $accents = remove_accents( $unspace ); - // Only lowercase letters are allowed + // Convert to lowercase $lower = strtolower( $accents ); // Explode into parts @@ -206,10 +231,11 @@ protected function first_letters( $string = '', $sep = '_' ) { * - No trailing underscores * * @since 1.0.0 + * @since 2.1.0 Allow uppercase letters * * @param string $name The name of the database table * - * @return mixed Sanitized database table name on success, False on error + * @return bool|string Sanitized database table name on success, False on error */ protected function sanitize_table_name( $name = '' ) { @@ -224,13 +250,13 @@ protected function sanitize_table_name( $name = '' ) { // Only non-accented table names (avoid truncation) $accents = remove_accents( $unspace ); - // Only lowercase characters, hyphens, and dashes (avoid index corruption) - $lower = sanitize_key( $accents ); + // Only upper & lower case letters, numbers, hyphens, and underscores + $replace = preg_replace( '/[^a-zA-Z0-9_\-]/', '', $accents ); // Replace hyphens with single underscores - $under = str_replace( '-', '_', $lower ); + $under = str_replace( '-', '_', $replace ); - // Single underscores only + // Replace double underscores with singles $single = str_replace( '__', '_', $under ); // Remove trailing underscores @@ -242,6 +268,29 @@ protected function sanitize_table_name( $name = '' ) { : $clean; } + /** + * Sanitize a column name string. + * + * Used to make sure that a column name value meets MySQL expectations. + * + * Applies the following formatting to a string: + * - Trim whitespace + * - No accents + * - No special characters + * - No hyphens + * - No double underscores + * - No trailing underscores + * + * @since 2.1.0 + * + * @param string $name The name of the database column + * + * @return bool|string Sanitized database column name on success, False on error + */ + protected function sanitize_column_name( $name = '' ) { + return $this->sanitize_table_name( $name ); + } + /** * Set class variables from arguments. * @@ -266,6 +315,23 @@ protected function set_vars( $args = array() ) { } } + /** + * Stash arguments and class variables. + * + * This is used to stash a copy of the original constructor arguments and + * the object variable values, for later comparison, reuse, or resetting + * back to a previous state. + * + * @since 2.1.0 + * @param array $args + */ + protected function stash_args( $args = array() ) { + $this->args = array( + 'param' => $args, + 'class' => get_object_vars( $this ) + ); + } + /** * Return the global database interface. * @@ -309,14 +375,19 @@ protected function get_db() { /** * Check if an operation succeeded. * + * Note: While "0" or "''" may be the return value of a successful result, + * for the purposes of database queries and this method, it isn't. + * When using this method, take care that your possible results do not + * pass falsy values on success. + * * @since 1.0.0 * - * @param mixed $result + * @param mixed $result Default false. * @return bool */ protected function is_success( $result = false ) { - // Bail if no row exists + // Bail if falsy result if ( empty( $result ) ) { $retval = false; diff --git a/src/Database/Column.php b/src/Database/Column.php index 04c4caf..21acce7 100644 --- a/src/Database/Column.php +++ b/src/Database/Column.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Column - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ @@ -17,6 +17,7 @@ * Base class used for each column for a custom table. * * @since 1.0.0 + * @since 2.1.0 Column::args[] stashes parsed & class arguments. * * @see Column::__construct() for accepted arguments. */ @@ -32,87 +33,134 @@ class Column extends Base { * fatal application errors. * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $name = ''; /** - * Type of database column. + * Column data type. * - * See: https://dev.mysql.com/doc/en/data-types.html + * Required. Must contain valid data type. + * + * Note: Magic & Fallback support for data types is only added as needed. + * It is recommended that you explicitly define all Column attributes. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/data-types.html * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $type = ''; /** - * Length of database column. + * Column value length. + * + * Recommended. Set to a reasonable number for your needs. + * + * Common usages: + * + * - bigint: 20 - for primary key IDs (relating ID columns across tables) + * - varchar: 20 - for registered object statuses or types + * - varchar: 255 - for hashes, user-agents, or URLs + * - varchar: 191 - utf8mb4 safe length (for $cache_key usages) * - * See: https://dev.mysql.com/doc/en/storage-requirements.html + * See: https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html * * @since 1.0.0 - * @var mixed + * @var bool|int Default false. Int to set length. */ public $length = false; /** - * Is integer unsigned? + * If integer type, is it unsigned? * - * See: https://dev.mysql.com/doc/en/numeric-type-overview.html + * Unsigned integers do not allow negative numbers. + * + * Set to false to allow negative numbers in int columns. + * + * Note: MySQL 8.0.17 deprecated unsigned Decimals, and support for them + * here will be appropriately removed. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/numeric-types.html * * @since 1.0.0 - * @var bool + * @var bool Default true for all int columns. */ public $unsigned = true; /** - * Is integer filled with zeroes? + * If integer type, fill with zeroes? + * + * Set to true to always fill numeric $length with zeroes. * - * See: https://dev.mysql.com/doc/en/numeric-type-overview.html + * See: https://dev.mysql.com/doc/refman/8.0/en/numeric-types.html * * @since 1.0.0 - * @var bool + * @var bool Default false for all numeric columns. */ public $zerofill = false; /** - * Is data in a binary format? + * If text type, store in a binary format? * - * See: https://dev.mysql.com/doc/en/binary-varbinary.html + * When used with a TEXT data type, the column is assigned the binary (_bin) + * collation of the column character set. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/binary-varbinary.html * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $binary = false; /** * Is null an allowed value? * - * See: https://dev.mysql.com/doc/en/data-type-defaults.html + * Set to true to explicitly allow storing a literal null value (which is + * likely to be different from the default value for the $type). + * + * Dev Note: In general, it is considered bad application design for a null + * value to coexist alongside a possible "0" or "''" value. + * + * When allowing null values, be sure that other areas of your + * program understand that this column's value could be null. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $allow_null = false; /** - * Typically empty/null, or date value. + * Default value when a Row is added without a value for this column. + * + * Typically "0" or "''", a zero date value, or some other value that is + * useful as an intelligent default for your Row objects to contain when + * no other value is explicitly assigned to them. + * + * Can be literal null if $allow_null is truthy. + * + * Invalid values will be dropped. + * + * Used by Query::default_item() to create an array full of default values. * - * See: https://dev.mysql.com/doc/en/data-type-defaults.html + * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html * * @since 1.0.0 - * @var string + * @var bool|int|string Default empty string. */ public $default = ''; /** * auto_increment, etc... * - * See: https://dev.mysql.com/doc/en/data-type-defaults.html + * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html * * @since 1.0.0 - * @var string + * @since 2.1.0 Allowed values checked via sanitize_extra() + * @since 2.1.0 Special values checked via special_args() + * @var string Default empty string. */ public $extra = ''; @@ -123,10 +171,10 @@ class Column extends Base { * most likely do not want to change this; if you do, you already know what * to do. * - * See: https://dev.mysql.com/doc/mysql/en/charset-column.html + * See: https://dev.mysql.com/doc/refman/8.0/en/charset-column.html * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $encoding = ''; @@ -137,10 +185,10 @@ class Column extends Base { * most likely do not want to change this; if you do, you already know what * to do. * - * See: https://dev.mysql.com/doc/mysql/en/charset-column.html + * See: https://dev.mysql.com/doc/refman/8.0/en/charset-column.html * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $collation = ''; @@ -151,7 +199,7 @@ class Column extends Base { * relative code, but you can include less than 1024 characters here. * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $comment = ''; @@ -160,36 +208,42 @@ class Column extends Base { /** * Is this the primary column? * + * Typically use this with: bigint, length 20, unsigned, auto_increment. + * * By default, columns are not the primary column. This is used by the Query * class for several critical functions, including (but not limited to) the * cache key, meta-key relationships, auto-incrementing, etc... * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $primary = false; /** * Is this the column used as a created date? * + * Use this with the "datetime" column type. + * * By default, columns do not represent the date a value was first entered. * This is used by the Query class to set its value automatically to the * current datetime value immediately before insert. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $created = false; /** * Is this the column used as a modified date? * + * Use this with the "datetime" column type. + * * By default, columns do not represent the date a value was last changed. * This is used by the Query class to update its value automatically to the * current datetime value immediately before insert|update. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $modified = false; @@ -201,47 +255,49 @@ class Column extends Base { * table, typically in such a way that is unrelated to the row data itself. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $uuid = false; /** Query Attributes ******************************************************/ /** - * What is the string-replace pattern? + * What is the string-replace format? * - * By default, column patterns will be guessed based on their type. Set this - * manually to `%s|%d|%f` only if you are doing something weird, or are + * By default, column formats will be guessed based on their type. Set this + * manually to "%s|%d|%f" only if you are doing something weird, or are * explicitly storing numeric values in text-based column types. * + * See: https://www.php.net/manual/en/function.printf.php + * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $pattern = ''; /** * Is this column searchable? * - * By default, columns are not searchable. When `true`, the Query class will + * By default, columns are not searchable. When "true", the Query class will * add this column to the results of search queries. * - * Avoid setting to `true` on large blobs of text, unless you've optimized + * Avoid setting to "true" on large blobs of text, unless you've optimized * your database server to accommodate these kinds of queries. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $searchable = false; /** * Is this column a date? * - * By default, columns do not support date queries. When `true`, the Query + * By default, columns do not support date queries. When "true", the Query * class will accept complex statements to help narrow results down to * specific periods of time for values in this column. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $date_query = false; @@ -256,34 +312,34 @@ class Column extends Base { * and text columns with intentionally limited lengths. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $sortable = false; /** * Is __in supported? * - * By default, columns support being queried using an `IN` statement. This + * By default, columns support being queried using an "IN" statement. This * allows the Query class to retrieve rows that match your array of values. * - * Consider setting this to `false` for longer text columns. + * Consider setting this to "false" for longer text columns. * * @since 1.0.0 - * @var bool + * @var bool Default true */ public $in = true; /** * Is __not_in supported? * - * By default, columns support being queried using a `NOT IN` statement. + * By default, columns support being queried using a "NOT IN" statement. * This allows the Query class to retrieve rows that do not match your array * of values. * - * Consider setting this to `false` for longer text columns. + * Consider setting this to "false" for longer text columns. * * @since 1.0.0 - * @var bool + * @var bool Default true. */ public $not_in = true; @@ -299,7 +355,7 @@ class Column extends Base { * Use in conjunction with a database index for speedy queries. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $cache_key = false; @@ -308,6 +364,8 @@ class Column extends Base { /** * Does this column fire a transition action when it's value changes? * + * Typically used with: varchar, length 20, cache_key. + * * By default, columns do not fire transition actions. In some cases, it may * be desirable to know when a database value changes, and what the old and * new values are when that happens. @@ -315,7 +373,7 @@ class Column extends Base { * The Query class is responsible for triggering the event action. * * @since 1.0.0 - * @var bool + * @var bool Default false. */ public $transition = false; @@ -329,7 +387,7 @@ class Column extends Base { * the default validation behavior. * * @since 1.0.0 - * @var string + * @var string Default empty string. */ public $validate = ''; @@ -389,7 +447,7 @@ class Column extends Base { * @type string $encoding Typically inherited from wpdb * @type string $collation Typically inherited from wpdb * @type string $comment Typically empty - * @type bool $pattern What is the string-replace pattern? + * @type string $pattern Pattern used to format the value * @type bool $primary Is this the primary column? * @type bool $created Is this the column used as a created date? * @type bool $modified Is this the column used as a modified date? @@ -421,66 +479,30 @@ public function __construct( $args = array() ) { /** Argument Handlers *****************************************************/ /** - * Parse column arguments + * Parse column arguments. * * @since 1.0.0 + * @since 2.1.0 Arguments are stashed. Bails if $args is empty. * @param array $args Default empty array. * @return array */ private function parse_args( $args = array() ) { - // Parse arguments - $r = wp_parse_args( $args, array( - - // Table - 'name' => '', - 'type' => '', - 'length' => '', - 'unsigned' => false, - 'zerofill' => false, - 'binary' => false, - 'allow_null' => false, - 'default' => '', - 'extra' => '', - 'encoding' => $this->get_db()->charset, - 'collation' => $this->get_db()->collate, - 'comment' => '', - - // Query - 'pattern' => false, - 'searchable' => false, - 'sortable' => false, - 'date_query' => false, - 'transition' => false, - 'in' => true, - 'not_in' => true, - - // Special - 'primary' => false, - 'created' => false, - 'modified' => false, - 'uuid' => false, - - // Cache - 'cache_key' => false, - - // Validation - 'validate' => '', + // Stash the arguments + $this->stash_args( $args ); - // Capabilities - 'caps' => array(), - - // Backwards Compatibility - 'aliases' => array(), + // Bail if no arguments + if ( empty( $args ) ) { + return array(); + } - // Column Relationships - 'relationships' => array() - ) ); + // Parse arguments + $r = wp_parse_args( $args, $this->args['class'] ); // Force some arguments for special column types $r = $this->special_args( $r ); - // Set the args before they are sanitized + // Set the arguments before they are validated & sanitized $this->set_vars( $r ); // Return array @@ -498,7 +520,9 @@ private function validate_args( $args = array() ) { // Sanitization callbacks $callbacks = array( - 'name' => 'sanitize_key', + + // Table + 'name' => array( $this, 'sanitize_column_name' ), 'type' => 'strtoupper', 'length' => 'intval', 'unsigned' => 'wp_validate_boolean', @@ -506,16 +530,18 @@ private function validate_args( $args = array() ) { 'binary' => 'wp_validate_boolean', 'allow_null' => 'wp_validate_boolean', 'default' => array( $this, 'sanitize_default' ), - 'extra' => 'wp_kses_data', + 'extra' => array( $this, 'sanitize_extra' ), 'encoding' => 'wp_kses_data', 'collation' => 'wp_kses_data', 'comment' => 'wp_kses_data', + // Special 'primary' => 'wp_validate_boolean', 'created' => 'wp_validate_boolean', 'modified' => 'wp_validate_boolean', 'uuid' => 'wp_validate_boolean', + // Query 'searchable' => 'wp_validate_boolean', 'sortable' => 'wp_validate_boolean', 'date_query' => 'wp_validate_boolean', @@ -524,6 +550,7 @@ private function validate_args( $args = array() ) { 'not_in' => 'wp_validate_boolean', 'cache_key' => 'wp_validate_boolean', + // Extras 'pattern' => array( $this, 'sanitize_pattern' ), 'validate' => array( $this, 'sanitize_validation' ), 'caps' => array( $this, 'sanitize_capabilities' ), @@ -531,7 +558,7 @@ private function validate_args( $args = array() ) { 'relationships' => array( $this, 'sanitize_relationships' ) ); - // Default args array + // Default return arguments $r = array(); // Loop through and try to execute callbacks @@ -541,7 +568,12 @@ private function validate_args( $args = array() ) { if ( isset( $callbacks[ $key ] ) && is_callable( $callbacks[ $key ] ) ) { $r[ $key ] = call_user_func( $callbacks[ $key ], $value ); - // Callback is malformed so just let it through to avoid breakage + /** + * Key has no validation method. + * + * Trust that the value has been validated. This may change in a + * future version. + */ } else { $r[ $key ] = $value; } @@ -552,47 +584,196 @@ private function validate_args( $args = array() ) { } /** - * Force column arguments for special column types + * Handle special special column argument values. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html * * @since 1.0.0 + * @since 2.1.0 Added support for SERIAL "extra" values. * @param array $args Default empty array. * @return array */ private function special_args( $args = array() ) { - // Primary key columns are always used as cache keys + // Handle specific "extra" aliases + if ( ! empty( $args['extra'] ) ) { + + /** + * The special "extra" values below are built into MySQL as + * shorthand for commonly used combinations of Column arguments. + */ + switch ( strtoupper( $args['extra'] ) ) { + + // Bigint + case 'SERIAL' : + $args['type'] = 'bigint'; + $args['length'] = '20'; + $args['unsigned'] = true; + // No break; keep going + + // Any int + case 'SERIAL DEFAULT VALUE' : + + // Skip if not an int type + if ( in_array( strtolower( $args['type'] ), array( 'tinyint', 'smallint', 'mediumint', 'int', 'bigint' ), true ) ) { + $args['allow_null'] = false; + $args['default'] = false; + $args['primary'] = true; + $args['pattern'] = '%d'; + $args['extra'] = 'AUTO_INCREMENT'; + } + } + } + + // Primary columns are expected (by Query) to always be cache keys if ( ! empty( $args['primary'] ) ) { $args['cache_key'] = true; - // All UUID columns need to follow a very specific pattern + // All UUID columns require these specific criteria } elseif ( ! empty( $args['uuid'] ) ) { $args['name'] = 'uuid'; $args['type'] = 'varchar'; $args['length'] = '100'; + $args['pattern'] = '%s'; $args['in'] = false; $args['not_in'] = false; $args['searchable'] = false; $args['sortable'] = false; } - // Return args + // Return arguments return (array) $args; } /** Public Helpers ********************************************************/ /** - * Return if a column type is numeric or not. + * Return if a column type is a bool. + * + * @since 2.1.0 + * @return bool True if bool type only. + */ + public function is_bool() { + return $this->is_type( array( + 'bool' + ) ); + } + + /** + * Return if a column type is a date. + * + * @since 2.1.0 + * @return bool True if any date or time. + */ + public function is_date_time() { + return $this->is_type( array( + 'date', + 'datetime', + 'timestamp', + 'time', + 'year' + ) ); + } + + /** + * Return if a column type is an integer. + * + * @since 2.1.0 + * @return bool True if int. + */ + public function is_int() { + return $this->is_type( array( + 'tinyint', + 'smallint', + 'mediumint', + 'int', + 'bigint' + ) ); + } + + /** + * Return if a column type is decimal. + * + * @since 2.1.0 + * @return bool True if float. + */ + public function is_decimal() { + return $this->is_type( array( + 'float', + 'double', + 'decimal' + ) ); + } + + /** + * Return if a column type is numeric. + * + * Consider using is_int() or is_decimal() for improved specificity. * * @since 1.0.0 - * @return bool + * @return bool True if bit, int, or float. */ public function is_numeric() { return $this->is_type( array( + + // Bit + 'bit', + + // Ints 'tinyint', - 'int', + 'smallint', 'mediumint', - 'bigint' + 'int', + 'bigint', + + // Other + 'float', + 'double', + 'decimal' + ) ); + } + + /** + * Return if a column type is a string. + * + * For binary strings (blobs) use is_binary(). + * + * @since 2.1.0 + * @return bool True if text. + */ + public function is_text() { + return $this->is_type( array( + + // Char + 'char', + 'varchar', + + // Text + 'tinytext', + 'text', + 'mediumtext', + 'longtext', + ) ); + } + + /** + * Return if a column type is binary. + * + * @since 2.1.0 + * @return bool True if binary. + */ + public function is_binary() { + return $this->is_type( array( + + // Binary + 'binary', + 'varbinary', + + // Blobs + 'tinyblob', + 'blob', + 'mediumblob', + 'longblob' ) ); } @@ -602,11 +783,18 @@ public function is_numeric() { * Return if this column is of a certain type. * * @since 1.0.0 - * @param mixed $type Default empty string. The type to check. Also accepts an array. - * @return bool True if of type, False if not + * @since 2.1.0 Empty $type returns false. + * @param array[string] $type Default empty string. The type to check. Also + * accepts an array. + * @return bool True if type matches. */ private function is_type( $type = '' ) { + // Bail if no type passed + if ( empty( $type ) ) { + return false; + } + // If string, cast to array if ( is_string( $type ) ) { $type = (array) $type; @@ -615,14 +803,41 @@ private function is_type( $type = '' ) { // Make them lowercase $types = array_map( 'strtolower', $type ); - // Return if match or not + // Return if match return (bool) in_array( strtolower( $this->type ), $types, true ); } + /** + * Return if this column is of a certain type. + * + * @since 2.1.0 + * @param array[string] $extra Default empty string. The extra to check. + * Also accepts an array. + * @return bool True if extra matches. + */ + private function is_extra( $extra = '' ) { + + // Bail if no extra passed + if ( empty( $extra ) ) { + return false; + } + + // If string, cast to array + if ( is_string( $extra ) ) { + $extra = (array) $extra; + } + + // Make them lowercase + $extras = array_map( 'strtoupper', $extra ); + + // Return if match + return (bool) in_array( strtolower( $this->extra ), $extras, true ); + } + /** Private Sanitizers ****************************************************/ /** - * Sanitize capabilities array + * Sanitize capabilities array. * * @since 1.0.0 * @param array $caps Default empty array. @@ -633,23 +848,30 @@ private function sanitize_capabilities( $caps = array() ) { 'select' => 'exist', 'insert' => 'exist', 'update' => 'exist', - 'delete' => 'exist' + 'delete' => 'exist', ) ); } /** - * Sanitize aliases array using `sanitize_key()` + * Sanitize aliases array. + * + * An array of other names that this column is known as. Useful for + * renaming a Column and wanting to continue supporting the old name(s). * * @since 1.0.0 * @param array $aliases Default empty array. * @return array */ private function sanitize_aliases( $aliases = array() ) { - return array_map( 'sanitize_key', $aliases ); + $func = array( $this, 'sanitize_column_name' ); + $aliases = array_filter( $aliases ); + $retval = array_map( $func, $aliases ); + + return $retval; } /** - * Sanitize relationships array + * Sanitize relationships array. * * @todo * @since 1.0.0 @@ -661,62 +883,101 @@ private function sanitize_relationships( $relationships = array() ) { } /** - * Sanitize the default value + * Sanitize the extra string. * - * @since 1.0.0 - * @param int|string|null $default - * @return int|string|null + * @since 2.1.0 + * @param string $value + * @return string */ - private function sanitize_default( $default = '' ) { + private function sanitize_extra( $value = '' ) { - // Null - if ( ( true === $this->allow_null ) && is_null( $default ) ) { - return null; + // Default return value + $retval = ''; - // String - } elseif ( is_string( $default ) ) { - return wp_kses_data( $default ); + // Allowed extra values + $allowed_extras = array( + 'AUTO_INCREMENT', + 'ON UPDATE CURRENT_TIMESTAMP', - // Integer - } elseif ( $this->is_numeric() ) { - return (int) $default; + // See: special_args() + 'SERIAL', + 'SERIAL DEFAULT VALUE', + ); + + // Always uppercase + $value = strtoupper( $value ); + + // Set return value if allowed + if ( in_array( $value, $allowed_extras, true ) ) { + $retval = $value; } - // @todo datetime, decimal, and other column types + // Return + return $retval; + } - // Unknown, so return the default's default - return ''; + /** + * Sanitize the default value. + * + * @since 1.0.0 + * @since 2.1.0 Uses validate() + * @param int|string|null $default + * @return int|string|null + */ + private function sanitize_default( $default = '' ) { + return $this->validate( $default ); } /** - * Sanitize the pattern + * Sanitize the pattern string. * * @since 1.0.0 - * @param string $pattern - * @return string + * @since 2.1.0 Falls back to using is_ methods if invalid param + * @param string $pattern Default '%s'. Allowed values: %s, %d, $f + * @return string Default '%s'. */ private function sanitize_pattern( $pattern = '%s' ) { // Allowed patterns - $allowed_patterns = array( '%s', '%d', '%f' ); + $allowed_patterns = array( + '%s', // String + '%d', // Integer (decimal) + '%f', // Float + ); // Return pattern if allowed if ( in_array( $pattern, $allowed_patterns, true ) ) { return $pattern; } - // Fallback to digit or string - return $this->is_numeric() - ? '%d' - : '%s'; + // Default string + $retval = '%s'; + + // Integer + if ( $this->is_int() ) { + $retval = '%d'; + + // Float + } elseif ( $this->is_decimal() ) { + $retval = '%f'; + } + + // Return + return $retval; } /** - * Sanitize the validation callback + * Sanitize the validation callback. + * + * This method accepts a function or method, and will return it if it is + * callable. If it is not callable, the best fallback callback is + * calculated based on varying column properties. * * @since 1.0.0 - * @param string $callback Default empty string. A callable PHP function name or method - * @return string The most appropriate callback function for the value + * @since 2.1.0 Explicit support for decimal, int, and numeric types. + * @param string $callback Default empty string. A callable PHP function + * name or method. + * @return string The most appropriate callback function for the value. */ private function sanitize_validation( $callback = '' ) { @@ -729,17 +990,25 @@ private function sanitize_validation( $callback = '' ) { if ( true === $this->uuid ) { $callback = array( $this, 'validate_uuid' ); - // Datetime fallback + // Datetime explicit fallback } elseif ( $this->is_type( 'datetime' ) ) { $callback = array( $this, 'validate_datetime' ); + // Intval fallback + } elseif ( $this->is_int() ) { + $callback = array( $this, 'validate_int' ); + // Decimal fallback - } elseif ( $this->is_type( 'decimal' ) ) { + } elseif ( $this->is_decimal() ) { $callback = array( $this, 'validate_decimal' ); - // Intval fallback + // Numeric fallback } elseif ( $this->is_numeric() ) { - $callback = 'intval'; + $callback = array( $this, 'validate_numeric' ); + + // Unknown text, string, or other... + } else { + $callback = 'wp_kses_data'; } // Return the callback @@ -749,80 +1018,222 @@ private function sanitize_validation( $callback = '' ) { /** Public Validators *****************************************************/ /** - * Fallback to validate a datetime value if no other is set. + * Validate a value. * - * This assumes NO_ZERO_DATES is off or overridden. + * Used by Column::sanitize_default() and Query to prevent invalid and + * unexpected values from being saved in the database. * - * If MySQL drops support for zero dates, this method will need to be - * updated to support different default values based on the environment. + * @since 2.1.0 + * @param int|string|null $value Default empty string. Value to validate. + * @param int|string|null $default Default empty string. Fallback if invalid. + * @return int|string|null + */ + public function validate( $value = '', $default = '' ) { + + // Check if a literal null value is allowed + $value = $this->validate_null( $value ); + + // Return null if allowed + if ( null === $value ) { + return null; + } + + // Return the callback (already sanitized as callable) + if ( ! empty( $this->validate ) ) { + return call_user_func( $this->validate, $value ); + } + + // Return the default + return $default; + } + + /** + * Validate a null value. * - * @since 1.0.0 - * @param string $value Default ''. A datetime value that needs validating - * @return string A valid datetime value + * Will return the $default if $allow_null is false. + * + * @since 2.1.0 + * @param int|string|null $value Default empty string. + * @return int|string|null */ - public function validate_datetime( $value = '' ) { + public function validate_null( $value = '' ) { - // Handle "empty" values - if ( empty( $value ) || ( '0000-00-00 00:00:00' === $value ) ) { - $value = ! empty( $this->default ) + // Value is null + if ( null === $value ) { + + // If null is allowed, return it + if ( true === $this->allow_null ) { + return null; + } + + /** + * Null was passed but is not allowed, so fallback to the default + * (but only if it is also not null.) + * + * If the default is null and null is not allowed, fallback to an + * empty string and allow MySQL to sort it out. + * + * Future versions of this validation method will attempt to return + * a less ambiguous value. + */ + $value = ( null !== $this->default ) ? $this->default : ''; - - // Convert to MySQL datetime format via gmdate() && strtotime - } elseif ( function_exists( 'gmdate' ) ) { - $value = gmdate( 'Y-m-d H:i:s', strtotime( $value ) ); } - // Return the validated value + // Return return $value; } /** - * Validate a decimal + * Validate a datetime value. * - * (Recommended decimal column length is '18,9'.) + * This assumes the following MySQL modes: + * - NO_ZERO_DATE is off (double negative is proof positive!) + * - ALLOW_INVALID_DATES is off * - * This is used to validate a mixed value before it is saved into a decimal - * column in a database table. + * When MySQL drops support for zero dates, this method will need to be + * updated to support different default values based on the environment. * - * Uses number_format() which does rounding to the last decimal if your - * value is longer than specified. + * See: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_allow_invalid_dates + * See: wpdb::set_sql_mode() * * @since 1.0.0 - * @param mixed $value Default empty string. The decimal value to validate - * @param int $decimals Default 9. The number of decimal points to accept - * @return float + * @since 2.1.0 Add support for CURRENT_TIMESTAMP. + * @param string $value Default ''. A datetime value that needs validating. + * @return string A valid datetime value. */ - public function validate_decimal( $value = 0, $decimals = 9 ) { + public function validate_datetime( $value = '' ) { - // Protect against non-numeric values - if ( ! is_numeric( $value ) ) { - $value = 0; + // Default empty datetime (value with NO_ZERO_DATE off) + $default_empty = '0000-00-00 00:00:00'; + + // Handle current_timestamp MySQL constant + if ( 'CURRENT_TIMESTAMP' === strtoupper( $value ) ) { + $value = 'CURRENT_TIMESTAMP'; + + // Fallback if "empty" value + } elseif ( empty( $value ) || ( $default_empty === $value ) ) { + $fallback = true; + + // All other values + } else { + + // Check if valid $value + $timestamp = strtotime( $value ); + + // Format if valid + if ( false !== $timestamp ) { + $value = gmdate( 'Y-m-d H:i:s', $timestamp ); + + // Fallback if invalid + } else { + $fallback = true; + } + } + + // Fallback to $default or empty string + if ( true === $fallback ) { + $value = (string) $this->default; } + // Return the validated value + return $value; + } + + /** + * Validate a decimal value. + * + * Default decimal position is '18,9' for currencies, so that rounding can + * be done inside of the application layer and outside of MySQL. + * + * @since 1.0.0 + * @since 2.1.0 Uses: validate_numeric(). + * @param int|string $value Default empty string. The decimal value to validate. + * @param int $decimals Default 9. The number of decimal points to accept. + * @return float Formatted to the number of decimals specified + */ + public function validate_decimal( $value = 0, $decimals = 9 ) { + // Protect against non-numeric decimals if ( ! is_numeric( $decimals ) ) { $decimals = 9; } - // Is the value negative? - $negative_exponent = ( $value < 0 ) + // Validate & return + return $this->validate_numeric( $value, $decimals ); + } + + /** + * Validate a numeric value. + * + * This is used to validate a mixed value before it is saved into any + * numeric column in a database table. + * + * Uses number_format() (without a thousands separator) which does rounding + * to the last decimal if the value is longer than specified. + * + * @since 2.1.0 + * @param int|string $value Default empty string. The numeric value to validate. + * @param int|bool $decimals Default false. Decimal position will be used, or 0. + * @return float + */ + public function validate_numeric( $value = 0, $decimals = false ) { + + // Protect against non-numeric values + if ( ! is_numeric( $value ) ) { + $value = ( $value !== $this->default ) + ? $this->default + : 0; + } + + // Is the value negative and allowed to be? + $negative_exponent = ( ( $value < 0 ) && ! empty( $this->unsigned ) ) ? -1 : 1; // Only numbers and period - $value = (float) preg_replace( '/[^0-9\.]/', '', (string) $value ); + $value = preg_replace( '/[^0-9\.]/', '', (string) $value ); + + // Attempt to find the decimal position + if ( false === $decimals ) { - // Format to number of decimals, and cast as float - $formatted = number_format( $value, $decimals, '.', '' ); + // Look for period + $period = strpos( $value, '.' ); + + // Period position, or 0 + $decimals = ( false !== $period ) + ? $period + : 0; + } + + // Format to number of decimals + $formatted = number_format( (float) $value, (int) $decimals, '.', '' ); // Adjust for negative values - $retval = $formatted * $negative_exponent; + $retval = ( $formatted * $negative_exponent ); // Return return $retval; } + /** + * Validate an integer value. + * + * This is used to validate an integer value before it is saved into any + * integer column in a database table. + * + * Uses: validate_numeric() to guard against non-numeric, invalid values + * being cast to a 1 when a fallback to $default is expected. + * + * @since 2.1.0 + * @param int $value Default zero. + * @return int + */ + public function validate_int( $value = 0 ) { + return (int) $this->validate_numeric( $value, false ); + } + /** * Validate a UUID. * @@ -876,88 +1287,123 @@ public function validate_uuid( $value = '' ) { /** Table Helpers *********************************************************/ /** - * Return a string representation of what this column's properties look like - * in a MySQL. + * Return a string representation of this column's properties as part of + * the "CREATE" string of a Table. * - * @todo - * @since 1.0.0 + * @since 2.1.0 * @return string */ public function get_create_string() { - // Default return val - $retval = ''; + // Create array + $create = array(); - // Bail if no name + // Name if ( ! empty( $this->name ) ) { - $retval .= $this->name; + $create[] = "`{$this->name}`"; } // Type if ( ! empty( $this->type ) ) { - $retval .= " {$this->type}"; - } - // Length - if ( ! empty( $this->length ) ) { - $retval .= '(' . $this->length . ')'; - } + // Lower looks nicer here for some reason... + $lower = strtolower( $this->type ); - // Unsigned - if ( ! empty( $this->unsigned ) ) { - $retval .= " unsigned"; - } + // Length + $create[] = ! empty( $this->length ) && is_numeric( $this->length ) + ? "{$lower}({$this->length})" + : $lower; + + // Binary column types + if ( $this->is_binary() ) { + $create[] = "CHARACTER SET binary"; + $create[] = "COLLATE binary"; - // Zerofill - if ( ! empty( $this->zerofill ) ) { - // TBD + // Non-binary column types + } else { + + // Encoding + if ( ! empty( $this->encoding ) ) { + $create[] = "CHARACTER SET {$this->encoding}"; + } + + // Collation + if ( ! empty( $this->collation ) ) { + + // Binary text uses "_bin" collation + $create[] = ( ! empty( $this->binary ) && $this->is_text() ) + ? "COLLATE {$this->collation}_bin" + : "COLLATE {$this->collation}"; + } + } } - // Binary - if ( ! empty( $this->binary ) ) { - // TBD + /** + * Note: unsigned Decimals are deprecated in MySQL 8.0.17, and this will + * be changed to is_int() at a later date. + */ + if ( $this->is_numeric() ) { + + // Unsigned + if ( ! empty( $this->unsigned ) ) { + $create[] = 'unsigned'; + } + + // Zerofill + if ( ! empty( $this->zerofill ) ) { + $create[] = 'zerofill'; + } } - // Allow null - if ( ! empty( $this->allow_null ) ) { - $retval .= " NOT NULL "; + // Disallow null + if ( false === $this->allow_null ) { + $create[] = 'not null'; } - // Default + // Default supplied, so trust it (for now...) if ( ! empty( $this->default ) ) { - $retval .= " default '{$this->default}'"; + $create[] = "default '{$this->default}'"; + + // allow_null with literal null defaults to null + } elseif ( ( true === $this->allow_null ) && ( null === $this->default ) ) { + $create[] = "default null"; - // A literal false means no default value + // Literal false means no default value } elseif ( false !== $this->default ) { - // Numeric + // Numeric (ints and decimals) if ( $this->is_numeric() ) { - $retval .= " default '0'"; - } elseif ( $this->is_type( 'datetime' ) ) { - $retval .= " default '0000-00-00 00:00:00'"; + + // Default "0" if _not_ autoincrementing (primary) + if ( ! $this->is_extra( 'AUTO_INCREMENT' ) ) { + $create[] = "default '0'"; + } + + // Datetime or Timestamp + } elseif ( $this->is_type( array( 'datetime', 'timestamp' ) ) ) { + + // Using the CURRENT_TIMESTAMP constant + if ( $this->is_extra( 'ON UPDATE CURRENT_TIMESTAMP' ) ) { + $create[] = "ON UPDATE current_timestamp()"; + + // @todo NO_ZERO_DATE + } elseif ( $this->is_type( 'datetime' ) ) { + $create[] = "default '0000-00-00 00:00:00'"; + } + + // All string types (texts and blobs) } else { - $retval .= " default ''"; + $create[] = "default ''"; } } // Extra if ( ! empty( $this->extra ) ) { - $retval .= " {$this->extra}"; - } - - // Encoding - if ( ! empty( $this->encoding ) ) { - - } else { - + $create[] = strtoupper( $this->extra ); } - // Collation - if ( ! empty( $this->collation ) ) { - - } else { - - } + // Format return value from create array + $retval = implode( ' ', $create ); // Return the create string return $retval; diff --git a/src/Database/Queries/Compare.php b/src/Database/Queries/Compare.php index 2e4b352..78375b2 100644 --- a/src/Database/Queries/Compare.php +++ b/src/Database/Queries/Compare.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Compare - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php index ae986cc..e1c12ae 100644 --- a/src/Database/Queries/Date.php +++ b/src/Database/Queries/Date.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Date - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ diff --git a/src/Database/Queries/Meta.php b/src/Database/Queries/Meta.php index 8ae6705..5d89a88 100644 --- a/src/Database/Queries/Meta.php +++ b/src/Database/Queries/Meta.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Meta - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.1.0 */ diff --git a/src/Database/Query.php b/src/Database/Query.php index a96e6e8..73ef97e 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Query - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ @@ -32,7 +32,7 @@ * @property string $item_shape * @property string $cache_group * @property string $last_changed - * @property array $columns + * @property array $schema * @property array $query_clauses * @property array $request_clauses * @property null|Queries\Meta $meta_query @@ -86,29 +86,30 @@ class Query extends Base { * * Use underscores between words. I.E. "term_relationship" * - * This is used to automatically generate action hooks. + * This is used to automatically generate hook names. * * @since 1.0.0 * @var string */ - protected $item_name = ''; + protected $item_name = 'item'; /** * Plural version for a group of items. * * Use underscores between words. I.E. "term_relationships" * - * This is used to automatically generate action hooks. + * This is used to automatically generate hook names. * * @since 1.0.0 * @var string */ - protected $item_name_plural = ''; + protected $item_name_plural = 'items'; /** * Name of class used to turn IDs into first-class objects. * - * This is used when looping through return values to guarantee their shape. + * This is used when looping through return values to guarantee that objects + * are the expected class. * * @since 1.0.0 * @var mixed @@ -147,6 +148,19 @@ class Query extends Base { */ protected $columns = array(); + /** Schema *************************************************************/ + + /** + * Schema object. + * + * A collection of Column and Index objects. Set to private so that it is + * not touched directly until this can be vetted and opened up. + * + * @since 2.1.0 + * @var Schema + */ + private $schema = null; + /** Clauses ***************************************************************/ /** @@ -155,29 +169,17 @@ class Query extends Base { * @since 1.0.0 * @var array */ - protected $query_clauses = array( - 'select' => '', - 'from' => '', - 'where' => array(), - 'groupby' => '', - 'orderby' => '', - 'limits' => '' - ); + protected $query_clauses = array(); /** - * Request clauses. + * SQL request clauses. * * @since 1.0.0 * @var array */ - protected $request_clauses = array( - 'select' => '', - 'from' => '', - 'where' => '', - 'groupby' => '', - 'orderby' => '', - 'limits' => '' - ); + protected $request_clauses = array(); + + /** Query Types ***********************************************************/ /** * Meta query container. @@ -239,6 +241,8 @@ class Query extends Base { protected $query_var_defaults = array(); /** + * Random default value for all query vars. + * * This private variable temporarily holds onto a random string used as the * default query var value. This is used internally when performing * comparisons, and allows for querying by falsy values. @@ -251,15 +255,10 @@ class Query extends Base { /** Results ***************************************************************/ /** - * List of items located by the query. + * The total number of items found by the SQL query. * - * @since 1.0.0 - * @var array|int - */ - public $items = array(); - - /** - * The amount of found items for the current query. + * This may differ from the item count, depending on the request and whether + * 'no_found_rows' is set. * * @since 1.0.0 * @var int @@ -275,13 +274,21 @@ class Query extends Base { protected $max_num_pages = 0; /** - * SQL for database query. + * The final SQL string generated by this class. * * @since 1.0.0 * @var string */ protected $request = ''; + /** + * Array of items retrieved by the SQL query. + * + * @since 1.0.0 + * @var array|int + */ + public $items = array(); + /** Methods ***************************************************************/ /** @@ -321,11 +328,7 @@ class Query extends Base { public function __construct( $query = array() ) { // Setup - $this->set_alias(); - $this->set_prefix(); - $this->set_columns(); - $this->set_item_shape(); - $this->set_query_var_defaults(); + $this->setup(); // Maybe execute a query if arguments were passed if ( ! empty( $query ) ) { @@ -333,6 +336,23 @@ public function __construct( $query = array() ) { } } + /** + * Setup the class variables. + * + * This method is public to allow subclasses to override it, and allow for + * it to be called directly on a class that has already been used. + * + * @since 2.1.0 + */ + public function setup() { + $this->set_alias(); + $this->set_prefixes(); + $this->set_schema(); + $this->set_item_shape(); + $this->set_query_var_defaults(); + $this->set_query_clause_defaults(); + } + /** * Queries the database and retrieves items or counts. * @@ -342,7 +362,7 @@ public function __construct( $query = array() ) { * @since 1.0.0 * * @param array|string $query Array or URL query string of parameters. - * @return array|int List of items, or number of items when 'count' is passed as a query var. + * @return array|int Array of items, or number of items when 'count' is passed as a query var. */ public function query( $query = array() ) { $this->parse_query( $query ); @@ -353,9 +373,9 @@ public function query( $query = array() ) { /** Private Setters *******************************************************/ /** - * Set the time when items were last changed. + * Set up the time when items were last changed. * - * We set this locally to avoid inconsistencies between method calls. + * Avoids inconsistencies between method calls. * * @since 1.0.0 */ @@ -377,25 +397,28 @@ private function set_alias() { } /** - * Prefix table names, cache groups, and other things. + * Set up prefixes on: + * - table name + * - table alias + * - cache group * * This is to avoid conflicts with other plugins or themes that might be - * doing their own things. + * using the global scope for data and cache storage. * - * @since 1.0.0 + * @since 2.1.0 */ - private function set_prefix() { + private function set_prefixes() { $this->table_name = $this->apply_prefix( $this->table_name ); $this->table_alias = $this->apply_prefix( $this->table_alias ); $this->cache_group = $this->apply_prefix( $this->cache_group, '-' ); } /** - * Set columns objects. + * Set up the Schema. * - * @since 1.0.0 + * @since 2.1.0 */ - private function set_columns() { + private function set_schema() { // Bail if no table schema if ( ! class_exists( $this->table_schema ) ) { @@ -403,12 +426,7 @@ private function set_columns() { } // Invoke a new table schema class - $schema = new $this->table_schema; - - // Maybe get the column objects - if ( ! empty( $schema->columns ) ) { - $this->columns = $schema->columns; - } + $this->schema = new $this->table_schema; } /** @@ -422,6 +440,33 @@ private function set_item_shape() { } } + /** + * Set default query clauses. + * + * @since 2.1.0 + */ + private function set_query_clause_defaults() { + + // Default query clauses + $this->query_clauses = array( + 'select' => '', + 'fields' => '', + 'count' => '', + 'from' => '', + 'join' => array(), + 'where' => array(), + 'groupby' => '', + 'orderby' => '', + 'limits' => '' + ); + + // Default request clauses are empty strings + $this->request_clauses = array_fill_keys( + array_keys( $this->query_clauses ), + '' + ); + } + /** * Set default query vars based on columns. * @@ -462,13 +507,8 @@ private function set_query_var_defaults() { 'update_meta_cache' => true ); - // Bail if no columns - if ( empty( $this->columns ) ) { - return; - } - // Direct column names - $names = wp_list_pluck( $this->columns, 'name' ); + $names = $this->get_column_names(); foreach ( $names as $name ) { $this->query_var_defaults[ $name ] = $this->query_var_default_value; } @@ -477,21 +517,21 @@ private function set_query_var_defaults() { $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); foreach ( $possible_ins as $in ) { $key = "{$in}__in"; - $this->query_var_defaults[ $key ] = false; + $this->query_var_defaults[ $key ] = $this->query_var_default_value; } // Possible not ins $possible_not_ins = $this->get_columns( array( 'not_in' => true ), 'and', 'name' ); foreach ( $possible_not_ins as $in ) { $key = "{$in}__not_in"; - $this->query_var_defaults[ $key ] = false; + $this->query_var_defaults[ $key ] = $this->query_var_default_value; } // Possible dates $possible_dates = $this->get_columns( array( 'date_query' => true ), 'and', 'name' ); foreach ( $possible_dates as $date ) { $key = "{$date}_query"; - $this->query_var_defaults[ $key ] = false; + $this->query_var_defaults[ $key ] = $this->query_var_default_value; } } @@ -509,44 +549,52 @@ private function set_request_clauses( $clauses = array() ) { ? 'SQL_CALC_FOUND_ROWS' : ''; + // Count + $count = ! empty( $clauses['count'] ) + ? $clauses['count'] + : ''; + // Fields - $fields = ! empty( $clauses['fields'] ) + $fields = ! empty( $clauses['fields'] ) ? $clauses['fields'] : ''; // Join - $join = ! empty( $clauses['join'] ) + $join = ! empty( $clauses['join'] ) ? $clauses['join'] : ''; // Where - $where = ! empty( $clauses['where'] ) + $where = ! empty( $clauses['where'] ) ? "WHERE {$clauses['where']}" : ''; // Group by - $groupby = ! empty( $clauses['groupby'] ) + $groupby = ! empty( $clauses['groupby'] ) ? "GROUP BY {$clauses['groupby']}" : ''; // Order by - $orderby = ! empty( $clauses['orderby'] ) + $orderby = ! empty( $clauses['orderby'] ) ? "ORDER BY {$clauses['orderby']}" : ''; // Limits - $limits = ! empty( $clauses['limits'] ) + $limits = ! empty( $clauses['limits'] ) ? $clauses['limits'] : ''; // Select & From $table = $this->get_table_name(); - $select = "SELECT {$found_rows} {$fields}"; - $from = "FROM {$table} {$this->table_alias} {$join}"; + $select = "SELECT {$found_rows}"; + $from = "FROM {$table} {$this->table_alias}"; // Put query into clauses array $this->request_clauses['select'] = $select; + $this->request_clauses['fields'] = $fields; + $this->request_clauses['count'] = $count; $this->request_clauses['from'] = $from; + $this->request_clauses['join'] = $join; $this->request_clauses['where'] = $where; $this->request_clauses['groupby'] = $groupby; $this->request_clauses['orderby'] = $orderby; @@ -572,14 +620,8 @@ private function set_request() { */ private function set_items( $item_ids = array() ) { - // Bail if counting, to avoid shaping items - if ( ! empty( $this->query_vars['count'] ) ) { - $this->items = $item_ids; - return; - } - - // Cast to integers - $item_ids = array_map( 'intval', $item_ids ); + // Shape item IDs + $item_ids = array_map( array( $this, 'shape_item_id' ), $item_ids ); // Prime item caches $this->prime_item_caches( $item_ids ); @@ -592,17 +634,14 @@ private function set_items( $item_ids = array() ) { * Populates found_items and max_num_pages properties for the current query * if the limit clause was used. * + * @todo: make safe for MySQL 8 + * * @since 1.0.0 * * @param mixed $item_ids Optional array of item IDs */ private function set_found_items( $item_ids = array() ) { - // Bail if items are empty - if ( empty( $item_ids ) ) { - return; - } - // Default to number of item IDs $this->found_items = count( (array) $item_ids ); @@ -611,21 +650,22 @@ private function set_found_items( $item_ids = array() ) { // Not grouped if ( is_numeric( $item_ids ) && empty( $this->query_vars['groupby'] ) ) { - $this->found_items = intval( $item_ids ); + $this->found_items = (int) $item_ids; } - // Not a count query - } elseif ( is_array( $item_ids ) && ( ! empty( $this->query_vars['number'] ) && empty( $this->query_vars['no_found_rows'] ) ) ) { + // Not a count query, and number of rows is limited + } elseif ( + is_array( $item_ids ) + && + ( + ! empty( $this->query_vars['number'] ) + && + empty( $this->query_vars['no_found_rows'] ) + ) + ) { - /** - * Filters the query used to retrieve found item count. - * - * @since 1.0.0 - * - * @param string $found_items_query SQL query. Default 'SELECT FOUND_ROWS()'. - * @param object $item_query The object instance. - */ - $found_items_query = (string) apply_filters_ref_array( $this->apply_prefix( "found_{$this->item_name_plural}_query" ), array( 'SELECT FOUND_ROWS()', &$this ) ); + // Get the found items SQL + $found_items_query = $this->filter_found_items_query(); // Maybe query for found items if ( ! empty( $found_items_query ) ) { @@ -708,7 +748,8 @@ private function get_date_query( $args = array() ) { /** * Return the current time as a UTC timestamp. * - * This is used by add_item() and update_item() + * This is used by add_item() and update_item() and is equivalent to + * CURRENT_TIMESTAMP in MySQL, but for the PHP server (not the MySQL one) * * @since 1.0.0 * @@ -794,25 +835,89 @@ private function get_column_by( $args = array() ) { /** * Get columns from an array of arguments. * + * Function arguments are passed into wp_filter_object_list() to filter the + * array of columns as needed. + * * @since 1.0.0 + * @since 2.1.0 * - * @param array $args Arguments to filter columns by. - * @param string $operator Optional. The logical operation to perform. - * @param bool|string $field Optional. A field from the object to place - * instead of the entire object. Default false. - * @return array Array of column. + * @static array $columns Local static copy of columns, abstracted to + * support different storage locations. + * @param array $args Arguments to filter columns by. + * @param string $operator Optional. The logical operation to perform. + * @param bool|string $field Optional. A field from the object to place + * instead of the entire object. Default false. + * @return array Array of columns. */ private function get_columns( $args = array(), $operator = 'and', $field = false ) { + static $columns = null; + + // Setup columns + if ( null === $columns ) { + + // Default columns + $columns = array(); + + // Legacy columns + if ( ! empty( $this->columns ) ) { + $columns = $this->columns; + } + + // Columns from Schema + if ( ! empty( $this->schema->columns ) ) { + $columns = $this->schema->columns; + } + } // Filter columns - $filter = wp_filter_object_list( $this->columns, $args, $operator, $field ); + $filter = wp_filter_object_list( $columns, $args, $operator, $field ); - // Return column or false + // Return columns or empty array return ! empty( $filter ) ? array_values( $filter ) : array(); } + /** + * Get a field from columns, by the intersection of key and values. + * + * This is used for retrieving an array of column fields by an array of + * other field values. + * + * Uses get_column_field() to allow passing of a default value. + * + * @since 2.1.0 + * @param string $key Name of property to compare $values to. + * @param array $values Values to get a column by. + * @param string $field Field to get from a column. + * @param mixed $default Default to use if no field is set. + * @return array + */ + private function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) { + + // Default return value + $retval = array(); + + // Bail if no values + if ( empty( $values ) ) { + return $retval; + } + + // Allow scalar values + if ( is_scalar( $values ) ) { + $values = array( $values ); + } + + // Get the column fields + foreach ( $values as $value ) { + $args = array( $key => $value ); + $retval[] = $this->get_column_field( $args, $field, $default ); + } + + // Return fields of columns + return $retval; + } + /** * Get a single database row by any column and value, skipping cache. * @@ -857,7 +962,7 @@ private function get_item_raw( $column_name = '', $column_value = '' ) { * * @since 1.0.0 * - * @return array|int List of items, or number of items when 'count' is passed as a query var. + * @return array|int Array of items, or number of items when 'count' is passed as a query var. */ private function get_items() { @@ -868,15 +973,12 @@ private function get_items() { * * @param Query &$this Current instance of Query, passed by reference. */ - do_action_ref_array( $this->apply_prefix( "pre_get_{$this->item_name_plural}" ), array( &$this ) ); - - // Never limit, never update item/meta caches when counting - if ( ! empty( $this->query_vars['count'] ) ) { - $this->query_vars['number'] = false; - $this->query_vars['no_found_rows'] = true; - $this->query_vars['update_item_cache'] = false; - $this->query_vars['update_meta_cache'] = false; - } + do_action_ref_array( + $this->apply_prefix( "pre_get_{$this->item_name_plural}" ), + array( + &$this + ) + ); // Check the cache $cache_key = $this->get_cache_key(); @@ -884,15 +986,17 @@ private function get_items() { // No cache value if ( false === $cache_value ) { - $item_ids = $this->get_item_ids(); + + // Query for item IDs + $result = $this->get_item_ids(); // Set the number of found items - $this->set_found_items( $item_ids ); + $this->set_found_items( $result ); // Format the cached value $cache_value = array( - 'item_ids' => $item_ids, - 'found_items' => intval( $this->found_items ), + 'item_ids' => $result, + 'found_items' => (int) $this->found_items, ); // Add value to the cache @@ -900,8 +1004,8 @@ private function get_items() { // Value exists in cache } else { - $item_ids = $cache_value['item_ids']; - $this->found_items = intval( $cache_value['found_items'] ); + $result = $cache_value['item_ids']; + $this->found_items = (int) $cache_value['found_items']; } // Pagination @@ -910,12 +1014,22 @@ private function get_items() { } // Cast to int if not grouping counts - if ( ! empty( $this->query_vars['count'] ) && empty( $this->query_vars['groupby'] ) ) { - $item_ids = intval( $item_ids ); + if ( ! empty( $this->query_vars['count'] ) ) { + + // Set items + $this->items = $result; + + // Not grouping, so cast to int + if ( empty( $this->query_vars['groupby'] ) ) { + $this->items = (int) $result; + } + + // Return + return $this->items; } - // Set items from IDs - $this->set_items( $item_ids ); + // Set items from result + $this->set_items( $result ); // Return array of items return $this->items; @@ -925,44 +1039,51 @@ private function get_items() { * Used internally to get a list of item IDs matching the query vars. * * @since 1.0.0 + * @since 2.1.0 Uses wp_parse_list() instead of wp_parse_id_list() * * @return mixed An array of item IDs if a full query. A single count of * item IDs if a count query. */ private function get_item_ids() { - // Setup primary column, and parse the where clause - $this->parse_where(); - - // Order & Order By - $order = $this->parse_order( $this->query_vars['order'] ); - $orderby = $this->get_order_by( $order ); - - // Limit & Offset - $limit = absint( $this->query_vars['number'] ); - $offset = absint( $this->query_vars['offset'] ); - - // Limits - if ( ! empty( $limit ) ) { - $limits = ! empty( $offset ) - ? "LIMIT {$offset}, {$limit}" - : "LIMIT {$limit}"; - } else { - $limits = ''; - } + // Parse 'where' & 'join' + $this->parse_where_join_vars(); // Where & Join - $where = implode( ' AND ', $this->query_clauses['where'] ); - $join = implode( ', ', $this->query_clauses['join'] ); + $where = $this->parse_where_clauses( $this->query_clauses['where'] ); + $join = $this->parse_join_clauses( $this->query_clauses['join'] ); + + // Order & Order By + $orderby = $this->parse_orderby( + $this->query_vars['orderby'], + $this->query_vars['order'] + ); // Group by $groupby = $this->parse_groupby( $this->query_vars['groupby'] ); + // Count + $count = $this->parse_count( + $this->query_vars['count'], + $this->query_vars['groupby'] + ); + // Fields - $fields = $this->parse_fields( $this->query_vars['fields'] ); + $fields = $this->parse_fields( + $this->query_vars['fields'], + $this->query_vars['count'], + $this->query_vars['groupby'] + ); + + // Limits + $limits = $this->parse_limits( + $this->query_vars['number'], + $this->query_vars['offset'] + ); - // Setup the query array (compact() is too opaque here) + // Setup the query array $query = array( + 'count' => $count, 'fields' => $fields, 'join' => $join, 'where' => $where, @@ -971,15 +1092,8 @@ private function get_item_ids() { 'groupby' => $groupby ); - /** - * Filters the item query clauses. - * - * @since 1.0.0 - * - * @param array $query A compacted array of item query clauses. - * @param Query &$this Current instance passed by reference. - */ - $clauses = (array) apply_filters_ref_array( $this->apply_prefix( "{$this->item_name_plural}_query_clauses" ), array( $query, &$this ) ); + // Filter the query clauses + $clauses = $this->filter_query_clauses( $query ); // Setup request $this->set_request_clauses( $clauses ); @@ -1001,118 +1115,104 @@ private function get_item_ids() { $item_ids = $this->get_db()->get_col( $this->request ); // Return parsed IDs - return wp_parse_id_list( $item_ids ); + return wp_parse_list( $item_ids ); } /** - * Get the ORDERBY clause. + * Used internally to generate an SQL string for searching across multiple + * columns. * * @since 1.0.0 + * @since 2.1.0 Bail early if parameters are empty. * - * @param string $order - * @return string + * @param string $string Search string. + * @param array $column_names Columns to search. + * @return string Search SQL. */ - private function get_order_by( $order = '' ) { - - // Default orderby primary column - $parsed = $this->parse_orderby(); - $orderby = "{$parsed} {$order}"; - - // Disable ORDER BY if counting, or: 'none', an empty array, or false. - if ( ! empty( $this->query_vars['count'] ) || in_array( $this->query_vars['orderby'], array( 'none', array(), false ), true ) ) { - $orderby = ''; - - // Ordering by something, so figure it out - } elseif ( ! empty( $this->query_vars['orderby'] ) ) { - - // Array of keys, or comma separated - $ordersby = is_array( $this->query_vars['orderby'] ) - ? $this->query_vars['orderby'] - : preg_split( '/[,\s]/', $this->query_vars['orderby'] ); - - $orderby_array = array(); - $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); - $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); - - // Loop through possible order by's - foreach ( $ordersby as $_key => $_value ) { - - // Skip if empty - if ( empty( $_value ) ) { - continue; - } - - // Key is numeric - if ( is_int( $_key ) ) { - $_orderby = $_value; - $_item = $order; - - // Key is string - } else { - $_orderby = $_key; - $_item = $_value; - } - - // Skip if not sortable - if ( ! in_array( $_value, $sortables, true ) ) { - continue; - } + private function get_search_sql( $string = '', $column_names = array() ) { - // Parse orderby - $parsed = $this->parse_orderby( $_orderby ); + // Bail if malformed string + if ( empty( $string ) || ! is_scalar( $string ) ) { + return ''; + } - // Skip if empty - if ( empty( $parsed ) ) { - continue; - } + // Bail if malformed columns + if ( empty( $column_names ) || ! is_array( $column_names ) ) { + return ''; + } - // Set if __in - if ( in_array( $_orderby, $possible_ins, true ) ) { - $orderby_array[] = "{$parsed} {$order}"; - continue; - } + // Array or String + $like = ( false !== strpos( $string, '*' ) ) + ? '%' . implode( '%', array_map( array( $this->get_db(), 'esc_like' ), explode( '*', $string ) ) ) . '%' + : '%' . $this->get_db()->esc_like( $string ) . '%'; - // Append parsed orderby to array - $orderby_array[] = $parsed . ' ' . $this->parse_order( $_item ); - } + // Default array + $searches = array(); - // Only set if valid orderby - if ( ! empty( $orderby_array ) ) { - $orderby = implode( ', ', $orderby_array ); - } + // Build search SQL + foreach ( $column_names as $column ) { + $searches[] = $this->get_db()->prepare( "{$column} LIKE %s", $like ); } - // Return parsed orderby - return $orderby; + // Concatinate + $values = implode( ' OR ', $searches ); + $retval = '(' . $values . ')'; + + // Return the clause + return $retval; } /** - * Used internally to generate an SQL string for searching across multiple - * columns. + * Used internally to generate the SQL string for IN and NOT IN clauses. * - * @since 1.0.0 + * The $values being passed in should not be validated, and they will be + * escaped before they are concatenated together and returned as a string. * - * @param string $string Search string. - * @param array $columns Columns to search. - * @return string Search SQL. + * @since 2.1.0 + * + * @param string $column_name Column name. + * @param array|string $values Array of values. + * @param bool $wrap To wrap in parenthesis. + * @param string $pattern Pattern to prepare with. + * + * @return string Escaped/prepared SQL, possibly wrapped in parenthesis. */ - private function get_search_sql( $string = '', $columns = array() ) { + private function get_in_sql( $column_name = '', $values = array(), $wrap = true, $pattern = '' ) { - // Array or String - $like = ( false !== strpos( $string, '*' ) ) - ? '%' . implode( '%', array_map( array( $this->get_db(), 'esc_like' ), explode( '*', $string ) ) ) . '%' - : '%' . $this->get_db()->esc_like( $string ) . '%'; + // Default return value + $retval = ''; - // Default array - $searches = array(); + // Bail if no values or column name + if ( empty( $values ) || empty( $column_name ) ) { + return $retval; + } - // Build search SQL - foreach ( $columns as $column ) { - $searches[] = $this->get_db()->prepare( "{$column} LIKE %s", $like ); + // Fallback to column pattern + if ( empty( $pattern ) || ! is_string( $pattern ) ) { + $pattern = $this->get_column_field( array( 'name' => $column_name ), 'pattern', '%s' ); } - // Return the clause - return '(' . implode( ' OR ', $searches ) . ')'; + // Fill an array of patterns to match the number of values + $count = count( $values ); + $patterns = array_fill( 0, $count, $pattern ); + + // Escape & prepare + $sql = implode( ', ', $patterns ); + $values = $this->get_db()->_escape( $values ); // May quote strings + $retval = $this->get_db()->prepare( $sql, $values ); // Catches quoted strings + + // Set return value to empty string if prepare() returns falsy + if ( empty( $retval ) ) { + $retval = ''; + } + + // Wrap them in parenthesis + if ( true === $wrap ) { + $retval = "({$retval})"; + } + + // Return in SQL + return $retval; } /** Private Parsers *******************************************************/ @@ -1137,6 +1237,15 @@ private function parse_query( $query = array() ) { $this->query_var_defaults ); + // If counting, override some other query_vars + if ( ! empty( $this->query_vars['count'] ) ) { + $this->query_vars['number'] = false; + $this->query_vars['orderby'] = ''; + $this->query_vars['no_found_rows'] = true; + $this->query_vars['update_item_cache'] = false; + $this->query_vars['update_meta_cache'] = false; + } + /** * Fires after the item query vars have been parsed. * @@ -1144,89 +1253,106 @@ private function parse_query( $query = array() ) { * * @param Query &$this The Query instance (passed by reference). */ - do_action_ref_array( $this->apply_prefix( "parse_{$this->item_name_plural}_query" ), array( &$this ) ); + do_action_ref_array( + $this->apply_prefix( "parse_{$this->item_name_plural}_query" ), + array( + &$this + ) + ); } /** - * Parse the where clauses for all known columns. + * Parse the 'where' and 'join' query clauses for all known columns. * * @todo split this method into smaller parts * - * @since 1.0.0 + * @since 2.1.0 */ - private function parse_where() { + private function parse_where_join_vars() { // Defaults - $where = $join = $searchable = $date_query = array(); + $where = $join = $date_query = array(); + + // Get all of the columns + $columns = $this->get_columns(); // Loop through columns - foreach ( $this->columns as $column ) { + foreach ( $columns as $column ) { - // Maybe add name to searchable array - if ( true === $column->searchable ) { - $searchable[] = $column->name; - } + // Get pattern + $pattern = $this->get_column_field( array( 'name' => $column->name ), 'pattern', '%s' ); // Literal column comparison - if ( ! $this->is_query_var_default( $column->name ) ) { + if ( false !== $column->by ) { - // Array (unprepared) - if ( is_array( $this->query_vars[ $column->name ] ) ) { - $where_id = "'" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $column->name ] ) ) . "'"; - $statement = "{$this->table_alias}.{$column->name} IN ({$where_id})"; + // Parse query variable + $where_id = $column->name; + $values = $this->parse_query_var( $this->query_vars, $where_id ); - // Add to where array - $where[ $column->name ] = $statement; + // Parse item for direct clause. + if ( false !== $values ) { - // Numeric/String/Float (prepared) - } else { - $pattern = $this->get_column_field( array( 'name' => $column->name ), 'pattern', '%s' ); - $where_id = $this->query_vars[ $column->name ]; - $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; + $column_value = reset( $values ); + $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); - // Add to where array - $where[ $column->name ] = $this->get_db()->prepare( $statement, $where_id ); + // Implode + } else { + $where_id = "{$where_id}__in"; + $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); + $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; + } } } // __in if ( true === $column->in ) { + + // Parse query var $where_id = "{$column->name}__in"; + $values = $this->parse_query_var( $this->query_vars, $where_id ); // Parse item for an IN clause. - if ( isset( $this->query_vars[ $where_id ] ) && is_array( $this->query_vars[ $where_id ] ) ) { + if ( false !== $values ) { // Convert single item arrays to literal column comparisons - if ( 1 === count( $this->query_vars[ $where_id ] ) ) { - $column_value = reset( $this->query_vars[ $where_id ] ); - $statement = "{$this->table_alias}.{$column->name} = %s"; - - $where[ $column->name ] = $this->get_db()->prepare( $statement, $column_value ); + if ( 1 === count( $values ) ) { + $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; + $where_id = $column->name; + $column_value = reset( $values ); + $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN ( '" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $where_id ] ) ) . "' )"; + $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); + $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; } } } // __not_in if ( true === $column->not_in ) { + + // Parse query var $where_id = "{$column->name}__not_in"; + $values = $this->parse_query_var( $this->query_vars, $where_id ); // Parse item for a NOT IN clause. - if ( isset( $this->query_vars[ $where_id ] ) && is_array( $this->query_vars[ $where_id ] ) ) { + if ( false !== $values ) { // Convert single item arrays to literal column comparisons - if ( 1 === count( $this->query_vars[ $where_id ] ) ) { - $column_value = reset( $this->query_vars[ $where_id ] ); - $statement = "{$this->table_alias}.{$column->name} != %s"; - - $where[ $column->name ] = $this->get_db()->prepare( $statement, $column_value ); + if ( 1 === count( $values ) ) { + $statement = "{$this->table_alias}.{$column->name} != {$pattern}"; + $where_id = $column->name; + $column_value = reset( $values ); + $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $where[ $where_id ] = "{$this->table_alias}.{$column->name} NOT IN ( '" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $where_id ] ) ) . "' )"; + $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); + $where[ $where_id ] = "{$this->table_alias}.{$column->name} NOT IN {$in_values}"; } } } @@ -1237,7 +1363,7 @@ private function parse_where() { $column_date = $this->query_vars[ $where_id ]; // Parse item - if ( ! empty( $column_date ) ) { + if ( ! empty( $column_date ) && ! $this->is_query_var_default( $where_id ) ) { // Default arguments $defaults = array( @@ -1265,9 +1391,16 @@ private function parse_where() { } } + /** Search ************************************************************/ + + // Get names of searchable columns + $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); + // Maybe search if columns are searchable. if ( ! empty( $searchable ) && strlen( $this->query_vars['search'] ) ) { - $search_columns = array(); + + // Default to all searchable columns + $search_columns = $searchable; // Intersect against known searchable columns if ( ! empty( $this->query_vars['search_columns'] ) ) { @@ -1277,21 +1410,8 @@ private function parse_where() { ); } - // Default to all searchable columns - if ( empty( $search_columns ) ) { - $search_columns = $searchable; - } - - /** - * Filters the columns to search in a Query search. - * - * @since 1.0.0 - * - * @param array $search_columns Array of column names to be searched. - * @param string $search Text being searched. - * @param Query $this The current Query instance. - */ - $search_columns = (array) apply_filters( $this->apply_prefix( "{$this->item_name_plural}_search_columns" ), $search_columns, $this->query_vars['search'], $this ); + // Filter search columns + $search_columns = $this->filter_search_columns( $search_columns ); // Add search query clause $where['search'] = $this->get_search_sql( $this->query_vars['search'], $search_columns ); @@ -1386,41 +1506,186 @@ private function parse_where() { $this->query_clauses['join'] = array_filter( $join ); } + /** + * Parse a single query variable value. + * + * @since 2.1.0 + * + * @param int|string|array $query_vars + * @param string $key + * @return int|string|array False if not set or default. + * Value if object or array. + * Attempts to parse a comma-separated string of + * possible keys or numbers. + */ + private function parse_query_var( $query_vars = '', $key = '' ) { + + // Bail if no query vars exist for that ID + if ( ! isset( $query_vars[ $key ] ) ) { + return false; + } + + // Get the value + $value = $query_vars[ $key ]; + + // Bail if equal to the exact default random value + if ( $value === $this->query_var_default_value ) { + return false; + } + + /** + * Early return objects, arrays, numerics, integers, or bools. + * + * These values assume the caller knew what it was doing, and simply + * pass themselves through as "parsed" without any extra handling. + */ + if ( + is_object( $value ) + || + is_array( $value ) + || + is_numeric( $value ) + || + is_int( $value ) + || + is_bool( $value ) + ) { + return array( $value ); + } + + /** + * Attempt to determine if a string contains a comma separated list of + * values that should be split into an array of values for an __in type + * of query. + */ + if ( is_string( $value ) ) { + + // Bail if string is over 100 chars long + if ( strlen( $value ) > 100 ) { + return $value; + } + + // Contains comma? + $comma = strpos( $value, ',' ); + + // Bail if no comma + if ( false === $comma ) { + return array( $value ); + } + + // Contains space? + $space = strpos( $value, ' ' ); + + // Bail if space is before comma + if ( $space < $comma ) { + return array( $value ); + } + + // Bail if first comma is more than 20 letters in + if ( $comma >= 20 ) { + return array( $value ); + } + + // Split by comma (and maybe spaces) + return preg_split( '#,\s*#', $value, -1, PREG_SPLIT_NO_EMPTY ); + } + + // Pass the value through + return array( $value ); + } + /** * Parse which fields to query for. * + * If making a 'count' request, this will return either an empty string or + * the same columns that are being used for the "GROUP BY" to avoid errors. + * + * If not counting, this always only includes the Primary column to more + * predictably hit the cache, but that may change in a future version. + * * @since 1.0.0 + * @since 2.1.0 Moved COUNT() SQL to parse_count() * - * @param string $fields - * @param bool $alias + * @param string[] $fields + * @param bool $count + * @param string[] $groupby + * @param bool $alias * @return string */ - private function parse_fields( $fields = '', $alias = true ) { + private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) { - // Get the primary column name - $primary = $this->get_primary_column_name(); + // Maybe fallback to $query_vars + if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { + $count = $this->query_vars['count']; + } // Default return value - $retval = ( true === $alias ) - ? "{$this->table_alias}.{$primary}" - : $primary; + $retval = ''; + + // Counting, so use groupby + if ( ! empty( $count ) ) { + + // Use groupby instead + if ( ! empty( $groupby ) ) { + $retval = $this->parse_groupby( $groupby, $alias ); + } + + // Not counting + } else { - // No fields - if ( empty( $fields ) && ! empty( $this->query_vars['count'] ) ) { + // Maybe fallback to $query_vars + if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { + $fields = $this->query_vars['fields']; + } - // Possible fields to group by - $groupby_names = $this->parse_groupby( $this->query_vars['groupby'], $alias ); - $groupby_names = ! empty( $groupby_names ) - ? "{$groupby_names}" - : ''; + // Get the primary column name + $primary = $this->get_primary_column_name(); - // Group by or total count - $retval = ! empty( $groupby_names ) - ? "{$groupby_names}, COUNT(*) as count" - : 'COUNT(*)'; + // Default return value + $retval = ( true === $alias ) + ? "{$this->table_alias}.{$primary}" + : $primary; } - // Return fields (or COUNT) + // Return fields + return $retval; + } + + /** + * Parse if counting, possibly grouping by columns. + * + * + * @since 2.1.0 + * @param bool $count + * @param string $groupby + * @param string $name + * @param bool $alias + * @return string + */ + private function parse_count( $count = false, $groupby = '', $name = 'count', $alias = true ) { + + // Maybe fallback to $query_vars + if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { + $count = $this->query_vars['count']; + } + + // Bail if not counting + if ( empty( $count ) ) { + return ''; + } + + // Default return value + $retval = 'COUNT(*)'; + + // Check for "GROUP BY" + $groupby_names = $this->parse_groupby( $groupby, $alias ); + + // Reformat if grouping counts together + if ( ! empty( $groupby_names ) ) { + $retval = ", {$retval} as {$name}"; + } + + // Return SQL return $retval; } @@ -1435,6 +1700,11 @@ private function parse_fields( $fields = '', $alias = true ) { */ private function parse_groupby( $groupby = '', $alias = true ) { + // Maybe fallback to $query_vars + if ( empty( $groupby ) && ! empty( $this->query_vars['groupby'] ) ) { + $groupby = $this->query_vars['groupby']; + } + // Bail if empty if ( empty( $groupby ) ) { return ''; @@ -1455,49 +1725,193 @@ private function parse_groupby( $groupby = '', $alias = true ) { } // Default return value - $retval = array(); + $retval = array(); + + // Maybe prepend table alias to key + foreach ( $intersect as $key ) { + $retval[] = ( true === $alias ) + ? "{$this->table_alias}.{$key}" + : $key; + } + + // Separate sanitized columns + return implode( ',', array_values( $retval ) ); + } + + /** + * Parse the ORDER BY clause. + * + * @since 1.0.0 As get_order_by + * @since 2.1.0 Renamed to parse_orderby and accepts $orderby, $order, and $alias + * + * @param string $orderby + * @param string $order + * @param bool $alias + * @return string + */ + private function parse_orderby( $orderby = '', $order = '', $alias = true ) { + + // Maybe fallback to $query_vars + if ( empty( $orderby ) && ! empty( $this->query_vars['orderby'] ) ) { + $orderby = $this->query_vars['orderby']; + } + + // Default orderby primary column + $parsed = $this->parse_single_orderby( $orderby, $alias ); + $order = $this->parse_order( $order ); + $orderby = "{$parsed} {$order}"; + + // Disable ORDER BY if counting, or: 'none', an empty array, or false. + if ( + + ! empty( $this->query_vars['count'] ) + + || + + in_array( $orderby, array( 'none', array(), false ), true ) + ) { + $orderby = ''; + + // Ordering by something, so figure it out + } elseif ( ! empty( $orderby ) ) { + + // Array of keys, or comma separated + $ordersby = $this->parse_query_var( $this->query_vars, 'orderby' ); + + $orderby_array = array(); + $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); + $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); + + // Loop through possible order by's + foreach ( $ordersby as $_key => $_value ) { + + // Skip if empty + if ( empty( $_value ) ) { + continue; + } + + // Key is numeric + if ( is_int( $_key ) ) { + $_orderby = $_value; + $_item = $order; + + // Key is string + } else { + $_orderby = $_key; + $_item = $_value; + } + + // Skip if not sortable + if ( ! in_array( $_value, $sortables, true ) ) { + continue; + } + + // Parse orderby + $parsed = $this->parse_single_orderby( $_orderby, $alias ); + + // Skip if empty + if ( empty( $parsed ) ) { + continue; + } + + // Set if __in + if ( in_array( $_orderby, $possible_ins, true ) ) { + $orderby_array[] = "{$parsed} {$order}"; + continue; + } + + // Append parsed orderby to array + $orderby_array[] = $parsed . ' ' . $this->parse_order( $_item ); + } + + // Only set if valid orderby + if ( ! empty( $orderby_array ) ) { + $orderby = implode( ', ', $orderby_array ); + } + } + + // Return parsed orderby + return $orderby; + } + + /** + * Parse all of the where clauses. + * + * @since 2.1.0 + * @param array $where + * @return string + */ + private function parse_where_clauses( $where = array() ) { + return implode( ' AND ', $where ); + } + + /** + * Parse all of the join clauses. + * + * @since 2.1.0 + * @param array $join + * @return string + */ + private function parse_join_clauses( $join = array() ) { + return implode( ', ', $join ); + } + + /** + * Parses the 'number' and 'offset' keys passed to the item query. + * + * @since 2.1.0 + * + * @param int $number + * @param int $offset + * @return string + */ + private function parse_limits( $number = 0, $offset = 0 ) { + + // Default return value + $retval = ''; - // Maybe prepend table alias to key - foreach ( $intersect as $key ) { - $retval[] = ( true === $alias ) - ? "{$this->table_alias}.{$key}" - : $key; + // No negative numbers + $limit = absint( $number ); + $offset = absint( $offset ); + + // Only limit & offset if not limit empty + if ( ! empty( $limit ) ) { + $retval = ! empty( $offset ) + ? "LIMIT {$offset}, {$limit}" + : "LIMIT {$limit}"; } - // Separate sanitized columns - return implode( ',', array_values( $retval ) ); + // Return + return $retval; } /** - * Parses and sanitizes 'orderby' keys passed to the item query. + * Parses and sanitizes a single 'orderby' key passed to the item query. + * + * This method assumes that $orderby is a valid Column name. * * @since 1.0.0 + * @since 2.1.0 Uses get_in_sql() * * @param string $orderby Field for the items to be ordered by. - * @return string Value to used in the ORDER clause. + * @param bool $alias Whether to append the table alias. + * @return string Value to used in the ORDER BY clause. */ - private function parse_orderby( $orderby = '' ) { - - // Get the primary column name - $primary = $this->get_primary_column_name(); - - // Default return value - $parsed = "{$this->table_alias}.{$primary}"; + private function parse_single_orderby( $orderby = '', $alias = true ) { - // Default to primary column + // Fallback to primary column if ( empty( $orderby ) ) { - $orderby = $primary; + $orderby = $this->get_primary_column_name(); } // __in if ( false !== strstr( $orderby, '__in' ) ) { $column_name = str_replace( '__in', '', $orderby ); - $column = $this->get_column_by( array( 'name' => $column_name ) ); - $item_in = $column->is_numeric() - ? implode( ',', array_map( 'absint', $this->query_vars[ $orderby ] ) ) - : implode( ',', $this->query_vars[ $orderby ] ); - - $parsed = "FIELD( {$this->table_alias}.{$column->name}, {$item_in} )"; + $item_in = $this->get_in_sql( $column_name, $this->query_vars[ $orderby ], false ); + $aliased = ( true === $alias ) + ? "{$this->table_alias}.{$column_name}" + : $column_name; + $retval = "FIELD( {$aliased}, {$item_in} )"; // Specific column } else { @@ -1505,12 +1919,14 @@ private function parse_orderby( $orderby = '' ) { // Orderby is a literal, sortable column name $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); if ( in_array( $orderby, $sortables, true ) ) { - $parsed = "{$this->table_alias}.{$orderby}"; + $retval = ( true === $alias ) + ? "{$this->table_alias}.{$orderby}" + : $orderby; } } // Return parsed value - return $parsed; + return $retval; } /** @@ -1518,11 +1934,12 @@ private function parse_orderby( $orderby = '' ) { * necessary. * * @since 1.0.0 + * @since 2.1.0 Default to 'DESC' * * @param string $order The 'order' query variable. * @return string The sanitized 'order' query variable. */ - private function parse_order( $order = '' ) { + private function parse_order( $order = 'DESC' ) { // Bail if malformed if ( empty( $order ) || ! is_string( $order ) ) { @@ -1543,113 +1960,119 @@ private function parse_order( $order = '' ) { * This will try to use item_shape, but will fallback to a private * method for querying and caching items. * - * If using the `fields` parameter, results will have unique shapes based on - * exactly what was requested. + * If using the "fields" query_var, results will be an array of stdClass + * objects with keys based on fields. * * @since 1.0.0 + * @since 2.1.0 Added $fields parameter. * - * @param array $items + * @param array $items Array of items to shape. + * @param array $fields Fields to get from items. * @return array */ - private function shape_items( $items = array() ) { + private function shape_items( $items = array(), $fields = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { + $fields = $this->query_vars['fields']; + } // Force to stdClass if querying for fields - if ( ! empty( $this->query_vars['fields'] ) ) { + if ( ! empty( $fields ) ) { $this->item_shape = 'stdClass'; } // Default return value $retval = array(); - // Use foreach because it's faster than array_map() + // Loop through items and get each item individually if ( ! empty( $items ) ) { foreach ( $items as $item ) { $retval[] = $this->get_item( $item ); } } - /** - * Filters the object query results. - * - * Looks like `edd_get_customers` - * - * @since 1.0.0 - * - * @param array $retval An array of items. - * @param object &$this Current instance of Query, passed by reference. - */ - $retval = (array) apply_filters_ref_array( $this->apply_prefix( "the_{$this->item_name_plural}" ), array( $retval, &$this ) ); + // Filter the items + $retval = $this->filter_items( $retval ); + + // Maybe return specific fields + if ( ! empty( $fields ) ) { + $retval = $this->get_item_fields( $retval, $fields ); + } - // Return filtered results - return ! empty( $this->query_vars['fields'] ) - ? $this->get_item_fields( $retval ) - : $retval; + // Return shaped items + return $retval; } /** - * Get specific item fields based on query_vars['fields']. + * Get specific fields from an array of items. * * @since 1.0.0 + * @since 2.1.0 Bails early if empty $fields. * - * @param array $items + * @param array $items Array of items to get fields from. + * @param array $fields Fields to get from items. * @return array */ - private function get_item_fields( $items = array() ) { + private function get_item_fields( $items = array(), $fields = array() ) { + + // Default return value + $retval = $items; + + // Maybe fallback to $query_vars + if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { + $fields = $this->query_vars['fields']; + } + + // Bail if no fields to get + if ( empty( $fields ) ) { + return $retval; + } // Get the primary column name $primary = $this->get_primary_column_name(); - // Get the query var fields - $fields = $this->query_vars['fields']; + // Sanitize fields + $fields = (array) array_map( 'sanitize_key', (array) $fields ); - // Strings need to be single columns - if ( is_string( $fields ) ) { - $field = sanitize_key( $fields ); - $items = ( 'ids' === $fields ) - ? wp_list_pluck( $items, $primary ) - : wp_list_pluck( $items, $field, $primary ); + // 'ids' is numerically keyed + if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { + $retval = wp_list_pluck( $items, $primary ); - // Arrays could be anything - } elseif ( is_array( $fields ) ) { - $new_items = array(); - $fields = array_flip( $fields ); + // Get fields from items + } else { + $retval = array(); + $fields = array_flip( $fields ); // Loop through items and pluck out the fields - foreach ( $items as $item_id => $item ) { - $new_items[ $item_id ] = (object) array_intersect_key( (array) $item, $fields ); + foreach ( $items as $item ) { + $retval[ $item->{$primary} ] = (object) array_intersect_key( (array) $item, $fields ); } - - // Set the items and unset the new items - $items = $new_items; - unset( $new_items ); } - // Return the item, possibly reduced - return $items; + // Return the item fields + return $retval; } /** * Shape an item ID from an object, array, or numeric value. * * @since 1.0.0 + * @since 2.1.0 Uses validate_item_field() instead of intval. * - * @param mixed $item - * @return int + * @param array|object|scalar $item + * @return int|string */ private function shape_item_id( $item = 0 ) { // Default return value - $retval = 0; + $retval = $item; // Get the primary column name $primary = $this->get_primary_column_name(); - // Numeric item ID - if ( is_numeric( $item ) ) { - $retval = $item; - // Object item - } elseif ( is_object( $item ) && isset( $item->{$primary} ) ) { + if ( is_object( $item ) && isset( $item->{$primary} ) ) { $retval = $item->{$primary}; // Array item @@ -1657,8 +2080,32 @@ private function shape_item_id( $item = 0 ) { $retval = $item[ $primary ]; } - // Return the item ID - return absint( $retval ); + // Return the validated item ID + return $this->validate_item_field( $retval, $primary ); + } + + /** + * Validate a single field of an item. + * + * Calls Column::validate() on the column. + * + * @since 2.1.0 + * @param mixed $value Value to validate. + * @param string $column_name Name of column. + * @return mixed A validated value + */ + private function validate_item_field( $value = '', $column_name = '' ) { + + // Get the column + $column = $this->get_column_by( array( 'name' => $column_name ) ); + + // Bail if no column found + if ( empty( $column ) ) { + return false; + } + + // Validate + return $column->validate( $value ); } /** Queries ***************************************************************/ @@ -1771,6 +2218,9 @@ public function get_item_by( $column_name = '', $column_value = '' ) { */ public function add_item( $data = array() ) { + // Default return value + $retval = false; + // Get the primary column name $primary = $this->get_primary_column_name(); @@ -1800,7 +2250,7 @@ public function add_item( $data = array() ) { unset( $item[ $primary ] ); } - // Cut out non-keys for meta + // Slice data that has columns, and cut out non-keys for meta $columns = $this->get_column_names(); $data = array_merge( $item, $data ); $meta = array_diff_key( $data, $columns ); @@ -1826,35 +2276,39 @@ public function add_item( $data = array() ) { $save[ $modified->name ] = $time; } - // Try to add - $table = $this->get_table_name(); + // Reduce & validate $reduce = $this->reduce_item( 'insert', $save ); $save = $this->validate_item( $reduce ); - $result = ! empty( $save ) - ? $this->get_db()->insert( $table, $save ) - : false; + + // Try to save + if ( ! empty( $save ) ) { + $table = $this->get_table_name(); + $names = array_keys( $save ); + $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' ); + $retval = $this->get_db()->insert( $table, $save, $save_format ); + } // Bail on failure - if ( ! $this->is_success( $result ) ) { + if ( ! $this->is_success( $retval ) ) { return false; } // Get the new item ID - $item_id = $this->get_db()->insert_id; + $retval = $this->get_db()->insert_id; // Maybe save meta keys if ( ! empty( $meta ) ) { - $this->save_extra_item_meta( $item_id, $meta ); + $this->save_extra_item_meta( $retval, $meta ); } // Update item cache(s) - $this->update_item_cache( $item_id ); + $this->update_item_cache( $retval ); // Transition item data - $this->transition_item( $item_id, $save, array() ); + $this->transition_item( $retval, $save, array() ); - // Return result - return $item_id; + // Return + return $retval; } /** @@ -1862,15 +2316,18 @@ public function add_item( $data = array() ) { * * @since 1.1.0 * - * @param int $item_id + * @param int|string $item_id * @param array $data - * @return bool + * @return bool|int */ public function copy_item( $item_id = 0, $data = array() ) { // Get the primary column name $primary = $this->get_primary_column_name(); + // Shape the primary item ID + $item_id = $this->shape_item_id( $item_id ); + // Get item by ID (from database, not cache) $item = $this->get_item_raw( $primary, $item_id ); @@ -1890,7 +2347,7 @@ public function copy_item( $item_id = 0, $data = array() ) { // Unset the primary key unset( $save[ $primary ] ); - // Return result + // Return result of add_item() return $this->add_item( $save ); } @@ -1899,12 +2356,15 @@ public function copy_item( $item_id = 0, $data = array() ) { * * @since 1.0.0 * - * @param int $item_id + * @param int|string $item_id * @param array $data * @return bool */ public function update_item( $item_id = 0, $data = array() ) { + // Default return value + $retval = false; + // Bail early if no data to update if ( empty( $data ) ) { return false; @@ -1960,17 +2420,22 @@ public function update_item( $item_id = 0, $data = array() ) { $save[ $modified->name ] = $this->get_current_time(); } - // Try to update - $table = $this->get_table_name(); + // Reduce & validate $reduce = $this->reduce_item( 'update', $save ); $save = $this->validate_item( $reduce ); - $where = array( $primary => $item_id ); - $result = ! empty( $save ) - ? $this->get_db()->update( $table, $save, $where ) - : false; + + // Try to update + if ( ! empty( $save ) ) { + $table = $this->get_table_name(); + $where = array( $primary => $item_id ); + $names = array_keys( $save ); + $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' ); + $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' ); + $retval = $this->get_db()->update( $table, $save, $where, $save_format, $where_format ); + } // Bail on failure - if ( ! $this->is_success( $result ) ) { + if ( ! $this->is_success( $retval ) ) { return false; } @@ -1980,8 +2445,8 @@ public function update_item( $item_id = 0, $data = array() ) { // Transition item data $this->transition_item( $item_id, $save, $item ); - // Return result - return $result; + // Return + return $retval; } /** @@ -1989,11 +2454,14 @@ public function update_item( $item_id = 0, $data = array() ) { * * @since 1.0.0 * - * @param int $item_id + * @param int|string $item_id * @return bool */ public function delete_item( $item_id = 0 ) { + // Default return value + $retval = false; + // Shape the item ID $item_id = $this->shape_item_id( $item_id ); @@ -2022,12 +2490,13 @@ public function delete_item( $item_id = 0 ) { } // Try to delete - $table = $this->get_table_name(); - $where = array( $primary => $item_id ); - $result = $this->get_db()->delete( $table, $where ); + $table = $this->get_table_name(); + $where = array( $primary => $item_id ); + $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' ); + $retval = $this->get_db()->delete( $table, $where, $where_format ); // Bail on failure - if ( ! $this->is_success( $result ) ) { + if ( ! $this->is_success( $retval ) ) { return false; } @@ -2038,30 +2507,19 @@ public function delete_item( $item_id = 0 ) { /** * Fires after an object has been deleted. * - * @since 2.1.0 + * @since 1.0.0 * * @param int $item_id The ID of the item that was deleted. * @param bool $result Whether the item was successfully deleted. */ - do_action( $this->apply_prefix( "{$this->item_name}_deleted" ), $item_id, $result ); - - // Return result - return $result; - } + do_action( + $this->apply_prefix( "{$this->item_name}_deleted" ), + $item_id, + $retval + ); - /** - * Filter an item before it is inserted of updated in the database. - * - * This method is public to allow subclasses to perform JIT manipulation - * of the parameters passed into it. - * - * @since 1.0.0 - * - * @param array $item - * @return array - */ - public function filter_item( $item = array() ) { - return (array) apply_filters_ref_array( $this->apply_prefix( "filter_{$this->item_name}_item" ), array( $item, &$this ) ); + // Return + return $retval; } /** @@ -2109,41 +2567,9 @@ private function validate_item( $item = array() ) { return $item; } - // Loop through item attributes + // Validate all item fields foreach ( $item as $key => $value ) { - - // Get the column - $column = $this->get_column_by( array( 'name' => $key ) ); - - // Null value is special for all item keys - if ( is_null( $value ) ) { - - // Bail if null is not allowed - if ( false === $column->allow_null ) { - return false; - } - - // Attempt to validate - } elseif ( ! empty( $column->validate ) && is_callable( $column->validate ) ) { - $validated = call_user_func( $column->validate, $value ); - - // Bail if error - if ( is_wp_error( $validated ) ) { - return false; - } - - // Update the value - $item[ $key ] = $validated; - - /** - * Fallback to using the raw value. - * - * Note: This may change at a later date, so do not rely on this. - * Please always validate all data. - */ - } else { - $item[ $key ] = $value; - } + $item[ $key ] = $this->validate_item_field( $value, $key ); } // Return the validated item @@ -2199,28 +2625,30 @@ private function reduce_item( $method = 'update', $item = array() ) { } /** - * Return an item comprised of all default values. + * Return an item comprised of all Column names as keys and their defaults + * as values. * - * This is used by `add_item()` to populate known default values, to ensure - * new item data is always what we expect it to be. + * This is used by `add_item()` to get an array of default item values that + * can be compared against, to determine if any values need to be saved into + * meta data instead. * * @since 1.0.0 + * @since 2.1.0 Uses array_combine() * + * @param array $args Default empty array. Parsed & passed into get_columns(). * @return array */ - private function default_item() { + private function default_item( $args = array() ) { - // Default return value - $retval = array(); + // Parse arguments + $r = wp_parse_args( $args ); // Get the column names and their defaults - $names = $this->get_columns( array(), 'and', 'name' ); - $defaults = $this->get_columns( array(), 'and', 'default' ); + $names = $this->get_columns( $r, 'and', 'name' ); + $defaults = $this->get_columns( $r, 'and', 'default' ); - // Put together an item using default values - foreach ( $names as $key => $name ) { - $retval[ $name ] = $defaults[ $key ]; - } + // Combine them + $retval = array_combine( $names, $defaults ); // Return return $retval; @@ -2234,7 +2662,7 @@ private function default_item() { * * @since 1.0.0 * - * @param int $item_id + * @param int|string $item_id * @param array $new_data * @param array $old_data */ @@ -2306,10 +2734,10 @@ private function transition_item( $item_id = 0, $new_data = array(), $old_data = * * @since 1.0.0 * - * @param int $item_id - * @param string $meta_key - * @param string $meta_value - * @param bool $unique + * @param int|string $item_id + * @param string $meta_key + * @param string $meta_value + * @param bool $unique * @return int|false The meta ID on success, false on failure. */ protected function add_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $unique = false ) { @@ -2339,9 +2767,9 @@ protected function add_item_meta( $item_id = 0, $meta_key = '', $meta_value = '' * * @since 1.0.0 * - * @param int $item_id - * @param string $meta_key - * @param bool $single + * @param int|string $item_id + * @param string $meta_key + * @param bool $single * @return mixed Single metadata value, or array of values */ protected function get_item_meta( $item_id = 0, $meta_key = '', $single = false ) { @@ -2371,10 +2799,10 @@ protected function get_item_meta( $item_id = 0, $meta_key = '', $single = false * * @since 1.0.0 * - * @param int $item_id - * @param string $meta_key - * @param string $meta_value - * @param string $prev_value + * @param int|string $item_id + * @param string $meta_key + * @param string $meta_value + * @param string $prev_value * @return bool True on successful update, false on failure. */ protected function update_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $prev_value = '' ) { @@ -2404,10 +2832,10 @@ protected function update_item_meta( $item_id = 0, $meta_key = '', $meta_value = * * @since 1.0.0 * - * @param int $item_id - * @param string $meta_key - * @param string $meta_value - * @param bool $delete_all + * @param int|string $item_id + * @param string $meta_key + * @param string $meta_value + * @param bool $delete_all * @return bool True on successful delete, false on failure. */ protected function delete_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $delete_all = false ) { @@ -2455,7 +2883,8 @@ private function get_registered_meta_keys( $object_subtype = '' ) { * * @since 1.0.0 * - * @param array $meta + * @param int|string $item_id + * @param array $meta */ private function save_extra_item_meta( $item_id = 0, $meta = array() ) { @@ -2494,7 +2923,7 @@ private function save_extra_item_meta( $item_id = 0, $meta = array() ) { * * @since 1.0.0 * - * @param int $item_id + * @param int|string $item_id */ private function delete_all_item_meta( $item_id = 0 ) { @@ -2518,10 +2947,11 @@ private function delete_all_item_meta( $item_id = 0 ) { $primary = $this->get_primary_column_name(); // Guess the item ID column for the meta table - $item_id_column = $this->apply_prefix( "{$this->item_name}_{$primary}" ); + $item_id_column = $this->apply_prefix( "{$this->item_name}_{$primary}" ); + $item_id_pattern = $this->get_column_field( array( 'name' => $primary ), 'pattern', '%s' ); // Get meta IDs - $query = "SELECT meta_id FROM {$table} WHERE {$item_id_column} = %d"; + $query = "SELECT meta_id FROM {$table} WHERE {$item_id_column} = {$item_id_pattern}"; $prepared = $this->get_db()->prepare( $query, $item_id ); $meta_ids = $this->get_db()->get_col( $prepared ); @@ -2573,7 +3003,7 @@ private function get_meta_table_name() { * Get the meta type for this query. * * This method exists to reduce some duplication for now. Future iterations - * will likely use Column::relationships to + * will likely use Column::relationships to more reliably predict this. * * @since 1.1.0 * @@ -2590,6 +3020,7 @@ private function get_meta_type() { * * @since 1.0.0 * + * @param string $group * @return string */ private function get_cache_key( $group = '' ) { @@ -2597,7 +3028,7 @@ private function get_cache_key( $group = '' ) { // Slice query vars $slice = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) ); - // Unset `fields` so it does not effect the cache key + // Unset "fields" so it does not effect the cache key unset( $slice['fields'] ); // Setup key & last_changed @@ -2694,7 +3125,11 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { // Accepts single values, so cast to array $item_ids = (array) $item_ids; - // Update item caches + /** + * Update item caches. + * + * Uses our own get_non_cached_ids() method to avoid + */ if ( ! empty( $force ) || ! empty( $this->query_vars['update_item_cache'] ) ) { // Look for non-cached IDs @@ -2708,10 +3143,10 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { // Get query parts $table = $this->get_table_name(); $primary = $this->get_primary_column_name(); + $ids = $this->get_in_sql( $primary, $ids ); // Query database - $query = "SELECT * FROM {$table} WHERE {$primary} IN (%s)"; - $ids = implode( ',', array_map( 'absint', $ids ) ); + $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; $prepare = sprintf( $query, $ids ); $results = $this->get_db()->get_results( $prepare ); @@ -2719,7 +3154,14 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { $this->update_item_cache( $results ); } - // Update meta data caches + /** + * Update meta data caches. + * + * Uses update_meta_cache() because it politely handles all of the + * uncached ID logic. This allows us to use the original (and likely + * larger) $item_ids array instead of $ids, thus ensuring the everything + * is cached according to our expectations. + */ if ( ! empty( $this->query_vars['update_meta_cache'] ) ) { $singular = rtrim( $this->table_name, 's' ); // sic update_meta_cache( $singular, $item_ids ); @@ -2738,6 +3180,7 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { * querying for it again. It's just safer this way. * * @since 1.0.0 + * @since 2.1.0 Uses shape_item_id() if $items is scalar * * @param int|object|array $items Primary ID if int. Row if object. Array * of objects if array. @@ -2745,13 +3188,16 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { private function update_item_cache( $items = array() ) { // Maybe query for single item - if ( is_numeric( $items ) ) { + if ( is_scalar( $items ) ) { // Get the primary column name $primary = $this->get_primary_column_name(); + // Shape the primary item ID + $item_id = $this->shape_item_id( $items ); + // Get item by ID (from database, not cache) - $items = $this->get_item_raw( $primary, $items ); + $items = $this->get_item_raw( $primary, $item_id ); } // Bail if no items to cache @@ -2887,6 +3333,7 @@ private function get_last_changed_cache( $group = '' ) { * Get array of non-cached item IDs. * * @since 1.0.0 + * @since 2.1.0 No longer uses shape_item_id() * * @param array $item_ids Array of item IDs * @param string $group Cache group. Defaults to $this->cache_group @@ -2906,11 +3353,8 @@ private function get_non_cached_ids( $item_ids = array(), $group = '' ) { // Loop through item IDs foreach ( $item_ids as $id ) { - // Shape the item ID - $id = $this->shape_item_id( $id ); - // Add to return value if not cached - if ( false === $this->cache_get( (string) $id, $group ) ) { + if ( false === $this->cache_get( $id, $group ) ) { $retval[] = $id; } } @@ -2953,9 +3397,9 @@ private function cache_add( $key = '', $value = '', $group = '', $expire = 0 ) { * * @since 1.0.0 * - * @param string $key Cache key. - * @param string $group Cache group. Defaults to $this->cache_group - * @param bool $force + * @param int|string $key Cache key. + * @param string $group Cache group. Defaults to $this->cache_group + * @param bool $force */ private function cache_get( $key = '', $group = '', $force = false ) { @@ -3030,10 +3474,150 @@ private function cache_delete( $key = '', $group = '' ) { wp_cache_delete( $key, $group ); } + /** Filters ***************************************************************/ + + /** + * Filter an item before it is inserted or updated in the database. + * + * @since 2.1.0 + * + * @param array $item The item data. + * @return array + */ + public function filter_item( $item = array() ) { + + /** + * Filters an item before it is inserted or updated. + * + * @since 1.0.0 + * + * @param array $item The item as an array. + * @param Query &$this Current instance passed by reference. + */ + return (array) apply_filters_ref_array( + $this->apply_prefix( "filter_{$this->item_name}_item" ), + array( + $item, + &$this + ) + ); + } + + /** + * Filter all shaped items after they are retrieved from the database. + * + * @since 2.1.0 + * + * @param array $items The item data. + * @return array + */ + public function filter_items( $items = array() ) { + + /** + * Filters the object query results after they have been shaped. + * + * @since 1.0.0 + * + * @param array $retval An array of items. + * @param Query &$this Current instance passed by reference. + */ + return (array) apply_filters_ref_array( + $this->apply_prefix( "the_{$this->item_name_plural}" ), + array( + $items, + &$this + ) + ); + } + + /** + * Filter the found items query. + * + * @since 2.1.0 + * + * @return string + */ + public function filter_found_items_query() { + + /** + * Filters the query used to retrieve the found item count. + * + * @since 1.0.0 + * + * @param string $query SQL query. Default 'SELECT FOUND_ROWS()'. + * @param Query &$this Current instance passed by reference. + */ + return (string) apply_filters_ref_array( + $this->apply_prefix( "found_{$this->item_name_plural}_query" ), + array( + 'SELECT FOUND_ROWS()', + &$this + ) + ); + } + + /** + * Filter the query clauses before they are parsed into a SQL string. + * + * @since 2.1.0 + * + * @param array $clauses All of the SQL query clauses. + * @return array + */ + public function filter_query_clauses( $clauses = array() ) { + + /** + * Filters the item query clauses. + * + * @since 1.0.0 + * + * @param array $clauses An array of query clauses. + * @param Query &$this Current instance passed by reference. + */ + return (array) apply_filters_ref_array( + $this->apply_prefix( "{$this->item_name_plural}_query_clauses" ), + array( + $clauses, + &$this + ) + ); + } + + /** + * Filters the columns to search by. + * + * @since 2.1.0 + * + * @param array $search_columns All of the columns to search. + * @return array + */ + public function filter_search_columns( $search_columns = array() ) { + + /** + * Filters the columns to search by. + * + * @since 1.0.0 + * @since 2.1.0 Uses apply_filters_ref_array() instead of apply_filters() + * + * @param array $search_columns Array of column names to be searched. + * @param Query &$this Current instance passed by reference. + */ + return (array) apply_filters_ref_array( + $this->apply_prefix( "{$this->item_name_plural}_search_columns" ), + array( + $search_columns, + &$this + ) + ); + } + + /** General ***************************************************************/ + /** * Fetch raw results directly from the database. * * @since 1.0.0 + * @since 2.1.0 Uses query() * * @param array $cols Columns for `SELECT`. * @param array $where_cols Where clauses. Each key-value pair in the array @@ -3055,117 +3639,17 @@ private function cache_delete( $key = '', $group = '' ) { */ public function get_results( $cols = array(), $where_cols = array(), $limit = 25, $offset = null, $output = OBJECT ) { - // Bail if no columns have been passed - if ( empty( $cols ) ) { - return null; - } - - // Fetch all the columns for the table being queried - $column_names = $this->get_column_names(); - - // Ensure valid column names have been passed for the `SELECT` clause - foreach ( $cols as $index => $column ) { - if ( ! array_key_exists( $column, $column_names ) ) { - unset( $cols[ $index ] ); - } - } - - // Columns to retrieve - $columns = implode( ',', $cols ); - - // Get the table name - $table = $this->get_table_name(); - - // Setup base query - $query = implode( ' ', array( - "SELECT", - $columns, - "FROM {$table} {$this->table_alias}", - "WHERE 1=1" + // Parse arguments + $r = wp_parse_args( $where_cols, array( + 'fields' => $cols, + 'number' => $limit, + 'offset' => $offset, + 'output' => $output, + 'update_item_cache' => false, + 'update_meta_cache' => false, ) ); - // Ensure valid columns have been passed for the `WHERE` clause - if ( ! empty( $where_cols ) ) { - - // Get keys from where columns - $columns = array_keys( $where_cols ); - - // Loop through columns and unset any invalid names - foreach ( $columns as $index => $column ) { - if ( ! array_key_exists( $column, $column_names ) ) { - unset( $where_cols[ $index ] ); - } - } - - // Parse WHERE clauses - foreach ( $where_cols as $column => $compare ) { - - // Basic WHERE clause - if ( ! is_array( $compare ) ) { - $pattern = $this->get_column_field( array( 'name' => $column ), 'pattern', '%s' ); - $statement = " AND {$this->table_alias}.{$column} = {$pattern} "; - $query .= $this->get_db()->prepare( $statement, $compare ); - - // More complex WHERE clause - } else { - $value = isset( $compare['value'] ) - ? $compare['value'] - : false; - - // Skip if a value was not provided - if ( false === $value ) { - continue; - } - - // Default compare clause to equals - $compare_clause = isset( $compare['compare_query'] ) - ? trim( strtoupper( $compare['compare_query'] ) ) - : '='; - - // Array (unprepared) - if ( is_array( $compare['value'] ) ) { - - // Default to IN if clause not specified - if ( ! in_array( $compare_clause, array( 'IN', 'NOT IN', 'BETWEEN' ), true ) ) { - $compare_clause = 'IN'; - } - - // Parse & escape for IN and NOT IN - if ( 'IN' === $compare_clause || 'NOT IN' === $compare_clause ) { - $value = "('" . implode( "','", $this->get_db()->_escape( $compare['value'] ) ) . "')"; - - // Parse & escape for BETWEEN - } elseif ( is_array( $value ) && 2 === count( $value ) && 'BETWEEN' === $compare_clause ) { - $_this = $this->get_db()->_escape( $value[0] ); - $_that = $this->get_db()->_escape( $value[1] ); - $value = " {$_this} AND {$_that} "; - } - } - - // Add WHERE clause - $query .= " AND {$this->table_alias}.{$column} {$compare_clause} {$value} "; - } - } - } - - // Maybe set an offset - if ( ! empty( $offset ) ) { - $values = explode( ',', $offset ); - $values = array_map( 'intval', array_filter( $values ) ); - $offset = implode( ',', $values ); - $query .= " OFFSET {$offset} "; - } - - // Maybe set a limit - if ( ! empty( $limit ) && ( $limit > 0 ) ) { - $limit = intval( $limit ); - $query .= " LIMIT {$limit} "; - } - - // Execute query - $results = $this->get_db()->get_results( $query, $output ); - - // Return results - return $results; + // Get items + return $this->query( $r ); } } diff --git a/src/Database/Row.php b/src/Database/Row.php index da810a1..defc039 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Row - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 80a7c99..311f71c 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Schema - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ @@ -21,11 +21,32 @@ * including global tables for multisite, and users tables. * * @since 1.0.0 + * @since 2.1.0 Added variables for Column & Index */ class Schema extends Base { + /** Item Types ************************************************************/ + + /** + * Schema Column class. + * + * @since 2.1.0 + * @var string + */ + protected $column = __NAMESPACE__ . '\\Column'; + + /** + * Schema Index class. + * + * @since 2.1.0 + * @var string + */ + protected $index = __NAMESPACE__ . '\\Index'; + + /** Item Objects **********************************************************/ + /** - * Array of database column objects to turn into Column. + * Array of database Column objects. * * @since 1.0.0 * @var array @@ -33,56 +54,266 @@ class Schema extends Base { protected $columns = array(); /** - * Invoke new column objects based on array of column data. + * Array of database Index objects. + * + * @since 2.1.0 + * @var array + */ + protected $indexes = array(); + + /** Public Methods ********************************************************/ + + /** + * Setup the Schema object, and parse any arguments passed in. * * @since 1.0.0 */ - public function __construct() { + public function __construct( $args = array() ) { + + // Setup the Schema + $this->setup(); + + // Parse arguments if not empty + if ( ! empty( $args ) ) { + $this->parse_args( $args ); + } + } + + /** + * Setup the class variables. + * + * This method includes legacy support for Schema objects that predefined + * their array of Columns. This approach will not be removed, as it was the + * only way to register Columns in all versions before 2.1.0. + * + * @since 2.1.0 + */ + public function setup() { + + // Legacy support for pre-set $columns array + if ( ! empty( $this->columns ) && is_array( $this->columns ) ) { + $this->setup_items( 'columns', $this->column, $this->columns ); + } + + // Legacy support for pre-set $indexes array + if ( ! empty( $this->indexes ) && is_array( $this->indexes ) ) { + $this->setup_items( 'indexes', $this->index, $this->indexes ); + } + } + + /** + * Parse all of the arguments. + * + * @since 2.1.0 + * @param array $args + */ + public function parse_args( $args = array() ) { - // Bail if no columns - if ( empty( $this->columns ) || ! is_array( $this->columns ) ) { + // Stash arguments + $this->stash_args( $args ); + + // Bail if no args to parse + if ( empty( $args ) ) { return; } - // Juggle original columns array - $columns = $this->columns; - $this->columns = array(); + // Types of objects to parse + $r = wp_parse_args( $args, $this->args['class'] ); - // Loop through columns and create objects from them - foreach ( $columns as $column ) { - if ( is_array( $column ) ) { - $this->columns[] = new Column( $column ); - } elseif ( $column instanceof Column ) { - $this->columns[] = $column; - } + // Set variables + $this->set_vars( $r ); + + // Parse item types + $this->parse_item_types(); + } + + /** + * Clear some part of the schema. + * + * Will clear all items if nothing is passed. + * + * @since 2.1.0 + * @param string $type The type of items to clear. + */ + public function clear( $type = '' ) { + + // Clearing specific + if ( ! empty( $type ) ) { + $this->{$type} = array(); + + // Clearing everything + } else { + $this->columns = array(); + $this->indexes = array(); } } /** - * Return the schema in string form. + * Add an item to a specific items array. * - * @since 1.0.0 + * @since 2.1.0 + * @param string $type Item type to add. + * @param string $class Class to shape item into. + * @param array|object $data Data to pass into class constructor. + * @return bool|object + */ + public function add_item( $type = 'column', $class = 'Column', $data = false ) { + + // Default return value + $retval = false; + + // Bail if no data to add + if ( empty( $data ) ) { + return false; + } + + // Array + if ( is_array( $data ) ) { + $retval = new $class( $data ); + + // Object + } elseif ( $data instanceof $class ) { + $retval = $data; + } + + // Bail if no + if ( empty( $retval ) ) { + return false; + } + + // Add item to array + $this->{$type}[] = $retval; + + // Return the item + return $retval; + } + + /** + * Return the SQL used for all items in a "CREATE TABLE" query. + * + * This does not include the "CREATE TABLE" directive itself, and is only + * used to generate the SQL inside of that kind of query. * - * @return string Calls get_create_string() on every column. + * @since 2.1.0 + * @return string */ - protected function to_string() { + public function get_create_table_string() { + + // Get strings + $strings = array( + $this->get_items_create_string( 'columns' ), + $this->get_items_create_string( 'indexes' ) + ); + + // Format + $retval = implode( ",\n", array_filter( $strings ) ); + + // Return + return $retval; + } + + /** Private Helpers *******************************************************/ + + /** + * Parse all item types. + * + * This simply calls setup() after all arguments have been parsed. + * A future version of setup() may require this method to change. + * + * @since 2.1.0 + */ + private function parse_item_types() { + $this->setup(); + } + + /** + * Setup an array of items. + * + * @since 2.1.0 + * @param string $type Type of items to setup. + * @param string $class Class to use to create objects. + * @param array $values Array of values to convert to objects. + * @return array Array of items that were setup. + */ + private function setup_items( $type = 'columns', $class = 'Column', $values = array() ) { + + // Bail if no items + if ( empty( $this->{$type} ) || ! is_array( $this->{$type} ) ) { + return array(); + } + + // Bail if no class + if ( empty( $class ) || ! class_exists( $class ) ) { + return array(); + } + + // Clear items for type + $this->clear( $type ); + + // Bail if no values + if ( empty( $values ) || ! is_array( $values ) ) { + return array(); + } + + // Loop through values and create objects from them + foreach ( $values as $item ) { + $this->add_item( $type, $class, $item ); + } + + // Return the items + return $this->{$type}; + } + + /** + * Return the SQL for an item type used in a "CREATE TABLE" query. + * + * @since 2.1.0 + * @param string $type Type of item. + * @return string Calls get_create_string() on every item. + */ + private function get_items_create_string( $type = 'columns' ) { // Default return value $retval = ''; - // Bail if no columns to convert - if ( empty( $this->columns ) ) { + // Bail if no items to get strings from + if ( empty( $this->{$type} ) || ! is_array( $this->{$type} ) ) { return $retval; } - // Loop through columns... - foreach ( $this->columns as $column_info ) { - if ( method_exists( $column_info, 'get_create_string' ) ) { - $retval .= '\n' . $column_info->get_create_string() . ', '; + // Improve readability + $indent = ' '; + + // Default strings + $strings = array(); + + // Loop through items... + foreach ( $this->{$type} as $item ) { + if ( method_exists( $item, 'get_create_string' ) ) { + $strings[] = $indent . $item->get_create_string(); } } - // Return the string + // Format + $retval = implode( ",\n", $strings ); + + // Return the SQL return $retval; } + + /** Deprecated ************************************************************/ + + /** + * Return the columns in string form. + * + * This method was deprecated in 2.1.0 because in previous versions it only + * included Columns and did not include Indexes. + * + * @since 1.0.0 + * @deprecated 2.1.0 + * @return string + */ + protected function to_string() { + return $this->get_items_create_string( 'columns' ); + } } diff --git a/src/Database/Table.php b/src/Database/Table.php index 970c0f1..320ce96 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -4,7 +4,7 @@ * * @package Database * @subpackage Table - * @copyright Copyright (c) 2021 + * @copyright 2021-2022 - JJJ and all BerlinDB contributors * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ @@ -621,7 +621,7 @@ public function count() { $query = "SELECT COUNT(*) FROM {$this->table_name}"; $result = $db->get_var( $query ); - // Query success/fail + // 0 on error/empty, number of rows on success return intval( $result ); } @@ -629,8 +629,9 @@ public function count() { * Check if column already exists. * * @since 1.0.0 + * @since 2.1.0 Uses sanitize_column_name(). * - * @param string $name Value + * @param string $name Column name to check. * * @return bool */ @@ -646,6 +647,7 @@ public function column_exists( $name = '' ) { // Query statement $query = "SHOW COLUMNS FROM {$this->table_name} LIKE %s"; + $name = $this->sanitize_column_name( $name ); $like = $db->esc_like( $name ); $prepared = $db->prepare( $query, $like ); $result = $db->query( $prepared ); @@ -658,9 +660,10 @@ public function column_exists( $name = '' ) { * Check if index already exists. * * @since 1.0.0 + * @since 2.1.0 Uses sanitize_column_name(). * - * @param string $name Value - * @param string $column Column name + * @param string $name Index name to check. + * @param string $column Column name to compare. * * @return bool */ @@ -681,6 +684,7 @@ public function index_exists( $name = '', $column = 'Key_name' ) { // Query statement $query = "SHOW INDEXES FROM {$this->table_name} WHERE {$column} LIKE %s"; + $name = $this->sanitize_column_name( $name ); $like = $db->esc_like( $name ); $prepared = $db->prepare( $query, $like ); $result = $db->query( $prepared ); From 7c515b0a2f9858c73af16ce48b0dd4fb9bd73647 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Mon, 27 Jun 2022 16:02:57 -0500 Subject: [PATCH 09/34] More readme edits --- README.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b43f163..f4a0dae 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,38 @@ # BerlinDB -BerlinDB is a collection of PHP classes and methods provides an ORM-like experience & interface to database tables in WordPress. +BerlinDB is a collection of PHP classes & methods to provide an ORM-like interface to database tables in WordPress. -The most common use-case for BerlinDB is a WordPress Plugin that needs to create custom database tables, but more advanced uses are possible, including managing and interfacing with the WordPress Core database tables themselves. +Use it to move data out of custom Post Types & Taxonomies and into custom database tables. -## Mission +Ensure perform reliably and scale effortlessly in highly available WordPress based web applications. +## Mission The primary mission of BerlinDB is to democratize data storage. -### Phase 1 -Reduce the overall labor required to perform routine & repetitive database interactions. +### Phase 1 - 2022 +Minimize the effort required to perform routine & repetitive database interactions. -### Phase 2 +### Phase 2 - 2022 Achieve platform agnosticism through smart abstractions and interoperability layers. -### Phase 3 +### Phase 3 - 2022 Generate the custom code that is necessary from any existing database table structure. -### Phase 4 +### Phase 4 - 2023 Automate database table structure changes for a seamless upgrade/rollback experience. -### Phase 5 +### Phase 5 - 2023 Manage all database connections to directly support reads, writes, clones, splitting, and sharding. ## Name -The name of this project comes from [WordCamp Europe 2019](https://europe.wordcamp.org/2019/) – which took place in the beautiful & historic capital city of Berlin, Germany – where it was originally exhibited & announced as an unnamed utility being used by the Sandhills Development engineering team. +This project is named for [WordCamp Europe 2019](https://europe.wordcamp.org/2019/) which took place in the beautiful & historic capital city of Berlin, Germany, where it was originally exhibited & announced as an unnamed utility being used by the Sandhills Development engineering team. -Peter Wilson recommended naming it "Berlin" to commemorate everyone in attendance for its unveiling. +Peter Wilson recommended naming it "Berlin" to commemorate everyone in attendance for its unveiling. Thanks, Peter! 🙏 -## Story +## Beginnings -The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning multiple continents, native languages, and years of conceptual development & iteration: +The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning several continents, native languages, and years of conceptual development & iteration: * BuddyPress (inspired by) * WordPress Multisite (inspired by) @@ -39,9 +40,7 @@ The code in this repository represents the cumulative effort of dozens of indivi * Sugar Calendar (2.0 and higher) * Restrict Content Pro (3.1 and higher) -The above projects use custom database tables to perform reliably and scale effortlessly in highly available WordPress based web applications. - -## Contribution +## Contributions Interested in contributing? See the [contributing guide](/CONTRIBUTING.md). From 811e399225dcfc27670b5787ec841ce1e3bed884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Sz=C3=A9pe?= Date: Mon, 27 Jun 2022 21:28:46 +0000 Subject: [PATCH 10/34] Remove temporary variable (#141) --- src/Database/Base.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index 0616213..1e9108c 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -163,11 +163,8 @@ protected function apply_prefix( $string = '', $sep = '_' ) { return $retval; } - // Setup prefixed string - $retval = $new_prefix . $retval; - - // Return the result - return $retval; + // Return prefixed string + return $new_prefix . $retval; } /** From 678cae9caba02d4a36a473bcd740e69d87056aa9 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Tue, 28 Jun 2022 17:13:15 -0500 Subject: [PATCH 11/34] Base: minor refactor to magic methods, and is_success() --- src/Database/Base.php | 52 +++++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index 0616213..0793180 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -69,20 +69,15 @@ class Base { */ public function __isset( $key = '' ) { - // No more uppercase ID properties ever - if ( 'ID' === $key ) { - $key = 'id'; - } - // Class method to try and call $method = "get_{$key}"; - // Return property if exists - if ( method_exists( $this, $method ) ) { + // Return callable method exists + if ( is_callable( array( $this, $method ) ) ) { return true; } - // Return get method results if exists + // Return property if exists return property_exists( $this, $key ); } @@ -96,19 +91,14 @@ public function __isset( $key = '' ) { */ public function __get( $key = '' ) { - // No more uppercase ID properties ever - if ( 'ID' === $key ) { - $key = 'id'; - } - // Class method to try and call $method = "get_{$key}"; - // Return property if exists - if ( method_exists( $this, $method ) ) { + // Return get method results if callable + if ( is_callable( array( $this, $method ) ) ) { return call_user_func( array( $this, $method ) ); - // Return get method results if exists + // Return property value if exists } elseif ( property_exists( $this, $key ) ) { return $this->{$key}; } @@ -164,10 +154,7 @@ protected function apply_prefix( $string = '', $sep = '_' ) { } // Setup prefixed string - $retval = $new_prefix . $retval; - - // Return the result - return $retval; + return $new_prefix . $retval; } /** @@ -381,27 +368,28 @@ protected function get_db() { * pass falsy values on success. * * @since 1.0.0 + * @since 2.1.0 Minor refactor to improve readability. * - * @param mixed $result Default false. + * @param mixed $result Optional. Default false. Any value to check. * @return bool */ protected function is_success( $result = false ) { - // Bail if falsy result - if ( empty( $result ) ) { - $retval = false; - - // Bail if an error occurred - } elseif ( is_wp_error( $result ) ) { - $this->last_error = $result; - $retval = false; + // Default return value + $retval = false; - // No errors - } else { + // Non-empty is success + if ( ! empty( $result ) ) { $retval = true; + + // But Error is still fail, so stash it + if ( is_wp_error( $result ) ) { + $this->last_error = $result; + $retval = false; + } } // Return the result - return $retval; + return (bool) $retval; } } From de871644a3970e101054273afc4fdd34b1f673e1 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Tue, 28 Jun 2022 17:13:43 -0500 Subject: [PATCH 12/34] Query: MySQL 8 support * Remove $columns * Improve query parsing to allow reuse for second COUNT(*) query_clause overrides * Add support for SELECT & EXPLAIN clauses * Add several new methods to abstract out newly repeated behaviors * Add is_valid_column() and get_query_var() and get_column_name_alias() to help with repeated code patterns * Add parse_query_vars() again, to help abstract only the parsing part * Use get_meta_type() when updating meta data * Update prime_item_caches() to not bail early so it can continue on and try updating meta data --- src/Database/Query.php | 1000 +++++++++++++++++++++++----------------- 1 file changed, 580 insertions(+), 420 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 73ef97e..dda58d2 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -138,16 +138,6 @@ class Query extends Base { */ protected $last_changed = ''; - /** Columns ***************************************************************/ - - /** - * Array of all database column objects. - * - * @since 1.0.0 - * @var array - */ - protected $columns = array(); - /** Schema *************************************************************/ /** @@ -300,29 +290,29 @@ class Query extends Base { * Optional. Array or query string of item query parameters. * Default empty. * - * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs) - * or empty (returns an array of complete item objects). Default empty. - * To do a date query against a field, append the field name with _query - * @type bool $count Whether to return a item count (true) or array of item objects. - * Default false. - * @type int $number Limit number of items to retrieve. Use 0 for no limit. - * Default 100. - * @type int $offset Number of items to offset the query. Used to build LIMIT clause. - * Default 0. - * @type bool $no_found_rows Whether to disable the `SQL_CALC_FOUND_ROWS` query. - * Default true. - * @type array|string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. - * Default '', to primary column ID. - * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'. - * Default 'DESC'. - * @type string $search Search term(s) to retrieve matching items for. - * Default empty. - * @type array $search_columns Array of column names to be searched. - * Default empty array. - * @type bool $update_item_cache Whether to prime the cache for found items. - * Default false. - * @type bool $update_meta_cache Whether to prime the meta cache for found items. - * Default false. + * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs) + * or empty (returns an array of complete item objects). Default empty. + * To do a date query against a field, append the field name with _query + * @type bool $count Return an item count (true) or array of item objects. + * Default false. + * @type int $number Limit number of items to retrieve. Use 0 for no limit. + * Default 100. + * @type int $offset Number of items to offset the query. Used to build LIMIT clause. + * Default 0. + * @type bool $no_found_rows Disable the separate COUNT(*) query. + * Default true. + * @type string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. + * Default '', to primary column ID. + * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'. + * Default 'DESC'. + * @type string $search Search term(s) to retrieve matching items for. + * Default empty. + * @type array $search_columns Array of column names to be searched. + * Default empty array. + * @type bool $update_item_cache Prime the cache for found items. + * Default false. + * @type bool $update_meta_cache Prime the meta cache for found items. + * Default false. * } */ public function __construct( $query = array() ) { @@ -337,7 +327,7 @@ public function __construct( $query = array() ) { } /** - * Setup the class variables. + * Setup class attributes that rely on other properties. * * This method is public to allow subclasses to override it, and allow for * it to be called directly on a class that has already been used. @@ -449,6 +439,7 @@ private function set_query_clause_defaults() { // Default query clauses $this->query_clauses = array( + 'explain' => '', 'select' => '', 'fields' => '', 'count' => '', @@ -484,17 +475,29 @@ private function set_query_var_defaults() { // Default query variables $this->query_var_defaults = array( + + // Statements + 'explain' => false, + 'select' => '', + + // Fields 'fields' => '', + 'groupby' => '', + + // Boundaries 'number' => 100, 'offset' => '', 'orderby' => $primary, 'order' => 'DESC', - 'groupby' => '', + + // Search 'search' => '', 'search_columns' => array(), + + // COUNT(*) 'count' => false, - // Disable SQL_CALC_FOUND_ROWS? + // Disable row count 'no_found_rows' => true, // Queries @@ -508,7 +511,7 @@ private function set_query_var_defaults() { ); // Direct column names - $names = $this->get_column_names(); + $names = array_flip( $this->get_column_names() ); foreach ( $names as $name ) { $this->query_var_defaults[ $name ] = $this->query_var_default_value; } @@ -536,86 +539,39 @@ private function set_query_var_defaults() { } /** - * Set the request clauses. + * Set $query_clauses by parsing $query_vars. * - * @since 1.0.0 - * - * @param array $clauses + * @since 2.1.0 */ - private function set_request_clauses( $clauses = array() ) { - - // Found rows - $found_rows = empty( $this->query_vars['no_found_rows'] ) - ? 'SQL_CALC_FOUND_ROWS' - : ''; - - // Count - $count = ! empty( $clauses['count'] ) - ? $clauses['count'] - : ''; - - // Fields - $fields = ! empty( $clauses['fields'] ) - ? $clauses['fields'] - : ''; - - // Join - $join = ! empty( $clauses['join'] ) - ? $clauses['join'] - : ''; - - // Where - $where = ! empty( $clauses['where'] ) - ? "WHERE {$clauses['where']}" - : ''; - - // Group by - $groupby = ! empty( $clauses['groupby'] ) - ? "GROUP BY {$clauses['groupby']}" - : ''; - - // Order by - $orderby = ! empty( $clauses['orderby'] ) - ? "ORDER BY {$clauses['orderby']}" - : ''; - - // Limits - $limits = ! empty( $clauses['limits'] ) - ? $clauses['limits'] - : ''; - - // Select & From - $table = $this->get_table_name(); - $select = "SELECT {$found_rows}"; - $from = "FROM {$table} {$this->table_alias}"; + private function set_query_clauses() { + $this->query_clauses = $this->parse_query_vars(); + } - // Put query into clauses array - $this->request_clauses['select'] = $select; - $this->request_clauses['fields'] = $fields; - $this->request_clauses['count'] = $count; - $this->request_clauses['from'] = $from; - $this->request_clauses['join'] = $join; - $this->request_clauses['where'] = $where; - $this->request_clauses['groupby'] = $groupby; - $this->request_clauses['orderby'] = $orderby; - $this->request_clauses['limits'] = $limits; + /** + * Set the $request_clauses. + * + * @since 1.0.0 + * @since 2.1.0 Uses parse_query_clauses() with support for new clauses. + */ + private function set_request_clauses() { + $this->request_clauses = $this->parse_query_clauses(); } /** - * Set the request. + * Set the $request. * * @since 1.0.0 + * @since 2.1.0 Uses parse_request_clauses() on $request_clauses. */ private function set_request() { - $filtered = array_filter( $this->request_clauses ); - $clauses = array_map( 'trim', $filtered ); - $this->request = implode( ' ', $clauses ); + $this->request = $this->parse_request_clauses(); } /** * Set items by mapping them through the single item callback. * * @since 1.0.0 + * @since 2.1.0 Moved 'count' logic back into get_items(). * @param array $item_ids */ private function set_items( $item_ids = array() ) { @@ -634,9 +590,8 @@ private function set_items( $item_ids = array() ) { * Populates found_items and max_num_pages properties for the current query * if the limit clause was used. * - * @todo: make safe for MySQL 8 - * * @since 1.0.0 + * @since 2.1.0 Uses filter_found_items_query(). * * @param mixed $item_ids Optional array of item IDs */ @@ -646,10 +601,10 @@ private function set_found_items( $item_ids = array() ) { $this->found_items = count( (array) $item_ids ); // Count query - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Not grouped - if ( is_numeric( $item_ids ) && empty( $this->query_vars['groupby'] ) ) { + if ( is_numeric( $item_ids ) && ! $this->get_query_var( 'groupby' ) ) { $this->found_items = (int) $item_ids; } @@ -658,18 +613,30 @@ private function set_found_items( $item_ids = array() ) { is_array( $item_ids ) && ( - ! empty( $this->query_vars['number'] ) - && - empty( $this->query_vars['no_found_rows'] ) + $this->get_query_var( 'number' ) && ! $this->get_query_var( 'no_found_rows' ) ) ) { - // Get the found items SQL - $found_items_query = $this->filter_found_items_query(); + // Override a few request clauses + $r = wp_parse_args( + array( + 'count' => 'COUNT(*)', + 'fields' => '', + 'limits' => '', + 'orderby' => '' + ), + $this->request_clauses + ); + + // Parse the new clauses + $query = $this->parse_request_clauses( $r ); + + // Filter the found items query + $query = $this->filter_found_items_query( $query ); // Maybe query for found items - if ( ! empty( $found_items_query ) ) { - $this->found_items = (int) $this->get_db()->get_var( $found_items_query ); + if ( ! empty( $query ) ) { + $this->found_items = (int) $this->get_db()->get_var( $query ); } } } @@ -679,7 +646,7 @@ private function set_found_items( $item_ids = array() ) { /** * Set a query var, to both defaults and request arrays. * - * This method is used to expose the private query_vars array to hooks, + * This method is used to expose the private $query_vars array to hooks, * allowing them to manipulate query vars just-in-time. * * @since 1.0.0 @@ -701,11 +668,45 @@ public function set_query_var( $key = '', $value = '' ) { * @return bool */ public function is_query_var_default( $key = '' ) { - return (bool) ( $this->query_vars[ $key ] === $this->query_var_default_value ); + return (bool) ( $this->get_query_var( $key ) === $this->query_var_default_value ); + } + + /** + * Is a column valid? + * + * @since 2.1.0 + * @param string $column_name + * @return bool + */ + private function is_valid_column( $column_name = '' ) { + + // Bail if column name not valid string + if ( empty( $column_name ) || ! is_string( $column_name ) ) { + return false; + } + + // Get all of the column names + $columns = $this->get_column_names(); + + // Return if column name exists + return isset( $columns[ $column_name ] ); } /** Private Getters *******************************************************/ + /** + * Get a query variable. + * + * @since 2.1.0 + * @param string $key + * @return mixed + */ + private function get_query_var( $key = '' ) { + return isset( $this->query_vars[ $key ] ) + ? $this->query_vars[ $key ] + : null; + } + /** * Pass-through method to return a new Meta object. * @@ -760,7 +761,10 @@ private function get_current_time() { } /** - * Return the literal table name (with prefix) from the database interface. + * Return the table name. + * + * Prefixed by the $table_prefix global, or get_blog_prefix() if + * is_multisite(). * * @since 1.0.0 * @@ -908,6 +912,11 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' $values = array( $values ); } + // Maybe fallback to $key + if ( empty( $field ) ) { + $field = $key; + } + // Get the column fields foreach ( $values as $value ) { $args = array( $key => $value ); @@ -918,10 +927,37 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' return $retval; } + /** + * Get a column name, possibly with the $table_alias append. + * + * @since 2.1.0 + * @param string $column_name + * @param bool $alias + * @return string + */ + private function get_column_name_aliased( $column_name = '', $alias = true ) { + + // Default return value + $retval = $column_name; + + /** + * Maybe append table alias. + * + * Also append a period, to separate it from the column name. + */ + if ( true === $alias ) { + $retval = "{$this->table_alias}.{$column_name}"; + } + + // Return SQL + return $retval; + } + /** * Get a single database row by any column and value, skipping cache. * * @since 1.0.0 + * @since 2.1.0 Uses is_valid_column() * * @param string $column_name Name of database column * @param mixed $column_value Value to query for @@ -929,13 +965,13 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' */ private function get_item_raw( $column_name = '', $column_value = '' ) { - // Bail if no name or value - if ( empty( $column_name ) || empty( $column_value ) ) { + // Bail if empty or non-scalar value + if ( empty( $column_value ) || ! is_scalar( $column_value ) ) { return false; } - // Bail if values aren't query'able - if ( ! is_string( $column_name ) || ! is_scalar( $column_value ) ) { + // Bail if invalid column + if ( ! $this->is_valid_column( $column_name ) ) { return false; } @@ -971,7 +1007,7 @@ private function get_items() { * * @since 1.0.0 * - * @param Query &$this Current instance of Query, passed by reference. + * @param Query &$this Current instance passed by reference. */ do_action_ref_array( $this->apply_prefix( "pre_get_{$this->item_name_plural}" ), @@ -1009,18 +1045,22 @@ private function get_items() { } // Pagination - if ( ! empty( $this->found_items ) && ! empty( $this->query_vars['number'] ) ) { - $this->max_num_pages = (int) ceil( $this->found_items / $this->query_vars['number'] ); + if ( ! empty( $this->found_items ) ) { + $number = (int) $this->get_query_var( 'number' ); + + if ( ! empty( $number ) ) { + $this->max_num_pages = (int) ceil( $this->found_items / $number ); + } } // Cast to int if not grouping counts - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Set items $this->items = $result; // Not grouping, so cast to int - if ( empty( $this->query_vars['groupby'] ) ) { + if ( ! $this->get_query_var( 'groupby' ) ) { $this->items = (int) $result; } @@ -1046,64 +1086,18 @@ private function get_items() { */ private function get_item_ids() { - // Parse 'where' & 'join' - $this->parse_where_join_vars(); - - // Where & Join - $where = $this->parse_where_clauses( $this->query_clauses['where'] ); - $join = $this->parse_join_clauses( $this->query_clauses['join'] ); - - // Order & Order By - $orderby = $this->parse_orderby( - $this->query_vars['orderby'], - $this->query_vars['order'] - ); - - // Group by - $groupby = $this->parse_groupby( $this->query_vars['groupby'] ); - - // Count - $count = $this->parse_count( - $this->query_vars['count'], - $this->query_vars['groupby'] - ); - - // Fields - $fields = $this->parse_fields( - $this->query_vars['fields'], - $this->query_vars['count'], - $this->query_vars['groupby'] - ); - - // Limits - $limits = $this->parse_limits( - $this->query_vars['number'], - $this->query_vars['offset'] - ); - - // Setup the query array - $query = array( - 'count' => $count, - 'fields' => $fields, - 'join' => $join, - 'where' => $where, - 'orderby' => $orderby, - 'limits' => $limits, - 'groupby' => $groupby - ); - - // Filter the query clauses - $clauses = $this->filter_query_clauses( $query ); + // Setup the query clauses + $this->set_query_clauses(); // Setup request - $this->set_request_clauses( $clauses ); + $this->set_request_clauses(); $this->set_request(); // Return count - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Get vars or results - $retval = empty( $this->query_vars['groupby'] ) + $retval = ! $this->get_query_var( 'groupby' ) ? $this->get_db()->get_var( $this->request ) : $this->get_db()->get_results( $this->request, ARRAY_A ); @@ -1182,8 +1176,8 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, // Default return value $retval = ''; - // Bail if no values or column name - if ( empty( $values ) || empty( $column_name ) ) { + // Bail if no values or invalid column + if ( empty( $values ) || ! $this->is_valid_column( $column_name ) ) { return $retval; } @@ -1221,24 +1215,23 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, * Parses arguments passed to the item query with default query parameters. * * @since 1.0.0 + * @since 2.1.0 Forces some $query_vars if counting * - * @see Query::__construct() - * - * @param array|string $query Array or string of Query arguments. + * @param array|string $query */ private function parse_query( $query = array() ) { - // Setup the query_vars_original var + // Setup the $query_vars_original var $this->query_var_originals = wp_parse_args( $query ); - // Setup the query_vars parsed var + // Setup the $query_vars parsed var $this->query_vars = wp_parse_args( $this->query_var_originals, $this->query_var_defaults ); - // If counting, override some other query_vars - if ( ! empty( $this->query_vars['count'] ) ) { + // If counting, override some other $query_vars + if ( $this->get_query_var( 'count' ) ) { $this->query_vars['number'] = false; $this->query_vars['orderby'] = ''; $this->query_vars['no_found_rows'] = true; @@ -1251,7 +1244,7 @@ private function parse_query( $query = array() ) { * * @since 1.0.0 * - * @param Query &$this The Query instance (passed by reference). + * @param Query &$this Current instance passed by reference. */ do_action_ref_array( $this->apply_prefix( "parse_{$this->item_name_plural}_query" ), @@ -1262,13 +1255,66 @@ private function parse_query( $query = array() ) { } /** - * Parse the 'where' and 'join' query clauses for all known columns. + * Parse all of the $query_vars. + * + * Optionally accepts an array of custom $query_vars that can be used + * instead of the default ones. * - * @todo split this method into smaller parts + * Calls filter_query_clauses() on the return value. + * + * @since 2.1.0 + * @param array $query_vars Optional. Default empty array. + * Fallback to Query::query_vars. + * @return array Query clauses, parsed from Query vars. + */ + private function parse_query_vars( $query_vars = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $query_vars ) && ! empty( $this->query_vars ) ) { + $query_vars = $this->query_vars; + } + + // Parse arguments + $r = wp_parse_args( $query_vars ); + + // Parse $query_vars + $where_join = $this->parse_where_join( $r ); + + // Parse all clauses + $clauses = array( + 'explain' => $this->parse_explain( $r['explain'] ), + 'select' => $this->parse_select(), + 'fields' => $this->parse_fields( $r['fields'], $r['count'], $r['groupby'] ), + 'count' => $this->parse_count( $r['count'], $r['groupby'] ), + 'from' => $this->parse_from(), + 'join' => $this->parse_join_clause( $where_join['join'] ), + 'where' => $this->parse_where_clause( $where_join['where'] ), + 'groupby' => $this->parse_groupby( $r['groupby'], 'GROUP BY ' ), + 'orderby' => $this->parse_orderby( $r['orderby'], $r['order'], 'ORDER BY ' ), + 'limits' => $this->parse_limits( $r['number'], $r['offset'] ) + ); + + // Return clauses + return $this->filter_query_clauses( $clauses ); + } + + /** + * Parse the 'where' and 'join' $query_vars for all known columns. * * @since 2.1.0 + * + * @param array $args Query vars + * @return array Array of 'where' and 'join' clauses. */ - private function parse_where_join_vars() { + private function parse_where_join( $args = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $args ) && ! empty( $this->query_vars ) ) { + $args = $this->query_vars; + } + + // Parse arguments + $r = wp_parse_args( $args ); // Defaults $where = $join = $date_query = array(); @@ -1279,30 +1325,32 @@ private function parse_where_join_vars() { // Loop through columns foreach ( $columns as $column ) { - // Get pattern - $pattern = $this->get_column_field( array( 'name' => $column->name ), 'pattern', '%s' ); + // Get column name, pattern, and aliased name + $name = $column->name; + $pattern = $this->get_column_field( array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->get_column_name_aliased( $name ); // Literal column comparison if ( false !== $column->by ) { // Parse query variable - $where_id = $column->name; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = $name; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for direct clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; + $statement = "{$aliased} = {$pattern}"; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { $where_id = "{$where_id}__in"; - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; } } } @@ -1311,23 +1359,23 @@ private function parse_where_join_vars() { if ( true === $column->in ) { // Parse query var - $where_id = "{$column->name}__in"; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = "{$name}__in"; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for an IN clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; - $where_id = $column->name; + $statement = "{$aliased} = {$pattern}"; + $where_id = $name; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; } } } @@ -1336,52 +1384,49 @@ private function parse_where_join_vars() { if ( true === $column->not_in ) { // Parse query var - $where_id = "{$column->name}__not_in"; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = "{$name}__not_in"; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for a NOT IN clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} != {$pattern}"; - $where_id = $column->name; + $statement = "{$aliased} != {$pattern}"; + $where_id = $name; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} NOT IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} NOT IN {$in_values}"; } } } // date_query if ( true === $column->date_query ) { - $where_id = "{$column->name}_query"; - $column_date = $this->query_vars[ $where_id ]; + $where_id = "{$name}_query"; + $column_date = $this->parse_query_var( $r, $where_id ); // Parse item - if ( ! empty( $column_date ) && ! $this->is_query_var_default( $where_id ) ) { - - // Default arguments - $defaults = array( - 'column' => "{$this->table_alias}.{$column->name}", - 'before' => $column_date, - 'inclusive' => true - ); + if ( false !== $column_date ) { - // Default date query - if ( is_string( $column_date ) ) { - $date_query[] = $defaults; + // Single + if ( 1 === count( $column_date ) ) { + $date_query[] = array( + 'column' => $aliased, + 'before' => reset( $column_date ), + 'inclusive' => true + ); - // Array query var - } elseif ( is_array( $column_date ) ) { + // Multi + } else { // Auto-fill column if empty if ( empty( $column_date['column'] ) ) { - $column_date['column'] = $defaults['column']; + $column_date['column'] = $aliased; } // Add clause to date query @@ -1397,15 +1442,15 @@ private function parse_where_join_vars() { $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); // Maybe search if columns are searchable. - if ( ! empty( $searchable ) && strlen( $this->query_vars['search'] ) ) { + if ( ! empty( $searchable ) && strlen( $r['search'] ) ) { // Default to all searchable columns $search_columns = $searchable; // Intersect against known searchable columns - if ( ! empty( $this->query_vars['search_columns'] ) ) { + if ( ! empty( $r['search_columns'] ) ) { $search_columns = array_intersect( - $this->query_vars['search_columns'], + $r['search_columns'], $searchable ); } @@ -1414,7 +1459,7 @@ private function parse_where_join_vars() { $search_columns = $this->filter_search_columns( $search_columns ); // Add search query clause - $where['search'] = $this->get_search_sql( $this->query_vars['search'], $search_columns ); + $where['search'] = $this->get_search_sql( $r['search'], $search_columns ); } /** Query Classes *****************************************************/ @@ -1422,17 +1467,18 @@ private function parse_where_join_vars() { // Get the primary column name $primary = $this->get_primary_column_name(); - // Get the meta table + // Get the meta type & table alias $table = $this->get_meta_type(); + $alias = $this->table_alias; // Set the " AND " regex pattern $and = '/^\s*AND\s*/'; // Maybe perform a meta query. - $meta_query = $this->query_vars['meta_query']; + $meta_query = $r['meta_query']; if ( ! empty( $meta_query ) && is_array( $meta_query ) ) { $this->meta_query = $this->get_meta_query( $meta_query ); - $clauses = $this->meta_query->get_sql( $table, $this->table_alias, $primary, $this ); + $clauses = $this->meta_query->get_sql( $table, $alias, $primary, $this ); // Not all objects have meta, so make sure this one exists if ( false !== $clauses ) { @@ -1444,18 +1490,16 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query query where clause $where['meta_query'] = preg_replace( $and, '', $clauses['where'] ); } } } // Maybe perform a compare query. - $compare_query = $this->query_vars['compare_query']; + $compare_query = $r['compare_query']; if ( ! empty( $compare_query ) && is_array( $compare_query ) ) { $this->compare_query = $this->get_compare_query( $compare_query ); - $clauses = $this->compare_query->get_sql( $table, $this->table_alias, $primary, $this ); + $clauses = $this->compare_query->get_sql( $table, $alias, $primary, $this ); // Not all objects can compare, so make sure this one exists if ( false !== $clauses ) { @@ -1467,8 +1511,6 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query where clause. $where['compare_query'] = preg_replace( $and, '', $clauses['where'] ); } } @@ -1477,12 +1519,12 @@ private function parse_where_join_vars() { // Only do a date query with an array $date_query = ! empty( $date_query ) ? $date_query - : $this->query_vars['date_query']; + : $r['date_query']; // Maybe perform a date query if ( ! empty( $date_query ) && is_array( $date_query ) ) { $this->date_query = $this->get_date_query( $date_query ); - $clauses = $this->date_query->get_sql( $this->table_name, $this->table_alias, $primary, $this ); + $clauses = $this->date_query->get_sql( $this->table_name, $alias, $primary, $this ); // Not all objects are dates, so make sure this one exists if ( false !== $clauses ) { @@ -1494,16 +1536,16 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query where clause. $where['date_query'] = preg_replace( $and, '', $clauses['where'] ); } } } - // Set where and join clauses, removing possible empties - $this->query_clauses['where'] = array_filter( $where ); - $this->query_clauses['join'] = array_filter( $join ); + // Return where & join, removing possible empties + return array( + 'where' => array_filter( $where ), + 'join' => array_filter( $join ) + ); } /** @@ -1594,6 +1636,42 @@ private function parse_query_var( $query_vars = '', $key = '' ) { return array( $value ); } + /** + * Parse if query to be EXPLAIN'ed. + * + * @since 2.1.0 + * @param bool $explain Default false. True to EXPLAIN. + * @return string + */ + private function parse_explain( $explain = false ) { + + // Maybe fallback to $query_vars + if ( empty( $explain ) ) { + $explain = $this->get_query_var( 'explain' ); + } + + // Default return value + $retval = ''; + + // Maybe explaining + if ( ! empty( $explain ) ) { + $retval = 'EXPLAIN'; + } + + // Return SQL + return $retval; + } + + /** + * Parse the "SELECT" part of the SQL. + * + * @since 2.1.0 + * @return string Default "SELECT". + */ + private function parse_select() { + return 'SELECT'; + } + /** * Parse which fields to query for. * @@ -1604,7 +1682,8 @@ private function parse_query_var( $query_vars = '', $key = '' ) { * predictably hit the cache, but that may change in a future version. * * @since 1.0.0 - * @since 2.1.0 Moved COUNT() SQL to parse_count() + * @since 2.1.0 Moved COUNT() SQL to parse_count() and uses parse_groupby() + * when counting to satisfy MySQL 8 and higher. * * @param string[] $fields * @param bool $count @@ -1615,8 +1694,8 @@ private function parse_query_var( $query_vars = '', $key = '' ) { private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { - $count = $this->query_vars['count']; + if ( empty( $count ) ) { + $count = $this->get_query_var( 'count' ); } // Default return value @@ -1627,24 +1706,22 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali // Use groupby instead if ( ! empty( $groupby ) ) { - $retval = $this->parse_groupby( $groupby, $alias ); + $retval = $this->parse_groupby( $groupby, '', $alias ); } - // Not counting + // Not counting, so use primary column } else { // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Get the primary column name $primary = $this->get_primary_column_name(); // Default return value - $retval = ( true === $alias ) - ? "{$this->table_alias}.{$primary}" - : $primary; + $retval = $this->get_column_name_aliased( $primary, $alias ); } // Return fields @@ -1652,8 +1729,10 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali } /** - * Parse if counting, possibly grouping by columns. + * Parse if counting. * + * When counting with groups, parse_fields() will return the required SQL to + * prevent errors. * * @since 2.1.0 * @param bool $count @@ -1665,8 +1744,8 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali private function parse_count( $count = false, $groupby = '', $name = 'count', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { - $count = $this->query_vars['count']; + if ( empty( $count ) ) { + $count = $this->get_query_var( 'count' ); } // Bail if not counting @@ -1678,7 +1757,7 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a $retval = 'COUNT(*)'; // Check for "GROUP BY" - $groupby_names = $this->parse_groupby( $groupby, $alias ); + $groupby_names = $this->parse_groupby( $groupby, '', $alias ); // Reformat if grouping counts together if ( ! empty( $groupby_names ) ) { @@ -1689,20 +1768,47 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a return $retval; } + /** + * Parse which table to query and whether to follow it with an alias. + * + * @since 2.1.0 + * @param string $table Optional. Default empty string. + * Fallback to get_table_name(). + * @param string $alias Optional. Default empty string. + * Fallback to $table_alias. + * @return string + */ + private function parse_from( $table = '', $alias = '' ) { + + // Maybe fallback to get_table_name() + if ( empty( $table ) ) { + $table = $this->get_table_name(); + } + + // Maybe fallback to $table_alias + if ( empty( $alias ) ) { + $alias = $this->table_alias; + } + + // Return + return "FROM {$table} {$alias}"; + } + /** * Parses and sanitizes the 'groupby' keys passed into the item query. * * @since 1.0.0 * * @param string $groupby + * @param string $before * @param bool $alias * @return string */ - private function parse_groupby( $groupby = '', $alias = true ) { + private function parse_groupby( $groupby = '', $before = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $groupby ) && ! empty( $this->query_vars['groupby'] ) ) { - $groupby = $this->query_vars['groupby']; + if ( empty( $groupby ) ) { + $groupby = $this->get_query_var( 'groupby' ); } // Bail if empty @@ -1710,128 +1816,119 @@ private function parse_groupby( $groupby = '', $alias = true ) { return ''; } - // Sanitize groupby columns - $groupby = (array) array_map( 'sanitize_key', (array) $groupby ); - - // Re'flip column names back around - $columns = array_flip( $this->get_column_names() ); + // Maybe cast to array + if ( ! is_array( $groupby ) ) { + $groupby = (array) $groupby; + } // Get the intersection of allowed column names to groupby columns - $intersect = array_intersect( $groupby, $columns ); + $intersect = $this->get_columns_field_by( 'name', $groupby ); - // Bail if invalid column + // Bail if invalid columns if ( empty( $intersect ) ) { return ''; } - // Default return value - $retval = array(); + // Column names array + $names = array(); // Maybe prepend table alias to key foreach ( $intersect as $key ) { - $retval[] = ( true === $alias ) - ? "{$this->table_alias}.{$key}" - : $key; + $names[] = $this->get_column_name_aliased( $key, $alias ); } - // Separate sanitized columns - return implode( ',', array_values( $retval ) ); + // Bail if nothing to groupby + if ( empty( $names ) && ! empty( $before ) ) { + return ''; + } + + // Format column names + $retval = implode( ',', $names ); + + // Return columns + return implode( ' ', array( $before, $retval ) ) ; } /** * Parse the ORDER BY clause. * * @since 1.0.0 As get_order_by - * @since 2.1.0 Renamed to parse_orderby and accepts $orderby, $order, and $alias + * @since 2.1.0 Renamed to parse_orderby and accepts $orderby, $order, $before, and $alias * * @param string $orderby * @param string $order + * @param string $before * @param bool $alias * @return string */ - private function parse_orderby( $orderby = '', $order = '', $alias = true ) { + private function parse_orderby( $orderby = '', $order = '', $before = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $orderby ) && ! empty( $this->query_vars['orderby'] ) ) { - $orderby = $this->query_vars['orderby']; + if ( empty( $orderby ) ) { + $orderby = $this->get_query_var( 'orderby' ); } - // Default orderby primary column - $parsed = $this->parse_single_orderby( $orderby, $alias ); - $order = $this->parse_order( $order ); - $orderby = "{$parsed} {$order}"; - - // Disable ORDER BY if counting, or: 'none', an empty array, or false. - if ( + // Bail if counting + if ( $this->get_query_var( 'count' ) ) { + return ''; + } - ! empty( $this->query_vars['count'] ) + // Bail if $orderby is a value that could cancel ordering + if ( in_array( $orderby, array( 'none', array(), false, null ), true ) ) { + return ''; + } - || + // Default return value + $retval = ''; - in_array( $orderby, array( 'none', array(), false ), true ) - ) { - $orderby = ''; + // Fallback to default orderby & order + if ( empty( $orderby ) ) { + $parsed = $this->parse_single_orderby( $orderby, $alias ); + $order = $this->parse_order( $order ); + $retval = "{$parsed} {$order}"; // Ordering by something, so figure it out - } elseif ( ! empty( $orderby ) ) { - - // Array of keys, or comma separated - $ordersby = $this->parse_query_var( $this->query_vars, 'orderby' ); - - $orderby_array = array(); - $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); - $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); - - // Loop through possible order by's - foreach ( $ordersby as $_key => $_value ) { + } else { - // Skip if empty - if ( empty( $_value ) ) { - continue; - } + // Cast orderby as an array + $ordersby = (array) $orderby; - // Key is numeric - if ( is_int( $_key ) ) { - $_orderby = $_value; - $_item = $order; + // Fill if numeric + if ( wp_is_numeric_array( $ordersby ) ) { + $ordersby = array_fill_keys( $ordersby, $order ); + } - // Key is string - } else { - $_orderby = $_key; - $_item = $_value; - } + // Default return value + $orderby_array = array(); - // Skip if not sortable - if ( ! in_array( $_value, $sortables, true ) ) { - continue; - } + // Loop through orderby's + foreach ( $ordersby as $key => $value ) { // Parse orderby - $parsed = $this->parse_single_orderby( $_orderby, $alias ); + $parsed = $this->parse_single_orderby( $key, $alias ); // Skip if empty if ( empty( $parsed ) ) { continue; } - // Set if __in - if ( in_array( $_orderby, $possible_ins, true ) ) { - $orderby_array[] = "{$parsed} {$order}"; - continue; - } - // Append parsed orderby to array - $orderby_array[] = $parsed . ' ' . $this->parse_order( $_item ); + $orderby_array[] = $parsed . ' ' . $this->parse_order( $value ); } // Only set if valid orderby if ( ! empty( $orderby_array ) ) { - $orderby = implode( ', ', $orderby_array ); + $retval = implode( ', ', $orderby_array ); } } + // Bail if nothing to orderby + if ( empty( $retval ) && ! empty( $before ) ) { + return ''; + } + // Return parsed orderby - return $orderby; + return implode( ' ', array( $before, $retval ) ); } /** @@ -1839,10 +1936,17 @@ private function parse_orderby( $orderby = '', $order = '', $alias = true ) { * * @since 2.1.0 * @param array $where - * @return string + * @return string A single SQL statement. */ - private function parse_where_clauses( $where = array() ) { - return implode( ' AND ', $where ); + private function parse_where_clause( $where = array() ) { + + // Bail if no where + if ( empty( $where ) ) { + return ''; + } + + // Return SQL + return 'WHERE ' . implode( ' AND ', $where ); } /** @@ -1850,12 +1954,62 @@ private function parse_where_clauses( $where = array() ) { * * @since 2.1.0 * @param array $join - * @return string + * @return string A single SQL statement. */ - private function parse_join_clauses( $join = array() ) { + private function parse_join_clause( $join = array() ) { + + // Return SQL return implode( ', ', $join ); } + /** + * Parse all of the SQL query clauses. + * + * @since 2.1.0 + * @param array $clauses + * @return array + */ + private function parse_query_clauses( $clauses = array() ) { + + // Maybe fallback to $query_clauses + if ( empty( $clauses ) && ! empty( $this->query_clauses ) ) { + $clauses = $this->query_clauses; + } + + // Default return value + $retval = wp_parse_args( $clauses ); + + // Return array of clauses + return $retval; + } + + /** + * Parse all SQL $request_clauses into a single SQL query string. + * + * @since 2.1.0 + * @param array $clauses + * @return string A single SQL statement. + */ + private function parse_request_clauses( $clauses = array() ) { + + // Maybe fallback to $request_clauses + if ( empty( $clauses ) && ! empty( $this->request_clauses ) ) { + $clauses = $this->request_clauses; + } + + // Bail if empty clauses + if ( empty( $clauses ) ) { + return ''; + } + + // Remove empties + $filtered = array_filter( $clauses ); + $retval = array_map( 'trim', $filtered ); + + // Return SQL + return implode( ' ', $retval ); + } + /** * Parses the 'number' and 'offset' keys passed to the item query. * @@ -1904,28 +2058,33 @@ private function parse_single_orderby( $orderby = '', $alias = true ) { $orderby = $this->get_primary_column_name(); } - // __in + // Default return value + $retval = ''; + + // Get possible columns an $orderby can belong to + $ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); + $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); + + // __in column if ( false !== strstr( $orderby, '__in' ) ) { - $column_name = str_replace( '__in', '', $orderby ); - $item_in = $this->get_in_sql( $column_name, $this->query_vars[ $orderby ], false ); - $aliased = ( true === $alias ) - ? "{$this->table_alias}.{$column_name}" - : $column_name; - $retval = "FIELD( {$aliased}, {$item_in} )"; - // Specific column - } else { + // Get column name from $orderby clause + $column_name = str_replace( '__in', '', $orderby ); - // Orderby is a literal, sortable column name - $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); - if ( in_array( $orderby, $sortables, true ) ) { - $retval = ( true === $alias ) - ? "{$this->table_alias}.{$orderby}" - : $orderby; + // Get values if valid column + if ( in_array( $column_name, $ins, true ) ) { + $values = $this->get_query_var( $orderby ); + $item_in = $this->get_in_sql( $column_name, $values, false ); + $aliased = $this->get_column_name_aliased( $column_name, $alias ); + $retval = "FIELD( {$aliased}, {$item_in} )"; } + + // Specific sortable column + } elseif ( in_array( $orderby, $sortables, true ) ) { + $retval = $this->get_column_name_aliased( $orderby, $alias ); } - // Return parsed value + // Return SQL return $retval; } @@ -1973,8 +2132,8 @@ private function parse_order( $order = 'DESC' ) { private function shape_items( $items = array(), $fields = array() ) { // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Force to stdClass if querying for fields @@ -2020,8 +2179,8 @@ private function get_item_fields( $items = array(), $fields = array() ) { $retval = $items; // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Bail if no fields to get @@ -2029,12 +2188,14 @@ private function get_item_fields( $items = array(), $fields = array() ) { return $retval; } + // Maybe cast to array + if ( ! is_array( $fields ) ) { + $fields = (array) $fields; + } + // Get the primary column name $primary = $this->get_primary_column_name(); - // Sanitize fields - $fields = (array) array_map( 'sanitize_key', (array) $fields ); - // 'ids' is numerically keyed if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { $retval = wp_list_pluck( $items, $primary ); @@ -2142,7 +2303,7 @@ public function get_item( $item_id = 0 ) { * Get a single database row by any column and value, possibly from cache. * * Take care to only use this method on columns with unique values, - * preferably with a cache group for that column. See: get_item(). + * preferably with a cache group for that column. * * @since 1.0.0 * @@ -2152,32 +2313,19 @@ public function get_item( $item_id = 0 ) { */ public function get_item_by( $column_name = '', $column_value = '' ) { - // Default return value - $retval = false; - - // Bail if no key or value - if ( empty( $column_name ) || empty( $column_value ) ) { - return $retval; - } - - // Bail if name is not a string - if ( ! is_string( $column_name ) ) { - return $retval; - } - - // Bail if value is not scalar (null values also not allowed) - if ( ! is_scalar( $column_value ) ) { - return $retval; + // Bail if empty or non-scalar value + if ( empty( $column_value ) || ! is_scalar( $column_value ) ) { + return false; } - // Get all of the column names - $columns = $this->get_column_names(); - // Bail if column does not exist - if ( ! isset( $columns[ $column_name ] ) ) { - return $retval; + if ( ! $this->is_valid_column( $column_name ) ) { + return false; } + // Default return value + $retval = false; + // Get all of the cache groups $groups = $this->get_cache_groups(); @@ -2972,31 +3120,32 @@ private function delete_all_item_meta( $item_id = 0 ) { /** * Get the meta table for this query. * - * Forked from WordPress\_get_meta_table() so it can be more accurately - * predicted in a future iteration and default to returning false. - * * @since 1.0.0 + * @since 2.1.0 Minor refactor to improve readability. * - * @return mixed Table name if exists, False if not + * @return bool|string Table name if exists, False if not. */ private function get_meta_table_name() { - // Get the meta-type - $type = $this->get_meta_type(); + // Default return value + $retval = false; - // Append "meta" to end of meta-type - $table_name = "{$type}meta"; + // Get the meta type + $type = $this->get_meta_type(); + + // Append "meta" to end of meta type + $table = "{$type}meta"; // Variable'ize the database interface, to use inside empty() - $db = $this->get_db(); + $db = $this->get_db(); // If not empty, return table name - if ( ! empty( $db->{$table_name} ) ) { - return $db->{$table_name}; + if ( ! empty( $db->{$table} ) ) { + $retval = $db->{$table}; } - // Default return false - return false; + // Return + return $retval; } /** @@ -3016,7 +3165,7 @@ private function get_meta_type() { /** Cache *****************************************************************/ /** - * Get cache key from query_vars and query_var_defaults. + * Get cache key from $query_vars and $query_var_defaults. * * @since 1.0.0 * @@ -3025,7 +3174,7 @@ private function get_meta_type() { */ private function get_cache_key( $group = '' ) { - // Slice query vars + // Slice $query_vars by default keys $slice = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) ); // Unset "fields" so it does not effect the cache key @@ -3035,7 +3184,7 @@ private function get_cache_key( $group = '' ) { $key = md5( serialize( $slice ) ); $last_changed = $this->get_last_changed_cache( $group ); - // Concatenate and return cache key + // Return the concatenated cache key return "get_{$this->item_name_plural}:{$key}:{$last_changed}"; } @@ -3099,8 +3248,7 @@ private function get_cache_groups() { } /** - * Maybe prime item & item-meta caches by querying 1 time for all un-cached - * items. + * Maybe prime item & item-meta caches. * * Accepts a single ID, or an array of IDs. * @@ -3108,7 +3256,11 @@ private function get_cache_groups() { * after an item is inserted in the database, but before items have been * "shaped" into proper objects, so object properties may not be set yet. * + * Queries the database 1 time for all non-cached item objects and 1 time + * for all non-cached item meta. + * * @since 1.0.0 + * @since 2.1.0 Uses get_meta_table_name() to * * @param array $item_ids * @param bool $force @@ -3128,45 +3280,51 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { /** * Update item caches. * - * Uses our own get_non_cached_ids() method to avoid + * Uses get_non_cached_ids() to remove item IDs that already exist in + * in the cache, then performs direct database query for the remaining + * IDs, and caches them. */ - if ( ! empty( $force ) || ! empty( $this->query_vars['update_item_cache'] ) ) { + if ( ! empty( $force ) || $this->get_query_var( 'update_item_cache' ) ) { // Look for non-cached IDs $ids = $this->get_non_cached_ids( $item_ids, $this->cache_group ); - // Bail if IDs are cached - if ( empty( $ids ) ) { - return false; - } + // Proceed if non-cached IDs exist + if ( ! empty( $ids ) ) { - // Get query parts - $table = $this->get_table_name(); - $primary = $this->get_primary_column_name(); - $ids = $this->get_in_sql( $primary, $ids ); + // Get query parts + $table = $this->get_table_name(); + $primary = $this->get_primary_column_name(); + $ids = $this->get_in_sql( $primary, $ids ); - // Query database - $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; - $prepare = sprintf( $query, $ids ); - $results = $this->get_db()->get_results( $prepare ); + // Query database + $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; + $prepare = sprintf( $query, $ids ); + $results = $this->get_db()->get_results( $prepare ); - // Update item cache(s) - $this->update_item_cache( $results ); + // Update item cache(s) + $this->update_item_cache( $results ); + } } /** * Update meta data caches. * * Uses update_meta_cache() because it politely handles all of the - * uncached ID logic. This allows us to use the original (and likely + * non-cached ID logic. This allows us to use the original (and likely * larger) $item_ids array instead of $ids, thus ensuring the everything * is cached according to our expectations. */ - if ( ! empty( $this->query_vars['update_meta_cache'] ) ) { - $singular = rtrim( $this->table_name, 's' ); // sic - update_meta_cache( $singular, $item_ids ); + if ( ! empty( $force ) || $this->get_query_var( 'update_meta_cache' ) ) { + + // Proceed if meta table exists + if ( $this->get_meta_table_name() ) { + $meta_type = $this->get_meta_type(); + update_meta_cache( $meta_type, $item_ids ); + } } + // Return true because something was cached return true; } @@ -3534,23 +3692,25 @@ public function filter_items( $items = array() ) { * Filter the found items query. * * @since 2.1.0 - * + * @param string $sql * @return string */ - public function filter_found_items_query() { + public function filter_found_items_query( $sql = '' ) { /** * Filters the query used to retrieve the found item count. * * @since 1.0.0 + * @since 2.1.0 Supports MySQL 8 by removing FOUND_ROWS() and uses + * $request_clauses instead. * * @param string $query SQL query. Default 'SELECT FOUND_ROWS()'. - * @param Query &$this Current instance passed by reference. + * @param Query &$this Current instance passed by reference. */ return (string) apply_filters_ref_array( $this->apply_prefix( "found_{$this->item_name_plural}_query" ), array( - 'SELECT FOUND_ROWS()', + $sql, &$this ) ); From 72a2266e91a2288960f28a52b7faf92063795cbe Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Tue, 28 Jun 2022 17:21:24 -0500 Subject: [PATCH 13/34] WIP - Issue/128 (#143) * Base: minor refactor to magic methods, and is_success() * Query: MySQL 8 support * Remove $columns * Improve query parsing to allow reuse for second COUNT(*) query_clause overrides * Add support for SELECT & EXPLAIN clauses * Add several new methods to abstract out newly repeated behaviors * Add is_valid_column() and get_query_var() and get_column_name_alias() to help with repeated code patterns * Add parse_query_vars() again, to help abstract only the parsing part * Use get_meta_type() when updating meta data * Update prime_item_caches() to not bail early so it can continue on and try updating meta data --- src/Database/Base.php | 47 +- src/Database/Query.php | 1000 +++++++++++++++++++++++----------------- 2 files changed, 599 insertions(+), 448 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index 1e9108c..080353d 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -69,20 +69,15 @@ class Base { */ public function __isset( $key = '' ) { - // No more uppercase ID properties ever - if ( 'ID' === $key ) { - $key = 'id'; - } - // Class method to try and call $method = "get_{$key}"; - // Return property if exists - if ( method_exists( $this, $method ) ) { + // Return callable method exists + if ( is_callable( array( $this, $method ) ) ) { return true; } - // Return get method results if exists + // Return property if exists return property_exists( $this, $key ); } @@ -96,19 +91,14 @@ public function __isset( $key = '' ) { */ public function __get( $key = '' ) { - // No more uppercase ID properties ever - if ( 'ID' === $key ) { - $key = 'id'; - } - // Class method to try and call $method = "get_{$key}"; - // Return property if exists - if ( method_exists( $this, $method ) ) { + // Return get method results if callable + if ( is_callable( array( $this, $method ) ) ) { return call_user_func( array( $this, $method ) ); - // Return get method results if exists + // Return property value if exists } elseif ( property_exists( $this, $key ) ) { return $this->{$key}; } @@ -378,27 +368,28 @@ protected function get_db() { * pass falsy values on success. * * @since 1.0.0 + * @since 2.1.0 Minor refactor to improve readability. * - * @param mixed $result Default false. + * @param mixed $result Optional. Default false. Any value to check. * @return bool */ protected function is_success( $result = false ) { - // Bail if falsy result - if ( empty( $result ) ) { - $retval = false; - - // Bail if an error occurred - } elseif ( is_wp_error( $result ) ) { - $this->last_error = $result; - $retval = false; + // Default return value + $retval = false; - // No errors - } else { + // Non-empty is success + if ( ! empty( $result ) ) { $retval = true; + + // But Error is still fail, so stash it + if ( is_wp_error( $result ) ) { + $this->last_error = $result; + $retval = false; + } } // Return the result - return $retval; + return (bool) $retval; } } diff --git a/src/Database/Query.php b/src/Database/Query.php index 73ef97e..dda58d2 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -138,16 +138,6 @@ class Query extends Base { */ protected $last_changed = ''; - /** Columns ***************************************************************/ - - /** - * Array of all database column objects. - * - * @since 1.0.0 - * @var array - */ - protected $columns = array(); - /** Schema *************************************************************/ /** @@ -300,29 +290,29 @@ class Query extends Base { * Optional. Array or query string of item query parameters. * Default empty. * - * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs) - * or empty (returns an array of complete item objects). Default empty. - * To do a date query against a field, append the field name with _query - * @type bool $count Whether to return a item count (true) or array of item objects. - * Default false. - * @type int $number Limit number of items to retrieve. Use 0 for no limit. - * Default 100. - * @type int $offset Number of items to offset the query. Used to build LIMIT clause. - * Default 0. - * @type bool $no_found_rows Whether to disable the `SQL_CALC_FOUND_ROWS` query. - * Default true. - * @type array|string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. - * Default '', to primary column ID. - * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'. - * Default 'DESC'. - * @type string $search Search term(s) to retrieve matching items for. - * Default empty. - * @type array $search_columns Array of column names to be searched. - * Default empty array. - * @type bool $update_item_cache Whether to prime the cache for found items. - * Default false. - * @type bool $update_meta_cache Whether to prime the meta cache for found items. - * Default false. + * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs) + * or empty (returns an array of complete item objects). Default empty. + * To do a date query against a field, append the field name with _query + * @type bool $count Return an item count (true) or array of item objects. + * Default false. + * @type int $number Limit number of items to retrieve. Use 0 for no limit. + * Default 100. + * @type int $offset Number of items to offset the query. Used to build LIMIT clause. + * Default 0. + * @type bool $no_found_rows Disable the separate COUNT(*) query. + * Default true. + * @type string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause. + * Default '', to primary column ID. + * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'. + * Default 'DESC'. + * @type string $search Search term(s) to retrieve matching items for. + * Default empty. + * @type array $search_columns Array of column names to be searched. + * Default empty array. + * @type bool $update_item_cache Prime the cache for found items. + * Default false. + * @type bool $update_meta_cache Prime the meta cache for found items. + * Default false. * } */ public function __construct( $query = array() ) { @@ -337,7 +327,7 @@ public function __construct( $query = array() ) { } /** - * Setup the class variables. + * Setup class attributes that rely on other properties. * * This method is public to allow subclasses to override it, and allow for * it to be called directly on a class that has already been used. @@ -449,6 +439,7 @@ private function set_query_clause_defaults() { // Default query clauses $this->query_clauses = array( + 'explain' => '', 'select' => '', 'fields' => '', 'count' => '', @@ -484,17 +475,29 @@ private function set_query_var_defaults() { // Default query variables $this->query_var_defaults = array( + + // Statements + 'explain' => false, + 'select' => '', + + // Fields 'fields' => '', + 'groupby' => '', + + // Boundaries 'number' => 100, 'offset' => '', 'orderby' => $primary, 'order' => 'DESC', - 'groupby' => '', + + // Search 'search' => '', 'search_columns' => array(), + + // COUNT(*) 'count' => false, - // Disable SQL_CALC_FOUND_ROWS? + // Disable row count 'no_found_rows' => true, // Queries @@ -508,7 +511,7 @@ private function set_query_var_defaults() { ); // Direct column names - $names = $this->get_column_names(); + $names = array_flip( $this->get_column_names() ); foreach ( $names as $name ) { $this->query_var_defaults[ $name ] = $this->query_var_default_value; } @@ -536,86 +539,39 @@ private function set_query_var_defaults() { } /** - * Set the request clauses. + * Set $query_clauses by parsing $query_vars. * - * @since 1.0.0 - * - * @param array $clauses + * @since 2.1.0 */ - private function set_request_clauses( $clauses = array() ) { - - // Found rows - $found_rows = empty( $this->query_vars['no_found_rows'] ) - ? 'SQL_CALC_FOUND_ROWS' - : ''; - - // Count - $count = ! empty( $clauses['count'] ) - ? $clauses['count'] - : ''; - - // Fields - $fields = ! empty( $clauses['fields'] ) - ? $clauses['fields'] - : ''; - - // Join - $join = ! empty( $clauses['join'] ) - ? $clauses['join'] - : ''; - - // Where - $where = ! empty( $clauses['where'] ) - ? "WHERE {$clauses['where']}" - : ''; - - // Group by - $groupby = ! empty( $clauses['groupby'] ) - ? "GROUP BY {$clauses['groupby']}" - : ''; - - // Order by - $orderby = ! empty( $clauses['orderby'] ) - ? "ORDER BY {$clauses['orderby']}" - : ''; - - // Limits - $limits = ! empty( $clauses['limits'] ) - ? $clauses['limits'] - : ''; - - // Select & From - $table = $this->get_table_name(); - $select = "SELECT {$found_rows}"; - $from = "FROM {$table} {$this->table_alias}"; + private function set_query_clauses() { + $this->query_clauses = $this->parse_query_vars(); + } - // Put query into clauses array - $this->request_clauses['select'] = $select; - $this->request_clauses['fields'] = $fields; - $this->request_clauses['count'] = $count; - $this->request_clauses['from'] = $from; - $this->request_clauses['join'] = $join; - $this->request_clauses['where'] = $where; - $this->request_clauses['groupby'] = $groupby; - $this->request_clauses['orderby'] = $orderby; - $this->request_clauses['limits'] = $limits; + /** + * Set the $request_clauses. + * + * @since 1.0.0 + * @since 2.1.0 Uses parse_query_clauses() with support for new clauses. + */ + private function set_request_clauses() { + $this->request_clauses = $this->parse_query_clauses(); } /** - * Set the request. + * Set the $request. * * @since 1.0.0 + * @since 2.1.0 Uses parse_request_clauses() on $request_clauses. */ private function set_request() { - $filtered = array_filter( $this->request_clauses ); - $clauses = array_map( 'trim', $filtered ); - $this->request = implode( ' ', $clauses ); + $this->request = $this->parse_request_clauses(); } /** * Set items by mapping them through the single item callback. * * @since 1.0.0 + * @since 2.1.0 Moved 'count' logic back into get_items(). * @param array $item_ids */ private function set_items( $item_ids = array() ) { @@ -634,9 +590,8 @@ private function set_items( $item_ids = array() ) { * Populates found_items and max_num_pages properties for the current query * if the limit clause was used. * - * @todo: make safe for MySQL 8 - * * @since 1.0.0 + * @since 2.1.0 Uses filter_found_items_query(). * * @param mixed $item_ids Optional array of item IDs */ @@ -646,10 +601,10 @@ private function set_found_items( $item_ids = array() ) { $this->found_items = count( (array) $item_ids ); // Count query - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Not grouped - if ( is_numeric( $item_ids ) && empty( $this->query_vars['groupby'] ) ) { + if ( is_numeric( $item_ids ) && ! $this->get_query_var( 'groupby' ) ) { $this->found_items = (int) $item_ids; } @@ -658,18 +613,30 @@ private function set_found_items( $item_ids = array() ) { is_array( $item_ids ) && ( - ! empty( $this->query_vars['number'] ) - && - empty( $this->query_vars['no_found_rows'] ) + $this->get_query_var( 'number' ) && ! $this->get_query_var( 'no_found_rows' ) ) ) { - // Get the found items SQL - $found_items_query = $this->filter_found_items_query(); + // Override a few request clauses + $r = wp_parse_args( + array( + 'count' => 'COUNT(*)', + 'fields' => '', + 'limits' => '', + 'orderby' => '' + ), + $this->request_clauses + ); + + // Parse the new clauses + $query = $this->parse_request_clauses( $r ); + + // Filter the found items query + $query = $this->filter_found_items_query( $query ); // Maybe query for found items - if ( ! empty( $found_items_query ) ) { - $this->found_items = (int) $this->get_db()->get_var( $found_items_query ); + if ( ! empty( $query ) ) { + $this->found_items = (int) $this->get_db()->get_var( $query ); } } } @@ -679,7 +646,7 @@ private function set_found_items( $item_ids = array() ) { /** * Set a query var, to both defaults and request arrays. * - * This method is used to expose the private query_vars array to hooks, + * This method is used to expose the private $query_vars array to hooks, * allowing them to manipulate query vars just-in-time. * * @since 1.0.0 @@ -701,11 +668,45 @@ public function set_query_var( $key = '', $value = '' ) { * @return bool */ public function is_query_var_default( $key = '' ) { - return (bool) ( $this->query_vars[ $key ] === $this->query_var_default_value ); + return (bool) ( $this->get_query_var( $key ) === $this->query_var_default_value ); + } + + /** + * Is a column valid? + * + * @since 2.1.0 + * @param string $column_name + * @return bool + */ + private function is_valid_column( $column_name = '' ) { + + // Bail if column name not valid string + if ( empty( $column_name ) || ! is_string( $column_name ) ) { + return false; + } + + // Get all of the column names + $columns = $this->get_column_names(); + + // Return if column name exists + return isset( $columns[ $column_name ] ); } /** Private Getters *******************************************************/ + /** + * Get a query variable. + * + * @since 2.1.0 + * @param string $key + * @return mixed + */ + private function get_query_var( $key = '' ) { + return isset( $this->query_vars[ $key ] ) + ? $this->query_vars[ $key ] + : null; + } + /** * Pass-through method to return a new Meta object. * @@ -760,7 +761,10 @@ private function get_current_time() { } /** - * Return the literal table name (with prefix) from the database interface. + * Return the table name. + * + * Prefixed by the $table_prefix global, or get_blog_prefix() if + * is_multisite(). * * @since 1.0.0 * @@ -908,6 +912,11 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' $values = array( $values ); } + // Maybe fallback to $key + if ( empty( $field ) ) { + $field = $key; + } + // Get the column fields foreach ( $values as $value ) { $args = array( $key => $value ); @@ -918,10 +927,37 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' return $retval; } + /** + * Get a column name, possibly with the $table_alias append. + * + * @since 2.1.0 + * @param string $column_name + * @param bool $alias + * @return string + */ + private function get_column_name_aliased( $column_name = '', $alias = true ) { + + // Default return value + $retval = $column_name; + + /** + * Maybe append table alias. + * + * Also append a period, to separate it from the column name. + */ + if ( true === $alias ) { + $retval = "{$this->table_alias}.{$column_name}"; + } + + // Return SQL + return $retval; + } + /** * Get a single database row by any column and value, skipping cache. * * @since 1.0.0 + * @since 2.1.0 Uses is_valid_column() * * @param string $column_name Name of database column * @param mixed $column_value Value to query for @@ -929,13 +965,13 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' */ private function get_item_raw( $column_name = '', $column_value = '' ) { - // Bail if no name or value - if ( empty( $column_name ) || empty( $column_value ) ) { + // Bail if empty or non-scalar value + if ( empty( $column_value ) || ! is_scalar( $column_value ) ) { return false; } - // Bail if values aren't query'able - if ( ! is_string( $column_name ) || ! is_scalar( $column_value ) ) { + // Bail if invalid column + if ( ! $this->is_valid_column( $column_name ) ) { return false; } @@ -971,7 +1007,7 @@ private function get_items() { * * @since 1.0.0 * - * @param Query &$this Current instance of Query, passed by reference. + * @param Query &$this Current instance passed by reference. */ do_action_ref_array( $this->apply_prefix( "pre_get_{$this->item_name_plural}" ), @@ -1009,18 +1045,22 @@ private function get_items() { } // Pagination - if ( ! empty( $this->found_items ) && ! empty( $this->query_vars['number'] ) ) { - $this->max_num_pages = (int) ceil( $this->found_items / $this->query_vars['number'] ); + if ( ! empty( $this->found_items ) ) { + $number = (int) $this->get_query_var( 'number' ); + + if ( ! empty( $number ) ) { + $this->max_num_pages = (int) ceil( $this->found_items / $number ); + } } // Cast to int if not grouping counts - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Set items $this->items = $result; // Not grouping, so cast to int - if ( empty( $this->query_vars['groupby'] ) ) { + if ( ! $this->get_query_var( 'groupby' ) ) { $this->items = (int) $result; } @@ -1046,64 +1086,18 @@ private function get_items() { */ private function get_item_ids() { - // Parse 'where' & 'join' - $this->parse_where_join_vars(); - - // Where & Join - $where = $this->parse_where_clauses( $this->query_clauses['where'] ); - $join = $this->parse_join_clauses( $this->query_clauses['join'] ); - - // Order & Order By - $orderby = $this->parse_orderby( - $this->query_vars['orderby'], - $this->query_vars['order'] - ); - - // Group by - $groupby = $this->parse_groupby( $this->query_vars['groupby'] ); - - // Count - $count = $this->parse_count( - $this->query_vars['count'], - $this->query_vars['groupby'] - ); - - // Fields - $fields = $this->parse_fields( - $this->query_vars['fields'], - $this->query_vars['count'], - $this->query_vars['groupby'] - ); - - // Limits - $limits = $this->parse_limits( - $this->query_vars['number'], - $this->query_vars['offset'] - ); - - // Setup the query array - $query = array( - 'count' => $count, - 'fields' => $fields, - 'join' => $join, - 'where' => $where, - 'orderby' => $orderby, - 'limits' => $limits, - 'groupby' => $groupby - ); - - // Filter the query clauses - $clauses = $this->filter_query_clauses( $query ); + // Setup the query clauses + $this->set_query_clauses(); // Setup request - $this->set_request_clauses( $clauses ); + $this->set_request_clauses(); $this->set_request(); // Return count - if ( ! empty( $this->query_vars['count'] ) ) { + if ( $this->get_query_var( 'count' ) ) { // Get vars or results - $retval = empty( $this->query_vars['groupby'] ) + $retval = ! $this->get_query_var( 'groupby' ) ? $this->get_db()->get_var( $this->request ) : $this->get_db()->get_results( $this->request, ARRAY_A ); @@ -1182,8 +1176,8 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, // Default return value $retval = ''; - // Bail if no values or column name - if ( empty( $values ) || empty( $column_name ) ) { + // Bail if no values or invalid column + if ( empty( $values ) || ! $this->is_valid_column( $column_name ) ) { return $retval; } @@ -1221,24 +1215,23 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, * Parses arguments passed to the item query with default query parameters. * * @since 1.0.0 + * @since 2.1.0 Forces some $query_vars if counting * - * @see Query::__construct() - * - * @param array|string $query Array or string of Query arguments. + * @param array|string $query */ private function parse_query( $query = array() ) { - // Setup the query_vars_original var + // Setup the $query_vars_original var $this->query_var_originals = wp_parse_args( $query ); - // Setup the query_vars parsed var + // Setup the $query_vars parsed var $this->query_vars = wp_parse_args( $this->query_var_originals, $this->query_var_defaults ); - // If counting, override some other query_vars - if ( ! empty( $this->query_vars['count'] ) ) { + // If counting, override some other $query_vars + if ( $this->get_query_var( 'count' ) ) { $this->query_vars['number'] = false; $this->query_vars['orderby'] = ''; $this->query_vars['no_found_rows'] = true; @@ -1251,7 +1244,7 @@ private function parse_query( $query = array() ) { * * @since 1.0.0 * - * @param Query &$this The Query instance (passed by reference). + * @param Query &$this Current instance passed by reference. */ do_action_ref_array( $this->apply_prefix( "parse_{$this->item_name_plural}_query" ), @@ -1262,13 +1255,66 @@ private function parse_query( $query = array() ) { } /** - * Parse the 'where' and 'join' query clauses for all known columns. + * Parse all of the $query_vars. + * + * Optionally accepts an array of custom $query_vars that can be used + * instead of the default ones. * - * @todo split this method into smaller parts + * Calls filter_query_clauses() on the return value. + * + * @since 2.1.0 + * @param array $query_vars Optional. Default empty array. + * Fallback to Query::query_vars. + * @return array Query clauses, parsed from Query vars. + */ + private function parse_query_vars( $query_vars = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $query_vars ) && ! empty( $this->query_vars ) ) { + $query_vars = $this->query_vars; + } + + // Parse arguments + $r = wp_parse_args( $query_vars ); + + // Parse $query_vars + $where_join = $this->parse_where_join( $r ); + + // Parse all clauses + $clauses = array( + 'explain' => $this->parse_explain( $r['explain'] ), + 'select' => $this->parse_select(), + 'fields' => $this->parse_fields( $r['fields'], $r['count'], $r['groupby'] ), + 'count' => $this->parse_count( $r['count'], $r['groupby'] ), + 'from' => $this->parse_from(), + 'join' => $this->parse_join_clause( $where_join['join'] ), + 'where' => $this->parse_where_clause( $where_join['where'] ), + 'groupby' => $this->parse_groupby( $r['groupby'], 'GROUP BY ' ), + 'orderby' => $this->parse_orderby( $r['orderby'], $r['order'], 'ORDER BY ' ), + 'limits' => $this->parse_limits( $r['number'], $r['offset'] ) + ); + + // Return clauses + return $this->filter_query_clauses( $clauses ); + } + + /** + * Parse the 'where' and 'join' $query_vars for all known columns. * * @since 2.1.0 + * + * @param array $args Query vars + * @return array Array of 'where' and 'join' clauses. */ - private function parse_where_join_vars() { + private function parse_where_join( $args = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $args ) && ! empty( $this->query_vars ) ) { + $args = $this->query_vars; + } + + // Parse arguments + $r = wp_parse_args( $args ); // Defaults $where = $join = $date_query = array(); @@ -1279,30 +1325,32 @@ private function parse_where_join_vars() { // Loop through columns foreach ( $columns as $column ) { - // Get pattern - $pattern = $this->get_column_field( array( 'name' => $column->name ), 'pattern', '%s' ); + // Get column name, pattern, and aliased name + $name = $column->name; + $pattern = $this->get_column_field( array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->get_column_name_aliased( $name ); // Literal column comparison if ( false !== $column->by ) { // Parse query variable - $where_id = $column->name; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = $name; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for direct clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; + $statement = "{$aliased} = {$pattern}"; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { $where_id = "{$where_id}__in"; - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; } } } @@ -1311,23 +1359,23 @@ private function parse_where_join_vars() { if ( true === $column->in ) { // Parse query var - $where_id = "{$column->name}__in"; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = "{$name}__in"; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for an IN clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} = {$pattern}"; - $where_id = $column->name; + $statement = "{$aliased} = {$pattern}"; + $where_id = $name; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; } } } @@ -1336,52 +1384,49 @@ private function parse_where_join_vars() { if ( true === $column->not_in ) { // Parse query var - $where_id = "{$column->name}__not_in"; - $values = $this->parse_query_var( $this->query_vars, $where_id ); + $where_id = "{$name}__not_in"; + $values = $this->parse_query_var( $r, $where_id ); // Parse item for a NOT IN clause. if ( false !== $values ) { // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$this->table_alias}.{$column->name} != {$pattern}"; - $where_id = $column->name; + $statement = "{$aliased} != {$pattern}"; + $where_id = $name; $column_value = reset( $values ); $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); // Implode } else { - $in_values = $this->get_in_sql( $column->name, $values, true, $pattern ); - $where[ $where_id ] = "{$this->table_alias}.{$column->name} NOT IN {$in_values}"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} NOT IN {$in_values}"; } } } // date_query if ( true === $column->date_query ) { - $where_id = "{$column->name}_query"; - $column_date = $this->query_vars[ $where_id ]; + $where_id = "{$name}_query"; + $column_date = $this->parse_query_var( $r, $where_id ); // Parse item - if ( ! empty( $column_date ) && ! $this->is_query_var_default( $where_id ) ) { - - // Default arguments - $defaults = array( - 'column' => "{$this->table_alias}.{$column->name}", - 'before' => $column_date, - 'inclusive' => true - ); + if ( false !== $column_date ) { - // Default date query - if ( is_string( $column_date ) ) { - $date_query[] = $defaults; + // Single + if ( 1 === count( $column_date ) ) { + $date_query[] = array( + 'column' => $aliased, + 'before' => reset( $column_date ), + 'inclusive' => true + ); - // Array query var - } elseif ( is_array( $column_date ) ) { + // Multi + } else { // Auto-fill column if empty if ( empty( $column_date['column'] ) ) { - $column_date['column'] = $defaults['column']; + $column_date['column'] = $aliased; } // Add clause to date query @@ -1397,15 +1442,15 @@ private function parse_where_join_vars() { $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); // Maybe search if columns are searchable. - if ( ! empty( $searchable ) && strlen( $this->query_vars['search'] ) ) { + if ( ! empty( $searchable ) && strlen( $r['search'] ) ) { // Default to all searchable columns $search_columns = $searchable; // Intersect against known searchable columns - if ( ! empty( $this->query_vars['search_columns'] ) ) { + if ( ! empty( $r['search_columns'] ) ) { $search_columns = array_intersect( - $this->query_vars['search_columns'], + $r['search_columns'], $searchable ); } @@ -1414,7 +1459,7 @@ private function parse_where_join_vars() { $search_columns = $this->filter_search_columns( $search_columns ); // Add search query clause - $where['search'] = $this->get_search_sql( $this->query_vars['search'], $search_columns ); + $where['search'] = $this->get_search_sql( $r['search'], $search_columns ); } /** Query Classes *****************************************************/ @@ -1422,17 +1467,18 @@ private function parse_where_join_vars() { // Get the primary column name $primary = $this->get_primary_column_name(); - // Get the meta table + // Get the meta type & table alias $table = $this->get_meta_type(); + $alias = $this->table_alias; // Set the " AND " regex pattern $and = '/^\s*AND\s*/'; // Maybe perform a meta query. - $meta_query = $this->query_vars['meta_query']; + $meta_query = $r['meta_query']; if ( ! empty( $meta_query ) && is_array( $meta_query ) ) { $this->meta_query = $this->get_meta_query( $meta_query ); - $clauses = $this->meta_query->get_sql( $table, $this->table_alias, $primary, $this ); + $clauses = $this->meta_query->get_sql( $table, $alias, $primary, $this ); // Not all objects have meta, so make sure this one exists if ( false !== $clauses ) { @@ -1444,18 +1490,16 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query query where clause $where['meta_query'] = preg_replace( $and, '', $clauses['where'] ); } } } // Maybe perform a compare query. - $compare_query = $this->query_vars['compare_query']; + $compare_query = $r['compare_query']; if ( ! empty( $compare_query ) && is_array( $compare_query ) ) { $this->compare_query = $this->get_compare_query( $compare_query ); - $clauses = $this->compare_query->get_sql( $table, $this->table_alias, $primary, $this ); + $clauses = $this->compare_query->get_sql( $table, $alias, $primary, $this ); // Not all objects can compare, so make sure this one exists if ( false !== $clauses ) { @@ -1467,8 +1511,6 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query where clause. $where['compare_query'] = preg_replace( $and, '', $clauses['where'] ); } } @@ -1477,12 +1519,12 @@ private function parse_where_join_vars() { // Only do a date query with an array $date_query = ! empty( $date_query ) ? $date_query - : $this->query_vars['date_query']; + : $r['date_query']; // Maybe perform a date query if ( ! empty( $date_query ) && is_array( $date_query ) ) { $this->date_query = $this->get_date_query( $date_query ); - $clauses = $this->date_query->get_sql( $this->table_name, $this->table_alias, $primary, $this ); + $clauses = $this->date_query->get_sql( $this->table_name, $alias, $primary, $this ); // Not all objects are dates, so make sure this one exists if ( false !== $clauses ) { @@ -1494,16 +1536,16 @@ private function parse_where_join_vars() { // Set where if ( ! empty( $clauses['where'] ) ) { - - // Remove " AND " from query where clause. $where['date_query'] = preg_replace( $and, '', $clauses['where'] ); } } } - // Set where and join clauses, removing possible empties - $this->query_clauses['where'] = array_filter( $where ); - $this->query_clauses['join'] = array_filter( $join ); + // Return where & join, removing possible empties + return array( + 'where' => array_filter( $where ), + 'join' => array_filter( $join ) + ); } /** @@ -1594,6 +1636,42 @@ private function parse_query_var( $query_vars = '', $key = '' ) { return array( $value ); } + /** + * Parse if query to be EXPLAIN'ed. + * + * @since 2.1.0 + * @param bool $explain Default false. True to EXPLAIN. + * @return string + */ + private function parse_explain( $explain = false ) { + + // Maybe fallback to $query_vars + if ( empty( $explain ) ) { + $explain = $this->get_query_var( 'explain' ); + } + + // Default return value + $retval = ''; + + // Maybe explaining + if ( ! empty( $explain ) ) { + $retval = 'EXPLAIN'; + } + + // Return SQL + return $retval; + } + + /** + * Parse the "SELECT" part of the SQL. + * + * @since 2.1.0 + * @return string Default "SELECT". + */ + private function parse_select() { + return 'SELECT'; + } + /** * Parse which fields to query for. * @@ -1604,7 +1682,8 @@ private function parse_query_var( $query_vars = '', $key = '' ) { * predictably hit the cache, but that may change in a future version. * * @since 1.0.0 - * @since 2.1.0 Moved COUNT() SQL to parse_count() + * @since 2.1.0 Moved COUNT() SQL to parse_count() and uses parse_groupby() + * when counting to satisfy MySQL 8 and higher. * * @param string[] $fields * @param bool $count @@ -1615,8 +1694,8 @@ private function parse_query_var( $query_vars = '', $key = '' ) { private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { - $count = $this->query_vars['count']; + if ( empty( $count ) ) { + $count = $this->get_query_var( 'count' ); } // Default return value @@ -1627,24 +1706,22 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali // Use groupby instead if ( ! empty( $groupby ) ) { - $retval = $this->parse_groupby( $groupby, $alias ); + $retval = $this->parse_groupby( $groupby, '', $alias ); } - // Not counting + // Not counting, so use primary column } else { // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Get the primary column name $primary = $this->get_primary_column_name(); // Default return value - $retval = ( true === $alias ) - ? "{$this->table_alias}.{$primary}" - : $primary; + $retval = $this->get_column_name_aliased( $primary, $alias ); } // Return fields @@ -1652,8 +1729,10 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali } /** - * Parse if counting, possibly grouping by columns. + * Parse if counting. * + * When counting with groups, parse_fields() will return the required SQL to + * prevent errors. * * @since 2.1.0 * @param bool $count @@ -1665,8 +1744,8 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali private function parse_count( $count = false, $groupby = '', $name = 'count', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $count ) && ! empty( $this->query_vars['count'] ) ) { - $count = $this->query_vars['count']; + if ( empty( $count ) ) { + $count = $this->get_query_var( 'count' ); } // Bail if not counting @@ -1678,7 +1757,7 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a $retval = 'COUNT(*)'; // Check for "GROUP BY" - $groupby_names = $this->parse_groupby( $groupby, $alias ); + $groupby_names = $this->parse_groupby( $groupby, '', $alias ); // Reformat if grouping counts together if ( ! empty( $groupby_names ) ) { @@ -1689,20 +1768,47 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a return $retval; } + /** + * Parse which table to query and whether to follow it with an alias. + * + * @since 2.1.0 + * @param string $table Optional. Default empty string. + * Fallback to get_table_name(). + * @param string $alias Optional. Default empty string. + * Fallback to $table_alias. + * @return string + */ + private function parse_from( $table = '', $alias = '' ) { + + // Maybe fallback to get_table_name() + if ( empty( $table ) ) { + $table = $this->get_table_name(); + } + + // Maybe fallback to $table_alias + if ( empty( $alias ) ) { + $alias = $this->table_alias; + } + + // Return + return "FROM {$table} {$alias}"; + } + /** * Parses and sanitizes the 'groupby' keys passed into the item query. * * @since 1.0.0 * * @param string $groupby + * @param string $before * @param bool $alias * @return string */ - private function parse_groupby( $groupby = '', $alias = true ) { + private function parse_groupby( $groupby = '', $before = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $groupby ) && ! empty( $this->query_vars['groupby'] ) ) { - $groupby = $this->query_vars['groupby']; + if ( empty( $groupby ) ) { + $groupby = $this->get_query_var( 'groupby' ); } // Bail if empty @@ -1710,128 +1816,119 @@ private function parse_groupby( $groupby = '', $alias = true ) { return ''; } - // Sanitize groupby columns - $groupby = (array) array_map( 'sanitize_key', (array) $groupby ); - - // Re'flip column names back around - $columns = array_flip( $this->get_column_names() ); + // Maybe cast to array + if ( ! is_array( $groupby ) ) { + $groupby = (array) $groupby; + } // Get the intersection of allowed column names to groupby columns - $intersect = array_intersect( $groupby, $columns ); + $intersect = $this->get_columns_field_by( 'name', $groupby ); - // Bail if invalid column + // Bail if invalid columns if ( empty( $intersect ) ) { return ''; } - // Default return value - $retval = array(); + // Column names array + $names = array(); // Maybe prepend table alias to key foreach ( $intersect as $key ) { - $retval[] = ( true === $alias ) - ? "{$this->table_alias}.{$key}" - : $key; + $names[] = $this->get_column_name_aliased( $key, $alias ); } - // Separate sanitized columns - return implode( ',', array_values( $retval ) ); + // Bail if nothing to groupby + if ( empty( $names ) && ! empty( $before ) ) { + return ''; + } + + // Format column names + $retval = implode( ',', $names ); + + // Return columns + return implode( ' ', array( $before, $retval ) ) ; } /** * Parse the ORDER BY clause. * * @since 1.0.0 As get_order_by - * @since 2.1.0 Renamed to parse_orderby and accepts $orderby, $order, and $alias + * @since 2.1.0 Renamed to parse_orderby and accepts $orderby, $order, $before, and $alias * * @param string $orderby * @param string $order + * @param string $before * @param bool $alias * @return string */ - private function parse_orderby( $orderby = '', $order = '', $alias = true ) { + private function parse_orderby( $orderby = '', $order = '', $before = '', $alias = true ) { // Maybe fallback to $query_vars - if ( empty( $orderby ) && ! empty( $this->query_vars['orderby'] ) ) { - $orderby = $this->query_vars['orderby']; + if ( empty( $orderby ) ) { + $orderby = $this->get_query_var( 'orderby' ); } - // Default orderby primary column - $parsed = $this->parse_single_orderby( $orderby, $alias ); - $order = $this->parse_order( $order ); - $orderby = "{$parsed} {$order}"; - - // Disable ORDER BY if counting, or: 'none', an empty array, or false. - if ( + // Bail if counting + if ( $this->get_query_var( 'count' ) ) { + return ''; + } - ! empty( $this->query_vars['count'] ) + // Bail if $orderby is a value that could cancel ordering + if ( in_array( $orderby, array( 'none', array(), false, null ), true ) ) { + return ''; + } - || + // Default return value + $retval = ''; - in_array( $orderby, array( 'none', array(), false ), true ) - ) { - $orderby = ''; + // Fallback to default orderby & order + if ( empty( $orderby ) ) { + $parsed = $this->parse_single_orderby( $orderby, $alias ); + $order = $this->parse_order( $order ); + $retval = "{$parsed} {$order}"; // Ordering by something, so figure it out - } elseif ( ! empty( $orderby ) ) { - - // Array of keys, or comma separated - $ordersby = $this->parse_query_var( $this->query_vars, 'orderby' ); - - $orderby_array = array(); - $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); - $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); - - // Loop through possible order by's - foreach ( $ordersby as $_key => $_value ) { + } else { - // Skip if empty - if ( empty( $_value ) ) { - continue; - } + // Cast orderby as an array + $ordersby = (array) $orderby; - // Key is numeric - if ( is_int( $_key ) ) { - $_orderby = $_value; - $_item = $order; + // Fill if numeric + if ( wp_is_numeric_array( $ordersby ) ) { + $ordersby = array_fill_keys( $ordersby, $order ); + } - // Key is string - } else { - $_orderby = $_key; - $_item = $_value; - } + // Default return value + $orderby_array = array(); - // Skip if not sortable - if ( ! in_array( $_value, $sortables, true ) ) { - continue; - } + // Loop through orderby's + foreach ( $ordersby as $key => $value ) { // Parse orderby - $parsed = $this->parse_single_orderby( $_orderby, $alias ); + $parsed = $this->parse_single_orderby( $key, $alias ); // Skip if empty if ( empty( $parsed ) ) { continue; } - // Set if __in - if ( in_array( $_orderby, $possible_ins, true ) ) { - $orderby_array[] = "{$parsed} {$order}"; - continue; - } - // Append parsed orderby to array - $orderby_array[] = $parsed . ' ' . $this->parse_order( $_item ); + $orderby_array[] = $parsed . ' ' . $this->parse_order( $value ); } // Only set if valid orderby if ( ! empty( $orderby_array ) ) { - $orderby = implode( ', ', $orderby_array ); + $retval = implode( ', ', $orderby_array ); } } + // Bail if nothing to orderby + if ( empty( $retval ) && ! empty( $before ) ) { + return ''; + } + // Return parsed orderby - return $orderby; + return implode( ' ', array( $before, $retval ) ); } /** @@ -1839,10 +1936,17 @@ private function parse_orderby( $orderby = '', $order = '', $alias = true ) { * * @since 2.1.0 * @param array $where - * @return string + * @return string A single SQL statement. */ - private function parse_where_clauses( $where = array() ) { - return implode( ' AND ', $where ); + private function parse_where_clause( $where = array() ) { + + // Bail if no where + if ( empty( $where ) ) { + return ''; + } + + // Return SQL + return 'WHERE ' . implode( ' AND ', $where ); } /** @@ -1850,12 +1954,62 @@ private function parse_where_clauses( $where = array() ) { * * @since 2.1.0 * @param array $join - * @return string + * @return string A single SQL statement. */ - private function parse_join_clauses( $join = array() ) { + private function parse_join_clause( $join = array() ) { + + // Return SQL return implode( ', ', $join ); } + /** + * Parse all of the SQL query clauses. + * + * @since 2.1.0 + * @param array $clauses + * @return array + */ + private function parse_query_clauses( $clauses = array() ) { + + // Maybe fallback to $query_clauses + if ( empty( $clauses ) && ! empty( $this->query_clauses ) ) { + $clauses = $this->query_clauses; + } + + // Default return value + $retval = wp_parse_args( $clauses ); + + // Return array of clauses + return $retval; + } + + /** + * Parse all SQL $request_clauses into a single SQL query string. + * + * @since 2.1.0 + * @param array $clauses + * @return string A single SQL statement. + */ + private function parse_request_clauses( $clauses = array() ) { + + // Maybe fallback to $request_clauses + if ( empty( $clauses ) && ! empty( $this->request_clauses ) ) { + $clauses = $this->request_clauses; + } + + // Bail if empty clauses + if ( empty( $clauses ) ) { + return ''; + } + + // Remove empties + $filtered = array_filter( $clauses ); + $retval = array_map( 'trim', $filtered ); + + // Return SQL + return implode( ' ', $retval ); + } + /** * Parses the 'number' and 'offset' keys passed to the item query. * @@ -1904,28 +2058,33 @@ private function parse_single_orderby( $orderby = '', $alias = true ) { $orderby = $this->get_primary_column_name(); } - // __in + // Default return value + $retval = ''; + + // Get possible columns an $orderby can belong to + $ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); + $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); + + // __in column if ( false !== strstr( $orderby, '__in' ) ) { - $column_name = str_replace( '__in', '', $orderby ); - $item_in = $this->get_in_sql( $column_name, $this->query_vars[ $orderby ], false ); - $aliased = ( true === $alias ) - ? "{$this->table_alias}.{$column_name}" - : $column_name; - $retval = "FIELD( {$aliased}, {$item_in} )"; - // Specific column - } else { + // Get column name from $orderby clause + $column_name = str_replace( '__in', '', $orderby ); - // Orderby is a literal, sortable column name - $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' ); - if ( in_array( $orderby, $sortables, true ) ) { - $retval = ( true === $alias ) - ? "{$this->table_alias}.{$orderby}" - : $orderby; + // Get values if valid column + if ( in_array( $column_name, $ins, true ) ) { + $values = $this->get_query_var( $orderby ); + $item_in = $this->get_in_sql( $column_name, $values, false ); + $aliased = $this->get_column_name_aliased( $column_name, $alias ); + $retval = "FIELD( {$aliased}, {$item_in} )"; } + + // Specific sortable column + } elseif ( in_array( $orderby, $sortables, true ) ) { + $retval = $this->get_column_name_aliased( $orderby, $alias ); } - // Return parsed value + // Return SQL return $retval; } @@ -1973,8 +2132,8 @@ private function parse_order( $order = 'DESC' ) { private function shape_items( $items = array(), $fields = array() ) { // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Force to stdClass if querying for fields @@ -2020,8 +2179,8 @@ private function get_item_fields( $items = array(), $fields = array() ) { $retval = $items; // Maybe fallback to $query_vars - if ( empty( $fields ) && ! empty( $this->query_vars['fields'] ) ) { - $fields = $this->query_vars['fields']; + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); } // Bail if no fields to get @@ -2029,12 +2188,14 @@ private function get_item_fields( $items = array(), $fields = array() ) { return $retval; } + // Maybe cast to array + if ( ! is_array( $fields ) ) { + $fields = (array) $fields; + } + // Get the primary column name $primary = $this->get_primary_column_name(); - // Sanitize fields - $fields = (array) array_map( 'sanitize_key', (array) $fields ); - // 'ids' is numerically keyed if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { $retval = wp_list_pluck( $items, $primary ); @@ -2142,7 +2303,7 @@ public function get_item( $item_id = 0 ) { * Get a single database row by any column and value, possibly from cache. * * Take care to only use this method on columns with unique values, - * preferably with a cache group for that column. See: get_item(). + * preferably with a cache group for that column. * * @since 1.0.0 * @@ -2152,32 +2313,19 @@ public function get_item( $item_id = 0 ) { */ public function get_item_by( $column_name = '', $column_value = '' ) { - // Default return value - $retval = false; - - // Bail if no key or value - if ( empty( $column_name ) || empty( $column_value ) ) { - return $retval; - } - - // Bail if name is not a string - if ( ! is_string( $column_name ) ) { - return $retval; - } - - // Bail if value is not scalar (null values also not allowed) - if ( ! is_scalar( $column_value ) ) { - return $retval; + // Bail if empty or non-scalar value + if ( empty( $column_value ) || ! is_scalar( $column_value ) ) { + return false; } - // Get all of the column names - $columns = $this->get_column_names(); - // Bail if column does not exist - if ( ! isset( $columns[ $column_name ] ) ) { - return $retval; + if ( ! $this->is_valid_column( $column_name ) ) { + return false; } + // Default return value + $retval = false; + // Get all of the cache groups $groups = $this->get_cache_groups(); @@ -2972,31 +3120,32 @@ private function delete_all_item_meta( $item_id = 0 ) { /** * Get the meta table for this query. * - * Forked from WordPress\_get_meta_table() so it can be more accurately - * predicted in a future iteration and default to returning false. - * * @since 1.0.0 + * @since 2.1.0 Minor refactor to improve readability. * - * @return mixed Table name if exists, False if not + * @return bool|string Table name if exists, False if not. */ private function get_meta_table_name() { - // Get the meta-type - $type = $this->get_meta_type(); + // Default return value + $retval = false; - // Append "meta" to end of meta-type - $table_name = "{$type}meta"; + // Get the meta type + $type = $this->get_meta_type(); + + // Append "meta" to end of meta type + $table = "{$type}meta"; // Variable'ize the database interface, to use inside empty() - $db = $this->get_db(); + $db = $this->get_db(); // If not empty, return table name - if ( ! empty( $db->{$table_name} ) ) { - return $db->{$table_name}; + if ( ! empty( $db->{$table} ) ) { + $retval = $db->{$table}; } - // Default return false - return false; + // Return + return $retval; } /** @@ -3016,7 +3165,7 @@ private function get_meta_type() { /** Cache *****************************************************************/ /** - * Get cache key from query_vars and query_var_defaults. + * Get cache key from $query_vars and $query_var_defaults. * * @since 1.0.0 * @@ -3025,7 +3174,7 @@ private function get_meta_type() { */ private function get_cache_key( $group = '' ) { - // Slice query vars + // Slice $query_vars by default keys $slice = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) ); // Unset "fields" so it does not effect the cache key @@ -3035,7 +3184,7 @@ private function get_cache_key( $group = '' ) { $key = md5( serialize( $slice ) ); $last_changed = $this->get_last_changed_cache( $group ); - // Concatenate and return cache key + // Return the concatenated cache key return "get_{$this->item_name_plural}:{$key}:{$last_changed}"; } @@ -3099,8 +3248,7 @@ private function get_cache_groups() { } /** - * Maybe prime item & item-meta caches by querying 1 time for all un-cached - * items. + * Maybe prime item & item-meta caches. * * Accepts a single ID, or an array of IDs. * @@ -3108,7 +3256,11 @@ private function get_cache_groups() { * after an item is inserted in the database, but before items have been * "shaped" into proper objects, so object properties may not be set yet. * + * Queries the database 1 time for all non-cached item objects and 1 time + * for all non-cached item meta. + * * @since 1.0.0 + * @since 2.1.0 Uses get_meta_table_name() to * * @param array $item_ids * @param bool $force @@ -3128,45 +3280,51 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { /** * Update item caches. * - * Uses our own get_non_cached_ids() method to avoid + * Uses get_non_cached_ids() to remove item IDs that already exist in + * in the cache, then performs direct database query for the remaining + * IDs, and caches them. */ - if ( ! empty( $force ) || ! empty( $this->query_vars['update_item_cache'] ) ) { + if ( ! empty( $force ) || $this->get_query_var( 'update_item_cache' ) ) { // Look for non-cached IDs $ids = $this->get_non_cached_ids( $item_ids, $this->cache_group ); - // Bail if IDs are cached - if ( empty( $ids ) ) { - return false; - } + // Proceed if non-cached IDs exist + if ( ! empty( $ids ) ) { - // Get query parts - $table = $this->get_table_name(); - $primary = $this->get_primary_column_name(); - $ids = $this->get_in_sql( $primary, $ids ); + // Get query parts + $table = $this->get_table_name(); + $primary = $this->get_primary_column_name(); + $ids = $this->get_in_sql( $primary, $ids ); - // Query database - $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; - $prepare = sprintf( $query, $ids ); - $results = $this->get_db()->get_results( $prepare ); + // Query database + $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; + $prepare = sprintf( $query, $ids ); + $results = $this->get_db()->get_results( $prepare ); - // Update item cache(s) - $this->update_item_cache( $results ); + // Update item cache(s) + $this->update_item_cache( $results ); + } } /** * Update meta data caches. * * Uses update_meta_cache() because it politely handles all of the - * uncached ID logic. This allows us to use the original (and likely + * non-cached ID logic. This allows us to use the original (and likely * larger) $item_ids array instead of $ids, thus ensuring the everything * is cached according to our expectations. */ - if ( ! empty( $this->query_vars['update_meta_cache'] ) ) { - $singular = rtrim( $this->table_name, 's' ); // sic - update_meta_cache( $singular, $item_ids ); + if ( ! empty( $force ) || $this->get_query_var( 'update_meta_cache' ) ) { + + // Proceed if meta table exists + if ( $this->get_meta_table_name() ) { + $meta_type = $this->get_meta_type(); + update_meta_cache( $meta_type, $item_ids ); + } } + // Return true because something was cached return true; } @@ -3534,23 +3692,25 @@ public function filter_items( $items = array() ) { * Filter the found items query. * * @since 2.1.0 - * + * @param string $sql * @return string */ - public function filter_found_items_query() { + public function filter_found_items_query( $sql = '' ) { /** * Filters the query used to retrieve the found item count. * * @since 1.0.0 + * @since 2.1.0 Supports MySQL 8 by removing FOUND_ROWS() and uses + * $request_clauses instead. * * @param string $query SQL query. Default 'SELECT FOUND_ROWS()'. - * @param Query &$this Current instance passed by reference. + * @param Query &$this Current instance passed by reference. */ return (string) apply_filters_ref_array( $this->apply_prefix( "found_{$this->item_name_plural}_query" ), array( - 'SELECT FOUND_ROWS()', + $sql, &$this ) ); From 70b0c9287b1433db607e391827f54c213b10dc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Sz=C3=A9pe?= Date: Wed, 29 Jun 2022 14:53:57 +0000 Subject: [PATCH 14/34] Resolve PHPStan Level 0 errors (#144) * Resolve PHPStan Level 0 errors * Fix boolean handling * Revert change for get_sql * Make $index public --- src/Database/Base.php | 2 ++ src/Database/Column.php | 4 +++- src/Database/Queries/Date.php | 4 ++-- src/Database/Query.php | 6 +++--- src/Database/Schema.php | 12 ++++++------ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index 080353d..8042bcf 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -21,6 +21,8 @@ * into a magic call handler and others. * * @since 1.0.0 + * + * @property array $args */ class Base { diff --git a/src/Database/Column.php b/src/Database/Column.php index 21acce7..98c46a9 100644 --- a/src/Database/Column.php +++ b/src/Database/Column.php @@ -1108,6 +1108,8 @@ public function validate_datetime( $value = '' ) { // Default empty datetime (value with NO_ZERO_DATE off) $default_empty = '0000-00-00 00:00:00'; + $fallback = false; + // Handle current_timestamp MySQL constant if ( 'CURRENT_TIMESTAMP' === strtoupper( $value ) ) { $value = 'CURRENT_TIMESTAMP'; @@ -1133,7 +1135,7 @@ public function validate_datetime( $value = '' ) { } // Fallback to $default or empty string - if ( true === $fallback ) { + if ( $fallback ) { $value = (string) $this->default; } diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php index e1c12ae..25ac3e0 100644 --- a/src/Database/Queries/Date.php +++ b/src/Database/Queries/Date.php @@ -653,8 +653,8 @@ public function get_sql() { * * @since 1.0.0 * - * @param string $sql Clauses of the date query. - * @param Date $this The Date query instance. + * @param array $sql Clauses of the date query. + * @param Date $instance The Date query instance. */ return (array) apply_filters( 'get_date_sql', $sql, $this ); } diff --git a/src/Database/Query.php b/src/Database/Query.php index dda58d2..657ce38 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1691,7 +1691,7 @@ private function parse_select() { * @param bool $alias * @return string */ - private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) { + private function parse_fields( $fields = array(), $count = false, $groupby = array(), $alias = true ) { // Maybe fallback to $query_vars if ( empty( $count ) ) { @@ -1838,7 +1838,7 @@ private function parse_groupby( $groupby = '', $before = '', $alias = true ) { } // Bail if nothing to groupby - if ( empty( $names ) && ! empty( $before ) ) { + if ( 0 === count( $names ) && ! empty( $before ) ) { return ''; } @@ -2676,7 +2676,7 @@ public function delete_item( $item_id = 0 ) { * * @since 1.0.0 * - * @param mixed ID of item, or row from database + * @param mixed $item ID of item, or row from database * @return mixed False on error, Object of single-object class type on success */ private function shape_item( $item = 0 ) { diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 311f71c..076efb3 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -41,7 +41,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - protected $index = __NAMESPACE__ . '\\Index'; + public $index = __NAMESPACE__ . '\\Index'; /** Item Objects **********************************************************/ @@ -51,7 +51,7 @@ class Schema extends Base { * @since 1.0.0 * @var array */ - protected $columns = array(); + public $columns = array(); /** * Array of database Index objects. @@ -152,10 +152,10 @@ public function clear( $type = '' ) { * Add an item to a specific items array. * * @since 2.1.0 - * @param string $type Item type to add. - * @param string $class Class to shape item into. - * @param array|object $data Data to pass into class constructor. - * @return bool|object + * @param string $type Item type to add. + * @param string $class Class to shape item into. + * @param array|object|false $data Data to pass into class constructor. + * @return object|false */ public function add_item( $type = 'column', $class = 'Column', $data = false ) { From 5b16eed6f62d3cedf66b856dd63c4ddaaecd8717 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 16:25:34 -0500 Subject: [PATCH 15/34] Query: bail early with obvious results (not $retval) This ends up being easier to understand than following the code backwards. --- src/Database/Query.php | 59 +++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 28fb531..32ff67a 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -899,14 +899,14 @@ private function get_columns( $args = array(), $operator = 'and', $field = false */ private function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) { - // Default return value - $retval = array(); - // Bail if no values if ( empty( $values ) ) { - return $retval; + return array(); } + // Default return value + $retval = array(); + // Allow scalar values if ( is_scalar( $values ) ) { $values = array( $values ); @@ -1173,12 +1173,9 @@ private function get_search_sql( $string = '', $column_names = array() ) { */ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, $pattern = '' ) { - // Default return value - $retval = ''; - // Bail if no values or invalid column if ( empty( $values ) || ! $this->is_valid_column( $column_name ) ) { - return $retval; + return ''; } // Fallback to column pattern @@ -1186,6 +1183,9 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, $pattern = $this->get_column_field( array( 'name' => $column_name ), 'pattern', '%s' ); } + // Default return value + $retval = ''; + // Fill an array of patterns to match the number of values $count = count( $values ); $patterns = array_fill( 0, $count, $pattern ); @@ -2175,9 +2175,6 @@ private function shape_items( $items = array(), $fields = array() ) { */ private function get_item_fields( $items = array(), $fields = array() ) { - // Default return value - $retval = $items; - // Maybe fallback to $query_vars if ( empty( $fields ) ) { $fields = $this->get_query_var( 'fields' ); @@ -2185,7 +2182,7 @@ private function get_item_fields( $items = array(), $fields = array() ) { // Bail if no fields to get if ( empty( $fields ) ) { - return $retval; + return $items; } // Maybe cast to array @@ -2196,13 +2193,15 @@ private function get_item_fields( $items = array(), $fields = array() ) { // Get the primary column name $primary = $this->get_primary_column_name(); + // Default return value + $retval = array(); + // 'ids' is numerically keyed if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { $retval = wp_list_pluck( $items, $primary ); // Get fields from items } else { - $retval = array(); $fields = array_flip( $fields ); // Loop through items and pluck out the fields @@ -2671,8 +2670,13 @@ public function delete_item( $item_id = 0 ) { } /** - * "Shape" an $item (likely an object) that was sourced either from cache - * or the database, into the object type set in Query::item_shape. + * "Shape" an $item (likely a stdClass object, sourced either from cache or + * the database) into the type of Row object defined in Query::item_shape. + * + * This grants each item object access to all of the methods & parameters + * from the Row class, which is particularly useful when it has been + * subclassed to add the custom functionality needed by your application. + * * * @since 1.0.0 * @@ -2796,10 +2800,7 @@ private function default_item( $args = array() ) { $defaults = $this->get_columns( $r, 'and', 'default' ); // Combine them - $retval = array_combine( $names, $defaults ); - - // Return - return $retval; + return array_combine( $names, $defaults ); } /** @@ -2832,15 +2833,9 @@ private function transition_item( $item_id = 0, $new_data = array(), $old_data = return; } - // If no old value(s), it's new + // If no old data, set all old values to "new" if ( empty( $old_data ) || ! is_array( $old_data ) ) { - $old_data = $new_data; - - // Set all old values to "new" - foreach ( $old_data as $key => $value ) { - $value = 'new'; - $old_data[ $key ] = $value; - } + $old_data = array_fill_keys( array_keys( $new_data ), 'new' ); } // Compare @@ -2857,7 +2852,7 @@ private function transition_item( $item_id = 0, $new_data = array(), $old_data = } // Do the actions - foreach ( $diff as $key => $value ) { + foreach ( array_keys( $diff ) as $key ) { $old_value = $old_data[ $key ]; $new_value = $new_data[ $key ]; $key_action = $this->apply_prefix( "transition_{$this->item_name}_{$key}" ); @@ -3500,14 +3495,14 @@ private function get_last_changed_cache( $group = '' ) { */ private function get_non_cached_ids( $item_ids = array(), $group = '' ) { - // Default return value - $retval = array(); - // Bail if no item IDs if ( empty( $item_ids ) ) { - return $retval; + return array(); } + // Default return value + $retval = array(); + // Loop through item IDs foreach ( $item_ids as $id ) { From a5efe0544d873bedf53204656767f6699bb4ce52 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:01:28 -0500 Subject: [PATCH 16/34] Base/Query: prevent fatals when get_db() returns false --- src/Database/Base.php | 10 +- src/Database/Query.php | 226 +++++++++++++++++++++++++++++------------ 2 files changed, 165 insertions(+), 71 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index 8042bcf..f3388f3 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -324,20 +324,20 @@ protected function stash_args( $args = array() ) { /** * Return the global database interface. * - * See: https://core.trac.wordpress.org/ticket/31556 - * * @since 1.0.0 + * @since 2.1.0 No longer copies a $GLOBALS superglobal value * * @return bool|\wpdb Database interface, or False if not set */ protected function get_db() { + global ${$this->db_global}; // Default database return value (might change) $retval = false; - // Look for a commonly used global database interface - if ( isset( $GLOBALS[ $this->db_global ] ) ) { - $retval = $GLOBALS[ $this->db_global ]; + // Look for the global database interface + if ( ! is_null( ${$this->db_global} ) ) { + $retval = ${$this->db_global}; } /* diff --git a/src/Database/Query.php b/src/Database/Query.php index 32ff67a..c2dc1ce 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -634,9 +634,12 @@ private function set_found_items( $item_ids = array() ) { // Filter the found items query $query = $this->filter_found_items_query( $query ); + // Get the database interface + $db = $this->get_db(); + // Maybe query for found items - if ( ! empty( $query ) ) { - $this->found_items = (int) $this->get_db()->get_var( $query ); + if ( ! empty( $query ) && ! empty( $db ) ) { + $this->found_items = (int) $db->get_var( $query ); } } } @@ -771,7 +774,14 @@ private function get_current_time() { * @return string */ private function get_table_name() { - return $this->get_db()->{$this->table_name}; + + // Get the database interface + $db = $this->get_db(); + + // Return SQL + return ! empty( $db ) + ? $db->{$this->table_name} + : $this->table_name; } /** @@ -899,14 +909,14 @@ private function get_columns( $args = array(), $operator = 'and', $field = false */ private function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) { + // Default return value + $retval = array(); + // Bail if no values if ( empty( $values ) ) { - return array(); + return $retval; } - // Default return value - $retval = array(); - // Allow scalar values if ( is_scalar( $values ) ) { $values = array( $values ); @@ -965,6 +975,14 @@ private function get_column_name_aliased( $column_name = '', $alias = true ) { */ private function get_item_raw( $column_name = '', $column_value = '' ) { + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return false; + } + // Bail if empty or non-scalar value if ( empty( $column_value ) || ! is_scalar( $column_value ) ) { return false; @@ -981,8 +999,8 @@ private function get_item_raw( $column_name = '', $column_value = '' ) { // Query database $query = "SELECT * FROM {$table} WHERE {$column_name} = {$pattern} LIMIT 1"; - $select = $this->get_db()->prepare( $query, $column_value ); - $result = $this->get_db()->get_row( $select ); + $select = $db->prepare( $query, $column_value ); + $result = $db->get_row( $select ); // Bail on failure if ( ! $this->is_success( $result ) ) { @@ -1093,20 +1111,28 @@ private function get_item_ids() { $this->set_request_clauses(); $this->set_request(); + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return array(); + } + // Return count if ( $this->get_query_var( 'count' ) ) { // Get vars or results $retval = ! $this->get_query_var( 'groupby' ) - ? $this->get_db()->get_var( $this->request ) - : $this->get_db()->get_results( $this->request, ARRAY_A ); + ? $db->get_var( $this->request ) + : $db->get_results( $this->request, ARRAY_A ); // Return vars or results return $retval; } // Get IDs - $item_ids = $this->get_db()->get_col( $this->request ); + $item_ids = $db->get_col( $this->request ); // Return parsed IDs return wp_parse_list( $item_ids ); @@ -1135,17 +1161,25 @@ private function get_search_sql( $string = '', $column_names = array() ) { return ''; } + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return ''; + } + // Array or String $like = ( false !== strpos( $string, '*' ) ) - ? '%' . implode( '%', array_map( array( $this->get_db(), 'esc_like' ), explode( '*', $string ) ) ) . '%' - : '%' . $this->get_db()->esc_like( $string ) . '%'; + ? '%' . implode( '%', array_map( array( $db, 'esc_like' ), explode( '*', $string ) ) ) . '%' + : '%' . $db->esc_like( $string ) . '%'; // Default array $searches = array(); // Build search SQL foreach ( $column_names as $column ) { - $searches[] = $this->get_db()->prepare( "{$column} LIKE %s", $like ); + $searches[] = $db->prepare( "{$column} LIKE %s", $like ); } // Concatinate @@ -1178,22 +1212,27 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, return ''; } + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return ''; + } + // Fallback to column pattern if ( empty( $pattern ) || ! is_string( $pattern ) ) { $pattern = $this->get_column_field( array( 'name' => $column_name ), 'pattern', '%s' ); } - // Default return value - $retval = ''; - // Fill an array of patterns to match the number of values $count = count( $values ); $patterns = array_fill( 0, $count, $pattern ); // Escape & prepare $sql = implode( ', ', $patterns ); - $values = $this->get_db()->_escape( $values ); // May quote strings - $retval = $this->get_db()->prepare( $sql, $values ); // Catches quoted strings + $values = $db->_escape( $values ); // May quote strings + $retval = $db->prepare( $sql, $values ); // Catches quoted strings // Set return value to empty string if prepare() returns falsy if ( empty( $retval ) ) { @@ -1308,6 +1347,17 @@ private function parse_query_vars( $query_vars = array() ) { */ private function parse_where_join( $args = array() ) { + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return array( + 'where' => array(), + 'join' => array() + ); + } + // Maybe fallback to $query_vars if ( empty( $args ) && ! empty( $this->query_vars ) ) { $args = $this->query_vars; @@ -1344,7 +1394,7 @@ private function parse_where_join( $args = array() ) { if ( 1 === count( $values ) ) { $statement = "{$aliased} = {$pattern}"; $column_value = reset( $values ); - $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); // Implode } else { @@ -1370,7 +1420,7 @@ private function parse_where_join( $args = array() ) { $statement = "{$aliased} = {$pattern}"; $where_id = $name; $column_value = reset( $values ); - $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); // Implode } else { @@ -1395,7 +1445,7 @@ private function parse_where_join( $args = array() ) { $statement = "{$aliased} != {$pattern}"; $where_id = $name; $column_value = reset( $values ); - $where[ $where_id ] = $this->get_db()->prepare( $statement, $column_value ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); // Implode } else { @@ -1691,7 +1741,7 @@ private function parse_select() { * @param bool $alias * @return string */ - private function parse_fields( $fields = array(), $count = false, $groupby = array(), $alias = true ) { + private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) { // Maybe fallback to $query_vars if ( empty( $count ) ) { @@ -1958,6 +2008,11 @@ private function parse_where_clause( $where = array() ) { */ private function parse_join_clause( $join = array() ) { + // Bail if no join + if ( empty( $join ) ) { + return ''; + } + // Return SQL return implode( ', ', $join ); } @@ -2175,6 +2230,9 @@ private function shape_items( $items = array(), $fields = array() ) { */ private function get_item_fields( $items = array(), $fields = array() ) { + // Default return value + $retval = $items; + // Maybe fallback to $query_vars if ( empty( $fields ) ) { $fields = $this->get_query_var( 'fields' ); @@ -2182,7 +2240,7 @@ private function get_item_fields( $items = array(), $fields = array() ) { // Bail if no fields to get if ( empty( $fields ) ) { - return $items; + return $retval; } // Maybe cast to array @@ -2193,15 +2251,13 @@ private function get_item_fields( $items = array(), $fields = array() ) { // Get the primary column name $primary = $this->get_primary_column_name(); - // Default return value - $retval = array(); - // 'ids' is numerically keyed if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { $retval = wp_list_pluck( $items, $primary ); // Get fields from items } else { + $retval = array(); $fields = array_flip( $fields ); // Loop through items and pluck out the fields @@ -2365,8 +2421,13 @@ public function get_item_by( $column_name = '', $column_value = '' ) { */ public function add_item( $data = array() ) { - // Default return value - $retval = false; + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return false; + } // Get the primary column name $primary = $this->get_primary_column_name(); @@ -2427,12 +2488,15 @@ public function add_item( $data = array() ) { $reduce = $this->reduce_item( 'insert', $save ); $save = $this->validate_item( $reduce ); + // Default return value + $retval = false; + // Try to save if ( ! empty( $save ) ) { $table = $this->get_table_name(); $names = array_keys( $save ); $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' ); - $retval = $this->get_db()->insert( $table, $save, $save_format ); + $retval = $db->insert( $table, $save, $save_format ); } // Bail on failure @@ -2441,7 +2505,7 @@ public function add_item( $data = array() ) { } // Get the new item ID - $retval = $this->get_db()->insert_id; + $retval = $db->insert_id; // Maybe save meta keys if ( ! empty( $meta ) ) { @@ -2509,8 +2573,13 @@ public function copy_item( $item_id = 0, $data = array() ) { */ public function update_item( $item_id = 0, $data = array() ) { - // Default return value - $retval = false; + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return false; + } // Bail early if no data to update if ( empty( $data ) ) { @@ -2571,6 +2640,9 @@ public function update_item( $item_id = 0, $data = array() ) { $reduce = $this->reduce_item( 'update', $save ); $save = $this->validate_item( $reduce ); + // Default return value + $retval = false; + // Try to update if ( ! empty( $save ) ) { $table = $this->get_table_name(); @@ -2578,7 +2650,7 @@ public function update_item( $item_id = 0, $data = array() ) { $names = array_keys( $save ); $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' ); $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' ); - $retval = $this->get_db()->update( $table, $save, $where, $save_format, $where_format ); + $retval = $db->update( $table, $save, $where, $save_format, $where_format ); } // Bail on failure @@ -2606,8 +2678,13 @@ public function update_item( $item_id = 0, $data = array() ) { */ public function delete_item( $item_id = 0 ) { - // Default return value - $retval = false; + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return false; + } // Shape the item ID $item_id = $this->shape_item_id( $item_id ); @@ -2640,7 +2717,7 @@ public function delete_item( $item_id = 0 ) { $table = $this->get_table_name(); $where = array( $primary => $item_id ); $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' ); - $retval = $this->get_db()->delete( $table, $where, $where_format ); + $retval = $db->delete( $table, $where, $where_format ); // Bail on failure if ( ! $this->is_success( $retval ) ) { @@ -2670,18 +2747,13 @@ public function delete_item( $item_id = 0 ) { } /** - * "Shape" an $item (likely a stdClass object, sourced either from cache or - * the database) into the type of Row object defined in Query::item_shape. - * - * This grants each item object access to all of the methods & parameters - * from the Row class, which is particularly useful when it has been - * subclassed to add the custom functionality needed by your application. - * + * Shape an item from the database into the type of object it always wanted + * to be when it grew up. * * @since 1.0.0 * - * @param int|object|array $item ID of item, or row from database - * @return object Object of single-object class type on success + * @param mixed ID of item, or row from database + * @return mixed False on error, Object of single-object class type on success */ private function shape_item( $item = 0 ) { @@ -2800,7 +2872,10 @@ private function default_item( $args = array() ) { $defaults = $this->get_columns( $r, 'and', 'default' ); // Combine them - return array_combine( $names, $defaults ); + $retval = array_combine( $names, $defaults ); + + // Return + return $retval; } /** @@ -2833,9 +2908,15 @@ private function transition_item( $item_id = 0, $new_data = array(), $old_data = return; } - // If no old data, set all old values to "new" + // If no old value(s), it's new if ( empty( $old_data ) || ! is_array( $old_data ) ) { - $old_data = array_fill_keys( array_keys( $new_data ), 'new' ); + $old_data = $new_data; + + // Set all old values to "new" + foreach ( $old_data as $key => $value ) { + $value = 'new'; + $old_data[ $key ] = $value; + } } // Compare @@ -2852,7 +2933,7 @@ private function transition_item( $item_id = 0, $new_data = array(), $old_data = } // Do the actions - foreach ( array_keys( $diff ) as $key ) { + foreach ( $diff as $key => $value ) { $old_value = $old_data[ $key ]; $new_value = $new_data[ $key ]; $key_action = $this->apply_prefix( "transition_{$this->item_name}_{$key}" ); @@ -3070,6 +3151,14 @@ private function save_extra_item_meta( $item_id = 0, $meta = array() ) { */ private function delete_all_item_meta( $item_id = 0 ) { + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return; + } + // Shape the item ID $item_id = $this->shape_item_id( $item_id ); @@ -3095,8 +3184,8 @@ private function delete_all_item_meta( $item_id = 0 ) { // Get meta IDs $query = "SELECT meta_id FROM {$table} WHERE {$item_id_column} = {$item_id_pattern}"; - $prepared = $this->get_db()->prepare( $query, $item_id ); - $meta_ids = $this->get_db()->get_col( $prepared ); + $prepared = $db->prepare( $query, $item_id ); + $meta_ids = $db->get_col( $prepared ); // Bail if no meta IDs to delete if ( empty( $meta_ids ) ) { @@ -3122,25 +3211,22 @@ private function delete_all_item_meta( $item_id = 0 ) { */ private function get_meta_table_name() { - // Default return value - $retval = false; - // Get the meta type - $type = $this->get_meta_type(); + $type = $this->get_meta_type(); // Append "meta" to end of meta type - $table = "{$type}meta"; + $table = "{$type}meta"; // Variable'ize the database interface, to use inside empty() - $db = $this->get_db(); + $db = $this->get_db(); // If not empty, return table name if ( ! empty( $db->{$table} ) ) { - $retval = $db->{$table}; + return $db->{$table}; } // Return - return $retval; + return false; } /** @@ -3264,6 +3350,14 @@ private function get_cache_groups() { */ private function prime_item_caches( $item_ids = array(), $force = false ) { + // Get the database interface + $db = $this->get_db(); + + // Bail if no database + if ( empty( $db ) ) { + return false; + } + // Bail if no items to cache if ( empty( $item_ids ) ) { return false; @@ -3295,7 +3389,7 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { // Query database $query = "SELECT * FROM {$table} WHERE {$primary} IN %s"; $prepare = sprintf( $query, $ids ); - $results = $this->get_db()->get_results( $prepare ); + $results = $db->get_results( $prepare ); // Update item cache(s) $this->update_item_cache( $results ); @@ -3495,14 +3589,14 @@ private function get_last_changed_cache( $group = '' ) { */ private function get_non_cached_ids( $item_ids = array(), $group = '' ) { + // Default return value + $retval = array(); + // Bail if no item IDs if ( empty( $item_ids ) ) { - return array(); + return $retval; } - // Default return value - $retval = array(); - // Loop through item IDs foreach ( $item_ids as $id ) { From fa64a8ee0e87f4f3d0e035898d4796804ea2fcc1 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:09:05 -0500 Subject: [PATCH 17/34] Table/Query: more database fatal prevention. --- src/Database/Query.php | 20 ++++++++++---------- src/Database/Table.php | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index c2dc1ce..a394780 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -978,7 +978,7 @@ private function get_item_raw( $column_name = '', $column_value = '' ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return false; } @@ -1114,7 +1114,7 @@ private function get_item_ids() { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return array(); } @@ -1164,7 +1164,7 @@ private function get_search_sql( $string = '', $column_names = array() ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return ''; } @@ -1215,7 +1215,7 @@ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return ''; } @@ -1350,7 +1350,7 @@ private function parse_where_join( $args = array() ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return array( 'where' => array(), @@ -2424,7 +2424,7 @@ public function add_item( $data = array() ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return false; } @@ -2576,7 +2576,7 @@ public function update_item( $item_id = 0, $data = array() ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return false; } @@ -2681,7 +2681,7 @@ public function delete_item( $item_id = 0 ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return false; } @@ -3154,7 +3154,7 @@ private function delete_all_item_meta( $item_id = 0 ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return; } @@ -3353,7 +3353,7 @@ private function prime_item_caches( $item_ids = array(), $force = false ) { // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return false; } diff --git a/src/Database/Table.php b/src/Database/Table.php index 320ce96..7860223 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -859,10 +859,10 @@ private function setup() { */ private function set_db_interface() { - // Get the database once, to avoid duplicate function calls + // Get the database interface $db = $this->get_db(); - // Bail if no database + // Bail if no database interface is available if ( empty( $db ) ) { return; } From fe344b78e5d9ef35b397244f967bebf6f91750e9 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:14:33 -0500 Subject: [PATCH 18/34] Table: softer language in inline comment --- src/Database/Table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index 7860223..eb84650 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -825,7 +825,7 @@ private function setup() { // Sanitize the database table name $this->name = $this->sanitize_table_name( $this->name ); - // Bail if database table name was garbage + // Bail if database table name sanitization failed if ( false === $this->name ) { return; } From c6b4d1dc44df695b70b49128196510211528fb17 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:24:00 -0500 Subject: [PATCH 19/34] Schema/Query: mark linked classes as protected. Also use __NAMESPACE__ --- src/Database/Query.php | 4 ++-- src/Database/Schema.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index a394780..9abfa3e 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -77,7 +77,7 @@ class Query extends Base { * @since 1.0.0 * @var string */ - protected $table_schema = '\\BerlinDB\\Database\\Schema'; + protected $table_schema = __NAMESPACE__ . '\\Schema'; /** Item ******************************************************************/ @@ -114,7 +114,7 @@ class Query extends Base { * @since 1.0.0 * @var mixed */ - protected $item_shape = '\\BerlinDB\\Database\\Row'; + protected $item_shape = __NAMESPACE__ . '\\Row'; /** Cache *****************************************************************/ diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 6420aaf..82b62a4 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -33,7 +33,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - public $column = __NAMESPACE__ . '\\Column'; + protected $column = __NAMESPACE__ . '\\Column'; /** * Schema Index class. @@ -41,7 +41,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - public $index = __NAMESPACE__ . '\\Index'; + protected $index = __NAMESPACE__ . '\\Index'; /** Item Objects **********************************************************/ From 85ea8278ae7f30761422a3b9a0de1d0a2bd68193 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:25:06 -0500 Subject: [PATCH 20/34] Column: missed a spot --- src/Database/Schema.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 82b62a4..6420aaf 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -33,7 +33,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - protected $column = __NAMESPACE__ . '\\Column'; + public $column = __NAMESPACE__ . '\\Column'; /** * Schema Index class. @@ -41,7 +41,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - protected $index = __NAMESPACE__ . '\\Index'; + public $index = __NAMESPACE__ . '\\Index'; /** Item Objects **********************************************************/ From 77310ed959149a6d1c2d34cbc4649ac200d46426 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:25:24 -0500 Subject: [PATCH 21/34] Column: I promise I'm usually better than this --- src/Database/Schema.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 6420aaf..82b62a4 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -33,7 +33,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - public $column = __NAMESPACE__ . '\\Column'; + protected $column = __NAMESPACE__ . '\\Column'; /** * Schema Index class. @@ -41,7 +41,7 @@ class Schema extends Base { * @since 2.1.0 * @var string */ - public $index = __NAMESPACE__ . '\\Index'; + protected $index = __NAMESPACE__ . '\\Index'; /** Item Objects **********************************************************/ From e54dfc9babd053174166dbe019a2b4d047617255 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:27:34 -0500 Subject: [PATCH 22/34] Schema: correct a default param value --- src/Database/Schema.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 82b62a4..5059c20 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -152,12 +152,12 @@ public function clear( $type = '' ) { * Add an item to a specific items array. * * @since 2.1.0 - * @param string $type Item type to add. - * @param string $class Class to shape item into. - * @param array|object|false $data Data to pass into class constructor. + * @param string $type Item type to add. + * @param string $class Class to shape item into. + * @param array|object $data Data to pass into class constructor. * @return object|false */ - public function add_item( $type = 'column', $class = 'Column', $data = false ) { + public function add_item( $type = 'column', $class = 'Column', $data = array() ) { // Default return value $retval = false; From 5f924b0afc4033ef4aa6b7db02843610f6269e2c Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 29 Jun 2022 19:48:27 -0500 Subject: [PATCH 23/34] Query: improve readability & docs in set_found_items() --- src/Database/Query.php | 47 ++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 9abfa3e..a181188 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -247,9 +247,6 @@ class Query extends Base { /** * The total number of items found by the SQL query. * - * This may differ from the item count, depending on the request and whether - * 'no_found_rows' is set. - * * @since 1.0.0 * @var int */ @@ -597,25 +594,40 @@ private function set_items( $item_ids = array() ) { */ private function set_found_items( $item_ids = array() ) { - // Default to number of item IDs - $this->found_items = count( (array) $item_ids ); + /** + * Default to count of item IDs. + * + * This is relevant for any kind of query. Either it is literal item IDs + * or it is the number of results returned by a 'count' and 'groupby' + * query. + */ + $retval = count( (array) $item_ids ); - // Count query + /** + * Count query. + * + * Possibly grouping results by some other columns. + */ if ( $this->get_query_var( 'count' ) ) { // Not grouped if ( is_numeric( $item_ids ) && ! $this->get_query_var( 'groupby' ) ) { - $this->found_items = (int) $item_ids; + $retval = $item_ids; } - // Not a count query, and number of rows is limited - } elseif ( - is_array( $item_ids ) - && - ( - $this->get_query_var( 'number' ) && ! $this->get_query_var( 'no_found_rows' ) - ) - ) { + /** + * Maybe perform a second COUNT(*) query immediately if: + * + * - 'count' query var is not truthy + * - 'no_found_row' query var is not truthy + * - 'number' query var is not falsy + * + * This second query uses most of the previously parsed $request_clauses + * and overrides a few to correct the SQL syntax. + * + * @since 2.1.0 No longer uses FOUND_ROWS() + */ + } elseif ( ! $this->get_query_var( 'no_found_rows' ) && $this->get_query_var( 'number' ) ) { // Override a few request clauses $r = wp_parse_args( @@ -639,9 +651,12 @@ private function set_found_items( $item_ids = array() ) { // Maybe query for found items if ( ! empty( $query ) && ! empty( $db ) ) { - $this->found_items = (int) $db->get_var( $query ); + $retval = $db->get_var( $query ); } } + + // Set found items + $this->found_items = (int) $retval; } /** Public Setters ********************************************************/ From 4ee4f51ed8ff9360d12476d314ad924d7cafa969 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 12:31:05 -0500 Subject: [PATCH 24/34] Issue/55 - Query: Add support for query handlers. (#123) * Query: Add support for query handlers. * Break apart parse_where() into multiple methods * These new methods return an array of where & join clauses * Those clauses get merged together to maintain backwards compatibility * Query: normalize Join/Where order. * Query: graduate "join" to first-class clause. * Schema: these need to stay protected * Query: remove array_flip from get_column_names * Query: docs * All: improve code consistency * Bail early with specific (non retval) values * Improve inline docs * Remove some unuseful references --- src/Database/Base.php | 39 ++- src/Database/Column.php | 11 +- src/Database/Query.php | 549 ++++++++++++++++++++++++---------------- src/Database/Schema.php | 14 +- 4 files changed, 360 insertions(+), 253 deletions(-) diff --git a/src/Database/Base.php b/src/Database/Base.php index f3388f3..c1ed89e 100644 --- a/src/Database/Base.php +++ b/src/Database/Base.php @@ -29,12 +29,12 @@ class Base { /** * The name of the PHP global that contains the primary database interface. * - * For example, WordPress traditionally uses 'wpdb', but other applications - * may use something else, or you may be doing something really cool that + * For example, WordPress uses 'wpdb', but other applications will use + * something else, or you may be doing something really cool that * requires a custom interface. * - * A future version of this utility may abstract this out entirely, so - * custom calls to the get_db() should be avoided if at all possible. + * A future version of BerlinDB will abstract this to a new class, so + * custom calls to the get_db() in your own code should be avoided. * * @since 1.0.0 * @var string @@ -177,14 +177,14 @@ protected function apply_prefix( $string = '', $sep = '_' ) { */ protected function first_letters( $string = '', $sep = '_' ) { - // Set empty default return value - $retval = ''; - // Bail if empty or not a string if ( empty( $string ) || ! is_string( $string ) ) { - return $retval; + return ''; } + // Default return value + $retval = ''; + // Trim spaces off the ends $unspace = trim( $string ); @@ -325,14 +325,14 @@ protected function stash_args( $args = array() ) { * Return the global database interface. * * @since 1.0.0 - * @since 2.1.0 No longer copies a $GLOBALS superglobal value + * @since 2.1.0 Improved PHP8 support, remove $GLOBALS superglobal usage * * @return bool|\wpdb Database interface, or False if not set */ protected function get_db() { global ${$this->db_global}; - // Default database return value (might change) + // Default return value $retval = false; // Look for the global database interface @@ -341,20 +341,15 @@ protected function get_db() { } /* - * Developer note: - * - * It should be impossible for a database table to be interacted with - * before the primary database interface is setup. - * - * However, because applications are complicated, it is unsafe to assume - * anything, so this silently returns false instead of halting everything. + * Note: If you are here because this method is returning false for you, + * that means a database Table or Query are being invoked too early in + * the lifecycle of the application. * - * If you are here because this method is returning false for you, that - * means the database table is being invoked too early in the lifecycle - * of the application. + * In WordPress, that means before require_wp_db() creates the $wpdb + * global (inside of the wp-settings.php file) and you may want to + * hook your custom code into 'admin_init' or 'plugins_loaded' instead. * - * In WordPress, that means before the $wpdb global is created; in other - * environments, you will need to adjust accordingly. + * The decision to return false here is likely to change in the future. */ // Return the database interface diff --git a/src/Database/Column.php b/src/Database/Column.php index 08b12ea..3b92efe 100644 --- a/src/Database/Column.php +++ b/src/Database/Column.php @@ -165,7 +165,7 @@ class Column extends Base { public $extra = ''; /** - * Typically inherited from the database interface (wpdb). + * Typically inherited from the database interface $db_global. * * By default, this will use the globally available database encoding. You * most likely do not want to change this; if you do, you already know what @@ -179,7 +179,7 @@ class Column extends Base { public $encoding = ''; /** - * Typically inherited from the database interface (wpdb). + * Typically inherited from the database interface $db_global. * * By default, this will use the globally available database collation. You * most likely do not want to change this; if you do, you already know what @@ -442,10 +442,10 @@ class Column extends Base { * @type bool $zerofill Is integer filled with zeroes? * @type bool $binary Is data in a binary format? * @type bool $allow_null Is null an allowed value? - * @type mixed $default Typically empty/null, or date value + * @type mixed $default Typically 0|'', null, or date value * @type string $extra auto_increment, etc... - * @type string $encoding Typically inherited from wpdb - * @type string $collation Typically inherited from wpdb + * @type string $encoding Typically inherited from $db_global + * @type string $collation Typically inherited from $db_global * @type string $comment Typically empty * @type string $pattern Pattern used to format the value * @type bool $primary Is this the primary column? @@ -1096,7 +1096,6 @@ public function validate_null( $value = '' ) { * updated to support different default values based on the environment. * * See: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_allow_invalid_dates - * See: wpdb::set_sql_mode() * * @since 1.0.0 * @since 2.1.0 Add support for CURRENT_TIMESTAMP. diff --git a/src/Database/Query.php b/src/Database/Query.php index a181188..d2116d6 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -22,30 +22,6 @@ * @since 1.0.0 * * @see Query::__construct() for accepted arguments. - * - * @property string $prefix - * @property string $table_name - * @property string $table_alias - * @property string $table_schema - * @property string $item_name - * @property string $item_name_plural - * @property string $item_shape - * @property string $cache_group - * @property string $last_changed - * @property array $schema - * @property array $query_clauses - * @property array $request_clauses - * @property null|Queries\Meta $meta_query - * @property null|Queries\Date $date_query - * @property null|Queries\Compare $compare_query - * @property array $query_vars - * @property array $query_var_originals - * @property array $query_var_defaults - * @property string $query_var_default_value - * @property array|int $items - * @property int $found_items - * @property int $max_num_pages - * @property string $request */ class Query extends Base { @@ -151,49 +127,36 @@ class Query extends Base { */ private $schema = null; - /** Clauses ***************************************************************/ + /** Handlers **************************************************************/ /** - * SQL query clauses. + * Query handlers. * - * @since 1.0.0 - * @var array - */ - protected $query_clauses = array(); - - /** - * SQL request clauses. + * An array of special classes used to parse Magic $query_vars into + * $query_clauses. * - * @since 1.0.0 + * @since 2.1.0 * @var array */ - protected $request_clauses = array(); + protected $query_handlers = array(); - /** Query Types ***********************************************************/ - - /** - * Meta query container. - * - * @since 1.0.0 - * @var null|object|Queries\Meta - */ - protected $meta_query = null; + /** Clauses ***************************************************************/ /** - * Date query container. + * SQL query clauses. * * @since 1.0.0 - * @var null|object|Queries\Date + * @var array */ - protected $date_query = null; + protected $query_clauses = array(); /** - * Compare query container. + * SQL request clauses. * * @since 1.0.0 - * @var null|object|Queries\Compare + * @var array */ - protected $compare_query = null; + protected $request_clauses = array(); /** Query Variables *******************************************************/ @@ -336,6 +299,7 @@ public function setup() { $this->set_prefixes(); $this->set_schema(); $this->set_item_shape(); + $this->set_query_handlers(); $this->set_query_var_defaults(); $this->set_query_clause_defaults(); } @@ -408,7 +372,7 @@ private function set_prefixes() { private function set_schema() { // Bail if no table schema - if ( ! class_exists( $this->table_schema ) ) { + if ( empty( $this->table_schema ) || ! class_exists( $this->table_schema ) ) { return; } @@ -428,7 +392,22 @@ private function set_item_shape() { } /** - * Set default query clauses. + * Set query handlers. + * + * @since 2.1.0 + */ + private function set_query_handlers() { + if ( empty( $this->query_handlers ) ) { + $this->query_handlers = array( + 'meta' => __NAMESPACE__ . '\\Queries\\Meta', + 'date' => __NAMESPACE__ . '\\Queries\\Date', + 'compare' => __NAMESPACE__ . '\\Queries\\Compare' + ); + } + } + + /** + * Set defaults for query (and also request) clauses. * * @since 2.1.0 */ @@ -459,6 +438,7 @@ private function set_query_clause_defaults() { * Set default query vars based on columns. * * @since 1.0.0 + * @since 2.1.0 */ private function set_query_var_defaults() { @@ -497,42 +477,82 @@ private function set_query_var_defaults() { // Disable row count 'no_found_rows' => true, - // Queries - 'meta_query' => null, // See Queries\Meta - 'date_query' => null, // See Queries\Date - 'compare_query' => null, // See Queries\Compare - // Caching 'update_item_cache' => true, 'update_meta_cache' => true ); - // Direct column names - $names = array_flip( $this->get_column_names() ); - foreach ( $names as $name ) { - $this->query_var_defaults[ $name ] = $this->query_var_default_value; - } + /** Column Names ******************************************************/ + + // All column names + $names = $this->get_column_names(); - // Possible ins - $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' ); - foreach ( $possible_ins as $in ) { - $key = "{$in}__in"; - $this->query_var_defaults[ $key ] = $this->query_var_default_value; + // Bail early if no columns + if ( empty( $names ) ) { + return; } - // Possible not ins - $possible_not_ins = $this->get_columns( array( 'not_in' => true ), 'and', 'name' ); - foreach ( $possible_not_ins as $in ) { - $key = "{$in}__not_in"; - $this->query_var_defaults[ $key ] = $this->query_var_default_value; + // Fill with default value + $defaults = array_fill_keys( $names, $this->query_var_default_value ); + + /** Specials **********************************************************/ + + // Special column query attributes + $specials = array( + 'in' => '__in', + 'not_in' => '__not_in', + 'date_query' => '_query' + ); + + // Loop through specials + foreach ( $specials as $column => $suffix ) { + + // Columns + $filter = array( $column => true ); + $columns = $this->get_column_names( $filter ); + + // Skip if no columns + if ( empty( $columns ) ) { + continue; + } + + // Add defaults + foreach ( $columns as $name ) { + $defaults[] = "{$name}{$suffix}"; + } } - // Possible dates - $possible_dates = $this->get_columns( array( 'date_query' => true ), 'and', 'name' ); - foreach ( $possible_dates as $date ) { - $key = "{$date}_query"; - $this->query_var_defaults[ $key ] = $this->query_var_default_value; + /** Query Objects *****************************************************/ + + // Loop through query handlers + foreach ( array_keys( $this->query_handlers ) as $id ) { + + // Set query key + $suffix = '_query'; + $query_key = strtolower( $id ) . $suffix; + + // Columns + $filter = array( $query_key => true ); + $columns = $this->get_column_names( $filter ); + + // Skip if no columns + if ( empty( $columns ) ) { + continue; + } + + // Add defaults + foreach ( $columns as $column ) { + $defaults[] = "{$name}{$suffix}"; + } } + + /** Defaults **********************************************************/ + + // Fill default keys with default value + $default_values = array_fill_keys( $defaults, $this->query_var_default_value ); + + // Merge defaults + $this->query_var_defaults = array_merge( $this->query_var_defaults, $default_values ); } /** @@ -703,11 +723,8 @@ private function is_valid_column( $column_name = '' ) { return false; } - // Get all of the column names - $columns = $this->get_column_names(); - - // Return if column name exists - return isset( $columns[ $column_name ] ); + // Return if column exists + return (bool) $this->get_column_by( array( 'name' => $column_name ) ); } /** Private Getters *******************************************************/ @@ -726,42 +743,30 @@ private function get_query_var( $key = '' ) { } /** - * Pass-through method to return a new Meta object. - * - * @since 1.0.0 - * - * @param array $args See Queries\Meta + * Return a new Query Handler object, if it exists. * - * @return Queries\Meta + * @since 2.1.0 + * @param string $query + * @param array $args + * @return object */ - private function get_meta_query( $args = array() ) { - return new Queries\Meta( $args ); - } + private function get_query_handler( $query = '', $args = array() ) { - /** - * Pass-through method to return a new Compare object. - * - * @since 1.0.0 - * - * @param array $args See Queries\Compare - * - * @return Queries\Compare - */ - private function get_compare_query( $args = array() ) { - return new Queries\Compare( $args ); - } + // Bail if no query + if ( empty( $this->query_handlers[ $query ] ) ) { + return; + } - /** - * Pass-through method to return a new Queries\Date object. - * - * @since 1.0.0 - * - * @param array $args See Queries\Date - * - * @return Queries\Date - */ - private function get_date_query( $args = array() ) { - return new Queries\Date( $args ); + // Setup the class name using the namespace + $class = $this->query_handlers[ $query ]; + + // Bail if class does not exist + if ( ! class_exists( $class ) ) { + return; + } + + // Return the query + return new $class( $args ); } /** @@ -803,11 +808,15 @@ private function get_table_name() { * Return array of column names. * * @since 1.0.0 + * @since 2.1.0 Pass $args and $operator to filter names. + * No longer calls array_flip(). * + * @param array $args Arguments to filter columns by. + * @param string $operator Optional. The logical operation to perform. * @return array */ - private function get_column_names() { - return array_flip( $this->get_columns( array(), 'and', 'name' ) ); + private function get_column_names( $args = array(), $operator = 'and' ) { + return $this->get_columns( $args, $operator, 'name' ); } /** @@ -924,12 +933,9 @@ private function get_columns( $args = array(), $operator = 'and', $field = false */ private function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) { - // Default return value - $retval = array(); - // Bail if no values if ( empty( $values ) ) { - return $retval; + return array(); } // Allow scalar values @@ -942,6 +948,9 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' $field = $key; } + // Default return value + $retval = array(); + // Get the column fields foreach ( $values as $value ) { $args = array( $key => $value ); @@ -1362,17 +1371,6 @@ private function parse_query_vars( $query_vars = array() ) { */ private function parse_where_join( $args = array() ) { - // Get the database interface - $db = $this->get_db(); - - // Bail if no database interface is available - if ( empty( $db ) ) { - return array( - 'where' => array(), - 'join' => array() - ); - } - // Maybe fallback to $query_vars if ( empty( $args ) && ! empty( $this->query_vars ) ) { $args = $this->query_vars; @@ -1381,14 +1379,69 @@ private function parse_where_join( $args = array() ) { // Parse arguments $r = wp_parse_args( $args ); + // Private WHERE methods + $methods = array( + 'parse_where_columns', + 'parse_where_search', + 'parse_where_query_handlers' + ); + + // Default results + $results = array(); + + // Get all results + foreach ( $methods as $method ) { + $results[] = $this->{$method}( $r ); + } + + // Pluck join/where from results + $join = wp_list_pluck( $results, 'join' ); + $where = wp_list_pluck( $results, 'where' ); + + // Set join/where subclauses to merged results + return array( + 'join' => call_user_func_array( 'array_merge', $join ), + 'where' => call_user_func_array( 'array_merge', $where ) + ); + } + + /** + * Parse join/where subclauses for all columns. + * + * Used by parse_where(). + * + * @since 2.1.0 + * @return array + */ + private function parse_where_columns( $query_vars = array() ) { + // Defaults - $where = $join = $date_query = array(); + $retval = array( + 'join' => array(), + 'where' => array() + ); + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return $retval; + } - // Get all of the columns - $columns = $this->get_columns(); + // All columns + $all_columns = $this->get_columns(); + + // Bail if no columns + if ( empty( $all_columns ) ) { + return $retval; + } + + // Default variable + $where = array(); // Loop through columns - foreach ( $columns as $column ) { + foreach ( $all_columns as $column ) { // Get column name, pattern, and aliased name $name = $column->name; @@ -1400,7 +1453,7 @@ private function parse_where_join( $args = array() ) { // Parse query variable $where_id = $name; - $values = $this->parse_query_var( $r, $where_id ); + $values = $this->parse_query_var( $query_vars, $where_id ); // Parse item for direct clause. if ( false !== $values ) { @@ -1425,7 +1478,7 @@ private function parse_where_join( $args = array() ) { // Parse query var $where_id = "{$name}__in"; - $values = $this->parse_query_var( $r, $where_id ); + $values = $this->parse_query_var( $query_vars, $where_id ); // Parse item for an IN clause. if ( false !== $values ) { @@ -1450,7 +1503,7 @@ private function parse_where_join( $args = array() ) { // Parse query var $where_id = "{$name}__not_in"; - $values = $this->parse_query_var( $r, $where_id ); + $values = $this->parse_query_var( $query_vars, $where_id ); // Parse item for a NOT IN clause. if ( false !== $values ) { @@ -1473,14 +1526,14 @@ private function parse_where_join( $args = array() ) { // date_query if ( true === $column->date_query ) { $where_id = "{$name}_query"; - $column_date = $this->parse_query_var( $r, $where_id ); + $column_date = $this->parse_query_var( $query_vars, $where_id ); // Parse item if ( false !== $column_date ) { // Single if ( 1 === count( $column_date ) ) { - $date_query[] = array( + $where['date_query'][] = array( 'column' => $aliased, 'before' => reset( $column_date ), 'inclusive' => true @@ -1495,27 +1548,62 @@ private function parse_where_join( $args = array() ) { } // Add clause to date query - $date_query[] = $column_date; + $where['date_query'][] = $column_date; } } } } - /** Search ************************************************************/ + // Return join/where subclauses + return array( + 'join' => array(), + 'where' => $where + ); + } + + /** + * Parse join/where subclauses for search queries. + * + * Used by parse_where(). + * + * @since 2.1.0 + * @return array + */ + private function parse_where_search( $query_vars = array() ) { + + // Get searchable columns + $searchable = $this->get_columns( + array( + 'searchable' => true + ), + 'and', + 'name' + ); + + // Bail if no search + if ( empty( $searchable ) || empty( $query_vars['search'] ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } + + // Default value + $where = array(); // Get names of searchable columns $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); - // Maybe search if columns are searchable. - if ( ! empty( $searchable ) && strlen( $r['search'] ) ) { + // Maybe search if columns are searchable + if ( ! empty( $searchable ) && strlen( $query_vars['search'] ) ) { // Default to all searchable columns $search_columns = $searchable; // Intersect against known searchable columns - if ( ! empty( $r['search_columns'] ) ) { + if ( ! empty( $query_vars['search_columns'] ) ) { $search_columns = array_intersect( - $r['search_columns'], + $query_vars['search_columns'], $searchable ); } @@ -1524,92 +1612,117 @@ private function parse_where_join( $args = array() ) { $search_columns = $this->filter_search_columns( $search_columns ); // Add search query clause - $where['search'] = $this->get_search_sql( $r['search'], $search_columns ); + $where['search'] = $this->get_search_sql( $query_vars['search'], $search_columns ); } - /** Query Classes *****************************************************/ + // Return join/where + return array( + 'join' => array(), + 'where' => $where + ); + } - // Get the primary column name - $primary = $this->get_primary_column_name(); + /** + * Parse join/where subclauses for query handler objects. + * + * Used by parse_where(). + * + * @since 2.1.0 + * @return array + */ + private function parse_where_query_handlers( $query_vars = array() ) { - // Get the meta type & table alias - $table = $this->get_meta_type(); - $alias = $this->table_alias; + // Bail if no queries + if ( empty( $this->query_handlers ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } - // Set the " AND " regex pattern - $and = '/^\s*AND\s*/'; + // Get query handlers + $handlers = array_filter( array_keys( $this->query_handlers ) ); - // Maybe perform a meta query. - $meta_query = $r['meta_query']; - if ( ! empty( $meta_query ) && is_array( $meta_query ) ) { - $this->meta_query = $this->get_meta_query( $meta_query ); - $clauses = $this->meta_query->get_sql( $table, $alias, $primary, $this ); + // Query clause arguments + $args = array( + 'primary_table' => $this->table_name, + 'primary_alias' => $this->table_alias, + 'primary_column' => $this->get_primary_column_name(), + 'meta_type' => $this->get_meta_type(), + 'query' => $this + ); - // Not all objects have meta, so make sure this one exists - if ( false !== $clauses ) { + // Default values + $join = $where = array(); - // Set join - if ( ! empty( $clauses['join'] ) ) { - $join['meta_query'] = $clauses['join']; - } + // Loop through queries + foreach ( $handlers as $id ) { - // Set where - if ( ! empty( $clauses['where'] ) ) { - $where['meta_query'] = preg_replace( $and, '', $clauses['where'] ); - } + // Skip + if ( empty( $id ) ) { + continue; } - } - // Maybe perform a compare query. - $compare_query = $r['compare_query']; - if ( ! empty( $compare_query ) && is_array( $compare_query ) ) { - $this->compare_query = $this->get_compare_query( $compare_query ); - $clauses = $this->compare_query->get_sql( $table, $alias, $primary, $this ); + // Build the key + $key = strtolower( $id ) . '_query'; + + // Skip if no query vars + if ( empty( $query_vars[ $key ] ) || ! is_array( $query_vars[ $key ] ) ) { + continue; + } - // Not all objects can compare, so make sure this one exists - if ( false !== $clauses ) { + // Add table alias to primary clause if not already set + if ( empty( $query_vars[ $key ][ 'alias'] ) ) { + $query_vars[ $key ][ 'alias'] = $args['table_alias']; + } - // Set join - if ( ! empty( $clauses['join'] ) ) { - $join['compare_query'] = $clauses['join']; - } + // Try to get the query handler + $handler = $this->get_query_handler( $id, $query_vars[ $key ] ); - // Set where - if ( ! empty( $clauses['where'] ) ) { - $where['compare_query'] = preg_replace( $and, '', $clauses['where'] ); - } + // Skip if no query handler + if ( empty( $handler ) ) { + continue; } - } - // Only do a date query with an array - $date_query = ! empty( $date_query ) - ? $date_query - : $r['date_query']; + // Default no subclauses + $subclauses = false; - // Maybe perform a date query - if ( ! empty( $date_query ) && is_array( $date_query ) ) { - $this->date_query = $this->get_date_query( $date_query ); - $clauses = $this->date_query->get_sql( $this->table_name, $alias, $primary, $this ); + // Set the key + $this->{$key} = $handler; - // Not all objects are dates, so make sure this one exists - if ( false !== $clauses ) { + // Set the callback + $callback = array( $this->{$key}, 'get_sql' ); - // Set join - if ( ! empty( $clauses['join'] ) ) { - $join['date_query'] = $clauses['join']; - } + // Try to get the SQL subclauses + if ( is_callable( $callback ) ) { + $subclauses = call_user_func( $callback, array( + $args['meta_type'], + $args['primary_table'], + $args['primary_column'], + $args['query'] + ) ); + } - // Set where - if ( ! empty( $clauses['where'] ) ) { - $where['date_query'] = preg_replace( $and, '', $clauses['where'] ); - } + // Skip if no SQL subclauses + if ( false === $subclauses ) { + continue; + } + + // Set join + if ( ! empty( $subclauses['join'] ) ) { + $join[ $key ] = $subclauses['join']; + } + + // Set where (removing " AND " from subclauses) + if ( ! empty( $subclauses['where'] ) ) { + $where[ $key ] = preg_replace( '/^\s*AND\s*/', '', $subclauses['where'] ); } } - // Return where & join, removing possible empties + // Return join/where subclauses return array( - 'where' => array_filter( $where ), - 'join' => array_filter( $join ) + 'join' => $join, + 'where' => $where ); } @@ -2245,9 +2358,6 @@ private function shape_items( $items = array(), $fields = array() ) { */ private function get_item_fields( $items = array(), $fields = array() ) { - // Default return value - $retval = $items; - // Maybe fallback to $query_vars if ( empty( $fields ) ) { $fields = $this->get_query_var( 'fields' ); @@ -2255,7 +2365,7 @@ private function get_item_fields( $items = array(), $fields = array() ) { // Bail if no fields to get if ( empty( $fields ) ) { - return $retval; + return $items; } // Maybe cast to array @@ -2263,6 +2373,9 @@ private function get_item_fields( $items = array(), $fields = array() ) { $fields = (array) $fields; } + // Default return value + $retval = $items; + // Get the primary column name $primary = $this->get_primary_column_name(); @@ -2474,7 +2587,7 @@ public function add_item( $data = array() ) { } // Slice data that has columns, and cut out non-keys for meta - $columns = $this->get_column_names(); + $columns = array_flip( $this->get_column_names() ); $data = array_merge( $item, $data ); $meta = array_diff_key( $data, $columns ); $save = array_intersect_key( $data, $columns ); @@ -2630,7 +2743,7 @@ public function update_item( $item_id = 0, $data = array() ) { ); // Slice data that has columns, and cut out non-keys for meta - $columns = $this->get_column_names(); + $columns = array_flip( $this->get_column_names() ); $data = array_diff_assoc( $data, $item ); $meta = array_diff_key( $data, $columns ); $save = array_intersect_key( $data, $columns ); @@ -3604,14 +3717,14 @@ private function get_last_changed_cache( $group = '' ) { */ private function get_non_cached_ids( $item_ids = array(), $group = '' ) { - // Default return value - $retval = array(); - // Bail if no item IDs if ( empty( $item_ids ) ) { - return $retval; + return array(); } + // Default return value + $retval = array(); + // Loop through item IDs foreach ( $item_ids as $id ) { diff --git a/src/Database/Schema.php b/src/Database/Schema.php index 5059c20..b309e8e 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -51,7 +51,7 @@ class Schema extends Base { * @since 1.0.0 * @var array */ - public $columns = array(); + protected $columns = array(); /** * Array of database Index objects. @@ -59,7 +59,7 @@ class Schema extends Base { * @since 2.1.0 * @var array */ - public $indexes = array(); + protected $indexes = array(); /** Public Methods ********************************************************/ @@ -176,7 +176,7 @@ public function add_item( $type = 'column', $class = 'Column', $data = array() ) $retval = $data; } - // Bail if no + // Bail if no item to add if ( empty( $retval ) ) { return false; } @@ -273,14 +273,14 @@ private function setup_items( $type = 'columns', $class = 'Column', $values = ar */ private function get_items_create_string( $type = 'columns' ) { - // Default return value - $retval = ''; - // Bail if no items to get strings from if ( empty( $this->{$type} ) || ! is_array( $this->{$type} ) ) { - return $retval; + return ''; } + // Default return value + $retval = ''; + // Improve readability $indent = ' '; From 563848d788643ed894d45037c0e93754d8a30807 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 16:42:10 -0500 Subject: [PATCH 25/34] Table: add repair & status methods --- src/Database/Table.php | 244 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 215 insertions(+), 29 deletions(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index eb84650..faffa82 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -156,7 +156,7 @@ public function __construct() { return; } - // Add the table to the database interface + // Add table to the database interface $this->set_db_interface(); // Set the database schema @@ -316,7 +316,7 @@ public function get_version() { /** * Install a database table * - * Creates the table and sets the version information if successful. + * Create table and set the version if successful. * * @since 1.0.0 */ @@ -334,8 +334,9 @@ public function install() { /** * Uninstall a database table * - * Drops the table and deletes the version information if successful and/or - * the table does not exist anymore. + * Drops table and deletes the version information if successful. + * + * If the table does not exist, the version will still be deleted. * * @since 1.0.0 */ @@ -353,7 +354,7 @@ public function uninstall() { /** Public Management *****************************************************/ /** - * Check if table already exists. + * Check if table exists. * * @since 1.0.0 * @@ -379,6 +380,38 @@ public function exists() { return $this->is_success( $result ); } + /** + * Get status of table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/show-table-status.html + * + * @since 2.1.0 + * + * @return object + */ + public function status() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $query = "SHOW TABLE STATUS LIKE %s"; + $like = $db->esc_like( $this->table_name ); + $prepared = $db->prepare( $query, $like ); + $query = (array) $db->get_results( $prepared ); + $result = end( $query ); + + // Does the table exist? + return $this->is_success( $result ) + ? $result + : false; + } + /** * Get columns from table. * @@ -397,8 +430,8 @@ public function columns() { } // Query statement - $query = "SHOW FULL COLUMNS FROM {$this->table_name}"; - $result = $db->get_results( $query ); + $sql = "SHOW FULL COLUMNS FROM {$this->table_name}"; + $result = $db->get_results( $sql ); // Return the results return $this->is_success( $result ) @@ -407,7 +440,7 @@ public function columns() { } /** - * Create the table. + * Create the database table. * * @since 1.0.0 * @@ -467,8 +500,8 @@ public function drop() { } // Query statement - $query = "DROP TABLE {$this->table_name}"; - $result = $db->query( $query ); + $sql = "DROP TABLE {$this->table_name}"; + $result = $db->query( $sql ); // Did the table get dropped? return $this->is_success( $result ); @@ -492,8 +525,8 @@ public function truncate() { } // Query statement - $query = "TRUNCATE TABLE {$this->table_name}"; - $result = $db->query( $query ); + $sql = "TRUNCATE TABLE {$this->table_name}"; + $result = $db->query( $sql ); // Did the table get truncated? return $this->is_success( $result ); @@ -517,8 +550,8 @@ public function delete_all() { } // Query statement - $query = "DELETE FROM {$this->table_name}"; - $result = $db->query( $query ); + $sql = "DELETE FROM {$this->table_name}"; + $result = $db->query( $sql ); // Return the results return $result; @@ -555,8 +588,8 @@ public function _clone( $new_table_name = '' ) { // Query statement $table = $this->apply_prefix( $table_name ); - $query = "CREATE TABLE {$table} LIKE {$this->table_name}"; - $result = $db->query( $query ); + $sql = "CREATE TABLE {$table} LIKE {$this->table_name}"; + $result = $db->query( $sql ); // Did the table get cloned? return $this->is_success( $result ); @@ -593,8 +626,8 @@ public function copy( $new_table_name = '' ) { // Query statement $table = $this->apply_prefix( $table_name ); - $query = "INSERT INTO {$table} SELECT * FROM {$this->table_name}"; - $result = $db->query( $query ); + $sql = "INSERT INTO {$table} SELECT * FROM {$this->table_name}"; + $result = $db->query( $sql ); // Did the table get copied? return $this->is_success( $result ); @@ -618,8 +651,8 @@ public function count() { } // Query statement - $query = "SELECT COUNT(*) FROM {$this->table_name}"; - $result = $db->get_var( $query ); + $sql = "SELECT COUNT(*) FROM {$this->table_name}"; + $result = $db->get_var( $sql ); // 0 on error/empty, number of rows on success return intval( $result ); @@ -646,10 +679,10 @@ public function column_exists( $name = '' ) { } // Query statement - $query = "SHOW COLUMNS FROM {$this->table_name} LIKE %s"; + $sql = "SHOW COLUMNS FROM {$this->table_name} LIKE %s"; $name = $this->sanitize_column_name( $name ); $like = $db->esc_like( $name ); - $prepared = $db->prepare( $query, $like ); + $prepared = $db->prepare( $sql, $like ); $result = $db->query( $prepared ); // Does the column exist? @@ -683,16 +716,169 @@ public function index_exists( $name = '', $column = 'Key_name' ) { } // Query statement - $query = "SHOW INDEXES FROM {$this->table_name} WHERE {$column} LIKE %s"; + $sql = "SHOW INDEXES FROM {$this->table_name} WHERE {$column} LIKE %s"; $name = $this->sanitize_column_name( $name ); $like = $db->esc_like( $name ); - $prepared = $db->prepare( $query, $like ); + $prepared = $db->prepare( $sql, $like ); $result = $db->query( $prepared ); // Does the index exist? return $this->is_success( $result ); } + /** Repair ****************************************************************/ + + /** + * Analyze the database table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html + * + * @since 2.1.0 + * + * @return bool|string + */ + public function analyze() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $sql = "ANALYZE TABLE {$this->table_name}"; + $query = (array) $db->get_results( $sql ); + $result = end( $query ); + + // Return message text + return ! empty( $result->Msg_text ) + ? $result->Msg_text + : false; + } + + /** + * Check the database table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/check-table.html + * + * @since 2.1.0 + * + * @return bool|string + */ + public function check() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $sql = "CHECK TABLE {$this->table_name}"; + $query = (array) $db->get_results( $sql ); + $result = end( $query ); + + // Return message text + return ! empty( $result->Msg_text ) + ? $result->Msg_text + : false; + } + + /** + * Get the Checksum the database table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/checksum-table.html + * + * @since 2.1.0 + * + * @return bool|string + */ + public function checksum() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $sql = "CHECKSUM TABLE {$this->table_name}"; + $query = (array) $db->get_results( $sql ); + $result = end( $query ); + + // Return checksum + return ! empty( $result->Checksum ) + ? $result->Checksum + : false; + } + + /** + * Optimize the database table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html + * + * @since 2.1.0 + * + * @return bool|string + */ + public function optimize() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $sql = "OPTIMIZE TABLE {$this->table_name}"; + $query = (array) $db->get_results( $sql ); + $result = end( $query ); + + // Return message text + return ! empty( $result->Msg_text ) + ? $result->Msg_text + : false; + } + + /** + * Repair the database table. + * + * See: https://dev.mysql.com/doc/refman/8.0/en/repair-table.html + * Note: Not supported by InnoDB, the default engine in MySQL 8 and higher. + * + * @since 2.1.0 + * + * @return bool|string + */ + public function repair() { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Query statement + $sql = "REPAIR TABLE {$this->table_name}"; + $query = (array) $db->get_results( $sql ); + $result = end( $query ); + + // Return message text + return ! empty( $result->Msg_text ) + ? $result->Msg_text + : false; + } + /** Upgrades **************************************************************/ /** @@ -878,7 +1064,7 @@ private function set_db_interface() { $tables = 'tables'; } - // Set the table prefix and prefix the table name + // Set table prefix and prefix table name $this->table_prefix = $db->get_blog_prefix( $site_id ); // Get the prefixed table name @@ -892,7 +1078,7 @@ private function set_db_interface() { $db->{$tables} = array(); } - // Add the table to the global table array + // Add table to the global table array $db->{$tables}[] = $this->prefixed_name; // Charset @@ -907,7 +1093,7 @@ private function set_db_interface() { } /** - * Set the database version for the table. + * Set table version in the database. * * @since 1.0.0 * @@ -930,7 +1116,7 @@ private function set_db_version( $version = '' ) { } /** - * Get the table version from the database. + * Get table version from the database. * * @since 1.0.0 */ @@ -941,7 +1127,7 @@ private function get_db_version() { } /** - * Delete the table version from the database. + * Delete table version from the database. * * @since 1.0.0 */ From db020bfc8b03a7c88c23f4937d7acdf98237ec97 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 16:57:52 -0500 Subject: [PATCH 26/34] Table: add rename() method, and some clean-up --- src/Database/Table.php | 68 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index faffa82..02cf353 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -148,7 +148,7 @@ abstract class Table extends Base { */ public function __construct() { - // Setup the database table + // Setup this database table $this->setup(); // Bail if setup failed @@ -223,7 +223,7 @@ public function switch_blog( $site_id = 0 ) { /** Public Helpers ********************************************************/ /** - * Maybe upgrade the database table. Handles creation & schema changes. + * Maybe upgrade this database table. Handles creation & schema changes. * * Hooked to the `admin_init` action. * @@ -270,7 +270,7 @@ public function needs_upgrade( $version = false ) { // Get the current database version $this->get_db_version(); - // Is the database table up to date? + // Is this database table up to date? $is_current = version_compare( $this->db_version, $version, '>=' ); // Return false if current, true if out of date @@ -440,7 +440,7 @@ public function columns() { } /** - * Create the database table. + * Create this database table. * * @since 1.0.0 * @@ -483,7 +483,7 @@ public function create() { } /** - * Drop the database table. + * Drop this database table. * * @since 1.0.0 * @@ -508,7 +508,7 @@ public function drop() { } /** - * Truncate the database table. + * Truncate this database table. * * @since 1.0.0 * @@ -533,7 +533,7 @@ public function truncate() { } /** - * Delete all items from the database table. + * Delete all items from this database table. * * @since 1.0.0 * @@ -564,7 +564,7 @@ public function delete_all() { * * @since 1.1.0 * - * @param string $new_table_name The name of the new table, without prefix + * @param string $new_table_name The name of the new table, no prefix * * @return bool */ @@ -602,7 +602,7 @@ public function _clone( $new_table_name = '' ) { * * @since 1.1.0 * - * @param string $new_table_name The name of the new table, without prefix + * @param string $new_table_name The name of the new table, no prefix * * @return bool */ @@ -634,7 +634,7 @@ public function copy( $new_table_name = '' ) { } /** - * Count the number of items in the database table. + * Count the number of items in this database table. * * @since 1.0.0 * @@ -658,6 +658,42 @@ public function count() { return intval( $result ); } + /** + * Rename this database table. + * + * @since 2.1.0 + * + * @param string $new_table_name The new name of the current table, no prefix + * + * @return bool + */ + public function rename( $new_table_name = '' ) { + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return false; + } + + // Sanitize the new table name + $table_name = $this->sanitize_table_name( $new_table_name ); + + // Bail if new table name is invalid + if ( empty( $table_name ) ) { + return false; + } + + // Query statement + $table = $this->apply_prefix( $table_name ); + $sql = "RENAME TABLE {$this->table_name} TO {$table}"; + $result = $db->query( $sql ); + + // Did the table get renamed? + return $this->is_success( $result ); + } + /** * Check if column already exists. * @@ -729,7 +765,7 @@ public function index_exists( $name = '', $column = 'Key_name' ) { /** Repair ****************************************************************/ /** - * Analyze the database table. + * Analyze this database table. * * See: https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html * @@ -759,7 +795,7 @@ public function analyze() { } /** - * Check the database table. + * Check this database table. * * See: https://dev.mysql.com/doc/refman/8.0/en/check-table.html * @@ -789,7 +825,7 @@ public function check() { } /** - * Get the Checksum the database table. + * Get the Checksum this database table. * * See: https://dev.mysql.com/doc/refman/8.0/en/checksum-table.html * @@ -819,7 +855,7 @@ public function checksum() { } /** - * Optimize the database table. + * Optimize this database table. * * See: https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html * @@ -849,7 +885,7 @@ public function optimize() { } /** - * Repair the database table. + * Repair this database table. * * See: https://dev.mysql.com/doc/refman/8.0/en/repair-table.html * Note: Not supported by InnoDB, the default engine in MySQL 8 and higher. @@ -1008,7 +1044,7 @@ private function setup() { return; } - // Sanitize the database table name + // Sanitize this database table name $this->name = $this->sanitize_table_name( $this->name ); // Bail if database table name sanitization failed From 3054f26586255455394ddd49eaeaa8030a4a78b1 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 17:17:43 -0500 Subject: [PATCH 27/34] Table: rename some vars --- src/Database/Table.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index 02cf353..a5a09b5 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -371,9 +371,9 @@ public function exists() { } // Query statement - $query = "SHOW TABLES LIKE %s"; + $sql = "SHOW TABLES LIKE %s"; $like = $db->esc_like( $this->table_name ); - $prepared = $db->prepare( $query, $like ); + $prepared = $db->prepare( $sql, $like ); $result = $db->get_var( $prepared ); // Does the table exist? @@ -400,9 +400,9 @@ public function status() { } // Query statement - $query = "SHOW TABLE STATUS LIKE %s"; + $sql = "SHOW TABLE STATUS LIKE %s"; $like = $db->esc_like( $this->table_name ); - $prepared = $db->prepare( $query, $like ); + $prepared = $db->prepare( $sql, $like ); $query = (array) $db->get_results( $prepared ); $result = end( $query ); From 195785bbbaab4c9d8388b72a1560ff54e8440aad Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 17:43:09 -0500 Subject: [PATCH 28/34] Query: rename query_handlers to query_var_parsers Includes some subsequent renames to keep things tidy --- src/Database/Query.php | 74 ++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index d2116d6..7c29631 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -127,19 +127,6 @@ class Query extends Base { */ private $schema = null; - /** Handlers **************************************************************/ - - /** - * Query handlers. - * - * An array of special classes used to parse Magic $query_vars into - * $query_clauses. - * - * @since 2.1.0 - * @var array - */ - protected $query_handlers = array(); - /** Clauses ***************************************************************/ /** @@ -205,6 +192,17 @@ class Query extends Base { */ protected $query_var_default_value = ''; + /** + * Query var parsers. + * + * An array of special classes used to parse Magic $query_vars into + * $query_clauses. + * + * @since 2.1.0 + * @var array + */ + protected $query_var_parsers = array(); + /** Results ***************************************************************/ /** @@ -299,7 +297,7 @@ public function setup() { $this->set_prefixes(); $this->set_schema(); $this->set_item_shape(); - $this->set_query_handlers(); + $this->set_query_var_parsers(); $this->set_query_var_defaults(); $this->set_query_clause_defaults(); } @@ -392,13 +390,13 @@ private function set_item_shape() { } /** - * Set query handlers. + * Set query var parsers. * * @since 2.1.0 */ - private function set_query_handlers() { - if ( empty( $this->query_handlers ) ) { - $this->query_handlers = array( + private function set_query_var_parsers() { + if ( empty( $this->query_var_parsers ) ) { + $this->query_var_parsers = array( 'meta' => __NAMESPACE__ . '\\Queries\\Meta', 'date' => __NAMESPACE__ . '\\Queries\\Date', 'compare' => __NAMESPACE__ . '\\Queries\\Compare' @@ -524,8 +522,8 @@ private function set_query_var_defaults() { /** Query Objects *****************************************************/ - // Loop through query handlers - foreach ( array_keys( $this->query_handlers ) as $id ) { + // Loop through query var parsers + foreach ( array_keys( $this->query_var_parsers ) as $id ) { // Set query key $suffix = '_query'; @@ -743,22 +741,22 @@ private function get_query_var( $key = '' ) { } /** - * Return a new Query Handler object, if it exists. + * Return a new query var parser object, if it exists. * * @since 2.1.0 * @param string $query * @param array $args * @return object */ - private function get_query_handler( $query = '', $args = array() ) { + private function get_query_var_parser( $query = '', $args = array() ) { // Bail if no query - if ( empty( $this->query_handlers[ $query ] ) ) { + if ( empty( $this->query_var_parsers[ $query ] ) ) { return; } // Setup the class name using the namespace - $class = $this->query_handlers[ $query ]; + $class = $this->query_var_parsers[ $query ]; // Bail if class does not exist if ( ! class_exists( $class ) ) { @@ -1383,7 +1381,7 @@ private function parse_where_join( $args = array() ) { $methods = array( 'parse_where_columns', 'parse_where_search', - 'parse_where_query_handlers' + 'parse_where_parsers' ); // Default results @@ -1623,25 +1621,25 @@ private function parse_where_search( $query_vars = array() ) { } /** - * Parse join/where subclauses for query handler objects. + * Parse join/where subclauses for query var parser objects. * * Used by parse_where(). * * @since 2.1.0 * @return array */ - private function parse_where_query_handlers( $query_vars = array() ) { + private function parse_where_parsers( $query_vars = array() ) { - // Bail if no queries - if ( empty( $this->query_handlers ) ) { + // Bail if no query var parsers + if ( empty( $this->query_var_parsers ) ) { return array( 'join' => array(), 'where' => array() ); } - // Get query handlers - $handlers = array_filter( array_keys( $this->query_handlers ) ); + // Get query var parsers + $parsers = array_filter( array_keys( $this->query_var_parsers ) ); // Query clause arguments $args = array( @@ -1655,8 +1653,8 @@ private function parse_where_query_handlers( $query_vars = array() ) { // Default values $join = $where = array(); - // Loop through queries - foreach ( $handlers as $id ) { + // Loop through parsers + foreach ( $parsers as $id ) { // Skip if ( empty( $id ) ) { @@ -1676,11 +1674,11 @@ private function parse_where_query_handlers( $query_vars = array() ) { $query_vars[ $key ][ 'alias'] = $args['table_alias']; } - // Try to get the query handler - $handler = $this->get_query_handler( $id, $query_vars[ $key ] ); + // Try to get the query var parser + $parser = $this->get_query_var_parser( $id, $query_vars[ $key ] ); - // Skip if no query handler - if ( empty( $handler ) ) { + // Skip if no query var parser + if ( empty( $parser ) ) { continue; } @@ -1688,7 +1686,7 @@ private function parse_where_query_handlers( $query_vars = array() ) { $subclauses = false; // Set the key - $this->{$key} = $handler; + $this->{$key} = $parser; // Set the callback $callback = array( $this->{$key}, 'get_sql' ); From 824b046c15fc358203267d74b57c232357d56942 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Sat, 2 Jul 2022 17:52:19 -0500 Subject: [PATCH 29/34] Queries: limit calls to get_db() --- src/Database/Queries/Date.php | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php index 25ac3e0..4ea7bcb 100644 --- a/src/Database/Queries/Date.php +++ b/src/Database/Queries/Date.php @@ -793,6 +793,9 @@ protected function get_sql_for_query( $query = array(), $depth = 0 ) { */ protected function get_sql_for_clause( $query = array(), $parent_query = array() ) { + // Get the database interface + $db = $this->get_db(); + // The sub-parts of a $where part. $where_parts = array(); @@ -814,11 +817,11 @@ protected function get_sql_for_clause( $query = array(), $parent_query = array() // Range queries. if ( ! empty( $query['after'] ) ) { - $where_parts[] = $this->get_db()->prepare( "{$column} {$gt} %s", $this->build_mysql_datetime( $query['after'], ! $inclusive, $now ) ); + $where_parts[] = $db->prepare( "{$column} {$gt} %s", $this->build_mysql_datetime( $query['after'], ! $inclusive, $now ) ); } if ( ! empty( $query['before'] ) ) { - $where_parts[] = $this->get_db()->prepare( "{$column} {$lt} %s", $this->build_mysql_datetime( $query['before'], $inclusive, $now ) ); + $where_parts[] = $db->prepare( "{$column} {$lt} %s", $this->build_mysql_datetime( $query['before'], $inclusive, $now ) ); } // Specific value queries. @@ -958,6 +961,10 @@ public function build_numeric_value( $compare = '=', $value = null ) { */ public function build_value( $compare = '=', $value = null ) { + // Get the database interface + $db = $this->get_db(); + + // MB if ( in_array( $compare, $this->multi_value_keys, true ) ) { if ( ! is_array( $value ) ) { $value = preg_split( '/[,\s]+/', $value ); @@ -970,25 +977,25 @@ public function build_value( $compare = '=', $value = null ) { case 'IN': case 'NOT IN': $compare_string = '(' . substr( str_repeat( ',%s', count( $value ) ), 1 ) . ')'; - $where = $this->get_db()->prepare( $compare_string, $value ); + $where = $db->prepare( $compare_string, $value ); break; case 'BETWEEN': case 'NOT BETWEEN': $value = array_slice( $value, 0, 2 ); - $where = $this->get_db()->prepare( '%s AND %s', $value ); + $where = $db->prepare( '%s AND %s', $value ); break; case 'LIKE': case 'NOT LIKE': - $value = '%' . $this->get_db()->esc_like( $value ) . '%'; - $where = $this->get_db()->prepare( '%s', $value ); + $value = '%' . $db->esc_like( $value ) . '%'; + $where = $db->prepare( '%s', $value ); break; // EXISTS with a value is interpreted as '='. case 'EXISTS': $compare = '='; - $where = $this->get_db()->prepare( '%s', $value ); + $where = $db->prepare( '%s', $value ); break; // 'value' is ignored for NOT EXISTS. @@ -997,7 +1004,7 @@ public function build_value( $compare = '=', $value = null ) { break; default: - $where = $this->get_db()->prepare( '%s', $value ); + $where = $db->prepare( '%s', $value ); break; } @@ -1213,6 +1220,9 @@ public function build_time_query( $column, $compare, $hour = null, $minute = nul return false; } + // Get the database interface + $db = $this->get_db(); + // Complex combined queries aren't supported for multi-value queries if ( in_array( $compare, $this->multi_value_keys, true ) ) { $retval = array(); @@ -1282,7 +1292,7 @@ public function build_time_query( $column, $compare, $hour = null, $minute = nul $query = "DATE_FORMAT( {$column}, %s ) {$compare} %f"; // Return the prepared SQL - return $this->get_db()->prepare( $query, $format, $time ); + return $db->prepare( $query, $format, $time ); } /** From 9757dbe5ecbeb31668fd3f46d0a9676f6c196dca Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Mon, 4 Jul 2022 12:44:27 -0500 Subject: [PATCH 30/34] Query: group "shape" methods together --- src/Database/Query.php | 184 +++++++++++++++++++++-------------------- 1 file changed, 94 insertions(+), 90 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 7c29631..604a07f 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -591,8 +591,9 @@ private function set_request() { */ private function set_items( $item_ids = array() ) { - // Shape item IDs - $item_ids = array_map( array( $this, 'shape_item_id' ), $item_ids ); + // Validate primary column values + $callback = array( $this, 'shape_item_id' ); + $item_ids = array_map( $callback, $item_ids ); // Prime item caches $this->prime_item_caches( $item_ids ); @@ -602,7 +603,8 @@ private function set_items( $item_ids = array() ) { } /** - * Populates found_items and max_num_pages properties for the current query + * Populates found_items for the current query. + * * if the limit clause was used. * * @since 1.0.0 @@ -643,7 +645,7 @@ private function set_found_items( $item_ids = array() ) { * This second query uses most of the previously parsed $request_clauses * and overrides a few to correct the SQL syntax. * - * @since 2.1.0 No longer uses FOUND_ROWS() + * @since 2.1.0 Performs a COUNT(*) query using $request_clauses. */ } elseif ( ! $this->get_query_var( 'no_found_rows' ) && $this->get_query_var( 'number' ) ) { @@ -2294,6 +2296,36 @@ private function parse_order( $order = 'DESC' ) { /** Private Shapers *******************************************************/ + /** + * Shape an item from the database into the type of object it always wanted + * to be when it grew up. + * + * @since 1.0.0 + * + * @param mixed ID of item, or row from database + * @return mixed False on error, Object of single-object class type on success + */ + private function shape_item( $item = 0 ) { + + // Get the item from an ID + if ( is_numeric( $item ) ) { + $item = $this->get_item( $item ); + } + + // Return the item if it's already shaped + if ( $item instanceof $this->item_shape ) { + return $item; + } + + // Shape the item as needed + $item = ! empty( $this->item_shape ) + ? new $this->item_shape( $item ) + : (object) $item; + + // Return the item object + return $item; + } + /** * Shape items into their most relevant objects. * @@ -2345,62 +2377,12 @@ private function shape_items( $items = array(), $fields = array() ) { } /** - * Get specific fields from an array of items. + * Validate the primary column value of an item. * - * @since 1.0.0 - * @since 2.1.0 Bails early if empty $fields. - * - * @param array $items Array of items to get fields from. - * @param array $fields Fields to get from items. - * @return array - */ - private function get_item_fields( $items = array(), $fields = array() ) { - - // Maybe fallback to $query_vars - if ( empty( $fields ) ) { - $fields = $this->get_query_var( 'fields' ); - } - - // Bail if no fields to get - if ( empty( $fields ) ) { - return $items; - } - - // Maybe cast to array - if ( ! is_array( $fields ) ) { - $fields = (array) $fields; - } - - // Default return value - $retval = $items; - - // Get the primary column name - $primary = $this->get_primary_column_name(); - - // 'ids' is numerically keyed - if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { - $retval = wp_list_pluck( $items, $primary ); - - // Get fields from items - } else { - $retval = array(); - $fields = array_flip( $fields ); - - // Loop through items and pluck out the fields - foreach ( $items as $item ) { - $retval[ $item->{$primary} ] = (object) array_intersect_key( (array) $item, $fields ); - } - } - - // Return the item fields - return $retval; - } - - /** - * Shape an item ID from an object, array, or numeric value. + * Accepts an object, array, or numeric value. * * @since 1.0.0 - * @since 2.1.0 Uses validate_item_field() instead of intval. + * @since 2.1.0 Uses validate_item_field() * * @param array|object|scalar $item * @return int|string @@ -2450,6 +2432,58 @@ private function validate_item_field( $value = '', $column_name = '' ) { return $column->validate( $value ); } + /** + * Get specific fields from an array of items. + * + * @since 1.0.0 + * @since 2.1.0 Bails early if empty $fields. + * + * @param array $items Array of items to get fields from. + * @param array $fields Fields to get from items. + * @return array + */ + private function get_item_fields( $items = array(), $fields = array() ) { + + // Maybe fallback to $query_vars + if ( empty( $fields ) ) { + $fields = $this->get_query_var( 'fields' ); + } + + // Bail if no fields to get + if ( empty( $fields ) ) { + return $items; + } + + // Maybe cast to array + if ( ! is_array( $fields ) ) { + $fields = (array) $fields; + } + + // Default return value + $retval = $items; + + // Get the primary column name + $primary = $this->get_primary_column_name(); + + // 'ids' is numerically keyed + if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) { + $retval = wp_list_pluck( $items, $primary ); + + // Get fields from items + } else { + $retval = array(); + $fields = array_flip( $fields ); + + // Loop through items and pluck out the fields + foreach ( $items as $item ) { + $retval[ $item->{$primary} ] = (object) array_intersect_key( (array) $item, $fields ); + } + } + + // Return the item fields + return $retval; + } + /** Queries ***************************************************************/ /** @@ -2872,36 +2906,6 @@ public function delete_item( $item_id = 0 ) { return $retval; } - /** - * Shape an item from the database into the type of object it always wanted - * to be when it grew up. - * - * @since 1.0.0 - * - * @param mixed ID of item, or row from database - * @return mixed False on error, Object of single-object class type on success - */ - private function shape_item( $item = 0 ) { - - // Get the item from an ID - if ( is_numeric( $item ) ) { - $item = $this->get_item( $item ); - } - - // Return the item if it's already shaped - if ( $item instanceof $this->item_shape ) { - return $item; - } - - // Shape the item as needed - $item = ! empty( $this->item_shape ) - ? new $this->item_shape( $item ) - : (object) $item; - - // Return the item object - return $item; - } - /** * Validate an item before it is updated in or added to the database. * @@ -3706,9 +3710,9 @@ private function get_last_changed_cache( $group = '' ) { * Get array of non-cached item IDs. * * @since 1.0.0 - * @since 2.1.0 No longer uses shape_item_id() + * @since 2.1.0 $item_ids expected to be shaped * - * @param array $item_ids Array of item IDs + * @param array $item_ids Array of shaped item IDs * @param string $group Cache group. Defaults to $this->cache_group * * @return array @@ -3919,7 +3923,7 @@ public function filter_found_items_query( $sql = '' ) { * @since 2.1.0 Supports MySQL 8 by removing FOUND_ROWS() and uses * $request_clauses instead. * - * @param string $query SQL query. Default 'SELECT FOUND_ROWS()'. + * @param string $query SQL query. * @param Query &$this Current instance passed by reference. */ return (string) apply_filters_ref_array( From 838dc9a0a0ba2f7f9af9e3a5cfb59fa55ff9751b Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Mon, 4 Jul 2022 13:20:03 -0500 Subject: [PATCH 31/34] Query: clean-up parse_where_search() --- src/Database/Query.php | 56 +++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index 604a07f..aadae72 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1408,7 +1408,7 @@ private function parse_where_join( $args = array() ) { /** * Parse join/where subclauses for all columns. * - * Used by parse_where(). + * Used by parse_where_join(). * * @since 2.1.0 * @return array @@ -1564,21 +1564,15 @@ private function parse_where_columns( $query_vars = array() ) { /** * Parse join/where subclauses for search queries. * - * Used by parse_where(). + * Used by parse_where_join(). * * @since 2.1.0 * @return array */ private function parse_where_search( $query_vars = array() ) { - // Get searchable columns - $searchable = $this->get_columns( - array( - 'searchable' => true - ), - 'and', - 'name' - ); + // Get names of searchable columns + $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); // Bail if no search if ( empty( $searchable ) || empty( $query_vars['search'] ) ) { @@ -1591,29 +1585,22 @@ private function parse_where_search( $query_vars = array() ) { // Default value $where = array(); - // Get names of searchable columns - $searchable = $this->get_columns( array( 'searchable' => true ), 'and', 'name' ); - - // Maybe search if columns are searchable - if ( ! empty( $searchable ) && strlen( $query_vars['search'] ) ) { + // Default to all searchable columns + $search_columns = $searchable; - // Default to all searchable columns - $search_columns = $searchable; + // Intersect against known searchable columns + if ( ! empty( $query_vars['search_columns'] ) ) { + $search_columns = array_intersect( + $query_vars['search_columns'], + $searchable + ); + } - // Intersect against known searchable columns - if ( ! empty( $query_vars['search_columns'] ) ) { - $search_columns = array_intersect( - $query_vars['search_columns'], - $searchable - ); - } + // Filter search columns + $search_columns = $this->filter_search_columns( $search_columns ); - // Filter search columns - $search_columns = $this->filter_search_columns( $search_columns ); - - // Add search query clause - $where['search'] = $this->get_search_sql( $query_vars['search'], $search_columns ); - } + // Add search query clause + $where['search'] = $this->get_search_sql( $query_vars['search'], $search_columns ); // Return join/where return array( @@ -1625,7 +1612,7 @@ private function parse_where_search( $query_vars = array() ) { /** * Parse join/where subclauses for query var parser objects. * - * Used by parse_where(). + * Used by parse_where_join(). * * @since 2.1.0 * @return array @@ -1731,14 +1718,15 @@ private function parse_where_parsers( $query_vars = array() ) { * * @since 2.1.0 * - * @param int|string|array $query_vars - * @param string $key + * @param array $query_vars + * @param string $key + * * @return int|string|array False if not set or default. * Value if object or array. * Attempts to parse a comma-separated string of * possible keys or numbers. */ - private function parse_query_var( $query_vars = '', $key = '' ) { + private function parse_query_var( $query_vars = array(), $key = '' ) { // Bail if no query vars exist for that ID if ( ! isset( $query_vars[ $key ] ) ) { From 98a5eb99df69ba14c6d07e406c506972dcff39b9 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Tue, 5 Jul 2022 11:16:01 -0500 Subject: [PATCH 32/34] Update src/Database/Query.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Viktor Szépe --- src/Database/Query.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index aadae72..626d5fd 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -1752,10 +1752,10 @@ private function parse_query_var( $query_vars = array(), $key = '' ) { || is_array( $value ) || - is_numeric( $value ) - || is_int( $value ) || + is_numeric( $value ) + || is_bool( $value ) ) { return array( $value ); From 4853bde184cb884d244f1029ef7ee7e3376fa065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20Sz=C3=A9pe?= Date: Wed, 13 Jul 2022 23:29:08 +0000 Subject: [PATCH 33/34] Fix boolean handling in Table (#151) `$global` is already a boolean --- src/Database/Table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index a5a09b5..7f77cab 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -1214,7 +1214,7 @@ function_exists( '_manually_load_plugin' ); * @return bool */ private function is_global() { - return ( true === $this->global ); + return $this->global; } /** From c6e06ae8d7676e50028925b11f4feb114cd8f4fb Mon Sep 17 00:00:00 2001 From: Robin Cornett Date: Mon, 3 Apr 2023 16:32:34 -0400 Subject: [PATCH 34/34] Set db version default to 1 (#157) #156 --- src/Database/Table.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Table.php b/src/Database/Table.php index 7f77cab..d948031 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -1158,8 +1158,8 @@ private function set_db_version( $version = '' ) { */ private function get_db_version() { $this->db_version = $this->is_global() - ? get_network_option( get_main_network_id(), $this->db_version_key, false ) - : get_option( $this->db_version_key, false ); + ? get_network_option( get_main_network_id(), $this->db_version_key, 1 ) + : get_option( $this->db_version_key, 1 ); } /**