From 18ff675aa87a4bf2cf69dadcd20fffa47797b083 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 6 Jul 2022 15:01:03 -0500 Subject: [PATCH 1/2] First commit to 3.0.0 experimental: * Traits * Parsers (yuck) * Operators (yuck) A bunch of things are broken here. --- src/Database/Column.php | 183 ++-- src/Database/Parsers/By.php | 278 +++++ src/Database/Parsers/Compare.php | 105 ++ src/Database/Parsers/Date.php | 443 ++++++++ src/Database/Parsers/In.php | 279 +++++ src/Database/Parsers/Meta.php | 550 ++++++++++ src/Database/Parsers/NotIn.php | 123 +++ src/Database/Parsers/Search.php | 181 ++++ src/Database/Queries/Compare.php | 180 ---- src/Database/Queries/Date.php | 1327 ----------------------- src/Database/Queries/Meta.php | 29 - src/Database/Query.php | 913 ++++++---------- src/Database/Row.php | 26 +- src/Database/Schema.php | 105 +- src/Database/Table.php | 135 ++- src/Database/{ => Traits}/Base.php | 22 +- src/Database/Traits/Boot.php | 127 +++ src/Database/Traits/Operator.php | 73 ++ src/Database/Traits/Parser.php | 1574 ++++++++++++++++++++++++++++ 19 files changed, 4287 insertions(+), 2366 deletions(-) create mode 100644 src/Database/Parsers/By.php create mode 100644 src/Database/Parsers/Compare.php create mode 100644 src/Database/Parsers/Date.php create mode 100644 src/Database/Parsers/In.php create mode 100644 src/Database/Parsers/Meta.php create mode 100644 src/Database/Parsers/NotIn.php create mode 100644 src/Database/Parsers/Search.php delete mode 100644 src/Database/Queries/Compare.php delete mode 100644 src/Database/Queries/Date.php delete mode 100644 src/Database/Queries/Meta.php rename src/Database/{ => Traits}/Base.php (95%) create mode 100644 src/Database/Traits/Boot.php create mode 100644 src/Database/Traits/Operator.php create mode 100644 src/Database/Traits/Parser.php diff --git a/src/Database/Column.php b/src/Database/Column.php index 3b92efe..a4dcd78 100644 --- a/src/Database/Column.php +++ b/src/Database/Column.php @@ -17,13 +17,52 @@ * Base class used for each column for a custom table. * * @since 1.0.0 - * @since 2.1.0 Column::args[] stashes parsed & class arguments. + * @since 3.0.0 Column::args[] stashes parsed & class arguments. * - * @see Column::__construct() for accepted arguments. + * @param array|string $args { + * Optional. Array or query string of order query parameters. Default empty. + * + * @type string $name Name of database column + * @type string $type Type of database column + * @type int $length Length of database column + * @type bool $unsigned Is integer unsigned? + * @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 0|'', null, or date value + * @type string $extra auto_increment, etc... + * @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? + * @type bool $created Is this the column used as a created date? + * @type bool $modified Is this the column used as a modified date? + * @type bool $uuid Is this the column used as a universally unique identifier? + * @type bool $searchable Is this column searchable? + * @type bool $sortable Is this column used in orderby? + * @type bool $date_query Is this column a datetime? + * @type bool $in Is __in supported? + * @type bool $not_in Is __not_in supported? + * @type bool $cache_key Is this column queried independently? + * @type bool $transition Does this column transition between changes? + * @type string $validate A callback function used to validate on save. + * @type array $caps Array of capabilities to check. + * @type array $aliases Array of possible column name aliases. + * @type array $relationships Array of columns in other tables this column relates to. + * } */ -class Column extends Base { +class Column { + + /** + * Use the following traits: + * + * @since 3.0.0 + */ + use Traits\Base; + use Traits\Boot; - /** Table Attributes ******************************************************/ + /** Attributes ************************************************************/ /** * Name for the database column. @@ -158,8 +197,8 @@ class Column extends Base { * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html * * @since 1.0.0 - * @since 2.1.0 Allowed values checked via sanitize_extra() - * @since 2.1.0 Special values checked via special_args() + * @since 3.0.0 Allowed values checked via sanitize_extra() + * @since 3.0.0 Special values checked via special_args() * @var string Default empty string. */ public $extra = ''; @@ -425,98 +464,16 @@ class Column extends Base { */ public $relationships = array(); - /** Methods ***************************************************************/ - - /** - * Sets up the order query, based on the query vars passed. - * - * @since 1.0.0 - * - * @param array|string $args { - * Optional. Array or query string of order query parameters. Default empty. - * - * @type string $name Name of database column - * @type string $type Type of database column - * @type int $length Length of database column - * @type bool $unsigned Is integer unsigned? - * @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 0|'', null, or date value - * @type string $extra auto_increment, etc... - * @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? - * @type bool $created Is this the column used as a created date? - * @type bool $modified Is this the column used as a modified date? - * @type bool $uuid Is this the column used as a universally unique identifier? - * @type bool $searchable Is this column searchable? - * @type bool $sortable Is this column used in orderby? - * @type bool $date_query Is this column a datetime? - * @type bool $in Is __in supported? - * @type bool $not_in Is __not_in supported? - * @type bool $cache_key Is this column queried independently? - * @type bool $transition Does this column transition between changes? - * @type string $validate A callback function used to validate on save. - * @type array $caps Array of capabilities to check. - * @type array $aliases Array of possible column name aliases. - * @type array $relationships Array of columns in other tables this column relates to. - * } - */ - public function __construct( $args = array() ) { - - // Parse arguments - $r = $this->parse_args( $args ); - - // Maybe set variables from arguments - if ( ! empty( $r ) ) { - $this->set_vars( $r ); - } - } - - /** Argument Handlers *****************************************************/ - - /** - * 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() ) { - - // Stash the arguments - $this->stash_args( $args ); - - // Bail if no arguments - if ( empty( $args ) ) { - return 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 arguments before they are validated & sanitized - $this->set_vars( $r ); - - // Return array - return $this->validate_args( $r ); - } - /** * Validate arguments after they are parsed. * - * @since 1.0.0 + * @since 1.0.0 Private. + * @since 3.0.0 Protected. + * * @param array $args Default empty array. * @return array */ - private function validate_args( $args = array() ) { + protected function validate_args( $args = array() ) { // Sanitization callbacks $callbacks = array( @@ -589,11 +546,11 @@ private function validate_args( $args = array() ) { * 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. + * @since 3.0.0 Added support for SERIAL "extra" values. * @param array $args Default empty array. * @return array */ - private function special_args( $args = array() ) { + protected function special_args( $args = array() ) { // Handle specific "extra" aliases if ( ! empty( $args['extra'] ) ) { @@ -650,7 +607,7 @@ private function special_args( $args = array() ) { /** * Return if a column type is a bool. * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if bool type only. */ public function is_bool() { @@ -662,7 +619,7 @@ public function is_bool() { /** * Return if a column type is a date. * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if any date or time. */ public function is_date_time() { @@ -678,7 +635,7 @@ public function is_date_time() { /** * Return if a column type is an integer. * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if int. */ public function is_int() { @@ -694,7 +651,7 @@ public function is_int() { /** * Return if a column type is decimal. * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if float. */ public function is_decimal() { @@ -738,7 +695,7 @@ public function is_numeric() { * * For binary strings (blobs) use is_binary(). * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if text. */ public function is_text() { @@ -759,7 +716,7 @@ public function is_text() { /** * Return if a column type is binary. * - * @since 2.1.0 + * @since 3.0.0 * @return bool True if binary. */ public function is_binary() { @@ -783,7 +740,7 @@ public function is_binary() { * Return if this column is of a certain type. * * @since 1.0.0 - * @since 2.1.0 Empty $type returns false. + * @since 3.0.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. @@ -810,7 +767,7 @@ private function is_type( $type = '' ) { /** * Return if this column is of a certain type. * - * @since 2.1.0 + * @since 3.0.0 * @param array[string] $extra Default empty string. The extra to check. * Also accepts an array. * @return bool True if extra matches. @@ -885,7 +842,7 @@ private function sanitize_relationships( $relationships = array() ) { /** * Sanitize the extra string. * - * @since 2.1.0 + * @since 3.0.0 * @param string $value * @return string */ @@ -920,7 +877,7 @@ private function sanitize_extra( $value = '' ) { * Sanitize the default value. * * @since 1.0.0 - * @since 2.1.0 Uses validate() + * @since 3.0.0 Uses validate() * @param int|string|null $default * @return int|string|null */ @@ -932,7 +889,7 @@ private function sanitize_default( $default = '' ) { * Sanitize the pattern string. * * @since 1.0.0 - * @since 2.1.0 Falls back to using is_ methods if invalid param + * @since 3.0.0 Falls back to using is_ methods if invalid param * @param string $pattern Default '%s'. Allowed values: %s, %d, $f * @return string Default '%s'. */ @@ -974,7 +931,7 @@ private function sanitize_pattern( $pattern = '%s' ) { * calculated based on varying column properties. * * @since 1.0.0 - * @since 2.1.0 Explicit support for decimal, int, and numeric types. + * @since 3.0.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. @@ -1023,7 +980,7 @@ private function sanitize_validation( $callback = '' ) { * Used by Column::sanitize_default() and Query to prevent invalid and * unexpected values from being saved in the database. * - * @since 2.1.0 + * @since 3.0.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 @@ -1052,7 +1009,7 @@ public function validate( $value = '', $default = '' ) { * * Will return the $default if $allow_null is false. * - * @since 2.1.0 + * @since 3.0.0 * @param int|string|null $value Default empty string. * @return int|string|null */ @@ -1098,7 +1055,7 @@ public function validate_null( $value = '' ) { * See: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_allow_invalid_dates * * @since 1.0.0 - * @since 2.1.0 Add support for CURRENT_TIMESTAMP. + * @since 3.0.0 Add support for CURRENT_TIMESTAMP. * @param string $value Default ''. A datetime value that needs validating. * @return string A valid datetime value. */ @@ -1150,7 +1107,7 @@ public function validate_datetime( $value = '' ) { * be done inside of the application layer and outside of MySQL. * * @since 1.0.0 - * @since 2.1.0 Uses: validate_numeric(). + * @since 3.0.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 @@ -1175,7 +1132,7 @@ public function validate_decimal( $value = 0, $decimals = 9 ) { * 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 + * @since 3.0.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 @@ -1228,7 +1185,7 @@ public function validate_numeric( $value = 0, $decimals = false ) { * 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 + * @since 3.0.0 * @param int $value Default zero. * @return int */ @@ -1292,7 +1249,7 @@ public function validate_uuid( $value = '' ) { * Return a string representation of this column's properties as part of * the "CREATE" string of a Table. * - * @since 2.1.0 + * @since 3.0.0 * @return string */ public function get_create_string() { diff --git a/src/Database/Parsers/By.php b/src/Database/Parsers/By.php new file mode 100644 index 0000000..077e6f4 --- /dev/null +++ b/src/Database/Parsers/By.php @@ -0,0 +1,278 @@ +caller( 'get_columns', array(), 'and', 'name' ); + + foreach ( $ins as $in ) { + $first_keys[] = $in; + } + + return $first_keys; + } + + /** + * Generate SQL WHERE clauses for a first-order query clause. + * + * "First-order" means that it's an array with a 'key' or 'value'. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` + * parameters. If not provided, a key will be generated automatically. + * @return array { + * Array containing WHERE SQL clauses to append to a first-order query. + * + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Get the database interface. + $db = $this->get_db(); + + // Get __in's in clause. + $ins = $this->get_first_order_clauses( $clause ); + + // Bail if no database or first-order clauses. + if ( empty( $db ) || empty( $ins ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } + + // Default where array. + $where = array(); + + // Loop through ins. + foreach ( $ins as $column => $query_var ) { + + // Get pattern and aliased name + $pattern = $this->caller( 'get_column_field', array( 'name' => $column ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $column ); + + // Parse query var + $values = $this->caller( 'parse_query_var', $clause, $column ); + + // Parse item for an IN clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $where_id = $column; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // Return join/where array. + return array( + 'join' => array(), + 'where' => $where + ); + } + + /** + * Parse join/where subclauses for all columns. + * + * Used by parse_where_join(). + * + * @since 3.0.0 + * @return array + */ + private function parse_where_columns( $query_vars = array() ) { + + // Defaults + $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; + } + + // 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 ( $all_columns as $column ) { + + // 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 = $name; + $values = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item for direct clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $where_id = "{$where_id}__in"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // __in + if ( true === $column->in ) { + + // Parse query var + $where_id = "{$name}__in"; + $values = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item for an IN clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $where_id = $name; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // __not_in + if ( true === $column->not_in ) { + + // Parse query var + $where_id = "{$name}__not_in"; + $values = $this->parse_query_var( $query_vars, $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 = "{$aliased} != {$pattern}"; + $where_id = $name; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $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 = "{$name}_query"; + $column_date = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item + if ( false !== $column_date ) { + + // Single + if ( 1 === count( $column_date ) ) { + $where['date_query'][] = array( + 'column' => $aliased, + 'before' => reset( $column_date ), + 'inclusive' => true + ); + + // Multi + } else { + + // Auto-fill column if empty + if ( empty( $column_date['column'] ) ) { + $column_date['column'] = $aliased; + } + + // Add clause to date query + $where['date_query'][] = $column_date; + } + } + } + } + + // Return join/where subclauses + return array( + 'join' => array(), + 'where' => $where + ); + } + +} diff --git a/src/Database/Parsers/Compare.php b/src/Database/Parsers/Compare.php new file mode 100644 index 0000000..68601b2 --- /dev/null +++ b/src/Database/Parsers/Compare.php @@ -0,0 +1,105 @@ + array(), + 'join' => array(), + ); + + // Maybe format compare clause. + if ( isset( $clause['compare'] ) ) { + $clause['compare'] = strtoupper( $clause['compare'] ); + + // Or set compare clause based on value. + } else { + $clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) + ? 'IN' + : '='; + } + + // Get all comparison operators. + $all_compares = $this->get_operators(); + + // Fallback to equals + if ( ! in_array( $clause['compare'], $all_compares, true ) ) { + $clause['compare'] = '='; + } + + // Uppercase or equals + if ( isset( $clause['compare_key'] ) && ( 'LIKE' === strtoupper( $clause['compare_key'] ) ) ) { + $clause['compare_key'] = strtoupper( $clause['compare_key'] ); + } else { + $clause['compare_key'] = '='; + } + + // Get comparison from clause + $compare = $clause['compare']; + + /** Build the WHERE clause ********************************************/ + + // Column name and value. + if ( array_key_exists( 'key', $clause ) && array_key_exists( 'value', $clause ) ) { + $column = $this->sanitize_column_name( $clause['key'] ); + $where = $this->build_value( $compare, $clause['value'], '%s' ); + + // Maybe add column, compare, & where to return value. + if ( ! empty( $where ) ) { + $retval['where'][] = "{$column} {$compare} {$where}"; + } + } + + /* + * Multiple WHERE clauses (for meta_key and meta_value) should + * be joined in parentheses. + */ + if ( 1 < count( $retval['where'] ) ) { + $retval['where'] = array( '( ' . implode( ' AND ', $retval['where'] ) . ' )' ); + } + + // Return join/where array. + return $retval; + } +} diff --git a/src/Database/Parsers/Date.php b/src/Database/Parsers/Date.php new file mode 100644 index 0000000..7434a26 --- /dev/null +++ b/src/Database/Parsers/Date.php @@ -0,0 +1,443 @@ +', '>=', '<', '<=', + * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Default '='. + * @type string $relation Optional. The boolean relationship between the date queries. Accepts 'OR' or 'AND'. + * Default 'OR'. + * @type int|array $start_of_week Optional. Day that week starts on. Accepts numbers 0-6 + * (0 = Sunday, 1 is Monday). Default 0. + * @type array ...$0 { + * Optional. An array of first-order clause parameters, or another fully-formed date query. + * + * @type array|string $before { + * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string, + * or array of 'year', 'month', 'day' values. + * + * @type string $year The four-digit year. Default empty. Accepts any four-digit year. + * @type string $month Optional when passing array.The month of the year. + * Default (string:empty)|(array:1). Accepts numbers 1-12. + * @type string $day Optional when passing array.The day of the month. + * Default (string:empty)|(array:1). Accepts numbers 1-31. + * } + * @type array|string $after { + * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string, + * or array of 'year', 'month', 'day' values. + * + * @type string $year The four-digit year. Accepts any four-digit year. Default empty. + * @type string $month Optional when passing array. The month of the year. Accepts numbers 1-12. + * Default (string:empty)|(array:12). + * @type string $day Optional when passing array.The day of the month. Accepts numbers 1-31. + * Default (string:empty)|(array:last day of month). + * } + * @type string $column Optional. Used to add a clause comparing a column other than the + * column specified in the top-level `$column` parameter. Accepts + * 'date_created', 'date_created_gmt', 'post_modified', 'post_modified_gmt', + * 'comment_date', 'comment_date_gmt'. Default is the value of + * top-level `$column`. + * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', + * '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. 'IN', + * 'NOT IN', 'BETWEEN', and 'NOT BETWEEN'. Comparisons support + * arrays in some time-related parameters. Default '='. + * @type int|array $start_of_week Optional. Day that week starts on. Accepts numbers 0-6 + * (0 = Sunday, 1 is Monday). Default 0. + * @type bool $inclusive Optional. Include results from dates specified in 'before' or + * 'after'. Default false. + * @type int|array $year Optional. The four-digit year number. Accepts any four-digit year + * or an array of years if `$compare` supports it. Default empty. + * @type int|array $month Optional. The two-digit month number. Accepts numbers 1-12 or an + * array of valid numbers if `$compare` supports it. Default empty. + * @type int|array $week Optional. The week number of the year. Accepts numbers 0-53 or an + * array of valid numbers if `$compare` supports it. Default empty. + * @type int|array $dayofyear Optional. The day number of the year. Accepts numbers 1-366 or an + * array of valid numbers if `$compare` supports it. + * @type int|array $day Optional. The day of the month. Accepts numbers 1-31 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|array $dayofweek Optional. The day number of the week. Accepts numbers 1-7 (1 is + * Sunday) or an array of valid numbers if `$compare` supports it. + * Default empty. + * @type int|array $dayofweek_iso Optional. The day number of the week (ISO). Accepts numbers 1-7 + * (1 is Monday) or an array of valid numbers if `$compare` supports it. + * Default empty. + * @type int|array $hour Optional. The hour of the day. Accepts numbers 0-23 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|array $minute Optional. The minute of the hour. Accepts numbers 0-60 or an array + * of valid numbers if `$compare` supports it. Default empty. + * @type int|array $second Optional. The second of the minute. Accepts numbers 0-60 or an + * array of valid numbers if `$compare` supports it. Default empty. + * } + * } + * } + */ +class Date { + + use \BerlinDB\Database\Traits\Parser; + + /** + * Array of first-order keys. + * + * @since 3.0.0 + * @var array + */ + public function get_first_keys() { + return array( + 'after', + 'before', + 'value', + 'year', + 'month', + 'monthnum', + 'week', + 'w', + 'dayofyear', + 'day', + 'dayofweek', + 'dayofweek_iso', + 'hour', + 'minute', + 'second' + ); + } + + /** + * Validates the given date_query values. + * + * Note that date queries with invalid date ranges are allowed to + * continue (though of course no items will be found for impossible dates). + * This method only generates debug notices for these cases. + * + * @since 3.0.0 + * @param array $date_query The date_query array. + * @return bool True if all values in the query are valid, false if one or more fail. + */ + public function validate_values( $date_query = array() ) { + + // Bail if empty. + if ( empty( $date_query ) ) { + return false; + } + + // Default return value. + $valid = true; + + /* + * Validate 'before' and 'after' up front, then let the + * validation routine continue to be sure that all invalid + * values generate errors too. + */ + if ( array_key_exists( 'before', $date_query ) && is_array( $date_query['before'] ) ) { + $valid = $this->validate_values( $date_query['before'] ); + } + + if ( array_key_exists( 'after', $date_query ) && is_array( $date_query['after'] ) ) { + $valid = $this->validate_values( $date_query['after'] ); + } + + // Values are passthroughs. + if ( array_key_exists( 'value', $date_query ) ) { + $valid = true; + } + + // Array containing all min-max checks. + $min_max_checks = array(); + + // Days per year. + if ( array_key_exists( 'year', $date_query ) ) { + /* + * If a year exists in the date query, we can use it to get the days. + * If multiple years are provided (as in a BETWEEN), use the first one. + */ + if ( is_array( $date_query['year'] ) ) { + $_year = reset( $date_query['year'] ); + } else { + $_year = $date_query['year']; + } + + $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 { + $max_days_of_year = 366; + } + + // Days of year. + $min_max_checks['dayofyear'] = array( + 'min' => 1, + 'max' => $max_days_of_year, + ); + + // Days per week. + $min_max_checks['dayofweek'] = array( + 'min' => 1, + 'max' => 7, + ); + + // Days per week. + $min_max_checks['dayofweek_iso'] = array( + 'min' => 1, + 'max' => 7, + ); + + // Months per year. + $min_max_checks['month'] = array( + 'min' => 1, + 'max' => 12, + ); + + // Weeks per year. + if ( isset( $_year ) ) { + /* + * If we have a specific year, use it to calculate number of weeks. + * Note: the number of weeks in a year is the date in which Dec 28 appears. + */ + $week_count = gmdate( 'W', gmmktime( 0, 0, 0, 12, 28, $_year ) ); + + // Otherwise set the week-count to a maximum of 53. + } else { + $week_count = 53; + } + + // Weeks per year. + $min_max_checks['week'] = array( + 'min' => 1, + 'max' => $week_count, + ); + + // Days per month. + $min_max_checks['day'] = array( + 'min' => 1, + 'max' => 31, + ); + + // Hours per day. + $min_max_checks['hour'] = array( + 'min' => 0, + 'max' => 23, + ); + + // Minutes per hour. + $min_max_checks['minute'] = array( + 'min' => 0, + 'max' => 59, + ); + + // Seconds per minute. + $min_max_checks['second'] = array( + 'min' => 0, + 'max' => 59, + ); + + // Loop through min/max checks. + foreach ( $min_max_checks as $key => $check ) { + + // Skip if not in query. + if ( ! array_key_exists( $key, $date_query ) ) { + continue; + } + + // Check for invalid values. + foreach ( (array) $date_query[ $key ] as $_value ) { + $is_between = ( $_value >= $check['min'] ) && ( $_value <= $check['max'] ); + + if ( ! is_numeric( $_value ) || empty( $is_between ) ) { + $valid = false; + } + } + } + + // Bail if invalid query. + if ( false === $valid ) { + return $valid; + } + + // Check what kinds of dates are being queried for. + $day_exists = array_key_exists( 'day', $date_query ) && is_numeric( $date_query['day'] ); + $month_exists = array_key_exists( 'month', $date_query ) && is_numeric( $date_query['month'] ); + $year_exists = array_key_exists( 'year', $date_query ) && is_numeric( $date_query['year'] ); + + // Checking at least day & month. + if ( ! empty( $day_exists ) && ! empty( $month_exists ) ) { + + // Check for year query, or fallback to 2012 (for flexibility). + $year = ! empty( $year_exists ) + ? $date_query['year'] + : '2012'; + + // Check the date. + if ( checkdate( $date_query['month'], $date_query['day'], $year ) ) { + $valid = false; + } + } + + // Return if valid or not + return $valid; + } + + /** + * Generate SQL for a query clause. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause. + * If not provided, a key will be generated automatically. + * + * @return array { + * Array containing JOIN and WHERE SQL clauses to append to the main query. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Get the database interface + $db = $this->get_db(); + + // The sub-parts of a $where part. + $where = array(); + + // Get first-order clauses + $now = $this->get_now( $clause ); + $column = $this->get_column( $clause ); + $compare = $this->get_compare( $clause ); + $start_of_week = $this->get_start_of_week( $clause ); + $inclusive = ! empty( $clause['inclusive'] ); + + // Assign greater-than and less-than values. + $lt = '<'; + $gt = '>'; + + // Also equal-to if inclusive. + if ( true === $inclusive ) { + $lt .= '='; + $gt .= '='; + } + + // Pattern is always string. + $pattern = '%s'; + + // Range queries. + if ( ! empty( $clause['after'] ) ) { + $where[] = $db->prepare( "{$column} {$gt} {$pattern}", $this->build_mysql_datetime( $clause['after'], ! $inclusive, $now ) ); + } + + if ( ! empty( $clause['before'] ) ) { + $where[] = $db->prepare( "{$column} {$lt} {$pattern}", $this->build_mysql_datetime( $clause['before'], $inclusive, $now ) ); + } + + // Specific value queries. + if ( isset( $clause['year'] ) && $value = $this->build_numeric_value( $compare, $clause['year'] ) ) { + $where[] = "YEAR( {$column} ) {$compare} {$value}"; + } + + if ( isset( $clause['month'] ) && $value = $this->build_numeric_value( $compare, $clause['month'] ) ) { + $where[] = "MONTH( {$column} ) {$compare} {$value}"; + } elseif ( isset( $clause['monthnum'] ) && $value = $this->build_numeric_value( $compare, $clause['monthnum'] ) ) { + $where[] = "MONTH( {$column} ) {$compare} {$value}"; + } + + if ( isset( $clause['week'] ) && false !== ( $value = $this->build_numeric_value( $compare, $clause['week'] ) ) ) { + $where[] = $this->build_mysql_week( $column, $start_of_week ) . " {$compare} {$value}"; + } elseif ( isset( $clause['w'] ) && false !== ( $value = $this->build_numeric_value( $compare, $clause['w'] ) ) ) { + $where[] = $this->build_mysql_week( $column, $start_of_week ) . " {$compare} {$value}"; + } + + if ( isset( $clause['dayofyear'] ) && $value = $this->build_numeric_value( $compare, $clause['dayofyear'] ) ) { + $where[] = "DAYOFYEAR( {$column} ) {$compare} {$value}"; + } + + if ( isset( $clause['day'] ) && $value = $this->build_numeric_value( $compare, $clause['day'] ) ) { + $where[] = "DAYOFMONTH( {$column} ) {$compare} {$value}"; + } + + if ( isset( $clause['dayofweek'] ) && $value = $this->build_numeric_value( $compare, $clause['dayofweek'] ) ) { + $where[] = "DAYOFWEEK( {$column} ) {$compare} {$value}"; + } + + if ( isset( $clause['dayofweek_iso'] ) && $value = $this->build_numeric_value( $compare, $clause['dayofweek_iso'] ) ) { + $where[] = "WEEKDAY( {$column} ) + 1 {$compare} {$value}"; + } + + // Straight value compare + if ( isset( $clause['value'] ) ) { + $value = $this->build_value( $compare, $clause['value'] ); + $where[] = "{$column} {$compare} $value"; + } + + // Hour/Minute/Second + if ( isset( $clause['hour'] ) || isset( $clause['minute'] ) || isset( $clause['second'] ) ) { + + // Avoid notices. + foreach ( array( 'hour', 'minute', 'second' ) as $unit ) { + if ( ! isset( $clause[ $unit ] ) ) { + $clause[ $unit ] = null; + } + } + + // Time query. + $time_query = $this->build_time_query( $column, $compare, $clause['hour'], $clause['minute'], $clause['second'] ); + + // Maybe add to where_parts + if ( ! empty( $time_query ) ) { + $where[] = $time_query; + } + } + + // Return join/where array + return array( + 'join' => array(), + 'where' => $where, + ); + } +} diff --git a/src/Database/Parsers/In.php b/src/Database/Parsers/In.php new file mode 100644 index 0000000..1afc608 --- /dev/null +++ b/src/Database/Parsers/In.php @@ -0,0 +1,279 @@ +caller( 'get_columns', array( 'in' => true ), 'and', 'name' ); + + foreach ( $ins as $in ) { + $first_keys[] = "{$in}__in"; + } + + return $first_keys; + } + + /** + * Generate SQL WHERE clauses for a first-order query clause. + * + * "First-order" means that it's an array with a 'key' or 'value'. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` + * parameters. If not provided, a key will be generated automatically. + * @return array { + * Array containing WHERE SQL clauses to append to a first-order query. + * + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Get the database interface. + $db = $this->get_db(); + + // Get __in's in clause. + $ins = $this->get_first_order_clauses( $clause ); + + // Bail if no database or first-order clauses. + if ( empty( $db ) || empty( $ins ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } + + // Default where array. + $where = array(); + + // Loop through ins. + foreach ( $ins as $column => $query_var ) { + + // Get pattern and aliased name + $name = str_replace( '__not_in', '', $column ); + $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $name ); + + // Parse query var + $values = $this->caller( 'parse_query_var', $clause, $column ); + + // Parse item for an IN clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $where_id = $column; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // Return join/where array. + return array( + 'join' => array(), + 'where' => $where + ); + } + + /** + * Parse join/where subclauses for all columns. + * + * Used by parse_where_join(). + * + * @since 3.0.0 + * @return array + */ + private function parse_where_columns( $query_vars = array() ) { + + // Defaults + $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; + } + + // 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 ( $all_columns as $column ) { + + // 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 = $name; + $values = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item for direct clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $where_id = "{$where_id}__in"; + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // __in + if ( true === $column->in ) { + + // Parse query var + $where_id = "{$name}__in"; + $values = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item for an IN clause. + if ( false !== $values ) { + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $where_id = $name; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $in_values = $this->get_in_sql( $name, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} IN {$in_values}"; + } + } + } + + // __not_in + if ( true === $column->not_in ) { + + // Parse query var + $where_id = "{$name}__not_in"; + $values = $this->parse_query_var( $query_vars, $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 = "{$aliased} != {$pattern}"; + $where_id = $name; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $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 = "{$name}_query"; + $column_date = $this->parse_query_var( $query_vars, $where_id ); + + // Parse item + if ( false !== $column_date ) { + + // Single + if ( 1 === count( $column_date ) ) { + $where['date_query'][] = array( + 'column' => $aliased, + 'before' => reset( $column_date ), + 'inclusive' => true + ); + + // Multi + } else { + + // Auto-fill column if empty + if ( empty( $column_date['column'] ) ) { + $column_date['column'] = $aliased; + } + + // Add clause to date query + $where['date_query'][] = $column_date; + } + } + } + } + + // Return join/where subclauses + return array( + 'join' => array(), + 'where' => $where + ); + } + +} diff --git a/src/Database/Parsers/Meta.php b/src/Database/Parsers/Meta.php new file mode 100644 index 0000000..d9da69b --- /dev/null +++ b/src/Database/Parsers/Meta.php @@ -0,0 +1,550 @@ +' + * - '>=' + * - '<' + * - '<=' + * - 'LIKE' + * - 'NOT LIKE' + * - 'IN' + * - 'NOT IN' + * - 'BETWEEN' + * - 'NOT BETWEEN' + * - 'REGEXP' + * - 'NOT REGEXP' + * - 'RLIKE' + * - 'EXISTS' + * - 'NOT EXISTS' + * Default is 'IN' when `$value` is an array, '=' otherwise. + * @type string $type MySQL data type that the meta_value column will be CAST to for + * comparisons. Accepts: + * - 'NUMERIC' + * - 'BINARY' + * - 'CHAR' + * - 'DATE' + * - 'DATETIME' + * - 'DECIMAL' + * - 'SIGNED' + * - 'TIME' + * - 'UNSIGNED' + * Default is 'CHAR'. + * } + * } + */ +class Meta { + + use \BerlinDB\Database\Traits\Parser { + get_sql as get_trait_sql; + } + + /** + * Database table to query for the metadata. + * + * @since 3.0.0 + * @var string + */ + public $meta_table = ''; + + /** + * Column in meta_table that represents the ID of the object the metadata + * belongs to. + * + * @since 3.0.0 + * @var string + */ + public $meta_column = ''; + + /** + * Database table where the metadata objects are stored. + * + * @since 3.0.0 + * @var string + */ + public $primary_table = ''; + + /** + * Column in primary_table that represents the ID of the object. + * + * @since 3.0.0 + * @var string + */ + public $primary_column = ''; + + /** + * A flat list of table aliases used in JOIN clauses. + * + * @since 3.0.0 + * @var array + */ + public $table_aliases = array(); + + /** + * Determines and validates what first-order keys to use. + * + * Use first $first_keys if passed and valid. + * + * @since 3.0.0 + * + * @param array $first_keys Array of first-order keys. + * + * @return array The first-order keys. + */ + protected function get_first_keys( $first_keys = array() ) { + $first_keys = array( + 'key', + 'value', + 'meta_query' + ); + + return $first_keys; + } + + /** + * Constructs a meta query based on 'meta_*' query vars + * + * @since 3.0.0 + * + * @param array $qv The query variables. + * @param Query $caller Query class. + */ + public function parse_query_vars( $qv = array(), $caller = null ) { + + // Default empty query. + $meta_query = array(); + + /* + * For orderby=meta_value to work correctly, simple query needs to be + * first (so that its table join is against an unaliased meta table) and + * needs to be its own clause (so it doesn't interfere with the logic of + * the rest of the meta_query). + */ + $simple_keys = array( 'key', 'compare', 'type', 'compare_key', 'type_key' ); + $simple_meta_query = array(); + + // Loop through simple keys. + foreach ( $simple_keys as $key ) { + if ( ! empty( $qv[ "meta_{$key}" ] ) ) { + $simple_meta_query[ $key ] = $qv[ "meta_{$key}" ]; + } + } + + // Back-compat for setting 'meta_value' = '' by default. + if ( isset( $qv['meta_value'] ) && ( '' !== $qv['meta_value'] ) && ( ! is_array( $qv['meta_value'] ) || $qv['meta_value'] ) ) { + $simple_meta_query['value'] = $qv['meta_value']; + } + + // Already exists? + $existing_meta_query = isset( $qv['meta_query'] ) && is_array( $qv['meta_query'] ) + ? $qv['meta_query'] + : array(); + + // Combine via "AND" relation. + if ( ! empty( $simple_meta_query ) && ! empty( $existing_meta_query ) ) { + $meta_query = array( + 'relation' => 'AND', + $simple_meta_query, + $existing_meta_query, + ); + + // Only primary. + } elseif ( ! empty( $simple_meta_query ) ) { + $meta_query = array( + $simple_meta_query, + ); + + // Only existing. + } elseif ( ! empty( $existing_meta_query ) ) { + $meta_query = $existing_meta_query; + } + + // Setup + $this->__construct( $meta_query, $caller ); + } + + /** + * Generates SQL clauses to be appended to a main query. + * + * @since 3.0.0 + * + * @param string $type Type of object. + * @param string $primary_table Primary table for the object being filtered. + * @param string $primary_column Primary column for the filtered object in $primary_table. + * + * @return string[]|false { + * Array containing JOIN and WHERE SQL clauses to append to the main query, + * or false if no table exists for the requested type. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql( $type = '', $primary_table = '', $primary_column = '' ) { + + // Attempt to get the secondary table. + $meta_table = _get_meta_table( $type ); + + // Bail if no object table. + if ( empty( $meta_table ) ) { + return false; + } + + // Aliases. + $this->table_aliases = array(); + + // Meta. + $this->meta_table = $this->sanitize_table_name( $meta_table ); + $this->meta_column = $this->sanitize_column_name( "{$type}_id" ); + + // Primary. + $this->primary_table = $this->sanitize_table_name( $primary_table ); + $this->primary_column = $this->sanitize_column_name( $primary_column ); + + // Return parent. + return $this->get_trait_sql( $type, $primary_table, $primary_column ); + } + + /** + * Generate SQL JOIN and WHERE clauses for a first-order query clause. + * + * "First-order" means that it's an array with a 'key' or 'value'. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` + * parameters. If not provided, a key will be generated automatically. + * @return string[] { + * Array containing JOIN and WHERE SQL clauses to append to a first-order query. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Get the database interface. + $db = $this->get_db(); + + // Default return value. + $retval = array( + 'where' => array(), + 'join' => array(), + ); + + return $retval; + + // Default column. + $column = 'meta_key'; + + $hello = $this->get_first_order_clauses( $clause ); + + //var_dump( $hello ); + + /** Compare ***********************************************************/ + + if ( isset( $clause['compare'] ) ) { + $clause['compare'] = strtoupper( $clause['compare'] ); + } else { + $clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) + ? 'IN' + : '='; + } + + // Operators. + $non_numeric_operators = wp_filter_object_list( $this->operators, array( 'numeric' => false ), 'AND', 'compare' ); + $numeric_operators = wp_filter_object_list( $this->operators, array( 'numeric' => true ), 'AND', 'compare' ); + + // Fallback if bad comparison. + if ( ! in_array( $clause['compare'], $non_numeric_operators, true ) && ! in_array( $clause['compare'], $numeric_operators, true ) ) { + $clause['compare'] = '='; + } + + $meta_compare = $clause['compare']; + + /** Compare Key *******************************************************/ + + if ( isset( $clause['compare_key'] ) ) { + $clause['compare_key'] = strtoupper( $clause['compare_key'] ); + } else { + $clause['compare_key'] = isset( $clause['key'] ) && is_array( $clause['key'] ) + ? 'IN' + : '='; + } + + if ( ! in_array( $clause['compare_key'], $non_numeric_operators, true ) ) { + $clause['compare_key'] = '='; + } + + $meta_compare_key = $clause['compare_key']; + + /** JOIN clause *******************************************************/ + + $join = ''; + + /** + * We prefer to avoid joins if possible. + * + * Look for an existing join compatible with this clause. + */ + $alias = $this->find_compatible_table_alias( $clause, $parent_query ); + + // No compatible alias, sooo make one! + if ( false === $alias ) { + $i = count( $this->table_aliases ); + $alias = ! empty( $i ) + ? 'mt' . $i + : $this->meta_table; + + // JOIN clauses for NOT EXISTS have their own syntax. + if ( 'NOT EXISTS' === $meta_compare ) { + $join .= " LEFT JOIN {$this->meta_table}"; + $join .= ! empty( $i ) + ? " AS {$alias}" + : ''; + + if ( 'LIKE' === $meta_compare_key ) { + $join .= $db->prepare( " ON ( {$this->primary_table}.{$this->primary_column} = {$alias}.{$this->meta_column} AND {$alias}.{$column} LIKE %s )", '%' . $db->esc_like( $clause['key'] ) . '%' ); + } else { + $join .= $db->prepare( " ON ( {$this->primary_table}.{$this->primary_column} = {$alias}.{$this->meta_column} AND {$alias}.{$column} = %s )", $clause['key'] ); + } + + // All other JOIN clauses. + } else { + $join .= " INNER JOIN {$this->meta_table}"; + $join .= ! empty( $i ) + ? " AS {$alias}" + : ''; + $join .= " ON ( {$this->primary_table}.{$this->primary_column} = {$alias}.{$this->meta_column} )"; + } + + // Add to possible aliases. + $this->table_aliases[] = $alias; + + // Add to return value. + $retval['join'][] = $join; + } + + // Save the alias to this clause, for future siblings to find. + $clause['alias'] = $alias; + + // Determine the data type. + $_meta_type = isset( $clause['type'] ) + ? $clause['type'] + : ''; + $meta_type = $this->get_cast_for_type( $_meta_type ); + $clause['cast'] = $meta_type; + + /** + * Fallback for clause keys is the table alias. + * + * Key must be a string. + */ + if ( is_int( $clause_key ) || ! $clause_key ) { + $clause_key = $clause['alias']; + } + + // Ensure unique clause keys, so none are overwritten. + $iterator = 1; + $clause_key_base = $clause_key; + + while ( isset( $this->clauses[ $clause_key ] ) ) { + $clause_key = $clause_key_base . '-' . $iterator; + $iterator++; + } + + // Store the clause in our flat array. + $this->clauses[ $clause_key ] =& $clause; + + /** WHERE clause ******************************************************/ + + // meta_key. + if ( array_key_exists( 'key', $clause ) ) { + if ( 'NOT EXISTS' === $meta_compare ) { + $retval['where'][] = "{$alias}.{$this->meta_column} IS NULL"; + + } else { + + // Get negative operators. + $neg = wp_filter_object_list( $this->operators, array( 'positive' => false ), 'AND', 'compare' ); + + /** + * In joined clauses negative operators have to be nested into a + * NOT EXISTS clause and flipped, to avoid returning records with + * matching post IDs but different meta keys. Here we prepare the + * nested clause. + */ + if ( in_array( $meta_compare_key, $neg, true ) ) { + + // Negative clauses may be reused. + $i = count( $this->table_aliases ); + $subquery_alias = ! empty( $i ) + ? 'mt' . $i + : $this->meta_table; + + // Add to table_aliases. + $this->table_aliases[] = $subquery_alias; + + // Setup start & end of meta compare SQL. + $meta_compare_string_start = 'NOT EXISTS ('; + $meta_compare_string_start .= "SELECT 1 FROM {$db->postmeta} {$subquery_alias} "; + $meta_compare_string_start .= "WHERE {$subquery_alias}.post_ID = {$alias}.post_ID "; + $meta_compare_string_end = 'LIMIT 1'; + $meta_compare_string_end .= ')'; + } + + // Default empty where. + $where = ''; + + // Which compare? + switch ( $meta_compare_key ) { + case '=': + case 'EXISTS': + $where = $db->prepare( "{$alias}.{$column} = %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + break; + + case 'LIKE': + $meta_compare_value = '%' . $db->esc_like( trim( $clause['key'] ) ) . '%'; + $where = $db->prepare( "{$alias}.{$column} LIKE %s", $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + break; + + case 'IN': + $meta_compare_string = "{$alias}.{$column} IN (" . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ')'; + $where = $db->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + break; + + case 'RLIKE': + case 'REGEXP': + $operator = $meta_compare_key; + if ( isset( $clause['type_key'] ) && 'BINARY' === strtoupper( $clause['type_key'] ) ) { + $cast = 'BINARY'; + } else { + $cast = ''; + } + $where = $db->prepare( "{$alias}.{$column} {$operator} {$cast} %s", trim( $clause['key'] ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + break; + + case '!=': + case 'NOT EXISTS': + $meta_compare_string = $meta_compare_string_start . "AND {$subquery_alias}.{$column} = %s " . $meta_compare_string_end; + $where = $db->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + break; + + case 'NOT LIKE': + $meta_compare_string = $meta_compare_string_start . "AND {$subquery_alias}.{$column} LIKE %s " . $meta_compare_string_end; + $meta_compare_value = '%' . $db->esc_like( trim( $clause['key'] ) ) . '%'; + $where = $db->prepare( $meta_compare_string, $meta_compare_value ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + break; + case 'NOT IN': + $array_subclause = '(' . substr( str_repeat( ',%s', count( $clause['key'] ) ), 1 ) . ') '; + $meta_compare_string = $meta_compare_string_start . "AND {$subquery_alias}.{$column} IN " . $array_subclause . $meta_compare_string_end; + $where = $db->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + break; + + case 'NOT REGEXP': + $operator = $meta_compare_key; + + if ( isset( $clause['type_key'] ) && ( 'BINARY' === strtoupper( $clause['type_key'] ) ) ) { + $cast = 'BINARY'; + } else { + $cast = ''; + } + + $meta_compare_string = $meta_compare_string_start . "AND {$subquery_alias}.{$column} REGEXP {$cast} %s " . $meta_compare_string_end; + $where = $db->prepare( $meta_compare_string, $clause['key'] ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + break; + } + + // Only add if non-empty. + if ( ! empty( $where ) ) { + $retval['where'][] = $where; + } + } + } + + // meta_value. + if ( array_key_exists( 'value', $clause ) ) { + $where = $this->build_value( $meta_compare, $clause['value'], '%s' ); + + // Not empty, so maybe cast... + if ( ! empty( $where ) ) { + + // Set column to meta_value + $column = 'meta_value'; + + // Default. + if ( 'CHAR' === $meta_type ) { + $retval['where'][] = "{$alias}.{$column} {$meta_compare} {$where}"; + + // CAST(). + } else { + $retval['where'][] = "CAST({$alias}.{$column} AS {$meta_type}) {$meta_compare} {$where}"; + } + } + } + + /* + * Multiple WHERE clauses (for meta_key and meta_value) should + * be joined in parentheses. + */ + if ( 1 < count( $retval['where'] ) ) { + $retval['where'] = array( '( ' . implode( ' AND ', $retval['where'] ) . ' )' ); + } + + // Return join/where clauses. + return $retval; + } +} diff --git a/src/Database/Parsers/NotIn.php b/src/Database/Parsers/NotIn.php new file mode 100644 index 0000000..18a9cee --- /dev/null +++ b/src/Database/Parsers/NotIn.php @@ -0,0 +1,123 @@ +caller( 'get_columns', array( 'not_in' => true ), 'and', 'name' ); + + foreach ( $not_ins as $not_in ) { + $first_keys[] = "{$not_in}__not_in"; + } + + return $first_keys; + } + + /** + * Generate SQL WHERE clauses for a first-order query clause. + * + * "First-order" means that it's an array with a 'key' or 'value'. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` + * parameters. If not provided, a key will be generated automatically. + * @return array { + * Array containing WHERE SQL clauses to append to a first-order query. + * + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Get the database interface. + $db = $this->get_db(); + + // Get __in's in clause. + $ins = $this->get_first_order_clauses( $clause ); + + // Bail if no database or first-order clauses. + if ( empty( $db ) || empty( $ins ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } + + // Default where array. + $where = array(); + + // Loop through ins. + foreach ( $ins as $column => $query_var ) { + + // Get pattern and aliased name + $name = str_replace( '__not_in', '', $column ); + $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $name ); + + // Parse query var + $values = $this->caller( 'parse_query_var', $clause, $column ); + + // Skip if parse fails. + if ( false === $values ) { + continue; + } + + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} != {$pattern}"; + $where_id = $column; + $column_value = reset( $values ); + $where[ $where_id ] = $db->prepare( $statement, $column_value ); + + // Implode + } else { + $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); + $where[ $where_id ] = "{$aliased} NOT IN {$in_values}"; + } + } + + // Return join/where array. + return array( + 'join' => array(), + 'where' => $where + ); + } +} diff --git a/src/Database/Parsers/Search.php b/src/Database/Parsers/Search.php new file mode 100644 index 0000000..8eb1920 --- /dev/null +++ b/src/Database/Parsers/Search.php @@ -0,0 +1,181 @@ +caller( 'get_columns', array( array( 'searchable' => true ), 'and', 'name' ) ); + + foreach ( $not_ins as $not_in ) { + $first_keys[] = "{$not_in}_search"; + } + + return $first_keys; + } + + /** + * Generate SQL WHERE clauses for a first-order query clause. + * + * "First-order" means that it's an array with a 'key' or 'value'. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` + * parameters. If not provided, a key will be generated automatically. + * @return array { + * Array containing WHERE SQL clauses to append to a first-order query. + * + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Bail if no search + if ( empty( $this->first_keys ) || empty( $clause['search'] ) ) { + return array( + 'join' => array(), + 'where' => array() + ); + } + + // Default value + $where = array(); + + // Default to all searchable columns + $search_columns = $this->first_keys; + + // Intersect against known searchable columns + if ( ! empty( $clause['search_columns'] ) ) { + $search_columns = array_intersect( + $clause['search_columns'], + $this->first_keys + ); + } + + // Filter search columns + $search_columns = $this->filter_search_columns( $search_columns ); + + // Add search query clause + $where['search'] = $this->get_search_sql( $clause['search'], $search_columns ); + + // Return join/where + return array( + 'join' => array(), + 'where' => $where + ); + } + + /** + * Used internally to generate an SQL string for searching across multiple + * columns. + * + * @since 1.0.0 + * @since 3.0.0 Bail early if parameters are empty. + * + * @param string $string Search string. + * @param array $column_names Columns to search. + * @return string Search SQL. + */ + private function get_search_sql( $string = '', $column_names = array() ) { + + // Bail if malformed string + if ( empty( $string ) || ! is_scalar( $string ) ) { + return ''; + } + + // Bail if malformed columns + if ( empty( $column_names ) || ! is_array( $column_names ) ) { + return ''; + } + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return ''; + } + + // Array or String + $like = ( false !== strpos( $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[] = $db->prepare( "{$column} LIKE %s", $like ); + } + + // Concatinate + $values = implode( ' OR ', $searches ); + $retval = '(' . $values . ')'; + + // Return the clause + return $retval; + } + + /** + * Filters the columns to search by. + * + * @since 3.0.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 3.0.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->caller->item_name_plural}_search_columns" ), + array( + $search_columns, + &$this + ) + ); + } +} diff --git a/src/Database/Queries/Compare.php b/src/Database/Queries/Compare.php deleted file mode 100644 index 78375b2..0000000 --- a/src/Database/Queries/Compare.php +++ /dev/null @@ -1,180 +0,0 @@ -', - '>=', - '<', - '<=', - 'LIKE', - 'NOT LIKE', - 'IN', - 'NOT IN', - 'BETWEEN', - 'NOT BETWEEN', - 'EXISTS', - 'NOT EXISTS', - 'REGEXP', - 'NOT REGEXP', - 'RLIKE', - ); - - // IN and BETWEEN - const IN_BETWEEN_COMPARES = array( - 'IN', - 'NOT IN', - 'BETWEEN', - 'NOT BETWEEN' - ); - - /** - * Generate SQL WHERE clauses for a first-order query clause. - * - * "First-order" means that it's an array with a 'key' or 'value'. - * - * @since 1.0.0 - * - * @param array $clause Query clause (passed by reference). - * @param array $parent_query Parent query array. - * @param string $clause_key Optional. The array key used to name the clause in the original `$meta_query` - * parameters. If not provided, a key will be generated automatically. - * @return array { - * Array containing WHERE SQL clauses to append to a first-order query. - * - * @type string $where SQL fragment to append to the main WHERE clause. - * } - */ - public function get_sql_for_clause( &$clause, $parent_query, $clause_key = '' ) { - global $wpdb; - - // Default chunks - $sql_chunks = array( - 'where' => array(), - 'join' => array(), - ); - - // Maybe format compare clause - if ( isset( $clause['compare'] ) ) { - $clause['compare'] = strtoupper( $clause['compare'] ); - - // Or set compare clause based on value - } else { - $clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) - ? 'IN' - : '='; - } - - // Fallback to equals - if ( ! in_array( $clause['compare'], self::ALL_COMPARES, true ) ) { - $clause['compare'] = '='; - } - - // Uppercase or equals - if ( isset( $clause['compare_key'] ) && ( 'LIKE' === strtoupper( $clause['compare_key'] ) ) ) { - $clause['compare_key'] = strtoupper( $clause['compare_key'] ); - } else { - $clause['compare_key'] = '='; - } - - // Get comparison from clause - $compare = $clause['compare']; - - /** Build the WHERE clause ********************************************/ - - // Column name and value. - if ( array_key_exists( 'key', $clause ) && array_key_exists( 'value', $clause ) ) { - $column = sanitize_key( $clause['key'] ); - $value = $clause['value']; - - // IN or BETWEEN - if ( in_array( $compare, self::IN_BETWEEN_COMPARES, true ) ) { - if ( ! is_array( $value ) ) { - $value = preg_split( '/[,\s]+/', $value ); - } - - // Anything else - } else { - $value = trim( $value ); - } - - // Format WHERE from compare value(s) - switch ( $compare ) { - case 'IN': - case 'NOT IN': - $compare_string = '(' . substr( str_repeat( ',%s', count( $value ) ), 1 ) . ')'; - $where = $wpdb->prepare( $compare_string, $value ); - break; - - case 'BETWEEN': - case 'NOT BETWEEN': - $value = array_slice( $value, 0, 2 ); - $where = $wpdb->prepare( '%s AND %s', $value ); - break; - - case 'LIKE': - case 'NOT LIKE': - $value = '%' . $wpdb->esc_like( $value ) . '%'; - $where = $wpdb->prepare( '%s', $value ); - break; - - // EXISTS with a value is interpreted as '='. - case 'EXISTS': - $compare = '='; - $where = $wpdb->prepare( '%s', $value ); - break; - - // 'value' is ignored for NOT EXISTS. - case 'NOT EXISTS': - $where = ''; - break; - - default: - $where = $wpdb->prepare( '%s', $value ); - break; - - } - - // Maybe add column, compare, & where to chunks - if ( ! empty( $where ) ) { - $sql_chunks['where'][] = "{$column} {$compare} {$where}"; - } - } - - /* - * Multiple WHERE clauses (for meta_key and meta_value) should - * be joined in parentheses. - */ - if ( 1 < count( $sql_chunks['where'] ) ) { - $sql_chunks['where'] = array( '( ' . implode( ' AND ', $sql_chunks['where'] ) . ' )' ); - } - - // Return - return $sql_chunks; - } -} \ No newline at end of file diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php deleted file mode 100644 index 4ea7bcb..0000000 --- a/src/Database/Queries/Date.php +++ /dev/null @@ -1,1327 +0,0 @@ -', - '>=', - '<', - '<=', - 'IN', - 'NOT IN', - 'BETWEEN', - 'NOT BETWEEN' - ); - - /** - * Supported multi-value comparison types - * - * @since 1.1.0 - * @var array - */ - public $multi_value_keys = array( - 'IN', - 'NOT IN', - 'BETWEEN', - 'NOT BETWEEN' - ); - - /** - * Supported relation types - * - * @since 1.1.0 - * @var array - */ - public $relation_keys = array( - 'OR', - 'AND' - ); - - /** - * Constructor. - * - * Time-related parameters that normally require integer values ('year', 'month', 'week', 'dayofyear', 'day', - * 'dayofweek', 'dayofweek_iso', 'hour', 'minute', 'second') accept arrays of integers for some values of - * 'compare'. When 'compare' is 'IN' or 'NOT IN', arrays are accepted; when 'compare' is 'BETWEEN' or 'NOT - * BETWEEN', arrays of two valid values are required. See individual argument descriptions for accepted values. - * - * @since 1.0.0 - * - * @param array $date_query { - * Array of date query clauses. - * - * @type array ...$0 { - * @type string $column Optional. The column to query against. If undefined, inherits the value of - * 'date_created'. Accepts 'date_created', 'date_created_gmt', - * 'post_modified','post_modified_gmt', 'comment_date', 'comment_date_gmt'. - * Default 'date_created'. - * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', '<', '<=', - * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. Default '='. - * @type string $relation Optional. The boolean relationship between the date queries. Accepts 'OR' or 'AND'. - * Default 'OR'. - * @type int|array $start_of_week Optional. Day that week starts on. Accepts numbers 0-6 - * (0 = Sunday, 1 is Monday). Default 0. - * @type array ...$0 { - * Optional. An array of first-order clause parameters, or another fully-formed date query. - * - * @type array|string $before { - * Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string, - * or array of 'year', 'month', 'day' values. - * - * @type string $year The four-digit year. Default empty. Accepts any four-digit year. - * @type string $month Optional when passing array.The month of the year. - * Default (string:empty)|(array:1). Accepts numbers 1-12. - * @type string $day Optional when passing array.The day of the month. - * Default (string:empty)|(array:1). Accepts numbers 1-31. - * } - * @type array|string $after { - * Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string, - * or array of 'year', 'month', 'day' values. - * - * @type string $year The four-digit year. Accepts any four-digit year. Default empty. - * @type string $month Optional when passing array. The month of the year. Accepts numbers 1-12. - * Default (string:empty)|(array:12). - * @type string $day Optional when passing array.The day of the month. Accepts numbers 1-31. - * Default (string:empty)|(array:last day of month). - * } - * @type string $column Optional. Used to add a clause comparing a column other than the - * column specified in the top-level `$column` parameter. Accepts - * 'date_created', 'date_created_gmt', 'post_modified', 'post_modified_gmt', - * 'comment_date', 'comment_date_gmt'. Default is the value of - * top-level `$column`. - * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', - * '<', '<=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'. 'IN', - * 'NOT IN', 'BETWEEN', and 'NOT BETWEEN'. Comparisons support - * arrays in some time-related parameters. Default '='. - * @type int|array $start_of_week Optional. Day that week starts on. Accepts numbers 0-6 - * (0 = Sunday, 1 is Monday). Default 0. - * @type bool $inclusive Optional. Include results from dates specified in 'before' or - * 'after'. Default false. - * @type int|array $year Optional. The four-digit year number. Accepts any four-digit year - * or an array of years if `$compare` supports it. Default empty. - * @type int|array $month Optional. The two-digit month number. Accepts numbers 1-12 or an - * array of valid numbers if `$compare` supports it. Default empty. - * @type int|array $week Optional. The week number of the year. Accepts numbers 0-53 or an - * array of valid numbers if `$compare` supports it. Default empty. - * @type int|array $dayofyear Optional. The day number of the year. Accepts numbers 1-366 or an - * array of valid numbers if `$compare` supports it. - * @type int|array $day Optional. The day of the month. Accepts numbers 1-31 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|array $dayofweek Optional. The day number of the week. Accepts numbers 1-7 (1 is - * Sunday) or an array of valid numbers if `$compare` supports it. - * Default empty. - * @type int|array $dayofweek_iso Optional. The day number of the week (ISO). Accepts numbers 1-7 - * (1 is Monday) or an array of valid numbers if `$compare` supports it. - * Default empty. - * @type int|array $hour Optional. The hour of the day. Accepts numbers 0-23 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|array $minute Optional. The minute of the hour. Accepts numbers 0-60 or an array - * of valid numbers if `$compare` supports it. Default empty. - * @type int|array $second Optional. The second of the minute. Accepts numbers 0-60 or an - * array of valid numbers if `$compare` supports it. Default empty. - * } - * } - * } - */ - public function __construct( $date_query = array() ) { - - // Bail if empty or not an array. - if ( empty( $date_query ) || ! is_array( $date_query ) ) { - return; - } - - // Set now, column, compare, relation, and start_of_week. - $this->now = $this->get_now( $date_query ); - $this->column = $this->get_column( $date_query ); - $this->compare = $this->get_compare( $date_query ); - $this->relation = $this->get_relation( $date_query ); - $this->start_of_week = $this->get_start_of_week( $date_query ); - - // Support for passing time-based keys in the top level of the array. - if ( ! isset( $date_query[0] ) ) { - $date_query = array( $date_query ); - } - - // Set the queries - $this->queries = $this->sanitize_query( $date_query ); - } - - /** - * Recursive-friendly query sanitizer. - * - * Ensures that each query-level clause has a 'relation' key, and that - * each first-order clause contains all the necessary keys from - * `$defaults`. - * - * @since 1.0.0 - * - * @param array $queries - * @param array $parent_query - * - * @return array Sanitized queries. - */ - public function sanitize_query( $queries = array(), $parent_query = array() ) { - - // Default return value. - $retval = array(); - - // Setup defaults. - $defaults = array( - 'now' => $this->get_now(), - 'column' => $this->get_column(), - 'compare' => $this->get_compare(), - 'relation' => $this->get_relation(), - 'start_of_week' => $this->get_start_of_week() - ); - - // Numeric keys should always have array values. - foreach ( $queries as $qkey => $qvalue ) { - if ( is_numeric( $qkey ) && ! is_array( $qvalue ) ) { - unset( $queries[ $qkey ] ); - } - } - - // Each query should have a value for each default key. - // Inherit from the parent when possible. - foreach ( $defaults as $dkey => $dvalue ) { - - // Skip if already set. - if ( isset( $queries[ $dkey ] ) ) { - continue; - } - - // Set the query. - if ( isset( $parent_query[ $dkey ] ) ) { - $queries[ $dkey ] = $parent_query[ $dkey ]; - } else { - $queries[ $dkey ] = $dvalue; - } - } - - // Validate the dates passed in the query. - if ( $this->is_first_order_clause( $queries ) ) { - $this->validate_date_values( $queries ); - } - - // Add queries to return array. - foreach ( $queries as $key => $q ) { - - // This is a first-order query. Trust the values and sanitize when building SQL. - if ( ! is_array( $q ) || in_array( $key, $this->time_keys, true ) ) { - $retval[ $key ] = $q; - - // Any array without a time key is another query, so we recurse. - } else { - $retval[] = $this->sanitize_query( $q, $queries ); - } - } - - // Return sanitized queries. - return $retval; - } - - /** - * Determine whether this is a first-order clause. - * - * Checks to see if the current clause has any time-related keys. - * If so, it's first-order. - * - * @since 1.0.0 - * - * @param array $query Query clause. - * - * @return bool True if this is a first-order clause. - */ - protected function is_first_order_clause( $query = array() ) { - $time_keys = array_intersect( $this->time_keys, array_keys( $query ) ); - - return ! empty( $time_keys ); - } - - /** - * Determines and validates what the current unix timestamp is. - * - * @since 1.1.0 - * - * @param array $query A date query or a date subquery. - * - * @return int The current unix timestamp. - */ - public function get_now( $query = array() ) { - - // Use now if passed - $retval = ! empty( $query['now'] ) && is_numeric( $query['now'] ) - ? absint( $query['now'] ) - : time(); - - return $retval; - } - - /** - * Determines and validates what comparison operator to use. - * - * @since 1.0.0 - * - * @param array $query A date query or a date subquery. - * - * @return string The comparison operator. - */ - public function get_column( $query = array() ) { - - // Use column if passed - $retval = ! empty( $query['column'] ) - ? esc_sql( $this->validate_column( $query['column'] ) ) - : $this->column; - - return $retval; - } - - /** - * Determines and validates what comparison operator to use. - * - * @since 1.0.0 - * - * @param array $query A date query or a date subquery. - * - * @return string The comparison operator. - */ - public function get_compare( $query = array() ) { - - // Compare must be in the allowed array - $retval = ! empty( $query['compare'] ) && in_array( $query['compare'], $this->comparison_keys, true ) - ? strtoupper( $query['compare'] ) - : $this->compare; - - return $retval; - } - - /** - * Determines and validates what relation to use. - * - * @since 1.0.0 - * - * @param array $query A date query or a date subquery. - * @return string The relation operator. - */ - public function get_relation( $query = array() ) { - - // Relation must be in the allowed array - $retval = ! empty( $query['relation'] ) && in_array( $query['relation'], $this->relation_keys, true ) - ? strtoupper( $query['relation'] ) - : $this->relation; - - return $retval; - } - - /** - * Determines and validates what start_of_week to use. - * - * @since 1.1.0 - * - * @param array $query A date query or a date subquery. - * - * @return int The comparison operator. - */ - public function get_start_of_week( $query = array() ) { - - // Use start of week if passed and valid - $retval = isset( $query['start_of_week'] ) && ( 6 >= (int) $query['start_of_week'] ) && ( 0 <= (int) $query['start_of_week'] ) - ? $query['start_of_week'] - : $this->start_of_week; - - return (int) $retval; - } - - /** - * Validates the given date_query values. - * - * Note that date queries with invalid date ranges are allowed to - * continue (though of course no items will be found for impossible dates). - * This method only generates debug notices for these cases. - * - * @since 1.0.0 - * - * @param array $date_query The date_query array. - * - * @return bool True if all values in the query are valid, false if one or more fail. - */ - public function validate_date_values( $date_query = array() ) { - - // Bail if empty. - if ( empty( $date_query ) ) { - return false; - } - - $valid = true; - - /* - * Validate 'before' and 'after' up front, then let the - * validation routine continue to be sure that all invalid - * values generate errors too. - */ - if ( array_key_exists( 'before', $date_query ) && is_array( $date_query['before'] ) ) { - $valid = $this->validate_date_values( $date_query['before'] ); - } - - if ( array_key_exists( 'after', $date_query ) && is_array( $date_query['after'] ) ) { - $valid = $this->validate_date_values( $date_query['after'] ); - } - - // Values are passthroughs. - if ( array_key_exists( 'value', $date_query ) ) { - $valid = true; - } - - // Array containing all min-max checks. - $min_max_checks = array(); - - // Days per year. - if ( array_key_exists( 'year', $date_query ) ) { - /* - * If a year exists in the date query, we can use it to get the days. - * If multiple years are provided (as in a BETWEEN), use the first one. - */ - if ( is_array( $date_query['year'] ) ) { - $_year = reset( $date_query['year'] ); - } else { - $_year = $date_query['year']; - } - - $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 { - $max_days_of_year = 366; - } - - // Days of year. - $min_max_checks['dayofyear'] = array( - 'min' => 1, - 'max' => $max_days_of_year, - ); - - // Days per week. - $min_max_checks['dayofweek'] = array( - 'min' => 1, - 'max' => 7, - ); - - // Days per week. - $min_max_checks['dayofweek_iso'] = array( - 'min' => 1, - 'max' => 7, - ); - - // Months per year. - $min_max_checks['month'] = array( - 'min' => 1, - 'max' => 12, - ); - - // Weeks per year. - if ( isset( $_year ) ) { - /* - * If we have a specific year, use it to calculate number of weeks. - * Note: the number of weeks in a year is the date in which Dec 28 appears. - */ - $week_count = gmdate( 'W', gmmktime( 0, 0, 0, 12, 28, $_year ) ); - - // Otherwise set the week-count to a maximum of 53. - } else { - $week_count = 53; - } - - // Weeks per year. - $min_max_checks['week'] = array( - 'min' => 1, - 'max' => $week_count, - ); - - // Days per month. - $min_max_checks['day'] = array( - 'min' => 1, - 'max' => 31, - ); - - // Hours per day. - $min_max_checks['hour'] = array( - 'min' => 0, - 'max' => 23, - ); - - // Minutes per hour. - $min_max_checks['minute'] = array( - 'min' => 0, - 'max' => 59, - ); - - // Seconds per minute. - $min_max_checks['second'] = array( - 'min' => 0, - 'max' => 59, - ); - - // Loop through min/max checks. - foreach ( $min_max_checks as $key => $check ) { - - // Skip if not in query. - if ( ! array_key_exists( $key, $date_query ) ) { - continue; - } - - // Check for invalid values. - foreach ( (array) $date_query[ $key ] as $_value ) { - $is_between = ( $_value >= $check['min'] ) && ( $_value <= $check['max'] ); - - if ( ! is_numeric( $_value ) || empty( $is_between ) ) { - $valid = false; - } - } - } - - // Bail if invalid query. - if ( false === $valid ) { - return $valid; - } - - // Check what kinds of dates are being queried for. - $day_exists = array_key_exists( 'day', $date_query ) && is_numeric( $date_query['day'] ); - $month_exists = array_key_exists( 'month', $date_query ) && is_numeric( $date_query['month'] ); - $year_exists = array_key_exists( 'year', $date_query ) && is_numeric( $date_query['year'] ); - - // Checking at least day & month. - if ( ! empty( $day_exists ) && ! empty( $month_exists ) ) { - - // Check for year query, or fallback to 2012 (for flexibility). - $year = ! empty( $year_exists ) - ? $date_query['year'] - : '2012'; - - // Parse the date to check. - $to_check = sprintf( '%s-%s-%s', $year, $date_query['month'], $date_query['day'] ); - - // Check the date. - if ( ! $this->checkdate( $date_query['month'], $date_query['day'], $year, $to_check ) ) { - $valid = false; - } - } - - // Return if valid or not - return $valid; - } - - /** - * Validates a column name parameter. - * - * @since 1.0.0 - * - * @param string $column The user-supplied column name. - * - * @return string A validated column name value. - */ - public function validate_column( $column = '' ) { - return preg_replace( '/[^a-zA-Z0-9_$\.]/', '', $column ); - } - - /** - * Generate WHERE clause to be appended to a main query. - * - * @since 1.0.0 - * - * @return array MySQL WHERE clauses. - */ - public function get_sql() { - $sql = $this->get_sql_clauses(); - - /** - * Filters the date query clauses. - * - * @since 1.0.0 - * - * @param array $sql Clauses of the date query. - * @param Date $instance The Date query instance. - */ - return (array) apply_filters( 'get_date_sql', $sql, $this ); - } - - /** - * Generate SQL clauses to be appended to a main query. - * - * Called by the public Date::get_sql(), this method is abstracted - * out to maintain parity with the other Query classes. - * - * @since 1.0.0 - * - * @return array { - * Array containing JOIN and WHERE SQL clauses to append to the main query. - * - * @type string $join SQL fragment to append to the main JOIN clause. - * @type string $where SQL fragment to append to the main WHERE clause. - * } - */ - protected function get_sql_clauses() { - $sql = $this->get_sql_for_query( $this->queries ); - - if ( ! empty( $sql['where'] ) ) { - $sql['where'] = ' AND ' . $sql['where']; - } - - return (array) apply_filters( 'get_date_sql_clauses', $sql, $this ); - } - - /** - * Generate SQL clauses for a single query array. - * - * If nested subqueries are found, this method recurses the tree to - * produce the properly nested SQL. - * - * @since 1.0.0 - * - * @param array $query Query to parse. - * @param int $depth Optional. Number of tree levels deep we currently are. - * Used to calculate indentation. Default 0. - * @return array { - * Array containing JOIN and WHERE SQL clauses to append to a single query array. - * - * @type string $join SQL fragment to append to the main JOIN clause. - * @type string $where SQL fragment to append to the main WHERE clause. - * } - */ - protected function get_sql_for_query( $query = array(), $depth = 0 ) { - $sql_chunks = array( - 'join' => array(), - 'where' => array(), - ); - - $sql = array( - 'join' => '', - 'where' => '', - ); - - $indent = ''; - for ( $i = 0; $i < $depth; $i++ ) { - $indent .= ' '; - } - - foreach ( $query as $key => $clause ) { - - if ( 'relation' === $key ) { - $relation = $query['relation']; - - } elseif ( is_array( $clause ) ) { - - // This is a first-order clause. - if ( $this->is_first_order_clause( $clause ) ) { - - // Get clauses & where count - $clause_sql = $this->get_sql_for_clause( $clause, $query ); - $where_count = count( $clause_sql['where'] ); - - if ( 0 === $where_count ) { - $sql_chunks['where'][] = ''; - - } elseif ( 1 === $where_count ) { - $sql_chunks['where'][] = $clause_sql['where'][0]; - - } else { - $sql_chunks['where'][] = '( ' . implode( ' AND ', $clause_sql['where'] ) . ' )'; - } - - $sql_chunks['join'] = array_merge( $sql_chunks['join'], $clause_sql['join'] ); - - // This is a subquery, so we recurse. - } else { - $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 ); - - $sql_chunks['where'][] = $clause_sql['where']; - $sql_chunks['join'][] = $clause_sql['join']; - } - } - } - - // Filter to remove empties. - $sql_chunks['join'] = array_filter( $sql_chunks['join'] ); - $sql_chunks['where'] = array_filter( $sql_chunks['where'] ); - - if ( empty( $relation ) ) { - $relation = 'AND'; - } - - // Filter duplicate JOIN clauses and combine into a single string. - if ( ! empty( $sql_chunks['join'] ) ) { - $sql['join'] = implode( ' ', array_unique( $sql_chunks['join'] ) ); - } - - // Generate a single WHERE clause with proper brackets and indentation. - if ( ! empty( $sql_chunks['where'] ) ) { - $sql['where'] = '( ' . "\n " . $indent . implode( ' ' . "\n " . $indent . $relation . ' ' . "\n " . $indent, $sql_chunks['where'] ) . "\n" . $indent . ')'; - } - - // Filter and return - return (array) apply_filters( 'get_date_sql_for_query', $sql, $query, $depth, $this ); - } - - /** - * Turns a first-order date query into SQL for a WHERE clause. - * - * @since 1.0.0 - * - * @param array $query Date query clause. - * @param array $parent_query Parent query of the current date query. - * - * @return array { - * Array containing JOIN and WHERE SQL clauses to append to the main query. - * - * @type string $join SQL fragment to append to the main JOIN clause. - * @type string $where SQL fragment to append to the main WHERE clause. - * } - */ - 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(); - - // Get first-order clauses - $now = $this->get_now( $query ); - $column = $this->get_column( $query ); - $compare = $this->get_compare( $query ); - $start_of_week = $this->get_start_of_week( $query ); - $inclusive = ! empty( $query['inclusive'] ); - - // Assign greater-than and less-than values. - $lt = '<'; - $gt = '>'; - - if ( true === $inclusive ) { - $lt .= '='; - $gt .= '='; - } - - // Range queries. - if ( ! empty( $query['after'] ) ) { - $where_parts[] = $db->prepare( "{$column} {$gt} %s", $this->build_mysql_datetime( $query['after'], ! $inclusive, $now ) ); - } - - if ( ! empty( $query['before'] ) ) { - $where_parts[] = $db->prepare( "{$column} {$lt} %s", $this->build_mysql_datetime( $query['before'], $inclusive, $now ) ); - } - - // Specific value queries. - if ( isset( $query['year'] ) && $value = $this->build_numeric_value( $compare, $query['year'] ) ) { - $where_parts[] = "YEAR( {$column} ) {$compare} {$value}"; - } - - if ( isset( $query['month'] ) && $value = $this->build_numeric_value( $compare, $query['month'] ) ) { - $where_parts[] = "MONTH( {$column} ) {$compare} {$value}"; - } elseif ( isset( $query['monthnum'] ) && $value = $this->build_numeric_value( $compare, $query['monthnum'] ) ) { - $where_parts[] = "MONTH( {$column} ) {$compare} {$value}"; - } - - if ( isset( $query['week'] ) && false !== ( $value = $this->build_numeric_value( $compare, $query['week'] ) ) ) { - $where_parts[] = $this->build_mysql_week( $column, $start_of_week ) . " {$compare} {$value}"; - } elseif ( isset( $query['w'] ) && false !== ( $value = $this->build_numeric_value( $compare, $query['w'] ) ) ) { - $where_parts[] = $this->build_mysql_week( $column, $start_of_week ) . " {$compare} {$value}"; - } - - if ( isset( $query['dayofyear'] ) && $value = $this->build_numeric_value( $compare, $query['dayofyear'] ) ) { - $where_parts[] = "DAYOFYEAR( {$column} ) {$compare} {$value}"; - } - - if ( isset( $query['day'] ) && $value = $this->build_numeric_value( $compare, $query['day'] ) ) { - $where_parts[] = "DAYOFMONTH( {$column} ) {$compare} {$value}"; - } - - if ( isset( $query['dayofweek'] ) && $value = $this->build_numeric_value( $compare, $query['dayofweek'] ) ) { - $where_parts[] = "DAYOFWEEK( {$column} ) {$compare} {$value}"; - } - - if ( isset( $query['dayofweek_iso'] ) && $value = $this->build_numeric_value( $compare, $query['dayofweek_iso'] ) ) { - $where_parts[] = "WEEKDAY( {$column} ) + 1 {$compare} {$value}"; - } - - // Straight value compare - if ( isset( $query['value'] ) ) { - $value = $this->build_value( $compare, $query['value'] ); - $where_parts[] = "{$column} {$compare} $value"; - } - - // Hour/Minute/Second - if ( isset( $query['hour'] ) || isset( $query['minute'] ) || isset( $query['second'] ) ) { - - // Avoid notices. - foreach ( array( 'hour', 'minute', 'second' ) as $unit ) { - if ( ! isset( $query[ $unit ] ) ) { - $query[ $unit ] = null; - } - } - - $time_query = $this->build_time_query( $column, $compare, $query['hour'], $query['minute'], $query['second'] ); - - if ( ! empty( $time_query ) ) { - $where_parts[] = $time_query; - } - } - - /* - * Return an array of 'join' and 'where' for compatibility - * with other query classes. - */ - return array( - 'where' => $where_parts, - 'join' => array(), - ); - } - - /** - * Builds and validates a value string based on the comparison operator. - * - * @since 1.0.0 - * - * @param string $compare The compare operator to use - * @param array|int|string $value The value - * - * @return string|bool|int The value to be used in SQL or false on error. - */ - public function build_numeric_value( $compare = '=', $value = null ) { - - // Bail if null value - if ( is_null( $value ) ) { - return false; - } - - switch ( $compare ) { - case 'IN': - case 'NOT IN': - $value = (array) $value; - - // Remove non-numeric values. - $value = array_filter( $value, 'is_numeric' ); - - if ( empty( $value ) ) { - return false; - } - - return '(' . implode( ',', array_map( 'intval', $value ) ) . ')'; - - case 'BETWEEN': - case 'NOT BETWEEN': - if ( ! is_array( $value ) || ( 2 !== count( $value ) ) ) { - $value = array( $value, $value ); - } else { - $value = array_values( $value ); - } - - // If either value is non-numeric, bail. - foreach ( $value as $v ) { - if ( ! is_numeric( $v ) ) { - return false; - } - } - - $value = array_map( 'intval', $value ); - - return $value[0] . ' AND ' . $value[1]; - - default: - if ( ! is_numeric( $value ) ) { - return false; - } - - return (int) $value; - } - } - - /** - * Builds and validates a value string based on the comparison operator. - * - * @since 1.0.0 - * - * @param string $compare The compare operator to use - * @param array|string $value The value - * - * @return string|false|int The value to be used in SQL or false on error. - */ - 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 ); - } - } else { - $value = trim( $value ); - } - - switch ( $compare ) { - case 'IN': - case 'NOT IN': - $compare_string = '(' . substr( str_repeat( ',%s', count( $value ) ), 1 ) . ')'; - $where = $db->prepare( $compare_string, $value ); - break; - - case 'BETWEEN': - case 'NOT BETWEEN': - $value = array_slice( $value, 0, 2 ); - $where = $db->prepare( '%s AND %s', $value ); - break; - - case 'LIKE': - case 'NOT LIKE': - $value = '%' . $db->esc_like( $value ) . '%'; - $where = $db->prepare( '%s', $value ); - break; - - // EXISTS with a value is interpreted as '='. - case 'EXISTS': - $compare = '='; - $where = $db->prepare( '%s', $value ); - break; - - // 'value' is ignored for NOT EXISTS. - case 'NOT EXISTS': - $where = ''; - break; - - default: - $where = $db->prepare( '%s', $value ); - break; - } - - return $where; - } - - /** - * Builds a MySQL format date/time based on some query parameters. - * - * You can pass an array of values (year, month, etc.) with missing parameter values being defaulted to - * either the maximum or minimum values (controlled by the $default_to parameter). Alternatively you can - * pass a string that will be run through strtotime(). - * - * @since 1.0.0 - * - * @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 - */ - public function build_mysql_datetime( $datetime = '', $default_to_max = false, $now = 0 ) { - - // Datetime is string - if ( is_string( $datetime ) ) { - - // Define matches so linters don't complain - $matches = array(); - - /* - * Try to parse some common date formats, so we can detect - * the level of precision and support the 'inclusive' parameter. - */ - - // Y - if ( preg_match( '/^(\d{4})$/', $datetime, $matches ) ) { - $datetime = array( - 'year' => intval( $matches[1] ), - ); - - // Y-m - } elseif ( preg_match( '/^(\d{4})\-(\d{2})$/', $datetime, $matches ) ) { - $datetime = array( - 'year' => intval( $matches[1] ), - 'month' => intval( $matches[2] ), - ); - - // Y-m-d - } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2})$/', $datetime, $matches ) ) { - $datetime = array( - 'year' => intval( $matches[1] ), - 'month' => intval( $matches[2] ), - 'day' => intval( $matches[3] ), - ); - - // Y-m-d H:i - } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2}) (\d{2}):(\d{2})$/', $datetime, $matches ) ) { - $datetime = array( - 'year' => intval( $matches[1] ), - 'month' => intval( $matches[2] ), - 'day' => intval( $matches[3] ), - 'hour' => intval( $matches[4] ), - 'minute' => intval( $matches[5] ), - ); - - // Y-m-d H:i:s - } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $datetime, $matches ) ) { - $datetime = array( - 'year' => intval( $matches[1] ), - 'month' => intval( $matches[2] ), - 'day' => intval( $matches[3] ), - 'hour' => intval( $matches[4] ), - 'minute' => intval( $matches[5] ), - 'second' => intval( $matches[6] ), - ); - } - } - - // No match; may be int or string - if ( ! is_array( $datetime ) ) { - - // Maybe format or use as-is - $datetime = ! is_int( $datetime ) - ? strtotime( $datetime, $now ) - : absint( $datetime ); - - // Return formatted - return gmdate( 'Y-m-d H:i:s', $datetime ); - } - - // Map to ints - $datetime = array_map( 'absint', $datetime ); - - // Year - if ( ! isset( $datetime['year'] ) ) { - $datetime['year'] = gmdate( 'Y', $now ); - } - - // Month - if ( ! isset( $datetime['month'] ) ) { - $datetime['month'] = ! empty( $default_to_max ) - ? 12 - : 1; - } - - // Day - if ( ! isset( $datetime['day'] ) ) { - $datetime['day'] = ! empty( $default_to_max ) - ? (int) gmdate( 't', gmmktime( 0, 0, 0, $datetime['month'], 1, $datetime['year'] ) ) - : 1; - } - - // Hour - if ( ! isset( $datetime['hour'] ) ) { - $datetime['hour'] = ! empty( $default_to_max ) - ? 23 - : 0; - } - - // Minute - if ( ! isset( $datetime['minute'] ) ) { - $datetime['minute'] = ! empty( $default_to_max ) - ? 59 - : 0; - } - - // Second - if ( ! isset( $datetime['second'] ) ) { - $datetime['second'] = ! empty( $default_to_max ) - ? 59 - : 0; - } - - // Combine and return - return sprintf( - '%04d-%02d-%02d %02d:%02d:%02d', - $datetime['year'], - $datetime['month'], - $datetime['day'], - $datetime['hour'], - $datetime['minute'], - $datetime['second'] - ); - } - - /** - * Return a MySQL expression for selecting the week number based on the - * day that the week starts. - * - * Uses the WordPress site option, if set. - * - * @since 1.0.0 - * - * @param string $column Database column. - * @param int $start_of_week Day that week starts on. 0 = Sunday. - * - * @return string SQL clause. - */ - public function build_mysql_week( $column = '', $start_of_week = 0 ) { - - // When does the week start? - switch ( $start_of_week ) { - - // Monday - case 1: - $retval = "WEEK( {$column}, 1 )"; - break; - - // Tuesday - Saturday - case 2: - case 3: - case 4: - case 5: - case 6: - $retval = "WEEK( DATE_SUB( {$column}, INTERVAL {$start_of_week} DAY ), 0 )"; - break; - - // Sunday - case 0: - default: - $retval = "WEEK( {$column}, 0 )"; - break; - } - - // Return SQL - return $retval; - } - - /** - * Builds a query string for comparing time values (hour, minute, second). - * - * If just hour, minute, or second is set than a normal comparison will be done. - * However if multiple values are passed, a pseudo-decimal time will be created - * in order to be able to accurately compare against. - * - * @since 1.0.0 - * - * @param string $column The column to query against. Needs to be pre-validated! - * @param string $compare The comparison operator. Needs to be pre-validated! - * @param int|null $hour Optional. An hour value (0-23). - * @param int|null $minute Optional. A minute value (0-59). - * @param int|null $second Optional. A second value (0-59). - * - * @return string|false A query part or false on failure. - */ - public function build_time_query( $column, $compare, $hour = null, $minute = null, $second = null ) { - - // Have to have at least one - if ( ! isset( $hour ) && ! isset( $minute ) && ! isset( $second ) ) { - 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(); - - // Hour - if ( isset( $hour ) && false !== ( $value = $this->build_numeric_value( $compare, $hour ) ) ) { - $retval[] = "HOUR( {$column} ) {$compare} {$value}"; - } - - // Minute - if ( isset( $minute ) && false !== ( $value = $this->build_numeric_value( $compare, $minute ) ) ) { - $retval[] = "MINUTE( {$column} ) {$compare} {$value}"; - } - - // Second - if ( isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $second ) ) ) { - $retval[] = "SECOND( {$column} ) {$compare} {$value}"; - } - - return implode( ' AND ', $retval ); - } - - // Cases where just one unit is set - - // Hour - if ( isset( $hour ) && ! isset( $minute ) && ! isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $hour ) ) ) { - return "HOUR( {$column} ) {$compare} {$value}"; - - // Minute - } elseif ( ! isset( $hour ) && isset( $minute ) && ! isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $minute ) ) ) { - return "MINUTE( {$column} ) {$compare} {$value}"; - - // Second - } elseif ( ! isset( $hour ) && ! isset( $minute ) && isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $second ) ) ) { - return "SECOND( {$column} ) {$compare} {$value}"; - } - - // Single units were already handled. Since hour & second isn't allowed, - // minute must to be set. - if ( ! isset( $minute ) ) { - return false; - } - - // Defaults - $format = $time = ''; - - // Hour - if ( null !== $hour ) { - $format .= '%H.'; - $time .= sprintf( '%02d', $hour ) . '.'; - } else { - $format .= '0.'; - $time .= '0.'; - } - - // Minute - $format .= '%i'; - $time .= sprintf( '%02d', $minute ); - - // Second - if ( isset( $second ) ) { - $format .= '%s'; - $time .= sprintf( '%02d', $second ); - } - - // Build the SQL - $query = "DATE_FORMAT( {$column}, %s ) {$compare} %f"; - - // Return the prepared SQL - return $db->prepare( $query, $format, $time ); - } - - /** - * Test if the supplied date is valid for the Gregorian calendar. - * - * @since 1.0.0 - * - * @link https://www.php.net/manual/en/function.checkdate.php - * - * @param int $month Month number. - * @param int $day Day number. - * @param int $year Year number. - * @param string $source_date The date to filter. - * - * @return bool True if valid date, false if not valid date. - */ - public function checkdate( $month = 0, $day = 0, $year = 0, $source_date = '' ) { - - // Check the date - $retval = checkdate( $month, $day, $year ); - - /** - * Filters whether the given date is valid for the Gregorian calendar. - * - * @since 1.0.0 - * - * @param bool $checkdate Whether the given date is valid. - * @param string $source_date Date to check. - */ - return (bool) apply_filters( 'wp_checkdate', $retval, $source_date ); - } -} diff --git a/src/Database/Queries/Meta.php b/src/Database/Queries/Meta.php deleted file mode 100644 index 5d89a88..0000000 --- a/src/Database/Queries/Meta.php +++ /dev/null @@ -1,29 +0,0 @@ -setup(); - - // Maybe execute a query if arguments were passed - if ( ! empty( $query ) ) { - $this->query( $query ); - } - } - /** * 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. * - * @since 2.1.0 + * @since 3.0.0 */ - public function setup() { + protected function sunrise() { $this->set_alias(); $this->set_prefixes(); $this->set_schema(); @@ -302,6 +290,17 @@ public function setup() { $this->set_query_clause_defaults(); } + /** + * Parse the query arguments. + * + * @since 3.0.0 + * + * @param array $args + */ + protected function parse_args( $args = array() ) { + $this->query( $args ); + } + /** * Queries the database and retrieves items or counts. * @@ -354,7 +353,7 @@ private function set_alias() { * This is to avoid conflicts with other plugins or themes that might be * using the global scope for data and cache storage. * - * @since 2.1.0 + * @since 3.0.0 */ private function set_prefixes() { $this->table_name = $this->apply_prefix( $this->table_name ); @@ -365,7 +364,7 @@ private function set_prefixes() { /** * Set up the Schema. * - * @since 2.1.0 + * @since 3.0.0 */ private function set_schema() { @@ -392,14 +391,81 @@ private function set_item_shape() { /** * Set query var parsers. * - * @since 2.1.0 + * @since 3.0.0 */ 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' + + // By + array( + 'name' => 'by', + 'query_var' => null, + 'column_filter' => array(), + 'column_suffix' => '', + 'class_name' => __NAMESPACE__ . '\\Parsers\\By', + 'default' => null, + ), + + // In + array( + 'name' => 'in', + 'query_var' => 'in_query', + 'column_filter' => array( 'in' => true ), + 'column_suffix' => '__in', + 'class_name' => __NAMESPACE__ . '\\Parsers\\In', + 'default' => null, + ), + + // Not In + array( + 'name' => 'not_in', + 'query_var' => 'not_in_query', + 'column_filter' => array( 'not_in' => true ), + 'column_suffix' => '__not_in', + 'class_name' => __NAMESPACE__ . '\\Parsers\\NotIn', + 'default' => null, + ), + + // Searchable + array( + 'name' => 'search', + 'query_var' => 'search', + 'column_filter' => array( 'searchable' => true ), + 'column_suffix' => '_search', + 'class_name' => __NAMESPACE__ . '\\Parsers\\Search', + 'default' => null, + ), + + // Date + array( + 'name' => 'date', + 'query_var' => 'date_query', + 'column_filter' => array( 'date_query' => true ), + 'column_suffix' => '_query', + 'class_name' => __NAMESPACE__ . '\\Parsers\\Date', + 'default' => null, + ), + + // Meta + array( + 'name' => 'meta', + 'query_var' => 'meta_query', + 'column_filter' => array( 'primary' => true ), + 'column_suffix' => '_meta', + 'class_name' => __NAMESPACE__ . '\\Parsers\\Meta', + 'default' => null, + ), + + // Compare + array( + 'name' => 'compare', + 'query_var' => 'compare_query', + 'column_filter' => array( 'primary' => true ), + 'column_suffix' => '_compare', + 'class_name' => __NAMESPACE__ . '\\Parsers\\Compare', + 'default' => null, + ), ); } } @@ -407,7 +473,7 @@ private function set_query_var_parsers() { /** * Set defaults for query (and also request) clauses. * - * @since 2.1.0 + * @since 3.0.0 */ private function set_query_clause_defaults() { @@ -416,7 +482,6 @@ private function set_query_clause_defaults() { 'explain' => '', 'select' => '', 'fields' => '', - 'count' => '', 'from' => '', 'join' => array(), 'where' => array(), @@ -436,7 +501,7 @@ private function set_query_clause_defaults() { * Set default query vars based on columns. * * @since 1.0.0 - * @since 2.1.0 + * @since 3.0.0 */ private function set_query_var_defaults() { @@ -465,10 +530,6 @@ private function set_query_var_defaults() { 'orderby' => $primary, 'order' => 'DESC', - // Search - 'search' => '', - 'search_columns' => array(), - // COUNT(*) 'count' => false, @@ -480,83 +541,62 @@ private function set_query_var_defaults() { 'update_meta_cache' => true ); - /** Column Names ******************************************************/ - - // All column names - $names = $this->get_column_names(); - - // Bail early if no columns - if ( empty( $names ) ) { - return; - } - - // 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 ) { + /** Query Parsers *****************************************************/ - // Columns - $filter = array( $column => true ); - $columns = $this->get_column_names( $filter ); + // Setup parsers array + $this->parsers = array(); - // Skip if no columns - if ( empty( $columns ) ) { + // Loop through query var parsers + foreach ( $this->query_var_parsers as $parser ) { + + // Parse arguments + $r = wp_parse_args( $parser, array( + 'name' => '', + 'query_var' => null, + 'column_filter' => array(), + 'column_suffix' => '', + 'class_name' => '', + 'default' => null, + ) ); + + // Get the parser class name. + $class = $r['class_name']; + + // Skip if no class. + if ( ! class_exists( $class ) ) { continue; } - // Add defaults - foreach ( $columns as $name ) { - $defaults[] = "{$name}{$suffix}"; - } - } - - /** Query Objects *****************************************************/ - - // Loop through query var parsers - foreach ( array_keys( $this->query_var_parsers ) as $id ) { - - // Set query key - $suffix = '_query'; - $query_key = strtolower( $id ) . $suffix; + // Setup the parser. + $this->parsers[ $r['name'] ] = new $class; - // Columns - $filter = array( $query_key => true ); - $columns = $this->get_column_names( $filter ); - - // Skip if no columns - if ( empty( $columns ) ) { - continue; + // Maybe add query var alone + if ( ! empty( $r['query_var'] ) ) { + $key = $r['query_var']; + $this->query_var_defaults[ $key ] = ( null === $r['default'] ) + ? $this->query_var_default_value + : $r['default']; } - // Add defaults - foreach ( $columns as $column ) { - $defaults[] = "{$name}{$suffix}"; + // Get column names. + $columns = $this->get_column_names( $r['column_filter'] ); + + // Add to defaults + if ( ! empty( $columns ) ) { + foreach ( $columns as $column ) { + $key = "{$column}{$r['column_suffix']}"; + $this->query_var_defaults[ $key ] = ( null === $r['default'] ) + ? $this->query_var_default_value + : $r['default']; + } } } - - /** 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 ); } /** * Set $query_clauses by parsing $query_vars. * - * @since 2.1.0 + * @since 3.0.0 */ private function set_query_clauses() { $this->query_clauses = $this->parse_query_vars(); @@ -566,7 +606,7 @@ private function set_query_clauses() { * Set the $request_clauses. * * @since 1.0.0 - * @since 2.1.0 Uses parse_query_clauses() with support for new clauses. + * @since 3.0.0 Uses parse_query_clauses() with support for new clauses. */ private function set_request_clauses() { $this->request_clauses = $this->parse_query_clauses(); @@ -576,7 +616,7 @@ private function set_request_clauses() { * Set the $request. * * @since 1.0.0 - * @since 2.1.0 Uses parse_request_clauses() on $request_clauses. + * @since 3.0.0 Uses parse_request_clauses() on $request_clauses. */ private function set_request() { $this->request = $this->parse_request_clauses(); @@ -586,7 +626,7 @@ private function set_request() { * 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(). + * @since 3.0.0 Moved 'count' logic back into get_items(). * @param array $item_ids */ private function set_items( $item_ids = array() ) { @@ -608,7 +648,7 @@ private function set_items( $item_ids = array() ) { * if the limit clause was used. * * @since 1.0.0 - * @since 2.1.0 Uses filter_found_items_query(). + * @since 3.0.0 Uses filter_found_items_query(). * * @param mixed $item_ids Optional array of item IDs */ @@ -645,15 +685,14 @@ 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 Performs a COUNT(*) query using $request_clauses. + * @since 3.0.0 Performs a COUNT(*) query using $request_clauses. */ } elseif ( ! $this->get_query_var( 'no_found_rows' ) && $this->get_query_var( 'number' ) ) { // Override a few request clauses $r = wp_parse_args( array( - 'count' => 'COUNT(*)', - 'fields' => '', + 'fields' => 'COUNT(*)', 'limits' => '', 'orderby' => '' ), @@ -712,7 +751,7 @@ public function is_query_var_default( $key = '' ) { /** * Is a column valid? * - * @since 2.1.0 + * @since 3.0.0 * @param string $column_name * @return bool */ @@ -727,95 +766,20 @@ private function is_valid_column( $column_name = '' ) { return (bool) $this->get_column_by( array( 'name' => $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; - } - - /** - * 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_var_parser( $query = '', $args = array() ) { - - // Bail if no query - if ( empty( $this->query_var_parsers[ $query ] ) ) { - return; - } - - // Setup the class name using the namespace - $class = $this->query_var_parsers[ $query ]; - - // Bail if class does not exist - if ( ! class_exists( $class ) ) { - return; - } - - // Return the query - return new $class( $args ); - } - - /** - * Return the current time as a UTC timestamp. - * - * 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 - * - * @return string - */ - private function get_current_time() { - return gmdate( "Y-m-d\TH:i:s\Z" ); - } - - /** - * Return the table name. - * - * Prefixed by the $table_prefix global, or get_blog_prefix() if - * is_multisite(). - * - * @since 1.0.0 - * - * @return string - */ - private function get_table_name() { - - // Get the database interface - $db = $this->get_db(); - - // Return SQL - return ! empty( $db ) - ? $db->{$this->table_name} - : $this->table_name; - } + /** Public Columns ********************************************************/ /** * Return array of column names. * * @since 1.0.0 - * @since 2.1.0 Pass $args and $operator to filter names. + * @since 3.0.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( $args = array(), $operator = 'and' ) { + public function get_column_names( $args = array(), $operator = 'and' ) { return $this->get_columns( $args, $operator, 'name' ); } @@ -826,7 +790,7 @@ private function get_column_names( $args = array(), $operator = 'and' ) { * * @return string Default "id", Primary column name if not empty */ - private function get_primary_column_name() { + public function get_primary_column_name() { return $this->get_column_field( array( 'primary' => true ), 'name', 'id' ); } @@ -840,7 +804,7 @@ private function get_primary_column_name() { * @param mixed $default Default to use if no field is set. * @return mixed Column object, or false */ - private function get_column_field( $args = array(), $field = '', $default = false ) { + public function get_column_field( $args = array(), $field = '', $default = false ) { // Get the column $column = $this->get_column_by( $args ); @@ -859,7 +823,7 @@ private function get_column_field( $args = array(), $field = '', $default = fals * @param array $args Arguments to get a column by. * @return mixed Column object, or false */ - private function get_column_by( $args = array() ) { + public function get_column_by( $args = array() ) { // Filter columns $filter = $this->get_columns( $args ); @@ -877,7 +841,7 @@ private function get_column_by( $args = array() ) { * array of columns as needed. * * @since 1.0.0 - * @since 2.1.0 + * @since 3.0.0 * * @static array $columns Local static copy of columns, abstracted to * support different storage locations. @@ -887,7 +851,7 @@ private function get_column_by( $args = array() ) { * instead of the entire object. Default false. * @return array Array of columns. */ - private function get_columns( $args = array(), $operator = 'and', $field = false ) { + public function get_columns( $args = array(), $operator = 'and', $field = false ) { static $columns = null; // Setup columns @@ -924,14 +888,14 @@ private function get_columns( $args = array(), $operator = 'and', $field = false * * Uses get_column_field() to allow passing of a default value. * - * @since 2.1.0 + * @since 3.0.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 ) { + public function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) { // Bail if no values if ( empty( $values ) ) { @@ -964,12 +928,12 @@ private function get_columns_field_by( $key = '', $values = array(), $field = '' /** * Get a column name, possibly with the $table_alias append. * - * @since 2.1.0 + * @since 3.0.0 * @param string $column_name * @param bool $alias * @return string */ - private function get_column_name_aliased( $column_name = '', $alias = true ) { + public function get_column_name_aliased( $column_name = '', $alias = true ) { // Default return value $retval = $column_name; @@ -987,11 +951,61 @@ private function get_column_name_aliased( $column_name = '', $alias = true ) { return $retval; } + /** Private Getters *******************************************************/ + + /** + * Get a query variable. + * + * @since 3.0.0 + * @param string $key + * @return mixed + */ + private function get_query_var( $key = '' ) { + return isset( $this->query_vars[ $key ] ) + ? $this->query_vars[ $key ] + : null; + } + + /** + * Return the current time as a UTC timestamp. + * + * 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 + * + * @return string + */ + private function get_current_time() { + return gmdate( "Y-m-d\TH:i:s\Z" ); + } + + /** + * Return the table name. + * + * Prefixed by the $table_prefix global, or get_blog_prefix() if + * is_multisite(). + * + * @since 1.0.0 + * + * @return string + */ + private function get_table_name() { + + // Get the database interface + $db = $this->get_db(); + + // Return SQL + return ! empty( $db ) + ? $db->{$this->table_name} + : $this->table_name; + } + /** * Get a single database row by any column and value, skipping cache. * * @since 1.0.0 - * @since 2.1.0 Uses is_valid_column() + * @since 3.0.0 Uses is_valid_column() * * @param string $column_name Name of database column * @param mixed $column_value Value to query for @@ -1121,7 +1135,7 @@ 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() + * @since 3.0.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. @@ -1162,65 +1176,13 @@ private function get_item_ids() { return wp_parse_list( $item_ids ); } - /** - * 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 $string Search string. - * @param array $column_names Columns to search. - * @return string Search SQL. - */ - private function get_search_sql( $string = '', $column_names = array() ) { - - // Bail if malformed string - if ( empty( $string ) || ! is_scalar( $string ) ) { - return ''; - } - - // Bail if malformed columns - if ( empty( $column_names ) || ! is_array( $column_names ) ) { - return ''; - } - - // Get the database interface - $db = $this->get_db(); - - // Bail if no database interface is available - if ( empty( $db ) ) { - return ''; - } - - // Array or String - $like = ( false !== strpos( $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[] = $db->prepare( "{$column} LIKE %s", $like ); - } - - // Concatinate - $values = implode( ' OR ', $searches ); - $retval = '(' . $values . ')'; - - // Return the clause - return $retval; - } - /** * Used internally to generate the SQL string for IN and NOT IN clauses. * * The $values being passed in should not be validated, and they will be * escaped before they are concatenated together and returned as a string. * - * @since 2.1.0 + * @since 3.0.0 * * @param string $column_name Column name. * @param array|string $values Array of values. @@ -1278,7 +1240,7 @@ 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 + * @since 3.0.0 Forces some $query_vars if counting * * @param array|string $query */ @@ -1325,7 +1287,7 @@ private function parse_query( $query = array() ) { * * Calls filter_query_clauses() on the return value. * - * @since 2.1.0 + * @since 3.0.0 * @param array $query_vars Optional. Default empty array. * Fallback to Query::query_vars. * @return array Query clauses, parsed from Query vars. @@ -1348,12 +1310,11 @@ private function parse_query_vars( $query_vars = 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 ' ), + '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'] ) ); @@ -1364,7 +1325,7 @@ private function parse_query_vars( $query_vars = array() ) { /** * Parse the 'where' and 'join' $query_vars for all known columns. * - * @since 2.1.0 + * @since 3.0.0 * * @param array $args Query vars * @return array Array of 'where' and 'join' clauses. @@ -1379,20 +1340,8 @@ 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_parsers' - ); - // Default results - $results = array(); - - // Get all results - foreach ( $methods as $method ) { - $results[] = $this->{$method}( $r ); - } + $results = array( $this->parse_where_parsers( $r ) ); // Pluck join/where from results $join = wp_list_pluck( $results, 'join' ); @@ -1405,216 +1354,12 @@ private function parse_where_join( $args = array() ) { ); } - /** - * Parse join/where subclauses for all columns. - * - * Used by parse_where_join(). - * - * @since 2.1.0 - * @return array - */ - private function parse_where_columns( $query_vars = array() ) { - - // Defaults - $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; - } - - // 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 ( $all_columns as $column ) { - - // 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 = $name; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for direct clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $where_id = "{$where_id}__in"; - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __in - if ( true === $column->in ) { - - // Parse query var - $where_id = "{$name}__in"; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for an IN clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __not_in - if ( true === $column->not_in ) { - - // Parse query var - $where_id = "{$name}__not_in"; - $values = $this->parse_query_var( $query_vars, $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 = "{$aliased} != {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $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 = "{$name}_query"; - $column_date = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item - if ( false !== $column_date ) { - - // Single - if ( 1 === count( $column_date ) ) { - $where['date_query'][] = array( - 'column' => $aliased, - 'before' => reset( $column_date ), - 'inclusive' => true - ); - - // Multi - } else { - - // Auto-fill column if empty - if ( empty( $column_date['column'] ) ) { - $column_date['column'] = $aliased; - } - - // Add clause to date query - $where['date_query'][] = $column_date; - } - } - } - } - - // Return join/where subclauses - return array( - 'join' => array(), - 'where' => $where - ); - } - - /** - * Parse join/where subclauses for search queries. - * - * Used by parse_where_join(). - * - * @since 2.1.0 - * @return array - */ - private function parse_where_search( $query_vars = array() ) { - - // 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'] ) ) { - return array( - 'join' => array(), - 'where' => array() - ); - } - - // Default value - $where = array(); - - // 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 - ); - } - - // 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 ); - - // Return join/where - return array( - 'join' => array(), - 'where' => $where - ); - } - /** * Parse join/where subclauses for query var parser objects. * * Used by parse_where_join(). * - * @since 2.1.0 + * @since 3.0.0 * @return array */ private function parse_where_parsers( $query_vars = array() ) { @@ -1627,66 +1372,76 @@ private function parse_where_parsers( $query_vars = array() ) { ); } - // Get query var parsers - $parsers = array_filter( array_keys( $this->query_var_parsers ) ); - // 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 + 'meta_type' => $this->get_meta_type(), + 'primary_table' => $this->table_name, + 'primary_alias' => $this->table_alias, + 'primary_column' => $this->get_primary_column_name(), + 'primary_pattern' => $this->get_column_field( array( 'primary' => true ), 'pattern', '%s' ), + 'query' => $this, ); // Default values $join = $where = array(); // Loop through parsers - foreach ( $parsers as $id ) { + foreach ( $this->query_var_parsers as $parser ) { - // Skip - if ( empty( $id ) ) { + // Skip if no name. + if ( empty( $parser['name'] ) ) { continue; } - // Build the key - $key = strtolower( $id ) . '_query'; - - // Skip if no query vars - if ( empty( $query_vars[ $key ] ) || ! is_array( $query_vars[ $key ] ) ) { + // Skip if no class. + if ( ! class_exists( $parser['class_name'] ) ) { continue; } - // Add table alias to primary clause if not already set - if ( empty( $query_vars[ $key ][ 'alias'] ) ) { - $query_vars[ $key ][ 'alias'] = $args['table_alias']; - } + // Default to all $query_vars. + $qv = $query_vars; - // Try to get the query var parser - $parser = $this->get_query_var_parser( $id, $query_vars[ $key ] ); + // Check if $query_vars contains the query_var for this parser + if ( ! is_null( $parser['query_var'] ) && ! empty( $query_vars[ $parser['query_var'] ] ) ) { - // Skip if no query var parser - if ( empty( $parser ) ) { - continue; + /** + * Maybe add table alias to primary clause if not already set. + * + * This will likely be a requirement in a future version, but + * for now we can kludge it in. + */ + if ( is_array( $query_vars[ $parser['query_var'] ] ) && empty( $query_vars[ $parser['query_var'] ][ 'alias'] ) ) { + $query_vars[ $parser['query_var'] ][ 'alias'] = $args['table_alias']; + } + + /** + * Maybe narrow the scope to just this $query_var, if not + * default $query_var value. + */ + if ( $this->query_var_default_value !== $query_vars[ $parser['query_var'] ] ) { + //$qv = $query_vars[ $parser['query_var'] ]; + } } + // Set the key from the name + $key = $parser['name']; + $class = $parser['class_name']; + + // Try to get the query var parser + $new_parser = new $class( $qv, $this ); + // Default no subclauses $subclauses = false; - // Set the key - $this->{$key} = $parser; - // Set the callback - $callback = array( $this->{$key}, 'get_sql' ); + $callback = array( $new_parser, 'get_sql' ); // Try to get the SQL subclauses if ( is_callable( $callback ) ) { - $subclauses = call_user_func( $callback, array( + $subclauses = call_user_func_array( $callback, array( $args['meta_type'], $args['primary_table'], $args['primary_column'], - $args['query'] ) ); } @@ -1716,7 +1471,7 @@ private function parse_where_parsers( $query_vars = array() ) { /** * Parse a single query variable value. * - * @since 2.1.0 + * @since 3.0.0 * * @param array $query_vars * @param string $key @@ -1726,7 +1481,7 @@ private function parse_where_parsers( $query_vars = array() ) { * Attempts to parse a comma-separated string of * possible keys or numbers. */ - private function parse_query_var( $query_vars = array(), $key = '' ) { + public function parse_query_var( $query_vars = array(), $key = '' ) { // Bail if no query vars exist for that ID if ( ! isset( $query_vars[ $key ] ) ) { @@ -1805,7 +1560,7 @@ private function parse_query_var( $query_vars = array(), $key = '' ) { /** * Parse if query to be EXPLAIN'ed. * - * @since 2.1.0 + * @since 3.0.0 * @param bool $explain Default false. True to EXPLAIN. * @return string */ @@ -1831,7 +1586,7 @@ private function parse_explain( $explain = false ) { /** * Parse the "SELECT" part of the SQL. * - * @since 2.1.0 + * @since 3.0.0 * @return string Default "SELECT". */ private function parse_select() { @@ -1848,7 +1603,7 @@ private function parse_select() { * 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() and uses parse_groupby() + * @since 3.0.0 Moved COUNT() SQL to parse_count() and uses parse_groupby() * when counting to satisfy MySQL 8 and higher. * * @param string[] $fields @@ -1870,10 +1625,8 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali // Counting, so use groupby if ( ! empty( $count ) ) { - // Use groupby instead - if ( ! empty( $groupby ) ) { - $retval = $this->parse_groupby( $groupby, '', $alias ); - } + // Use count instead + $retval = $this->parse_count( $count, $groupby ); // Not counting, so use primary column } else { @@ -1900,7 +1653,7 @@ private function parse_fields( $fields = '', $count = false, $groupby = '', $ali * When counting with groups, parse_fields() will return the required SQL to * prevent errors. * - * @since 2.1.0 + * @since 3.0.0 * @param bool $count * @param string $groupby * @param string $name @@ -1927,7 +1680,7 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a // Reformat if grouping counts together if ( ! empty( $groupby_names ) ) { - $retval = ", {$retval} as {$name}"; + $retval = "{$groupby_names}, {$retval} as {$name}"; } // Return SQL @@ -1937,7 +1690,7 @@ private function parse_count( $count = false, $groupby = '', $name = 'count', $a /** * Parse which table to query and whether to follow it with an alias. * - * @since 2.1.0 + * @since 3.0.0 * @param string $table Optional. Default empty string. * Fallback to get_table_name(). * @param string $alias Optional. Default empty string. @@ -2019,7 +1772,7 @@ private function parse_groupby( $groupby = '', $before = '', $alias = true ) { * 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, $before, and $alias + * @since 3.0.0 Renamed to parse_orderby and accepts $orderby, $order, $before, and $alias * * @param string $orderby * @param string $order @@ -2100,7 +1853,7 @@ private function parse_orderby( $orderby = '', $order = '', $before = '', $alias /** * Parse all of the where clauses. * - * @since 2.1.0 + * @since 3.0.0 * @param array $where * @return string A single SQL statement. */ @@ -2118,7 +1871,7 @@ private function parse_where_clause( $where = array() ) { /** * Parse all of the join clauses. * - * @since 2.1.0 + * @since 3.0.0 * @param array $join * @return string A single SQL statement. */ @@ -2136,7 +1889,7 @@ private function parse_join_clause( $join = array() ) { /** * Parse all of the SQL query clauses. * - * @since 2.1.0 + * @since 3.0.0 * @param array $clauses * @return array */ @@ -2157,7 +1910,7 @@ private function parse_query_clauses( $clauses = array() ) { /** * Parse all SQL $request_clauses into a single SQL query string. * - * @since 2.1.0 + * @since 3.0.0 * @param array $clauses * @return string A single SQL statement. */ @@ -2184,7 +1937,7 @@ private function parse_request_clauses( $clauses = array() ) { /** * Parses the 'number' and 'offset' keys passed to the item query. * - * @since 2.1.0 + * @since 3.0.0 * * @param int $number * @param int $offset @@ -2216,7 +1969,7 @@ private function parse_limits( $number = 0, $offset = 0 ) { * This method assumes that $orderby is a valid Column name. * * @since 1.0.0 - * @since 2.1.0 Uses get_in_sql() + * @since 3.0.0 Uses get_in_sql() * * @param string $orderby Field for the items to be ordered by. * @param bool $alias Whether to append the table alias. @@ -2264,7 +2017,7 @@ private function parse_single_orderby( $orderby = '', $alias = true ) { * necessary. * * @since 1.0.0 - * @since 2.1.0 Default to 'DESC' + * @since 3.0.0 Default to 'DESC' * * @param string $order The 'order' query variable. * @return string The sanitized 'order' query variable. @@ -2324,7 +2077,7 @@ private function shape_item( $item = 0 ) { * objects with keys based on fields. * * @since 1.0.0 - * @since 2.1.0 Added $fields parameter. + * @since 3.0.0 Added $fields parameter. * * @param array $items Array of items to shape. * @param array $fields Fields to get from items. @@ -2370,7 +2123,7 @@ private function shape_items( $items = array(), $fields = array() ) { * Accepts an object, array, or numeric value. * * @since 1.0.0 - * @since 2.1.0 Uses validate_item_field() + * @since 3.0.0 Uses validate_item_field() * * @param array|object|scalar $item * @return int|string @@ -2401,7 +2154,7 @@ private function shape_item_id( $item = 0 ) { * * Calls Column::validate() on the column. * - * @since 2.1.0 + * @since 3.0.0 * @param mixed $value Value to validate. * @param string $column_name Name of column. * @return mixed A validated value @@ -2424,7 +2177,7 @@ private function validate_item_field( $value = '', $column_name = '' ) { * Get specific fields from an array of items. * * @since 1.0.0 - * @since 2.1.0 Bails early if empty $fields. + * @since 3.0.0 Bails early if empty $fields. * * @param array $items Array of items to get fields from. * @param array $fields Fields to get from items. @@ -2975,7 +2728,7 @@ private function reduce_item( $method = 'update', $item = array() ) { * meta data instead. * * @since 1.0.0 - * @since 2.1.0 Uses array_combine() + * @since 3.0.0 Uses array_combine() * * @param array $args Default empty array. Parsed & passed into get_columns(). * @return array @@ -3323,7 +3076,7 @@ private function delete_all_item_meta( $item_id = 0 ) { * Get the meta table for this query. * * @since 1.0.0 - * @since 2.1.0 Minor refactor to improve readability. + * @since 3.0.0 Minor refactor to improve readability. * * @return bool|string Table name if exists, False if not. */ @@ -3459,7 +3212,7 @@ private function get_cache_groups() { * for all non-cached item meta. * * @since 1.0.0 - * @since 2.1.0 Uses get_meta_table_name() to + * @since 3.0.0 Uses get_meta_table_name() to * * @param array $item_ids * @param bool $force @@ -3545,7 +3298,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 + * @since 3.0.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. @@ -3698,7 +3451,7 @@ private function get_last_changed_cache( $group = '' ) { * Get array of non-cached item IDs. * * @since 1.0.0 - * @since 2.1.0 $item_ids expected to be shaped + * @since 3.0.0 $item_ids expected to be shaped * * @param array $item_ids Array of shaped item IDs * @param string $group Cache group. Defaults to $this->cache_group @@ -3844,7 +3597,7 @@ private function cache_delete( $key = '', $group = '' ) { /** * Filter an item before it is inserted or updated in the database. * - * @since 2.1.0 + * @since 3.0.0 * * @param array $item The item data. * @return array @@ -3871,7 +3624,7 @@ public function filter_item( $item = array() ) { /** * Filter all shaped items after they are retrieved from the database. * - * @since 2.1.0 + * @since 3.0.0 * * @param array $items The item data. * @return array @@ -3898,7 +3651,7 @@ public function filter_items( $items = array() ) { /** * Filter the found items query. * - * @since 2.1.0 + * @since 3.0.0 * @param string $sql * @return string */ @@ -3908,7 +3661,7 @@ 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 + * @since 3.0.0 Supports MySQL 8 by removing FOUND_ROWS() and uses * $request_clauses instead. * * @param string $query SQL query. @@ -3926,7 +3679,7 @@ public function filter_found_items_query( $sql = '' ) { /** * Filter the query clauses before they are parsed into a SQL string. * - * @since 2.1.0 + * @since 3.0.0 * * @param array $clauses All of the SQL query clauses. * @return array @@ -3950,41 +3703,13 @@ public function filter_query_clauses( $clauses = array() ) { ); } - /** - * 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() + * @since 3.0.0 Uses query() * * @param array $cols Columns for `SELECT`. * @param array $where_cols Where clauses. Each key-value pair in the array diff --git a/src/Database/Row.php b/src/Database/Row.php index defc039..209c0ea 100644 --- a/src/Database/Row.php +++ b/src/Database/Row.php @@ -26,31 +26,17 @@ * * @since 1.0.0 */ -class Row extends Base { +class Row { /** - * Construct a database object. + * Use the following traits: * - * @since 1.0.0 - * - * @param mixed $item Null by default, Array/Object if not + * @since 3.0.0 */ - public function __construct( $item = null ) { - if ( ! empty( $item ) ) { - $this->init( $item ); - } - } + use Traits\Base; + use Traits\Boot; - /** - * Initialize class properties based on data array. - * - * @since 1.0.0 - * - * @param array $data - */ - private function init( $data = array() ) { - $this->set_vars( $data ); - } + /** Methods ***************************************************************/ /** * Determines whether the current row exists. diff --git a/src/Database/Schema.php b/src/Database/Schema.php index b309e8e..1db68a7 100644 --- a/src/Database/Schema.php +++ b/src/Database/Schema.php @@ -21,16 +21,24 @@ * including global tables for multisite, and users tables. * * @since 1.0.0 - * @since 2.1.0 Added variables for Column & Index + * @since 3.0.0 Added variables for Column & Index */ -class Schema extends Base { +class Schema { - /** Item Types ************************************************************/ + /** + * Use the following traits: + * + * @since 3.0.0 + */ + use Traits\Base; + use Traits\Boot; + + /** Attributes ************************************************************/ /** * Schema Column class. * - * @since 2.1.0 + * @since 3.0.0 * @var string */ protected $column = __NAMESPACE__ . '\\Column'; @@ -38,7 +46,7 @@ class Schema extends Base { /** * Schema Index class. * - * @since 2.1.0 + * @since 3.0.0 * @var string */ protected $index = __NAMESPACE__ . '\\Index'; @@ -56,7 +64,7 @@ class Schema extends Base { /** * Array of database Index objects. * - * @since 2.1.0 + * @since 3.0.0 * @var array */ protected $indexes = array(); @@ -64,19 +72,21 @@ class Schema extends Base { /** Public Methods ********************************************************/ /** - * Setup the Schema object, and parse any arguments passed in. + * Early setup for Legacy $columns support. * - * @since 1.0.0 + * @since 3.0.0 */ - public function __construct( $args = array() ) { - - // Setup the Schema + protected function sunrise() { $this->setup(); + } - // Parse arguments if not empty - if ( ! empty( $args ) ) { - $this->parse_args( $args ); - } + /** + * Late setup for modern $columns & $index support. + * + * @since 3.0.0 + */ + protected function init() { + $this->setup(); } /** @@ -84,9 +94,9 @@ public function __construct( $args = array() ) { * * 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. + * only way to register Columns in all versions before 3.0.0. * - * @since 2.1.0 + * @since 3.0.0 */ public function setup() { @@ -101,38 +111,13 @@ public function setup() { } } - /** - * Parse all of the arguments. - * - * @since 2.1.0 - * @param array $args - */ - public function parse_args( $args = array() ) { - - // Stash arguments - $this->stash_args( $args ); - - // Bail if no args to parse - if ( empty( $args ) ) { - return; - } - - // Types of objects to parse - $r = wp_parse_args( $args, $this->args['class'] ); - - // 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 + * @since 3.0.0 + * * @param string $type The type of items to clear. */ public function clear( $type = '' ) { @@ -151,10 +136,12 @@ public function clear( $type = '' ) { /** * Add an item to a specific items array. * - * @since 2.1.0 + * @since 3.0.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 object|false */ public function add_item( $type = 'column', $class = 'Column', $data = array() ) { @@ -194,7 +181,8 @@ public function add_item( $type = 'column', $class = 'Column', $data = array() ) * This does not include the "CREATE TABLE" directive itself, and is only * used to generate the SQL inside of that kind of query. * - * @since 2.1.0 + * @since 3.0.0 + * * @return string */ public function get_create_table_string() { @@ -214,25 +202,15 @@ public function get_create_table_string() { /** 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 + * @since 3.0.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() ) { @@ -267,8 +245,10 @@ private function setup_items( $type = 'columns', $class = 'Column', $values = ar /** * Return the SQL for an item type used in a "CREATE TABLE" query. * - * @since 2.1.0 + * @since 3.0.0 + * * @param string $type Type of item. + * * @return string Calls get_create_string() on every item. */ private function get_items_create_string( $type = 'columns' ) { @@ -306,11 +286,12 @@ private function get_items_create_string( $type = 'columns' ) { /** * Return the columns in string form. * - * This method was deprecated in 2.1.0 because in previous versions it only + * This method was deprecated in 3.0.0 because in previous versions it only * included Columns and did not include Indexes. * * @since 1.0.0 - * @deprecated 2.1.0 + * @deprecated 3.0.0 + * * @return string */ protected function to_string() { diff --git a/src/Database/Table.php b/src/Database/Table.php index a5a09b5..adbcacf 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -30,7 +30,17 @@ * * @since 1.0.0 */ -abstract class Table extends Base { +class Table { + + /** + * Use the following traits: + * + * @since 3.0.0 + */ + use Traits\Base; + use Traits\Boot; + + /** Attributes ************************************************************/ /** * Table name, without the global table prefix. @@ -126,7 +136,7 @@ abstract class Table extends Base { * 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 + * @since 3.0.0 * @var string */ protected $comment = ''; @@ -139,14 +149,12 @@ abstract class Table extends Base { */ protected $upgrades = array(); - /** Methods ***************************************************************/ - /** - * Hook into queries, admin screens, and more! + * Called after initialization. * - * @since 1.0.0 + * @since 3.0.0 */ - public function __construct() { + protected function init() { // Setup this database table $this->setup(); @@ -159,8 +167,8 @@ public function __construct() { // Add table to the database interface $this->set_db_interface(); - // Set the database schema - $this->set_schema(); + // Add the database schema + $this->add_schema(); // Add hooks $this->add_hooks(); @@ -171,6 +179,65 @@ public function __construct() { } } + /** Argument Handlers *****************************************************/ + + /** + * Validate arguments after they are parsed. + * + * @since 3.0.0 + * @param array $args Default empty array. + * @return array + */ + protected function validate_args( $args = array() ) { + + // Sanitization callbacks + $callbacks = array( + + // Table + 'name' => array( $this, 'sanitize_table_name' ), + 'description' => 'wp_kses_data', + 'version' => 'wp_kses_data', + 'global' => 'wp_validate_boolean', + 'db_version_key' => 'wp_kses_data', + 'db_version' => 'wp_kses_data', + 'table_prefix' => array( $this, 'sanitize_table_name' ), + 'table_name' => array( $this, 'sanitize_table_name' ), + 'prefixed_name' => array( $this, 'sanitize_table_name' ), + 'schema' => '', + 'charset_collation' => 'wp_kses_data', + 'comment' => array( $this, 'sanitize_extra' ), + + // Extras + 'upgrades' => '' + ); + + // Default return arguments + $r = array(); + + // Loop through and try to execute callbacks + foreach ( $args as $key => $value ) { + + // Callback is callable + if ( isset( $callbacks[ $key ] ) && is_callable( $callbacks[ $key ] ) ) { + $r[ $key ] = call_user_func( $callbacks[ $key ], $value ); + + /** + * Key has no validation method. + * + * Trust that the value has been validated. This may change in a + * future version. + */ + } else { + $r[ $key ] = $value; + } + } + + // Return sanitized arguments + return $r; + } + + /** Magic *****************************************************************/ + /** * Compatibility for clone() method for PHP versions less than 7.0. * @@ -189,15 +256,6 @@ public function __call( $function = '', $args = array() ) { } } - /** Abstract **************************************************************/ - - /** - * Setup this database table. - * - * @since 1.0.0 - */ - protected abstract function set_schema(); - /** Multisite *************************************************************/ /** @@ -385,7 +443,7 @@ public function exists() { * * See: https://dev.mysql.com/doc/refman/8.0/en/show-table-status.html * - * @since 2.1.0 + * @since 3.0.0 * * @return object */ @@ -456,8 +514,16 @@ public function create() { return false; } - // Bail if schema not initialized (tables need at least 1 column) - if ( empty( $this->schema ) ) { + // Bail if no schema to call + if ( ! is_callable( array( $this->schema, 'get_create_table_string' ) ) ) { + return false; + } + + // Get the "CREATE TABLE" string + $create_table_string = $this->schema->get_create_table_string(); + + // Bail if no create string. + if ( empty( $create_table_string ) ) { return false; } @@ -465,7 +531,7 @@ public function create() { $sql = array( 'CREATE TABLE', $this->table_name, - "( {$this->schema} )", + "( {$create_table_string} )", $this->charset_collation, ); @@ -661,7 +727,7 @@ public function count() { /** * Rename this database table. * - * @since 2.1.0 + * @since 3.0.0 * * @param string $new_table_name The new name of the current table, no prefix * @@ -698,7 +764,7 @@ public function rename( $new_table_name = '' ) { * Check if column already exists. * * @since 1.0.0 - * @since 2.1.0 Uses sanitize_column_name(). + * @since 3.0.0 Uses sanitize_column_name(). * * @param string $name Column name to check. * @@ -729,7 +795,7 @@ public function column_exists( $name = '' ) { * Check if index already exists. * * @since 1.0.0 - * @since 2.1.0 Uses sanitize_column_name(). + * @since 3.0.0 Uses sanitize_column_name(). * * @param string $name Index name to check. * @param string $column Column name to compare. @@ -769,7 +835,7 @@ public function index_exists( $name = '', $column = 'Key_name' ) { * * See: https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html * - * @since 2.1.0 + * @since 3.0.0 * * @return bool|string */ @@ -799,7 +865,7 @@ public function analyze() { * * See: https://dev.mysql.com/doc/refman/8.0/en/check-table.html * - * @since 2.1.0 + * @since 3.0.0 * * @return bool|string */ @@ -829,7 +895,7 @@ public function check() { * * See: https://dev.mysql.com/doc/refman/8.0/en/checksum-table.html * - * @since 2.1.0 + * @since 3.0.0 * * @return bool|string */ @@ -859,7 +925,7 @@ public function checksum() { * * See: https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html * - * @since 2.1.0 + * @since 3.0.0 * * @return bool|string */ @@ -890,7 +956,7 @@ public function optimize() { * 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 + * @since 3.0.0 * * @return bool|string */ @@ -1173,6 +1239,15 @@ private function delete_db_version() { : delete_option( $this->db_version_key ); } + /** + * Add the schema class. + * + * @since 3.0.0 + */ + private function add_schema() { + $this->schema = new $this->schema; + } + /** * Add class hooks to the parent application actions. * diff --git a/src/Database/Base.php b/src/Database/Traits/Base.php similarity index 95% rename from src/Database/Base.php rename to src/Database/Traits/Base.php index c1ed89e..9a07b33 100644 --- a/src/Database/Base.php +++ b/src/Database/Traits/Base.php @@ -8,7 +8,7 @@ * @license https://opensource.org/licenses/MIT MIT * @since 1.0.0 */ -namespace BerlinDB\Database; +namespace BerlinDB\Database\Traits; // Exit if accessed directly defined( 'ABSPATH' ) || exit; @@ -20,11 +20,11 @@ * classes that extend it, starting with a magic getter, but likely expanding * into a magic call handler and others. * - * @since 1.0.0 + * @since 3.0.0 * * @property array $args */ -class Base { +trait Base { /** * The name of the PHP global that contains the primary database interface. @@ -62,7 +62,7 @@ class Base { /** Public ****************************************************************/ /** - * Magic isset'ter for immutability. + * Magic isset(). * * @since 1.0.0 * @@ -84,7 +84,7 @@ public function __isset( $key = '' ) { } /** - * Magic getter for immutability. + * Magic get(). * * @since 1.0.0 * @@ -126,7 +126,7 @@ public function to_array() { * Maybe append the prefix to string. * * @since 1.0.0 - * @since 2.1.0 Prevents double prefixing + * @since 3.0.0 Prevents double prefixing * * @param string $string * @param string $sep @@ -220,7 +220,7 @@ protected function first_letters( $string = '', $sep = '_' ) { * - No trailing underscores * * @since 1.0.0 - * @since 2.1.0 Allow uppercase letters + * @since 3.0.0 Allow uppercase letters * * @param string $name The name of the database table * @@ -270,7 +270,7 @@ protected function sanitize_table_name( $name = '' ) { * - No double underscores * - No trailing underscores * - * @since 2.1.0 + * @since 3.0.0 * * @param string $name The name of the database column * @@ -311,7 +311,7 @@ protected function set_vars( $args = array() ) { * the object variable values, for later comparison, reuse, or resetting * back to a previous state. * - * @since 2.1.0 + * @since 3.0.0 * @param array $args */ protected function stash_args( $args = array() ) { @@ -325,7 +325,7 @@ protected function stash_args( $args = array() ) { * Return the global database interface. * * @since 1.0.0 - * @since 2.1.0 Improved PHP8 support, remove $GLOBALS superglobal usage + * @since 3.0.0 Improved PHP8 support, remove $GLOBALS superglobal usage * * @return bool|\wpdb Database interface, or False if not set */ @@ -365,7 +365,7 @@ protected function get_db() { * pass falsy values on success. * * @since 1.0.0 - * @since 2.1.0 Minor refactor to improve readability. + * @since 3.0.0 Minor refactor to improve readability. * * @param mixed $result Optional. Default false. Any value to check. * @return bool diff --git a/src/Database/Traits/Boot.php b/src/Database/Traits/Boot.php new file mode 100644 index 0000000..dc8d5db --- /dev/null +++ b/src/Database/Traits/Boot.php @@ -0,0 +1,127 @@ +boot( $args ); + } + + /** + * Initialize the table. + * + * @since 3.0.0 + */ + protected function boot( $args = array() ) { + + // Early. + $this->sunrise(); + + // Parse arguments. + $r = $this->parse_args( $args ); + + // Maybe set variables from arguments. + if ( ! empty( $r ) ) { + $this->set_vars( $r ); + } + + // Initialize. + $this->init(); + } + + /** + * Called early, before arguments are parsed. + * + * @since 3.0.0 + */ + protected function sunrise() { + + } + + /** Argument Handlers *****************************************************/ + + /** + * Parse arguments. + * + * @since 3.0.0 Arguments are stashed. Bails if $args is empty. + * @param array $args Default empty array. + * @return array + */ + protected function parse_args( $args = array() ) { + + // Stash the arguments + $this->stash_args( $args ); + + // Bail if no arguments + if ( empty( $args ) ) { + return 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 arguments before they are validated & sanitized + $this->set_vars( $r ); + + // Return array + return $this->validate_args( $r ); + } + + /** + * Parse special arguments. + * + * @since 3.0.0 + * @param array $args + * @return array + */ + protected function special_args( $args = array() ) { + return $args; + } + + /** + * Validate arguments. + * + * @since 3.0.0 + * @param array $args + * @return array + */ + protected function validate_args( $args = array() ) { + return $args; + } + + /** + * Initialize. + * + * @since 3.0.0 + */ + protected function init() { + + } +} diff --git a/src/Database/Traits/Operator.php b/src/Database/Traits/Operator.php new file mode 100644 index 0000000..e668985 --- /dev/null +++ b/src/Database/Traits/Operator.php @@ -0,0 +1,73 @@ +" or "<" or "BETWEEN" type of operator? + * + * @since 3.0.0 + * @var bool + */ + protected $numeric = false; + + + protected function get_sql( $value = null, $pattern = '%s' ) { + + } + + protected function init( $args = array() ) { + foreach ( $args as $key => $value ) { + $this->{$key} = $value; + } + } +} diff --git a/src/Database/Traits/Parser.php b/src/Database/Traits/Parser.php new file mode 100644 index 0000000..9b66f58 --- /dev/null +++ b/src/Database/Traits/Parser.php @@ -0,0 +1,1574 @@ + '=', + 'positive' => true, + 'multi' => false, + 'numeric' => false + ), + array( + 'compare' => '!=', + 'positive' => false, + 'multi' => false, + 'numeric' => false + ), + + // > + array( + 'compare' => '>', + 'positive' => true, + 'multi' => false, + 'numeric' => true + ), + array( + 'compare' => '>=', + 'positive' => true, + 'multi' => false, + 'numeric' => true + ), + + // < + array( + 'compare' => '<', + 'positive' => true, + 'multi' => false, + 'numeric' => true + ), + array( + 'compare' => '<=', + 'positive' => true, + 'multi' => false, + 'numeric' => true + ), + + // LIKE + array( + 'compare' => 'LIKE', + 'positive' => true, + 'multi' => false, + 'numeric' => false + ), + array( + 'compare' => 'NOT LIKE', + 'positive' => false, + 'multi' => false, + 'numeric' => false + ), + + // IN + array( + 'compare' => 'IN', + 'positive' => true, + 'multi' => true, + 'numeric' => false + ), + array( + 'compare' => 'NOT IN', + 'positive' => false, + 'multi' => true, + 'numeric' => false + ), + + // BETWEEN + array( + 'compare' => 'BETWEEN', + 'positive' => true, + 'multi' => true, + 'numeric' => true + ), + array( + 'compare' => 'NOT BETWEEN', + 'positive' => false, + 'multi' => true, + 'numeric' => true + ), + + // EXISTS + array( + 'compare' => 'EXISTS', + 'positive' => true, + 'multi' => false, + 'numeric' => false + ), + array( + 'compare' => 'NOT EXISTS', + 'positive' => false, + 'multi' => false, + 'numeric' => false + ), + + // REGEXP + array( + 'compare' => 'REGEXP', + 'positive' => true, + 'multi' => false, + 'numeric' => false + ), + array( + 'compare' => 'NOT REGEXP', + 'positive' => false, + 'multi' => false, + 'numeric' => false + ), + + // RLIKE + array( + 'compare' => 'RLIKE', + 'positive' => true, + 'multi' => false, + 'numeric' => false + ) + ); + + /** + * Supported multi-value comparison types. + * + * @since 3.0.0 + * @var array + */ + public $multi_value_keys = array(); + + /** + * Supported relation types. + * + * @since 3.0.0 + * @var array + */ + public $relation_keys = array( + 'OR', + 'AND' + ); + + /** + * Whether the query contains any OR relations. + * + * @since 3.0.0 + * @var bool + */ + protected $has_or_relation = false; + + /** + * Constructor. + * + * @since 3.0.0 + */ + public function __construct( $query_vars = array(), $caller = null ) { + $this->init( $query_vars, $caller ); + } + + /** + * Initialize the parser. + * + * When 'compare' is: + * - 'IN' or 'NOT IN' - arrays are accepted + * - 'BETWEEN' or 'NOT BETWEEN' - arrays of two valid values are required + * + * See individual argument descriptions for accepted values. + * + * @since 3.0.0 + * + * @param array $query_vars { + * Array of query clauses. + * + * @type array ...$0 { + * @type string $column Optional. The column to query against. + * Default ''. + * @type string $compare Optional. The comparison operator. Accepts '=', '!=', '>', '>=', '<', '<=', + * 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'LIKE', 'RLIKE'. Default '='. + * @type string $relation Optional. The boolean relationship between the queries. Accepts 'OR' or 'AND'. + * Default 'OR'. + * @type array ...$0 { + * Optional. An array of first-order clause parameters, or another fully-formed query. + * } + * } + * } + * @param Query $caller The Query class that invoked this parser. + */ + public function init( $query_vars = array(), $caller = null ) { + + // Set the caller & first_keys. + $this->set_caller( $caller ); + $this->set_first_keys( array() ); + + // Set default class attributes from query. + $this->now = $this->get_now( $query_vars ); + $this->column = $this->get_column( $query_vars ); + $this->compare = $this->get_compare( $query_vars ); + $this->relation = $this->get_relation( $query_vars ); + $this->start_of_week = $this->get_start_of_week( $query_vars ); + + // Support for passing some key in the top level of the array. + if ( ! isset( $query_vars[ 0 ] ) ) { + $query_vars = array( $query_vars ); + } + + // Set the queries. + $this->queries = $this->sanitize_query( $query_vars ); + } + + /** + * Sets the caller. + * + * @since 3.0.0 + * + * @param Query $caller + */ + protected function set_caller( $caller = null ) { + $this->caller = $caller; + } + + /** + * Sets the first-order keys to use. + * + * @since 3.0.0 + * + * @param array $first_keys Array of first-order keys. + */ + protected function set_first_keys( $first_keys = array() ) { + $this->first_keys = $this->get_first_keys( $first_keys ); + } + + /** + * Recursive-friendly query sanitizer. + * + * Ensures that each query-level clause has a 'relation' key, and that + * each first-order clause contains all the necessary keys from $defaults. + * + * @since 3.0.0 + * + * @param array $queries + * @param array $parent_query + * + * @return array Sanitized queries. + */ + public function sanitize_query( $queries = array(), $parent_query = array() ) { + + // Bail if bad queries. + if ( empty( $queries ) || ! is_array( $queries ) ) { + return array(); + } + + // Default return value. + $retval = array(); + + // Setup defaults. + $defaults = $this->get_defaults(); + + // Numeric keys should always have array values. + foreach ( $queries as $qkey => $qvalue ) { + if ( is_numeric( $qkey ) && ! is_array( $qvalue ) ) { + unset( $queries[ $qkey ] ); + } + } + + /** + * Each query should have a value for each default key. + * + * Inherit from the parent when possible. + */ + foreach ( $defaults as $dkey => $dvalue ) { + + // Skip if already set. + if ( isset( $queries[ $dkey ] ) ) { + continue; + } + + // Set the query. + $queries[ $dkey ] = isset( $parent_query[ $dkey ] ) + ? $parent_query[ $dkey ] + : $dvalue; + } + + // Validate the values passed in the query. + if ( $this->is_first_order_clause( $queries ) ) { + $this->validate_values( $queries ); + } + + // Default empty relation. + $relation = ''; + + // Add queries to return array. + foreach ( $queries as $key => $query ) { + + // Set relation. + if ( 'relation' === $key ) { + $relation = strtoupper( $query ); + } + + /** + * This is a first-order query. + * + * Trust the values and sanitize when building SQL. + */ + if ( ! is_array( $query ) || in_array( $key, $this->first_keys, true ) ) { + $retval[ $key ] = $query; + + /** + * This is a first-order query. + * + * Trust the values and sanitize when building SQL. + */ + } elseif ( $this->is_first_order_clause( $query ) ) { + $retval[ $key ] = $query; + + /** + * Any array without a $first_key is another query, so we recurse. + */ + } else { + $cleaned = $this->sanitize_query( $query, $queries ); + + // Add non-empty queries only. + if ( ! empty( $cleaned ) ) { + $retval[ $key ] = $cleaned; + } + } + } + + // Bail if nothing to do. + if ( empty( $retval ) ) { + return $retval; + } + + // Sanitize the 'relation' key provided in the query. + if ( 'OR' === $relation ) { + $retval['relation'] = 'OR'; + $this->has_or_relation = true; + + /* + * If there is only a single clause, call the relation 'OR'. + * This value will not actually be used to join clauses, but it + * simplifies the logic around combining key-only queries. + */ + } elseif ( 1 === count( $retval ) ) { + $retval['relation'] = 'OR'; + + // Default to AND. + } else { + $retval['relation'] = 'AND'; + } + + // Return sanitized queries. + return $retval; + } + + /** + * Determine if this is a first-order clause. + * + * If it includes anything from $first_keys. + * + * @since 3.0.0 + * + * @param array $query Query clause. + * + * @return bool True if this is a first-order clause. + */ + protected function is_first_order_clause( $query = array() ) { + return (bool) $this->get_first_order_clauses( $query ); + } + + /** + * Get the intersection of first-order keys in the $query keys. + * + * @since 3.0.0 + * + * @param array $query Query clause. + * + * @return array + */ + protected function get_first_order_clauses( $query = array() ) { + + // Bail if empty. + if ( empty( $query ) || empty( $this->first_keys ) ) { + return array(); + } + + // Get intersection. + $intersect = array_intersect( $this->first_keys, array_keys( $query ) ); + + // Bail if no intersection. + if ( empty( $intersect ) ) { + return array(); + } + + // Get keys & clauses. + $retval = array_intersect_key( $query, array_flip( $intersect ) ); + + return $retval; + } + + /** + * Get $operators, possibly filtered & plucked. + * + * @since 3.0.0 + * + * @param array $filter Optional. An array of key => value arguments to match + * against each object. Default empty array. + * @param bool|string $field Optional. A field from the object to place instead + * of the entire object. Default false. + * @return array + */ + public function get_operators( $filter = array(), $field = 'compare' ) { + return wp_filter_object_list( $this->operators, $filter, 'and', $field ); + } + + /** + * Determines and validates the default values for a query or subquery. + * + * @since 3.0.0 + * + * @param array $query A query or subquery. + * + * @return array The comparison operator. + */ + public function get_defaults( $query = array() ) { + return array( + 'now' => $this->get_now( $query ), + 'column' => $this->get_column( $query ), + 'compare' => $this->get_compare( $query ), + 'relation' => $this->get_relation( $query ), + 'start_of_week' => $this->get_start_of_week( $query ) + ); + } + + /** + * Determines and validates which column to use. + * + * Use column if passed. + * + * @since 3.0.0 + * + * @param array $query A query or subquery. + * + * @return string The comparison operator. + */ + protected function get_column( $query = array() ) { + return ! empty( $query['column'] ) + ? esc_sql( $this->validate_column( $query['column'] ) ) + : $this->column; + } + + /** + * Determines and validates which comparison operator to use. + * + * Compare must be in the $comparison_keys array. + * + * @since 3.0.0 + * + * @param array $query A query or a subquery. + * + * @return string The comparison operator. + */ + protected function get_compare( $query = array() ) { + static $comparison_keys = null; + + if ( null === $comparison_keys ) { + $comparison_keys = $this->get_operators(); + } + + return ! empty( $query['compare'] ) && in_array( $query['compare'], $comparison_keys, true ) + ? strtoupper( $query['compare'] ) + : $this->compare; + } + + /** + * Determines and validates which relation to use. + * + * Relation must be in the $relation_keys array. + * + * @since 3.0.0 + * + * @param array $query A query or a subquery. + * + * @return string The relation operator. + */ + protected function get_relation( $query = array() ) { + return ! empty( $query['relation'] ) && in_array( $query['relation'], $this->relation_keys, true ) + ? strtoupper( $query['relation'] ) + : $this->relation; + } + + /** + * Determines and validates what the current UNIX timestamp is. + * + * Use now if passed, or time(). + * + * @since 3.0.0 + * + * @param array $query A date query or a date subquery. + * + * @return int The current UNIX timestamp. + */ + protected function get_now( $query = array() ) { + return ! empty( $query['now'] ) && is_numeric( $query['now'] ) + ? (int) $query['now'] + : time(); + } + + /** + * Determines and validates what start_of_week to use. + * + * Use start of week if passed and valid. + * + * @since 3.0.0 + * + * @param array $query A date query or a date subquery. + * + * @return int The comparison operator. + */ + protected function get_start_of_week( $query = array() ) { + return (int) isset( $query['start_of_week'] ) && ( 6 >= (int) $query['start_of_week'] ) && ( 0 <= (int) $query['start_of_week'] ) + ? $query['start_of_week'] + : $this->start_of_week; + } + + /** + * Determines and validates what first-order keys to use. + * + * Use first $first_keys if passed and valid. + * + * @since 3.0.0 + * + * @param array $first_keys Array of first-order keys. + * + * @return array The first-order keys. + */ + protected function get_first_keys( $first_keys = array() ) { + return ! empty( $first_keys ) && is_array( $first_keys ) + ? $first_keys + : $this->first_keys; + } + + /** + * Generates SQL clauses to be appended to a main query. + * + * @since 3.0.0 + * + * @param string $type Type of object. + * @param string $primary_table Primary table for the object being filtered. + * @param string $primary_column Primary column for the filtered object in $primary_table. + * + * @return string[]|false { + * Array containing JOIN and WHERE SQL clauses to append to the main query, + * or false if no table exists for the requested type. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql( $type = '', $primary_table = '', $primary_column = '' ) { + + // Get the SQL clauses. + $retval = $this->get_sql_clauses(); + + /** + * If any JOINs are LEFT JOINs (as in the case of NOT EXISTS) then all + * JOINs should be LEFT. Otherwise items with no values will be excluded + * from results. + */ + if ( false !== strpos( $retval['join'], 'LEFT JOIN' ) ) { + $retval['join'] = str_replace( 'INNER JOIN', 'LEFT JOIN', $retval['join'] ); + } + + // Return join/where array. + return $retval; + } + + /** + * Generate SQL clauses to be appended to a main query. + * + * Called by the public get_sql(), this method is abstracted + * out to maintain parity with the other Query classes. + * + * @since 3.0.0 + * + * @return array { + * Array containing JOIN and WHERE SQL clauses to append to the main query. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + protected function get_sql_clauses() { + + // Get SQL join/where array. + $queries = $this->queries; + $retval = $this->get_sql_for_query( $queries ); + + // Maybe prefix 'where' with " AND " + if ( ! empty( $retval[ 'where' ] ) ) { + $retval[ 'where' ] = ' AND ' . $retval[ 'where' ]; + } + + // Return join/where array. + return $retval; + } + + /** + * Generate SQL clauses for a single query array. + * + * If nested subqueries are found, this method recurses the tree to + * produce the properly nested SQL. + * + * @since 3.0.0 + * + * @param array $query Query to parse. + * @param int $depth Optional. Number of tree levels deep we currently are. + * Used to calculate indentation. Default 0. + * @return array { + * Array containing JOIN and WHERE SQL clauses to append to a single query array. + * + * @type string $join SQL fragment to append to the main JOIN clause. + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + protected function get_sql_for_query( &$query = array(), $depth = 0 ) { + + // SQL parts. + $sql = array( + 'join' => array(), + 'where' => array(), + ); + + // Default return values. + $retval = array( + 'join' => '', + 'where' => '', + ); + + // Default strings. + $indent = $relation = ''; + + // Set indentation using depth. + for ( $i = 0; $i < $depth; $i++ ) { + $indent .= ' '; + } + + // Bail if no query. + if ( empty( $query ) ) { + return $retval; + } + + // Loop through query keys & clauses. + foreach ( $query as $key => &$clause ) { + + // Set $relation if set. + if ( 'relation' === $key ) { + $relation = $query[ 'relation' ]; + } + + if ( is_array( $clause ) ) { + + // This is a first-order clause. + if ( $this->is_first_order_clause( $clause ) ) { + + // Get clauses & where count. + $clause_sql = $this->get_sql_for_clause( $clause, $query, $key ); + $where_count = count( $clause_sql[ 'where' ] ); + + // Empty SQL. + if ( 0 === $where_count ) { + $sql[ 'where' ][] = ''; + + // Add clause. + } elseif ( 1 === $where_count ) { + $sql[ 'where' ][] = reset( $clause_sql[ 'where' ] ); + + // Implode many clauses. + } else { + $sql[ 'where' ][] = '( ' . implode( ' AND ', $clause_sql[ 'where' ] ) . ' )'; + } + + // Merge joins. + $sql[ 'join' ] = array_merge( $sql[ 'join' ], $clause_sql[ 'join' ] ); + + // This is a subquery, so we recurse. + } else { + $clause_sql = $this->get_sql_for_query( $clause, $depth + 1 ); + + // Add clauses to SQL. + $sql[ 'join' ][] = $clause_sql[ 'join' ]; + $sql[ 'where' ][] = $clause_sql[ 'where' ]; + } + } + } + + // Filter to remove empties. + $sql[ 'join' ] = array_filter( $sql[ 'join' ] ); + $sql[ 'where' ] = array_filter( $sql[ 'where' ] ); + + // Default relation. + if ( empty( $relation ) ) { + $relation = 'AND'; + } + + // Remove duplicate JOIN clauses, and combine into a single string. + if ( ! empty( $sql[ 'join' ] ) ) { + $retval[ 'join' ] = implode( ' ', array_unique( $sql[ 'join' ] ) ); + } + + // Generate a single WHERE clause with proper brackets and indentation. + if ( ! empty( $sql[ 'where' ] ) ) { + $retval[ 'where' ] = '( ' . "\n {$indent}" . implode( " \n {$indent}{$relation} \n {$indent}", $sql[ 'where' ] ) . "\n{$indent}" . ')'; + } + + // Return join/where array. + return $retval; + } + + /** + * Generate SQL for a query clause. + * + * @since 3.0.0 + * + * @param array $clause Query clause (passed by reference). + * @param array $parent_query Parent query array. + * @param string $clause_key Optional. The array key used to name the clause. + * If not provided, a key will be generated automatically. + * @return array { + * Array containing WHERE SQL clauses to append to a first-order query. + * + * @type string $where SQL fragment to append to the main WHERE clause. + * } + */ + public function get_sql_for_clause( &$clause = array(), $parent_query = array(), $clause_key = '' ) { + + // Default return value. + $retval = array( + 'join' => array(), + 'where' => array(), + ); + + // Maybe format compare clause. + if ( isset( $clause['compare'] ) ) { + $clause['compare'] = strtoupper( $clause['compare'] ); + + // Or set compare clause based on value. + } else { + $clause['compare'] = isset( $clause['value'] ) && is_array( $clause['value'] ) + ? 'IN' + : '='; + } + + // Get all comparison operators. + $all_compares = $this->$this->get_operators(); + + // Fallback to equals + if ( ! in_array( $clause['compare'], $all_compares, true ) ) { + $clause['compare'] = '='; + } + + // Uppercase or equals + if ( isset( $clause['compare_key'] ) && ( 'LIKE' === strtoupper( $clause['compare_key'] ) ) ) { + $clause['compare_key'] = strtoupper( $clause['compare_key'] ); + } else { + $clause['compare_key'] = '='; + } + + // Get comparison from clause + $compare = $clause['compare']; + + /** Build the WHERE clause ********************************************/ + + // Column name and value. + if ( array_key_exists( 'key', $clause ) && array_key_exists( 'value', $clause ) ) { + $column = $this->sanitize_column_name( $clause['key'] ); + $where = $this->build_value( $compare, $clause['value'], '%s' ); + + // Maybe add column, compare, & where to return value. + if ( ! empty( $where ) ) { + $retval['where'][] = "{$column} {$compare} {$where}"; + } + } + + // Multiple WHERE clauses should be joined in parentheses. + if ( 1 < count( $retval['where'] ) ) { + $retval['where'] = array( '( ' . implode( ' AND ', $retval['where'] ) . ' )' ); + } + + // Return join/where array. + return $retval; + } + + /** + * Return the appropriate alias for the given type if applicable. + * + * @since 3.0.0 + * + * @param string $type MySQL type to CAST(). + * @return string MySQL type. + */ + public function get_cast_for_type( $type = '' ) { + + // Bail if empty. + if ( empty( $type ) ) { + return 'CHAR'; + } + + // Convert to uppercase. + $upper_type = strtoupper( $type ); + + // Bail if no match. + if ( ! preg_match( '/^(?:BINARY|CHAR|DATE|DATETIME|SIGNED|UNSIGNED|TIME|NUMERIC(?:\(\d+(?:,\s?\d+)?\))?|DECIMAL(?:\(\d+(?:,\s?\d+)?\))?)$/', $upper_type ) ) { + return 'CHAR'; + } + + // Fallback support for old 'NUMERIC' type. + if ( 'NUMERIC' === $upper_type ) { + $upper_type = 'SIGNED'; + } + + // Return uppercase type. + return $upper_type; + } + + /** + * Validates the given query values. + * + * @since 3.0.0 + * @param array $query The query array. + * @return bool True if all values in the query are valid, false if one or + * more fail. + */ + public function validate_values( $query = array() ) { + + // Bail if empty. + if ( empty( $query ) ) { + return false; + } + + // Default valid. + $valid = true; + + // Values are passthroughs. + if ( array_key_exists( 'value', $query ) ) { + $valid = true; + } + + // Return if valid or not. + return $valid; + } + + /** + * Validates a column name parameter. + * + * Keeps upper & lower case letters, numbers, periods, and underscores. + * + * @since 3.0.0 + * @param string $column The user-supplied column name. + * @return string A validated column name value. + */ + protected function validate_column( $column = '' ) { + return preg_replace( '/[^a-zA-Z0-9_$\.]/', '', $column ); + } + + /** + * Builds and validates a value string based on the comparison operator. + * + * @since 3.0.0 + * + * @param string $compare The compare operator to use + * @param array|int|string $value The value + * + * @return string|bool|int The value to be used in SQL or false on error. + */ + protected function build_numeric_value( $compare = '=', $value = null ) { + + // Bail if null value. + if ( is_null( $value ) ) { + return false; + } + + // Cast to array. + $value = (array) $value; + + // Remove non-numeric values. + $value = array_filter( $value, 'is_numeric' ); + + // Bail if no values. + if ( empty( $value ) ) { + return false; + } + + // Map to ints. + $values = array_map( 'intval', $value ); + + // Compare. + switch ( $compare ) { + + // IN & NOT IN. + case 'IN': + case 'NOT IN': + return '(' . implode( ',', $values ) . ')'; + + // BETWEEN & NOT BETWEEN. + case 'BETWEEN': + case 'NOT BETWEEN': + + // Exactly 2 values. + if ( 2 === count( $value ) ) { + $value = array_values( $value ); + + // Not 2 values, so guess, by using first & last. + } else { + $value = array( + reset( $value ), + end( $value ) + ); + } + + return $values[0] . ' AND ' . $values[1]; + + // Everything else. + default: + return (int) reset( $value ); + } + } + + /** + * Builds and validates a value string based on the comparison operator. + * + * @since 3.0.0 + * + * @param string $compare The compare operator to use. + * @param array|string $value The value. + * @param string $pattern The pattern. + * + * @return string|false|int The value to be used in SQL or false on error. + */ + protected function build_value( $compare = '=', $value = null, $pattern = '%s' ) { + + // Get the database interface. + $db = $this->get_db(); + + // Bail if no database. + if ( empty( $db ) ) { + return ''; + } + + // Maybe split value by commas & spaces if multi. + if ( is_scalar( $value ) ) { + + // Trim empties. + $value = trim( $value ); + + // Get multi-value comparison operators. + $mvk = $this->get_operators( array( 'multi' => true ) ); + + /** + * Maybe split value by commas or spaces to support certain multi- + * value compare keys with values like: "100, 200". + */ + if ( in_array( $compare, $mvk, true ) ) { + $value = preg_split( '/[,\s]+/', $value ); + } + } + + // Compare. + switch ( $compare ) { + case 'IN': + case 'NOT IN': + $in = '(' . substr( str_repeat( ",{$pattern}", count( $value ) ), 1 ) . ')'; + $retval = $db->prepare( $in, $value ); + break; + + case 'BETWEEN': + case 'NOT BETWEEN': + $value = array_slice( $value, 0, 2 ); + $retval = $db->prepare( "{$pattern} AND {$pattern}", $value ); + break; + + case 'LIKE': + case 'NOT LIKE': + $value = '%' . $db->esc_like( $value ) . '%'; + $retval = $db->prepare( $pattern, $value ); + break; + + // EXISTS with a value is interpreted as '='. + case 'EXISTS': + $compare = '='; + $retval = $db->prepare( $pattern, $value ); + break; + + // 'value' is ignored for NOT EXISTS. + case 'NOT EXISTS': + $retval = ''; + break; + + default: + $retval = $db->prepare( $pattern, $value ); + break; + } + + // Return + return $retval; + } + + /** + * Builds a MySQL format date/time based on some query parameters. + * + * You can pass an array of values (year, month, etc.) with missing + * parameter values being defaulted to either the maximum or minimum values + * (controlled by the $default_to parameter). + * + * Alternatively you can pass a string that will be run through strtotime(). + * + * @since 3.0.0 + * + * @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 + */ + protected function build_mysql_datetime( $datetime = '', $default_to_max = false, $now = 0 ) { + + // Datetime is string + if ( is_string( $datetime ) ) { + + // Define matches so linters don't complain + $matches = array(); + + /* + * Try to parse some common date formats, so we can detect + * the level of precision and support the 'inclusive' parameter. + */ + + // Y + if ( preg_match( '/^(\d{4})$/', $datetime, $matches ) ) { + $datetime = array( + 'year' => intval( $matches[1] ), + ); + + // Y-m + } elseif ( preg_match( '/^(\d{4})\-(\d{2})$/', $datetime, $matches ) ) { + $datetime = array( + 'year' => intval( $matches[1] ), + 'month' => intval( $matches[2] ), + ); + + // Y-m-d + } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2})$/', $datetime, $matches ) ) { + $datetime = array( + 'year' => intval( $matches[1] ), + 'month' => intval( $matches[2] ), + 'day' => intval( $matches[3] ), + ); + + // Y-m-d H:i + } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2}) (\d{2}):(\d{2})$/', $datetime, $matches ) ) { + $datetime = array( + 'year' => intval( $matches[1] ), + 'month' => intval( $matches[2] ), + 'day' => intval( $matches[3] ), + 'hour' => intval( $matches[4] ), + 'minute' => intval( $matches[5] ), + ); + + // Y-m-d H:i:s + } elseif ( preg_match( '/^(\d{4})\-(\d{2})\-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $datetime, $matches ) ) { + $datetime = array( + 'year' => intval( $matches[1] ), + 'month' => intval( $matches[2] ), + 'day' => intval( $matches[3] ), + 'hour' => intval( $matches[4] ), + 'minute' => intval( $matches[5] ), + 'second' => intval( $matches[6] ), + ); + } + } + + // No match; may be int or string + if ( ! is_array( $datetime ) ) { + + // Maybe format or use as-is + $datetime = ! is_int( $datetime ) + ? strtotime( $datetime, $now ) + : (int) $datetime; + + // Return formatted + return gmdate( 'Y-m-d H:i:s', $datetime ); + } + + // Map to ints + $datetime = array_map( 'intval', $datetime ); + + // Year + if ( ! isset( $datetime['year'] ) ) { + $datetime['year'] = gmdate( 'Y', $now ); + } + + // Month + if ( ! isset( $datetime['month'] ) ) { + $datetime['month'] = ! empty( $default_to_max ) + ? 12 + : 1; + } + + // Day + if ( ! isset( $datetime['day'] ) ) { + $datetime['day'] = ! empty( $default_to_max ) + ? (int) gmdate( 't', gmmktime( 0, 0, 0, $datetime['month'], 1, $datetime['year'] ) ) + : 1; + } + + // Hour + if ( ! isset( $datetime['hour'] ) ) { + $datetime['hour'] = ! empty( $default_to_max ) + ? 23 + : 0; + } + + // Minute + if ( ! isset( $datetime['minute'] ) ) { + $datetime['minute'] = ! empty( $default_to_max ) + ? 59 + : 0; + } + + // Second + if ( ! isset( $datetime['second'] ) ) { + $datetime['second'] = ! empty( $default_to_max ) + ? 59 + : 0; + } + + // Combine and return + return sprintf( + '%04d-%02d-%02d %02d:%02d:%02d', + $datetime['year'], + $datetime['month'], + $datetime['day'], + $datetime['hour'], + $datetime['minute'], + $datetime['second'] + ); + } + + /** + * Return a MySQL expression for selecting the week number based on the + * day that the week starts. + * + * Uses the WordPress site option, if set. + * + * @since 1.0.0 + * + * @param string $column Database column. + * @param int $start_of_week Day that week starts on. 0 = Sunday. + * + * @return string SQL clause. + */ + protected function build_mysql_week( $column = '', $start_of_week = 0 ) { + + // When does the week start? + switch ( $start_of_week ) { + + // Monday + case 1: + $retval = "WEEK( {$column}, 1 )"; + break; + + // Tuesday - Saturday + case 2: + case 3: + case 4: + case 5: + case 6: + $retval = "WEEK( DATE_SUB( {$column}, INTERVAL {$start_of_week} DAY ), 0 )"; + break; + + // Sunday + case 0: + default: + $retval = "WEEK( {$column}, 0 )"; + break; + } + + // Return SQL + return $retval; + } + + /** + * Builds a query string for comparing time values (hour, minute, second). + * + * If just hour, minute, or second is set than a normal comparison will be done. + * However if multiple values are passed, a pseudo-decimal time will be created + * in order to be able to accurately compare against. + * + * @since 3.0.0 + * + * @param string $column The column to query against. Needs to be pre-validated! + * @param string $compare The comparison operator. Needs to be pre-validated! + * @param int|null $hour Optional. An hour value (0-23). + * @param int|null $minute Optional. A minute value (0-59). + * @param int|null $second Optional. A second value (0-59). + * + * @return string|false A query part or false on failure. + */ + protected function build_time_query( $column = '', $compare = '=', $hour = null, $minute = null, $second = null ) { + + // Have to have at least one. + if ( ! isset( $hour ) && ! isset( $minute ) && ! isset( $second ) ) { + return false; + } + + // Get the database interface. + $db = $this->get_db(); + + // Bail if no database. + if ( empty( $db ) ) { + return false; + } + + // Get multi-value comparison operators. + $mvk = $this->get_operators( array( 'multi' => true ) ); + + /** + * Complex combined queries aren't supported for multi-value queries. + */ + if ( in_array( $compare, $mvk, true ) ) { + $retval = array(); + + // Hour. + if ( isset( $hour ) && false !== ( $value = $this->build_numeric_value( $compare, $hour ) ) ) { + $retval[] = "HOUR( {$column} ) {$compare} {$value}"; + } + + // Minute. + if ( isset( $minute ) && false !== ( $value = $this->build_numeric_value( $compare, $minute ) ) ) { + $retval[] = "MINUTE( {$column} ) {$compare} {$value}"; + } + + // Second. + if ( isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $second ) ) ) { + $retval[] = "SECOND( {$column} ) {$compare} {$value}"; + } + + // Return SQL. + return implode( ' AND ', $retval ); + } + + // Cases where just one unit is set + + // Hour. + if ( isset( $hour ) && ! isset( $minute ) && ! isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $hour ) ) ) { + return "HOUR( {$column} ) {$compare} {$value}"; + + // Minute. + } elseif ( ! isset( $hour ) && isset( $minute ) && ! isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $minute ) ) ) { + return "MINUTE( {$column} ) {$compare} {$value}"; + + // Second. + } elseif ( ! isset( $hour ) && ! isset( $minute ) && isset( $second ) && false !== ( $value = $this->build_numeric_value( $compare, $second ) ) ) { + return "SECOND( {$column} ) {$compare} {$value}"; + } + + /** + * Single units were already handled. + * + * Since hour & second isn't allowed, minute must to be set. + */ + if ( ! isset( $minute ) ) { + return false; + } + + // Defaults. + $format = $time = ''; + + // Hour. + if ( null !== $hour ) { + $format .= '%H.'; + $time .= sprintf( '%02d', $hour ) . '.'; + } else { + $format .= '0.'; + $time .= '0.'; + } + + // Minute. + $format .= '%i'; + $time .= sprintf( '%02d', $minute ); + + // Second. + if ( isset( $second ) ) { + $format .= '%s'; + $time .= sprintf( '%02d', $second ); + } + + // Build the SQL. + $query = "DATE_FORMAT( {$column}, %s ) {$compare} %f"; + + // Return the prepared SQL. + return $db->prepare( $query, $format, $time ); + } + + /** + * Used to generate the SQL string for IN and NOT IN clauses. + * + * The $values being passed in should not be validated, and they will be + * escaped before they are concatenated together and returned as a string. + * + * @since 3.0.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. + */ + protected function build_in_sql( $column_name = '', $values = array(), $wrap = true, $pattern = '' ) { + + // Bail if no values or invalid column + if ( empty( $values ) || ! $this->caller( 'is_valid_column', array( $column_name ) ) ) { + return ''; + } + + // Get the database interface + $db = $this->get_db(); + + // Bail if no database interface is available + if ( empty( $db ) ) { + return ''; + } + + // Fallback to column pattern + if ( empty( $pattern ) || ! is_string( $pattern ) ) { + $pattern = $this->caller( 'get_column_field', array( array( 'name' => $column_name ), 'pattern', '%s' ) ); + } + + // 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 = $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 ) ) { + $retval = ''; + } + + // Wrap them in parenthesis + if ( true === $wrap ) { + $retval = "({$retval})"; + } + + // Return in SQL + return $retval; + } + + /** + * Identify an existing table alias that is compatible with the current + * query clause. + * + * Avoid unnecessary table JOINs by allowing each clause to look for an + * existing table alias that is compatible with the query that it needs + * to perform. + * + * An existing alias is compatible if: + * (a) it is a sibling of $clause (under the scope of the same relation) + * (b) the combination of operator and relation between the clauses allows + * for a shared table join. + * + * In the case of Meta, this only applies to 'IN' clauses that are connected + * by the relation 'OR'. + * + * @since 3.0.0 + * + * @param array $clause Query clause. + * @param array $parent_query Parent query of $clause. + * + * @return string|false Table alias if found, otherwise false. + */ + protected function find_compatible_table_alias( $clause = array(), $parent_query = array() ) { + + // Bail if no $parent_query. + if ( empty( $parent_query ) || ! is_array( $parent_query ) ) { + return false; + } + + // Default return value. + $retval = false; + + // Loop through sibling queries. + foreach ( $parent_query as $sibling ) { + + // Skip if the sibling has no alias. + if ( empty( $sibling['alias'] ) ) { + continue; + } + + // Skip if not a first-order clause. + if ( ! is_array( $sibling ) || ! $this->is_first_order_clause( $sibling ) ) { + continue; + } + + // Default empty compares for sibling. + $compatible_compares = array(); + + /** + * Clauses connected by OR can share JOINs as long as they have + * "positive" operators. + */ + if ( 'OR' === $parent_query['relation'] ) { + $compatible_compares = $this->get_operators( array( 'positive' => true ) ); + + /** + * Clauses JOIN'ed by AND with "negative" operators share a JOIN + * only if they also share a key. + */ + } elseif ( isset( $sibling['key'] ) && isset( $clause['key'] ) && ( $sibling['key'] === $clause['key'] ) ) { + $compatible_compares = $this->get_operators( array( 'positive' => false ) ); + } + + // Format comparisons. + $clause_compare = strtoupper( $clause['compare'] ); + $sibling_compare = strtoupper( $sibling['compare'] ); + + // Use alias if sibling & clause comparisons are OK. + if ( in_array( $clause_compare, $compatible_compares, true ) && in_array( $sibling_compare, $compatible_compares, true ) ) { + $retval = preg_replace( '/\W/', '_', $sibling['alias'] ); + break; + } + } + + // Return the alias + return $retval; + } + + protected function caller( $method = '', ...$args ) { + + // Bail if no caller + if ( empty( $this->caller ) ) { + return null; + } + + // Call it + return call_user_func( + array( $this->caller, $method ), + ...$args + ); + } +} From 54647819286fcb90717d78a4fd6eb8d82f6f42f1 Mon Sep 17 00:00:00 2001 From: John James Jacoby Date: Wed, 6 Jul 2022 17:16:10 -0500 Subject: [PATCH 2/2] Listen to Stan. --- src/Database/Parsers/By.php | 187 +++----------------------------- src/Database/Parsers/Date.php | 11 +- src/Database/Parsers/In.php | 189 +++------------------------------ src/Database/Parsers/NotIn.php | 19 ++-- 4 files changed, 48 insertions(+), 358 deletions(-) diff --git a/src/Database/Parsers/By.php b/src/Database/Parsers/By.php index 077e6f4..dbe2dee 100644 --- a/src/Database/Parsers/By.php +++ b/src/Database/Parsers/By.php @@ -87,192 +87,35 @@ public function get_sql_for_clause( &$clause = array(), $parent_query = array(), // Loop through ins. foreach ( $ins as $column => $query_var ) { - // Get pattern and aliased name - $pattern = $this->caller( 'get_column_field', array( 'name' => $column ), 'pattern', '%s' ); - $aliased = $this->caller( 'get_column_name_aliased', $column ); - // Parse query var $values = $this->caller( 'parse_query_var', $clause, $column ); // Parse item for an IN clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $where_id = $column; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } + if ( false === $values ) { + continue; } - } - - // Return join/where array. - return array( - 'join' => array(), - 'where' => $where - ); - } - - /** - * Parse join/where subclauses for all columns. - * - * Used by parse_where_join(). - * - * @since 3.0.0 - * @return array - */ - private function parse_where_columns( $query_vars = array() ) { - - // Defaults - $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; - } - - // 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 ( $all_columns as $column ) { - - // 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 = $name; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for direct clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $where_id = "{$where_id}__in"; - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __in - if ( true === $column->in ) { - - // Parse query var - $where_id = "{$name}__in"; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for an IN clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __not_in - if ( true === $column->not_in ) { - - // Parse query var - $where_id = "{$name}__not_in"; - $values = $this->parse_query_var( $query_vars, $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 = "{$aliased} != {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $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 = "{$name}_query"; - $column_date = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item - if ( false !== $column_date ) { - - // Single - if ( 1 === count( $column_date ) ) { - $where['date_query'][] = array( - 'column' => $aliased, - 'before' => reset( $column_date ), - 'inclusive' => true - ); - - // Multi - } else { + // Get pattern and aliased name + $pattern = $this->caller( 'get_column_field', array( 'name' => $column ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $column ); - // Auto-fill column if empty - if ( empty( $column_date['column'] ) ) { - $column_date['column'] = $aliased; - } + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $column_value = reset( $values ); + $where[ $column ] = $db->prepare( $statement, $column_value ); - // Add clause to date query - $where['date_query'][] = $column_date; - } - } + // Implode + } else { + $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); + $where[ "{$column}__in" ] = "{$aliased} IN {$in_values}"; } } - // Return join/where subclauses + // Return join/where array. return array( 'join' => array(), 'where' => $where ); } - } diff --git a/src/Database/Parsers/Date.php b/src/Database/Parsers/Date.php index 7434a26..c678d06 100644 --- a/src/Database/Parsers/Date.php +++ b/src/Database/Parsers/Date.php @@ -123,12 +123,17 @@ class Date { use \BerlinDB\Database\Traits\Parser; /** - * Array of first-order keys. + * Determines and validates what first-order keys to use. + * + * Use first $first_keys if passed and valid. * * @since 3.0.0 - * @var array + * + * @param array $first_keys Array of first-order keys. + * + * @return array The first-order keys. */ - public function get_first_keys() { + protected function get_first_keys( $first_keys = array() ) { return array( 'after', 'before', diff --git a/src/Database/Parsers/In.php b/src/Database/Parsers/In.php index 1afc608..f0eadd1 100644 --- a/src/Database/Parsers/In.php +++ b/src/Database/Parsers/In.php @@ -87,193 +87,36 @@ public function get_sql_for_clause( &$clause = array(), $parent_query = array(), // Loop through ins. foreach ( $ins as $column => $query_var ) { - // Get pattern and aliased name - $name = str_replace( '__not_in', '', $column ); - $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); - $aliased = $this->caller( 'get_column_name_aliased', $name ); - // Parse query var $values = $this->caller( 'parse_query_var', $clause, $column ); // Parse item for an IN clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $where_id = $column; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } + if ( false === $values ) { + continue; } - } - // Return join/where array. - return array( - 'join' => array(), - 'where' => $where - ); - } - - /** - * Parse join/where subclauses for all columns. - * - * Used by parse_where_join(). - * - * @since 3.0.0 - * @return array - */ - private function parse_where_columns( $query_vars = array() ) { - - // Defaults - $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; - } - - // 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 ( $all_columns as $column ) { - - // 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 = $name; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for direct clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $where_id = "{$where_id}__in"; - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __in - if ( true === $column->in ) { - - // Parse query var - $where_id = "{$name}__in"; - $values = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item for an IN clause. - if ( false !== $values ) { - - // Convert single item arrays to literal column comparisons - if ( 1 === count( $values ) ) { - $statement = "{$aliased} = {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $in_values = $this->get_in_sql( $name, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} IN {$in_values}"; - } - } - } - - // __not_in - if ( true === $column->not_in ) { - - // Parse query var - $where_id = "{$name}__not_in"; - $values = $this->parse_query_var( $query_vars, $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 = "{$aliased} != {$pattern}"; - $where_id = $name; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); - - // Implode - } else { - $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 = "{$name}_query"; - $column_date = $this->parse_query_var( $query_vars, $where_id ); - - // Parse item - if ( false !== $column_date ) { - - // Single - if ( 1 === count( $column_date ) ) { - $where['date_query'][] = array( - 'column' => $aliased, - 'before' => reset( $column_date ), - 'inclusive' => true - ); - - // Multi - } else { + // Get pattern and aliased name + $name = str_replace( '__not_in', '', $column ); + $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $name ); - // Auto-fill column if empty - if ( empty( $column_date['column'] ) ) { - $column_date['column'] = $aliased; - } + // Convert single item arrays to literal column comparisons + if ( 1 === count( $values ) ) { + $statement = "{$aliased} = {$pattern}"; + $column_value = reset( $values ); + $where[ $name ] = $db->prepare( $statement, $column_value ); - // Add clause to date query - $where['date_query'][] = $column_date; - } - } + // Implode + } else { + $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); + $where[ $column ] = "{$aliased} IN {$in_values}"; } } - // Return join/where subclauses + // Return join/where array. return array( 'join' => array(), 'where' => $where ); } - } diff --git a/src/Database/Parsers/NotIn.php b/src/Database/Parsers/NotIn.php index 18a9cee..de0e124 100644 --- a/src/Database/Parsers/NotIn.php +++ b/src/Database/Parsers/NotIn.php @@ -87,11 +87,6 @@ public function get_sql_for_clause( &$clause = array(), $parent_query = array(), // Loop through ins. foreach ( $ins as $column => $query_var ) { - // Get pattern and aliased name - $name = str_replace( '__not_in', '', $column ); - $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); - $aliased = $this->caller( 'get_column_name_aliased', $name ); - // Parse query var $values = $this->caller( 'parse_query_var', $clause, $column ); @@ -100,17 +95,21 @@ public function get_sql_for_clause( &$clause = array(), $parent_query = array(), continue; } + // Get pattern and aliased name + $name = str_replace( '__not_in', '', $column ); + $pattern = $this->caller( 'get_column_field', array( 'name' => $name ), 'pattern', '%s' ); + $aliased = $this->caller( 'get_column_name_aliased', $name ); + // Convert single item arrays to literal column comparisons if ( 1 === count( $values ) ) { - $statement = "{$aliased} != {$pattern}"; - $where_id = $column; - $column_value = reset( $values ); - $where[ $where_id ] = $db->prepare( $statement, $column_value ); + $statement = "{$aliased} != {$pattern}"; + $column_value = reset( $values ); + $where[ $name ] = $db->prepare( $statement, $column_value ); // Implode } else { $in_values = $this->caller( 'get_in_sql', $column, $values, true, $pattern ); - $where[ $where_id ] = "{$aliased} NOT IN {$in_values}"; + $where[ $column ] = "{$aliased} NOT IN {$in_values}"; } }