diff --git a/README.md b/README.md
index 50132e2..55bdad1 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,68 @@
# BerlinDB
-BerlinDB is a collection of PHP classes and functions that aims to provide an ORM-like experience and interface to WordPress database tables.
+BerlinDB is a collection of PHP classes & methods to provide an ORM-like interface to database tables in WordPress.
-This repository contains all of the code that is required to be included in your WordPress project.
+Use it to move data out of custom Post Types & Taxonomies and into custom database tables.
-The most common use-case for BerlinDB is a WordPress Plugin that needs to create custom database tables, but more advanced uses are possible, including managing and interfacing with the WordPress Core database tables themselves.
+Ensure perform reliably and scale effortlessly in highly available WordPress based web applications.
-Future repositories in this organization will contain examples, extensions, drop-ins, unit tests, and more.
+## Mission
+The primary mission of BerlinDB is to democratize data storage.
-----
+### Phase 1 - 2022
+Minimize the effort required to perform routine & repetitive database interactions.
-The name of this project comes from WordCamp Europe 2019, where it was originally announced as an unnamed library. Thank you to Peter Wilson for the idea to pay homage to such a wonderful audience.
+### Phase 2 - 2022
+Achieve platform agnosticism through smart abstractions and interoperability layers.
-----
+### Phase 3 - 2022
+Generate the custom code that is necessary from any existing database table structure.
-The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning multiple continents, native languages, and years of conceptual development:
+### Phase 4 - 2023
+Automate database table structure changes for a seamless upgrade/rollback experience.
+### Phase 5 - 2023
+Manage all database connections to directly support reads, writes, clones, splitting, and sharding.
+
+## Name
+
+This project is named for [WordCamp Europe 2019](https://europe.wordcamp.org/2019/) which took place in the beautiful & historic capital city of Berlin, Germany, where it was originally exhibited & announced as an unnamed utility being used by the Sandhills Development engineering team.
+
+Peter Wilson recommended naming it "Berlin" to commemorate everyone in attendance for its unveiling. Thanks, Peter! 🙏
+
+## Beginnings
+
+The code in this repository represents the cumulative effort of dozens of individuals across multiple projects, spanning several continents, native languages, and years of conceptual development & iteration:
+
+* BuddyPress (inspired by)
+* WordPress Multisite (inspired by)
* Easy Digital Downloads (3.0 and higher)
* Sugar Calendar (2.0 and higher)
* Restrict Content Pro (3.1 and higher)
-* WordPress Multisite (inspired by)
-* BuddyPress (inspired by)
-These projects all require custom database tables to achieve their goals (and to meet the expectations that their users have in them) to perform and scale flawlessly in a highly available WordPress based web application.
+## Contributions
+
+Interested in contributing? See the [contributing guide](/CONTRIBUTING.md).
+
+## Support
+
+Have a question? [Open a new issue](https://github.com/berlindb/core/issues/new) and someone will try to help.
+
+## License
+
+MIT. Please enjoy this code freely & openly, [as is](/LICENSE).
+
+If you are using BerlinDB in a commercial product, please consider [becoming a sponsor](https://github.com/sponsors/jjj?frequency=recurring&sponsor=jjj).
+
+## Created By
-Each of these projects originally implemented their own bespoke approaches to database management, resulting in a massive amount of code duplication, rework, and eventual fragmentation of approaches and ideas.
+- [@JJJ](https://x.com/JJJ) - https://jjj.blog
-This project helps avoid those issues by (somewhat magically) limiting how much code you need to write to accomplish the same repetitive database related tasks.
+## Credits
-----
+This organization is currently managed by Triple J Software, Inc..
-This organization was created by John James Jacoby while working at Sandhills Development, LLC.
+Special thanks to:
+- Sandhills Development, LLC
+- Awesome Motive, Inc.
+- JJJ's GitHub Sponsors
diff --git a/autoloader.php b/autoloader.php
index f4bf1db..59a17eb 100644
--- a/autoloader.php
+++ b/autoloader.php
@@ -1,30 +1,31 @@
=0.11.6"
+ },
+ "require-dev": {
+ "composer/composer": "^1.8",
+ "phing/phing": "^2.16.3",
+ "php-parallel-lint/php-parallel-lint": "^1.2.0",
+ "phpstan/phpstan-strict-rules": "^0.11 || ^0.12"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "PHPStan\\ExtensionInstaller\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\ExtensionInstaller\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Composer plugin for automatic installation of PHPStan extensions",
+ "support": {
+ "issues": "https://github.com/phpstan/extension-installer/issues",
+ "source": "https://github.com/phpstan/extension-installer/tree/1.1.0"
+ },
+ "time": "2020-12-13T13:06:13+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "0.12.99",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan.git",
+ "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4d40f1d759942f523be267a1bab6884f46ca3f7",
+ "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "0.12-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "support": {
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "source": "https://github.com/phpstan/phpstan/tree/0.12.99"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/phpstan",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2021-09-12T20:09:55+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php73",
+ "version": "v1.26.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php73.git",
+ "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85",
+ "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.26-dev"
+ },
+ "thanks": {
+ "name": "symfony/polyfill",
+ "url": "https://github.com/symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php73\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-05-24T11:49:31+00:00"
+ },
+ {
+ "name": "szepeviktor/phpstan-wordpress",
+ "version": "v0.7.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/szepeviktor/phpstan-wordpress.git",
+ "reference": "bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/szepeviktor/phpstan-wordpress/zipball/bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72",
+ "reference": "bdbea69b2ba4a69998c3b6fe2b7106d78a23bd72",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "php-stubs/wordpress-stubs": "^4.7 || ^5.0",
+ "phpstan/phpstan": "^0.12.26",
+ "symfony/polyfill-php73": "^1.12.0"
+ },
+ "require-dev": {
+ "composer/composer": "^1.10.22",
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.7",
+ "php-parallel-lint/php-parallel-lint": "^1.1",
+ "phpstan/phpstan-strict-rules": "^0.12",
+ "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.6"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "SzepeViktor\\PHPStan\\WordPress\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "WordPress extensions for PHPStan",
+ "keywords": [
+ "PHPStan",
+ "code analyse",
+ "code analysis",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/szepeviktor/phpstan-wordpress/issues",
+ "source": "https://github.com/szepeviktor/phpstan-wordpress/tree/v0.7.7"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.me/szepeviktor",
+ "type": "custom"
+ }
+ ],
+ "time": "2021-07-14T09:19:15+00:00"
+ }
+ ],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
@@ -14,5 +314,5 @@
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
- "plugin-api-version": "2.2.0"
+ "plugin-api-version": "2.3.0"
}
diff --git a/src/Database/Base.php b/src/Database/Base.php
index 68dbd34..e369ec4 100644
--- a/src/Database/Base.php
+++ b/src/Database/Base.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Base
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -21,6 +24,8 @@
* into a magic call handler and others.
*
* @since 1.0.0
+ *
+ * @property array $args
*/
#[\AllowDynamicProperties]
class Base {
@@ -28,12 +33,12 @@ class Base {
/**
* The name of the PHP global that contains the primary database interface.
*
- * For example, WordPress traditionally uses 'wpdb', but other applications
- * may use something else, or you may be doing something really cool that
+ * For example, WordPress uses 'wpdb', but other applications will use
+ * something else, or you may be doing something really cool that
* requires a custom interface.
*
- * A future version of this utility may abstract this out entirely, so
- * custom calls to the get_db() should be avoided if at all possible.
+ * A future version of BerlinDB will abstract this to a new class, so
+ * custom calls to the get_db() in your own code should be avoided.
*
* @since 1.0.0
* @var string
@@ -70,25 +75,16 @@ class Base {
*/
public function __isset( $key = '' ) {
- // No more uppercase ID properties ever
- if ( 'ID' === $key ) {
- $key = 'id';
- }
-
// Class method to try and call
$method = "get_{$key}";
- // Return property if exists
- if ( method_exists( $this, $method ) ) {
- return true;
-
- // Return get method results if exists
- } elseif ( property_exists( $this, $key ) ) {
+ // Return callable method exists
+ if ( is_callable( array( $this, $method ) ) ) {
return true;
}
- // Return false if not exists
- return false;
+ // Return property if exists
+ return property_exists( $this, $key );
}
/**
@@ -101,19 +97,14 @@ public function __isset( $key = '' ) {
*/
public function __get( $key = '' ) {
- // No more uppercase ID properties ever
- if ( 'ID' === $key ) {
- $key = 'id';
- }
-
// Class method to try and call
$method = "get_{$key}";
- // Return property if exists
- if ( method_exists( $this, $method ) ) {
+ // Return get method results if callable
+ if ( is_callable( array( $this, $method ) ) ) {
return call_user_func( array( $this, $method ) );
- // Return get method results if exists
+ // Return property value if exists
} elseif ( property_exists( $this, $key ) ) {
return $this->{$key};
}
@@ -139,15 +130,37 @@ public function to_array() {
* Maybe append the prefix to string.
*
* @since 1.0.0
+ * @since 2.1.0 Prevents double prefixing
*
* @param string $string
* @param string $sep
* @return string
*/
protected function apply_prefix( $string = '', $sep = '_' ) {
- return ! empty( $this->prefix )
- ? "{$this->prefix}{$sep}{$string}"
- : $string;
+
+ // Bail if not a string
+ if ( ! is_string( $string ) ) {
+ return '';
+ }
+
+ // Trim spaces off the ends
+ $retval = trim( $string );
+
+ // Bail if no prefix
+ if ( empty( $this->prefix ) ) {
+ return $retval;
+ }
+
+ // Setup new prefix
+ $new_prefix = $this->prefix . $sep;
+
+ // Bail if already prefixed
+ if ( 0 === strpos( $string, $new_prefix ) ) {
+ return $retval;
+ }
+
+ // Return prefixed string
+ return $new_prefix . $retval;
}
/**
@@ -162,27 +175,27 @@ protected function apply_prefix( $string = '', $sep = '_' ) {
*
* @since 1.0.0
*
- * @param string $string
- * @param string $sep
+ * @param string $string Default empty string.
+ * @param string $sep Default "_".
* @return string
*/
protected function first_letters( $string = '', $sep = '_' ) {
- // Set empty default return value
- $retval = '';
-
// Bail if empty or not a string
if ( empty( $string ) || ! is_string( $string ) ) {
- return $retval;
+ return '';
}
+ // Default return value
+ $retval = '';
+
// Trim spaces off the ends
$unspace = trim( $string );
// Only non-accented table names (avoid truncation)
$accents = remove_accents( $unspace );
- // Only lowercase letters are allowed
+ // Convert to lowercase
$lower = strtolower( $accents );
// Explode into parts
@@ -211,10 +224,11 @@ protected function first_letters( $string = '', $sep = '_' ) {
* - No trailing underscores
*
* @since 1.0.0
+ * @since 2.1.0 Allow uppercase letters
*
* @param string $name The name of the database table
*
- * @return string Sanitized database table name
+ * @return bool|string Sanitized database table name on success, False on error
*/
protected function sanitize_table_name( $name = '' ) {
@@ -229,25 +243,45 @@ protected function sanitize_table_name( $name = '' ) {
// Only non-accented table names (avoid truncation)
$accents = remove_accents( $unspace );
- // Only lowercase characters, hyphens, and dashes (avoid index corruption)
- $lower = sanitize_key( $accents );
+ // Only upper & lower case letters, numbers, hyphens, and underscores
+ $replace = preg_replace( '/[^a-zA-Z0-9_\-]/', '', $accents );
// Replace hyphens with single underscores
- $under = str_replace( '-', '_', $lower );
+ $under = str_replace( '-', '_', $replace );
- // Single underscores only
+ // Replace double underscores with singles
$single = str_replace( '__', '_', $under );
// Remove trailing underscores
$clean = trim( $single, '_' );
- // Bail if table name was garbaged
- if ( empty( $clean ) ) {
- return false;
- }
+ // Bail if table name was garbaged or return the cleaned table name
+ return empty( $clean )
+ ? false
+ : $clean;
+ }
- // Return the cleaned table name
- return $clean;
+ /**
+ * Sanitize a column name string.
+ *
+ * Used to make sure that a column name value meets MySQL expectations.
+ *
+ * Applies the following formatting to a string:
+ * - Trim whitespace
+ * - No accents
+ * - No special characters
+ * - No hyphens
+ * - No double underscores
+ * - No trailing underscores
+ *
+ * @since 2.1.0
+ *
+ * @param string $name The name of the database column
+ *
+ * @return bool|string Sanitized database column name on success, False on error
+ */
+ protected function sanitize_column_name( $name = '' ) {
+ return $this->sanitize_table_name( $name );
}
/**
@@ -275,39 +309,51 @@ protected function set_vars( $args = array() ) {
}
/**
- * Return the global database interface.
+ * Stash arguments and class variables.
*
- * See: https://core.trac.wordpress.org/ticket/31556
+ * This is used to stash a copy of the original constructor arguments and
+ * the object variable values, for later comparison, reuse, or resetting
+ * back to a previous state.
+ *
+ * @since 2.1.0
+ * @param array $args
+ */
+ protected function stash_args( $args = array() ) {
+ $this->args = array(
+ 'param' => $args,
+ 'class' => get_object_vars( $this )
+ );
+ }
+
+ /**
+ * Return the global database interface.
*
* @since 1.0.0
+ * @since 2.1.0 Improved PHP8 support, remove $GLOBALS superglobal usage
*
- * @return \wpdb Database interface, or False if not set
+ * @return bool|\wpdb Database interface, or False if not set
*/
protected function get_db() {
+ global ${$this->db_global};
- // Default database return value (might change)
+ // Default return value
$retval = false;
- // Look for a commonly used global database interface
- if ( isset( $GLOBALS[ $this->db_global ] ) ) {
- $retval = $GLOBALS[ $this->db_global ];
+ // Look for the global database interface
+ if ( ! is_null( ${$this->db_global} ) ) {
+ $retval = ${$this->db_global};
}
/*
- * Developer note:
- *
- * It should be impossible for a database table to be interacted with
- * before the primary database interface is setup.
+ * Note: If you are here because this method is returning false for you,
+ * that means a database Table or Query are being invoked too early in
+ * the lifecycle of the application.
*
- * However, because applications are complicated, it is unsafe to assume
- * anything, so this silently returns false instead of halting everything.
+ * In WordPress, that means before require_wp_db() creates the $wpdb
+ * global (inside of the wp-settings.php file) and you may want to
+ * hook your custom code into 'admin_init' or 'plugins_loaded' instead.
*
- * If you are here because this method is returning false for you, that
- * means the database table is being invoked too early in the lifecycle
- * of the application.
- *
- * In WordPress, that means before the $wpdb global is created; in other
- * environments, you will need to adjust accordingly.
+ * The decision to return false here is likely to change in the future.
*/
// Return the database interface
@@ -317,25 +363,31 @@ protected function get_db() {
/**
* Check if an operation succeeded.
*
+ * Note: While "0" or "''" may be the return value of a successful result,
+ * for the purposes of database queries and this method, it isn't.
+ * When using this method, take care that your possible results do not
+ * pass falsy values on success.
+ *
* @since 1.0.0
+ * @since 2.1.0 Minor refactor to improve readability.
*
- * @param mixed $result
+ * @param mixed $result Optional. Default false. Any value to check.
* @return bool
*/
protected function is_success( $result = false ) {
- // Bail if no row exists
- if ( empty( $result ) ) {
- $retval = false;
-
- // Bail if an error occurred
- } elseif ( is_wp_error( $result ) ) {
- $this->last_error = $result;
- $retval = false;
+ // Default return value
+ $retval = false;
- // No errors
- } else {
+ // Non-empty is success
+ if ( ! empty( $result ) ) {
$retval = true;
+
+ // But Error is still fail, so stash it
+ if ( is_wp_error( $result ) ) {
+ $this->last_error = $result;
+ $retval = false;
+ }
}
// Return the result
diff --git a/src/Database/Column.php b/src/Database/Column.php
index dd7fc9c..b30d695 100644
--- a/src/Database/Column.php
+++ b/src/Database/Column.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Column
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -17,6 +20,7 @@
* Base class used for each column for a custom table.
*
* @since 1.0.0
+ * @since 2.1.0 Column::args[] stashes parsed & class arguments.
*
* @see Column::__construct() for accepted arguments.
*/
@@ -32,115 +36,162 @@ class Column extends Base {
* fatal application errors.
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $name = '';
/**
- * Type of database column.
+ * Column data type.
+ *
+ * Required. Must contain valid data type.
*
- * See: https://dev.mysql.com/doc/en/data-types.html
+ * Note: Magic & Fallback support for data types is only added as needed.
+ * It is recommended that you explicitly define all Column attributes.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/data-types.html
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $type = '';
/**
- * Length of database column.
+ * Column value length.
+ *
+ * Recommended. Set to a reasonable number for your needs.
*
- * See: https://dev.mysql.com/doc/en/storage-requirements.html
+ * Common usages:
+ *
+ * - bigint: 20 - for primary key IDs (relating ID columns across tables)
+ * - varchar: 20 - for registered object statuses or types
+ * - varchar: 255 - for hashes, user-agents, or URLs
+ * - varchar: 191 - utf8mb4 safe length (for $cache_key usages)
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html
*
* @since 1.0.0
- * @var string
+ * @var bool|int Default false. Int to set length.
*/
public $length = false;
/**
- * Is integer unsigned?
+ * If integer type, is it unsigned?
+ *
+ * Unsigned integers do not allow negative numbers.
+ *
+ * Set to false to allow negative numbers in int columns.
*
- * See: https://dev.mysql.com/doc/en/numeric-type-overview.html
+ * Note: MySQL 8.0.17 deprecated unsigned Decimals, and support for them
+ * here will be appropriately removed.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/numeric-types.html
*
* @since 1.0.0
- * @var bool
+ * @var bool Default true for all int columns.
*/
public $unsigned = true;
/**
- * Is integer filled with zeroes?
+ * If integer type, fill with zeroes?
+ *
+ * Set to true to always fill numeric $length with zeroes.
*
- * See: https://dev.mysql.com/doc/en/numeric-type-overview.html
+ * See: https://dev.mysql.com/doc/refman/8.0/en/numeric-types.html
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false for all numeric columns.
*/
public $zerofill = false;
/**
- * Is data in a binary format?
+ * If text type, store in a binary format?
*
- * See: https://dev.mysql.com/doc/en/binary-varbinary.html
+ * When used with a TEXT data type, the column is assigned the binary (_bin)
+ * collation of the column character set.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/binary-varbinary.html
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $binary = false;
/**
* Is null an allowed value?
*
- * See: https://dev.mysql.com/doc/en/data-type-defaults.html
+ * Set to true to explicitly allow storing a literal null value (which is
+ * likely to be different from the default value for the $type).
+ *
+ * Dev Note: In general, it is considered bad application design for a null
+ * value to coexist alongside a possible "0" or "''" value.
+ *
+ * When allowing null values, be sure that other areas of your
+ * program understand that this column's value could be null.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $allow_null = false;
/**
- * Typically empty/null, or date value.
+ * Default value when a Row is added without a value for this column.
+ *
+ * Typically "0" or "''", a zero date value, or some other value that is
+ * useful as an intelligent default for your Row objects to contain when
+ * no other value is explicitly assigned to them.
+ *
+ * Can be literal null if $allow_null is truthy.
*
- * See: https://dev.mysql.com/doc/en/data-type-defaults.html
+ * Invalid values will be dropped.
+ *
+ * Used by Query::default_item() to create an array full of default values.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
*
* @since 1.0.0
- * @var string
+ * @var bool|int|string Default empty string.
*/
public $default = '';
/**
* auto_increment, etc...
*
- * See: https://dev.mysql.com/doc/en/data-type-defaults.html
+ * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
*
* @since 1.0.0
- * @var string
+ * @since 2.1.0 Allowed values checked via sanitize_extra()
+ * @since 2.1.0 Special values checked via special_args()
+ * @var string Default empty string.
*/
public $extra = '';
/**
- * Typically inherited from the database interface (wpdb).
+ * Typically inherited from the database interface $db_global.
*
* By default, this will use the globally available database encoding. You
* most likely do not want to change this; if you do, you already know what
* to do.
*
- * See: https://dev.mysql.com/doc/mysql/en/charset-column.html
+ * See: https://dev.mysql.com/doc/refman/8.0/en/charset-column.html
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $encoding = '';
/**
- * Typically inherited from the database interface (wpdb).
+ * Typically inherited from the database interface $db_global.
*
* By default, this will use the globally available database collation. You
* most likely do not want to change this; if you do, you already know what
* to do.
*
- * See: https://dev.mysql.com/doc/mysql/en/charset-column.html
+ * See: https://dev.mysql.com/doc/refman/8.0/en/charset-column.html
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $collation = '';
@@ -151,7 +202,7 @@ class Column extends Base {
* relative code, but you can include less than 1024 characters here.
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $comment = '';
@@ -160,36 +211,42 @@ class Column extends Base {
/**
* Is this the primary column?
*
+ * Typically use this with: bigint, length 20, unsigned, auto_increment.
+ *
* By default, columns are not the primary column. This is used by the Query
* class for several critical functions, including (but not limited to) the
* cache key, meta-key relationships, auto-incrementing, etc...
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $primary = false;
/**
* Is this the column used as a created date?
*
+ * Use this with the "datetime" column type.
+ *
* By default, columns do not represent the date a value was first entered.
* This is used by the Query class to set its value automatically to the
* current datetime value immediately before insert.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $created = false;
/**
* Is this the column used as a modified date?
*
+ * Use this with the "datetime" column type.
+ *
* By default, columns do not represent the date a value was last changed.
* This is used by the Query class to update its value automatically to the
* current datetime value immediately before insert|update.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $modified = false;
@@ -201,47 +258,49 @@ class Column extends Base {
* table, typically in such a way that is unrelated to the row data itself.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $uuid = false;
/** Query Attributes ******************************************************/
/**
- * What is the string-replace pattern?
+ * What is the string-replace format?
*
- * By default, column patterns will be guessed based on their type. Set this
- * manually to `%s|%d|%f` only if you are doing something weird, or are
+ * By default, column formats will be guessed based on their type. Set this
+ * manually to "%s|%d|%f" only if you are doing something weird, or are
* explicitly storing numeric values in text-based column types.
*
+ * See: https://www.php.net/manual/en/function.printf.php
+ *
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $pattern = '';
/**
* Is this column searchable?
*
- * By default, columns are not searchable. When `true`, the Query class will
+ * By default, columns are not searchable. When "true", the Query class will
* add this column to the results of search queries.
*
- * Avoid setting to `true` on large blobs of text, unless you've optimized
+ * Avoid setting to "true" on large blobs of text, unless you've optimized
* your database server to accommodate these kinds of queries.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $searchable = false;
/**
* Is this column a date?
*
- * By default, columns do not support date queries. When `true`, the Query
+ * By default, columns do not support date queries. When "true", the Query
* class will accept complex statements to help narrow results down to
* specific periods of time for values in this column.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $date_query = false;
@@ -256,34 +315,34 @@ class Column extends Base {
* and text columns with intentionally limited lengths.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $sortable = false;
/**
* Is __in supported?
*
- * By default, columns support being queried using an `IN` statement. This
+ * By default, columns support being queried using an "IN" statement. This
* allows the Query class to retrieve rows that match your array of values.
*
- * Consider setting this to `false` for longer text columns.
+ * Consider setting this to "false" for longer text columns.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default true
*/
public $in = true;
/**
* Is __not_in supported?
*
- * By default, columns support being queried using a `NOT IN` statement.
+ * By default, columns support being queried using a "NOT IN" statement.
* This allows the Query class to retrieve rows that do not match your array
* of values.
*
- * Consider setting this to `false` for longer text columns.
+ * Consider setting this to "false" for longer text columns.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default true.
*/
public $not_in = true;
@@ -299,7 +358,7 @@ class Column extends Base {
* Use in conjunction with a database index for speedy queries.
*
* @since 1.0.0
- * @var string
+ * @var bool Default false.
*/
public $cache_key = false;
@@ -308,6 +367,8 @@ class Column extends Base {
/**
* Does this column fire a transition action when it's value changes?
*
+ * Typically used with: varchar, length 20, cache_key.
+ *
* By default, columns do not fire transition actions. In some cases, it may
* be desirable to know when a database value changes, and what the old and
* new values are when that happens.
@@ -315,7 +376,7 @@ class Column extends Base {
* The Query class is responsible for triggering the event action.
*
* @since 1.0.0
- * @var bool
+ * @var bool Default false.
*/
public $transition = false;
@@ -329,7 +390,7 @@ class Column extends Base {
* the default validation behavior.
*
* @since 1.0.0
- * @var string
+ * @var string Default empty string.
*/
public $validate = '';
@@ -374,7 +435,7 @@ class Column extends Base {
*
* @since 1.0.0
*
- * @param string|array $args {
+ * @param array|string $args {
* Optional. Array or query string of order query parameters. Default empty.
*
* @type string $name Name of database column
@@ -384,12 +445,12 @@ class Column extends Base {
* @type bool $zerofill Is integer filled with zeroes?
* @type bool $binary Is data in a binary format?
* @type bool $allow_null Is null an allowed value?
- * @type mixed $default Typically empty/null, or date value
+ * @type mixed $default Typically 0|'', null, or date value
* @type string $extra auto_increment, etc...
- * @type string $encoding Typically inherited from wpdb
- * @type string $collation Typically inherited from wpdb
+ * @type string $encoding Typically inherited from $db_global
+ * @type string $collation Typically inherited from $db_global
* @type string $comment Typically empty
- * @type bool $pattern What is the string-replace pattern?
+ * @type string $pattern Pattern used to format the value
* @type bool $primary Is this the primary column?
* @type bool $created Is this the column used as a created date?
* @type bool $modified Is this the column used as a modified date?
@@ -421,66 +482,30 @@ public function __construct( $args = array() ) {
/** Argument Handlers *****************************************************/
/**
- * Parse column arguments
+ * Parse column arguments.
*
* @since 1.0.0
+ * @since 2.1.0 Arguments are stashed. Bails if $args is empty.
* @param array $args Default empty array.
* @return array
*/
private function parse_args( $args = array() ) {
- // Parse arguments
- $r = wp_parse_args( $args, array(
-
- // Table
- 'name' => '',
- 'type' => '',
- 'length' => '',
- 'unsigned' => false,
- 'zerofill' => false,
- 'binary' => false,
- 'allow_null' => false,
- 'default' => '',
- 'extra' => '',
- 'encoding' => $this->get_db()->charset,
- 'collation' => $this->get_db()->collate,
- 'comment' => '',
-
- // Query
- 'pattern' => false,
- 'searchable' => false,
- 'sortable' => false,
- 'date_query' => false,
- 'transition' => false,
- 'in' => true,
- 'not_in' => true,
-
- // Special
- 'primary' => false,
- 'created' => false,
- 'modified' => false,
- 'uuid' => false,
-
- // Cache
- 'cache_key' => false,
-
- // Validation
- 'validate' => '',
+ // Stash the arguments
+ $this->stash_args( $args );
- // Capabilities
- 'caps' => array(),
-
- // Backwards Compatibility
- 'aliases' => array(),
+ // Bail if no arguments
+ if ( empty( $args ) ) {
+ return array();
+ }
- // Column Relationships
- 'relationships' => array()
- ) );
+ // Parse arguments
+ $r = wp_parse_args( $args, $this->args['class'] );
// Force some arguments for special column types
$r = $this->special_args( $r );
- // Set the args before they are sanitized
+ // Set the arguments before they are validated & sanitized
$this->set_vars( $r );
// Return array
@@ -498,7 +523,9 @@ private function validate_args( $args = array() ) {
// Sanitization callbacks
$callbacks = array(
- 'name' => 'sanitize_key',
+
+ // Table
+ 'name' => array( $this, 'sanitize_column_name' ),
'type' => 'strtoupper',
'length' => 'intval',
'unsigned' => 'wp_validate_boolean',
@@ -506,16 +533,18 @@ private function validate_args( $args = array() ) {
'binary' => 'wp_validate_boolean',
'allow_null' => 'wp_validate_boolean',
'default' => array( $this, 'sanitize_default' ),
- 'extra' => 'wp_kses_data',
+ 'extra' => array( $this, 'sanitize_extra' ),
'encoding' => 'wp_kses_data',
'collation' => 'wp_kses_data',
'comment' => 'wp_kses_data',
+ // Special
'primary' => 'wp_validate_boolean',
'created' => 'wp_validate_boolean',
'modified' => 'wp_validate_boolean',
'uuid' => 'wp_validate_boolean',
+ // Query
'searchable' => 'wp_validate_boolean',
'sortable' => 'wp_validate_boolean',
'date_query' => 'wp_validate_boolean',
@@ -524,6 +553,7 @@ private function validate_args( $args = array() ) {
'not_in' => 'wp_validate_boolean',
'cache_key' => 'wp_validate_boolean',
+ // Extras
'pattern' => array( $this, 'sanitize_pattern' ),
'validate' => array( $this, 'sanitize_validation' ),
'caps' => array( $this, 'sanitize_capabilities' ),
@@ -531,7 +561,7 @@ private function validate_args( $args = array() ) {
'relationships' => array( $this, 'sanitize_relationships' )
);
- // Default args array
+ // Default return arguments
$r = array();
// Loop through and try to execute callbacks
@@ -541,7 +571,12 @@ private function validate_args( $args = array() ) {
if ( isset( $callbacks[ $key ] ) && is_callable( $callbacks[ $key ] ) ) {
$r[ $key ] = call_user_func( $callbacks[ $key ], $value );
- // Callback is malformed so just let it through to avoid breakage
+ /**
+ * Key has no validation method.
+ *
+ * Trust that the value has been validated. This may change in a
+ * future version.
+ */
} else {
$r[ $key ] = $value;
}
@@ -552,47 +587,196 @@ private function validate_args( $args = array() ) {
}
/**
- * Force column arguments for special column types
+ * Handle special special column argument values.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
*
* @since 1.0.0
+ * @since 2.1.0 Added support for SERIAL "extra" values.
* @param array $args Default empty array.
* @return array
*/
private function special_args( $args = array() ) {
- // Primary key columns are always used as cache keys
+ // Handle specific "extra" aliases
+ if ( ! empty( $args['extra'] ) ) {
+
+ /**
+ * The special "extra" values below are built into MySQL as
+ * shorthand for commonly used combinations of Column arguments.
+ */
+ switch ( strtoupper( $args['extra'] ) ) {
+
+ // Bigint
+ case 'SERIAL' :
+ $args['type'] = 'bigint';
+ $args['length'] = '20';
+ $args['unsigned'] = true;
+ // No break; keep going
+
+ // Any int
+ case 'SERIAL DEFAULT VALUE' :
+
+ // Skip if not an int type
+ if ( in_array( strtolower( $args['type'] ), array( 'tinyint', 'smallint', 'mediumint', 'int', 'bigint' ), true ) ) {
+ $args['allow_null'] = false;
+ $args['default'] = false;
+ $args['primary'] = true;
+ $args['pattern'] = '%d';
+ $args['extra'] = 'AUTO_INCREMENT';
+ }
+ }
+ }
+
+ // Primary columns are expected (by Query) to always be cache keys
if ( ! empty( $args['primary'] ) ) {
$args['cache_key'] = true;
- // All UUID columns need to follow a very specific pattern
+ // All UUID columns require these specific criteria
} elseif ( ! empty( $args['uuid'] ) ) {
$args['name'] = 'uuid';
$args['type'] = 'varchar';
$args['length'] = '100';
+ $args['pattern'] = '%s';
$args['in'] = false;
$args['not_in'] = false;
$args['searchable'] = false;
$args['sortable'] = false;
}
- // Return args
+ // Return arguments
return (array) $args;
}
/** Public Helpers ********************************************************/
/**
- * Return if a column type is numeric or not.
+ * Return if a column type is a bool.
+ *
+ * @since 2.1.0
+ * @return bool True if bool type only.
+ */
+ public function is_bool() {
+ return $this->is_type( array(
+ 'bool'
+ ) );
+ }
+
+ /**
+ * Return if a column type is a date.
+ *
+ * @since 2.1.0
+ * @return bool True if any date or time.
+ */
+ public function is_date_time() {
+ return $this->is_type( array(
+ 'date',
+ 'datetime',
+ 'timestamp',
+ 'time',
+ 'year'
+ ) );
+ }
+
+ /**
+ * Return if a column type is an integer.
+ *
+ * @since 2.1.0
+ * @return bool True if int.
+ */
+ public function is_int() {
+ return $this->is_type( array(
+ 'tinyint',
+ 'smallint',
+ 'mediumint',
+ 'int',
+ 'bigint'
+ ) );
+ }
+
+ /**
+ * Return if a column type is decimal.
+ *
+ * @since 2.1.0
+ * @return bool True if float.
+ */
+ public function is_decimal() {
+ return $this->is_type( array(
+ 'float',
+ 'double',
+ 'decimal'
+ ) );
+ }
+
+ /**
+ * Return if a column type is numeric.
+ *
+ * Consider using is_int() or is_decimal() for improved specificity.
*
* @since 1.0.0
- * @return bool
+ * @return bool True if bit, int, or float.
*/
public function is_numeric() {
return $this->is_type( array(
+
+ // Bit
+ 'bit',
+
+ // Ints
'tinyint',
- 'int',
+ 'smallint',
'mediumint',
- 'bigint'
+ 'int',
+ 'bigint',
+
+ // Other
+ 'float',
+ 'double',
+ 'decimal'
+ ) );
+ }
+
+ /**
+ * Return if a column type is a string.
+ *
+ * For binary strings (blobs) use is_binary().
+ *
+ * @since 2.1.0
+ * @return bool True if text.
+ */
+ public function is_text() {
+ return $this->is_type( array(
+
+ // Char
+ 'char',
+ 'varchar',
+
+ // Text
+ 'tinytext',
+ 'text',
+ 'mediumtext',
+ 'longtext',
+ ) );
+ }
+
+ /**
+ * Return if a column type is binary.
+ *
+ * @since 2.1.0
+ * @return bool True if binary.
+ */
+ public function is_binary() {
+ return $this->is_type( array(
+
+ // Binary
+ 'binary',
+ 'varbinary',
+
+ // Blobs
+ 'tinyblob',
+ 'blob',
+ 'mediumblob',
+ 'longblob'
) );
}
@@ -602,11 +786,18 @@ public function is_numeric() {
* Return if this column is of a certain type.
*
* @since 1.0.0
- * @param mixed $type Default empty string. The type to check. Also accepts an array.
- * @return bool True if of type, False if not
+ * @since 2.1.0 Empty $type returns false.
+ * @param array[string] $type Default empty string. The type to check. Also
+ * accepts an array.
+ * @return bool True if type matches.
*/
private function is_type( $type = '' ) {
+ // Bail if no type passed
+ if ( empty( $type ) ) {
+ return false;
+ }
+
// If string, cast to array
if ( is_string( $type ) ) {
$type = (array) $type;
@@ -615,14 +806,41 @@ private function is_type( $type = '' ) {
// Make them lowercase
$types = array_map( 'strtolower', $type );
- // Return if match or not
+ // Return if match
return (bool) in_array( strtolower( $this->type ), $types, true );
}
+ /**
+ * Return if this column is of a certain type.
+ *
+ * @since 2.1.0
+ * @param array[string] $extra Default empty string. The extra to check.
+ * Also accepts an array.
+ * @return bool True if extra matches.
+ */
+ private function is_extra( $extra = '' ) {
+
+ // Bail if no extra passed
+ if ( empty( $extra ) ) {
+ return false;
+ }
+
+ // If string, cast to array
+ if ( is_string( $extra ) ) {
+ $extra = (array) $extra;
+ }
+
+ // Make them lowercase
+ $extras = array_map( 'strtoupper', $extra );
+
+ // Return if match
+ return (bool) in_array( strtolower( $this->extra ), $extras, true );
+ }
+
/** Private Sanitizers ****************************************************/
/**
- * Sanitize capabilities array
+ * Sanitize capabilities array.
*
* @since 1.0.0
* @param array $caps Default empty array.
@@ -633,23 +851,30 @@ private function sanitize_capabilities( $caps = array() ) {
'select' => 'exist',
'insert' => 'exist',
'update' => 'exist',
- 'delete' => 'exist'
+ 'delete' => 'exist',
) );
}
/**
- * Sanitize aliases array using `sanitize_key()`
+ * Sanitize aliases array.
+ *
+ * An array of other names that this column is known as. Useful for
+ * renaming a Column and wanting to continue supporting the old name(s).
*
* @since 1.0.0
* @param array $aliases Default empty array.
* @return array
*/
private function sanitize_aliases( $aliases = array() ) {
- return array_map( 'sanitize_key', $aliases );
+ $func = array( $this, 'sanitize_column_name' );
+ $aliases = array_filter( $aliases );
+ $retval = array_map( $func, $aliases );
+
+ return $retval;
}
/**
- * Sanitize relationships array
+ * Sanitize relationships array.
*
* @todo
* @since 1.0.0
@@ -661,62 +886,101 @@ private function sanitize_relationships( $relationships = array() ) {
}
/**
- * Sanitize the default value
+ * Sanitize the extra string.
*
- * @since 1.0.0
- * @param string $default
- * @return string|null
+ * @since 2.1.0
+ * @param string $value
+ * @return string
*/
- private function sanitize_default( $default = '' ) {
+ private function sanitize_extra( $value = '' ) {
- // Null
- if ( ( true === $this->allow_null ) && is_null( $default ) ) {
- return null;
+ // Default return value
+ $retval = '';
- // String
- } elseif ( is_string( $default ) ) {
- return wp_kses_data( $default );
+ // Allowed extra values
+ $allowed_extras = array(
+ 'AUTO_INCREMENT',
+ 'ON UPDATE CURRENT_TIMESTAMP',
- // Integer
- } elseif ( $this->is_numeric() ) {
- return (int) $default;
+ // See: special_args()
+ 'SERIAL',
+ 'SERIAL DEFAULT VALUE',
+ );
+
+ // Always uppercase
+ $value = strtoupper( $value );
+
+ // Set return value if allowed
+ if ( in_array( $value, $allowed_extras, true ) ) {
+ $retval = $value;
}
- // @todo datetime, decimal, and other column types
+ // Return
+ return $retval;
+ }
- // Unknown, so return the default's default
- return '';
+ /**
+ * Sanitize the default value.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Uses validate()
+ * @param int|string|null $default
+ * @return int|string|null
+ */
+ private function sanitize_default( $default = '' ) {
+ return $this->validate( $default );
}
/**
- * Sanitize the pattern
+ * Sanitize the pattern string.
*
* @since 1.0.0
- * @param string $pattern
- * @return string
+ * @since 2.1.0 Falls back to using is_ methods if invalid param
+ * @param string $pattern Default '%s'. Allowed values: %s, %d, $f
+ * @return string Default '%s'.
*/
private function sanitize_pattern( $pattern = '%s' ) {
// Allowed patterns
- $allowed_patterns = array( '%s', '%d', '%f' );
+ $allowed_patterns = array(
+ '%s', // String
+ '%d', // Integer (decimal)
+ '%f', // Float
+ );
// Return pattern if allowed
if ( in_array( $pattern, $allowed_patterns, true ) ) {
return $pattern;
}
- // Fallback to digit or string
- return $this->is_numeric()
- ? '%d'
- : '%s';
+ // Default string
+ $retval = '%s';
+
+ // Integer
+ if ( $this->is_int() ) {
+ $retval = '%d';
+
+ // Float
+ } elseif ( $this->is_decimal() ) {
+ $retval = '%f';
+ }
+
+ // Return
+ return $retval;
}
/**
- * Sanitize the validation callback
+ * Sanitize the validation callback.
+ *
+ * This method accepts a function or method, and will return it if it is
+ * callable. If it is not callable, the best fallback callback is
+ * calculated based on varying column properties.
*
* @since 1.0.0
- * @param string $callback Default empty string. A callable PHP function name or method
- * @return string The most appropriate callback function for the value
+ * @since 2.1.0 Explicit support for decimal, int, and numeric types.
+ * @param string $callback Default empty string. A callable PHP function
+ * name or method.
+ * @return string The most appropriate callback function for the value.
*/
private function sanitize_validation( $callback = '' ) {
@@ -729,17 +993,25 @@ private function sanitize_validation( $callback = '' ) {
if ( true === $this->uuid ) {
$callback = array( $this, 'validate_uuid' );
- // Datetime fallback
+ // Datetime explicit fallback
} elseif ( $this->is_type( 'datetime' ) ) {
$callback = array( $this, 'validate_datetime' );
+ // Intval fallback
+ } elseif ( $this->is_int() ) {
+ $callback = array( $this, 'validate_int' );
+
// Decimal fallback
- } elseif ( $this->is_type( 'decimal' ) ) {
+ } elseif ( $this->is_decimal() ) {
$callback = array( $this, 'validate_decimal' );
- // Intval fallback
+ // Numeric fallback
} elseif ( $this->is_numeric() ) {
- $callback = 'intval';
+ $callback = array( $this, 'validate_numeric' );
+
+ // Unknown text, string, or other...
+ } else {
+ $callback = 'wp_kses_data';
}
// Return the callback
@@ -749,80 +1021,224 @@ private function sanitize_validation( $callback = '' ) {
/** Public Validators *****************************************************/
/**
- * Fallback to validate a datetime value if no other is set.
+ * Validate a value.
*
- * This assumes NO_ZERO_DATES is off or overridden.
+ * Used by Column::sanitize_default() and Query to prevent invalid and
+ * unexpected values from being saved in the database.
*
- * If MySQL drops support for zero dates, this method will need to be
- * updated to support different default values based on the environment.
+ * @since 2.1.0
+ * @param int|string|null $value Default empty string. Value to validate.
+ * @param int|string|null $default Default empty string. Fallback if invalid.
+ * @return int|string|null
+ */
+ public function validate( $value = '', $default = '' ) {
+
+ // Check if a literal null value is allowed
+ $value = $this->validate_null( $value );
+
+ // Return null if allowed
+ if ( null === $value ) {
+ return null;
+ }
+
+ // Return the callback (already sanitized as callable)
+ if ( ! empty( $this->validate ) ) {
+ return call_user_func( $this->validate, $value );
+ }
+
+ // Return the default
+ return $default;
+ }
+
+ /**
+ * Validate a null value.
*
- * @since 1.0.0
- * @param string $value Default ''. A datetime value that needs validating
- * @return string A valid datetime value
+ * Will return the $default if $allow_null is false.
+ *
+ * @since 2.1.0
+ * @param int|string|null $value Default empty string.
+ * @return int|string|null
*/
- public function validate_datetime( $value = '' ) {
+ public function validate_null( $value = '' ) {
+
+ // Value is null
+ if ( null === $value ) {
+
+ // If null is allowed, return it
+ if ( true === $this->allow_null ) {
+ return null;
+ }
- // Handle "empty" values
- if ( empty( $value ) || ( '0000-00-00 00:00:00' === $value ) ) {
- $value = ! empty( $this->default )
+ /**
+ * Null was passed but is not allowed, so fallback to the default
+ * (but only if it is also not null.)
+ *
+ * If the default is null and null is not allowed, fallback to an
+ * empty string and allow MySQL to sort it out.
+ *
+ * Future versions of this validation method will attempt to return
+ * a less ambiguous value.
+ */
+ $value = ( null !== $this->default )
? $this->default
: '';
-
- // Convert to MySQL datetime format via gmdate() && strtotime
- } elseif ( function_exists( 'gmdate' ) ) {
- $value = gmdate( 'Y-m-d H:i:s', strtotime( $value ) );
}
- // Return the validated value
+ // Return
return $value;
}
/**
- * Validate a decimal
+ * Validate a datetime value.
*
- * (Recommended decimal column length is '18,9'.)
+ * This assumes the following MySQL modes:
+ * - NO_ZERO_DATE is off (double negative is proof positive!)
+ * - ALLOW_INVALID_DATES is off
*
- * This is used to validate a mixed value before it is saved into a decimal
- * column in a database table.
+ * When MySQL drops support for zero dates, this method will need to be
+ * updated to support different default values based on the environment.
*
- * Uses number_format() which does rounding to the last decimal if your
- * value is longer than specified.
+ * See: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_allow_invalid_dates
*
* @since 1.0.0
- * @param mixed $value Default empty string. The decimal value to validate
- * @param int $decimals Default 9. The number of decimal points to accept
- * @return float
+ * @since 2.1.0 Add support for CURRENT_TIMESTAMP.
+ * @param string $value Default ''. A datetime value that needs validating.
+ * @return string A valid datetime value.
*/
- public function validate_decimal( $value = 0, $decimals = 9 ) {
+ public function validate_datetime( $value = '' ) {
- // Protect against non-numeric values
- if ( ! is_numeric( $value ) ) {
- $value = 0;
+ // Default empty datetime (value with NO_ZERO_DATE off)
+ $default_empty = '0000-00-00 00:00:00';
+
+ // Not using the $default yet
+ $use_default = false;
+
+ // Handle current_timestamp MySQL constant
+ if ( 'CURRENT_TIMESTAMP' === strtoupper( $value ) ) {
+ $value = 'CURRENT_TIMESTAMP';
+
+ // Fallback if "empty" value
+ } elseif ( empty( $value ) || ( $default_empty === $value ) ) {
+ $use_default = true;
+
+ // All other values
+ } else {
+
+ // Check if valid $value
+ $timestamp = strtotime( $value );
+
+ // Format if valid
+ if ( false !== $timestamp ) {
+ $value = gmdate( 'Y-m-d H:i:s', $timestamp );
+
+ // Fallback if invalid
+ } else {
+ $use_default = true;
+ }
}
+ // Fallback to $default
+ if ( ! empty( $use_default ) ) {
+ $value = (string) $this->default;
+ }
+
+ // Return the validated value
+ return $value;
+ }
+
+ /**
+ * Validate a decimal value.
+ *
+ * Default decimal position is '18,9' for currencies, so that rounding can
+ * be done inside of the application layer and outside of MySQL.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Uses: validate_numeric().
+ * @param int|string $value Default empty string. The decimal value to validate.
+ * @param int $decimals Default 9. The number of decimal points to accept.
+ * @return float Formatted to the number of decimals specified
+ */
+ public function validate_decimal( $value = 0, $decimals = 9 ) {
+
// Protect against non-numeric decimals
if ( ! is_numeric( $decimals ) ) {
$decimals = 9;
}
- // Is the value negative?
- $negative_exponent = ( $value < 0 )
+ // Validate & return
+ return $this->validate_numeric( $value, $decimals );
+ }
+
+ /**
+ * Validate a numeric value.
+ *
+ * This is used to validate a mixed value before it is saved into any
+ * numeric column in a database table.
+ *
+ * Uses number_format() (without a thousands separator) which does rounding
+ * to the last decimal if the value is longer than specified.
+ *
+ * @since 2.1.0
+ * @param int|string $value Default empty string. The numeric value to validate.
+ * @param int|bool $decimals Default false. Decimal position will be used, or 0.
+ * @return float
+ */
+ public function validate_numeric( $value = 0, $decimals = false ) {
+
+ // Protect against non-numeric values
+ if ( ! is_numeric( $value ) ) {
+ $value = ( $value !== $this->default )
+ ? $this->default
+ : 0;
+ }
+
+ // Is the value negative and allowed to be?
+ $negative_exponent = ( ( $value < 0 ) && ! empty( $this->unsigned ) )
? -1
: 1;
// Only numbers and period
$value = preg_replace( '/[^0-9\.]/', '', (string) $value );
- // Format to number of decimals, and cast as float
- $formatted = number_format( $value, $decimals, '.', '' );
+ // Attempt to find the decimal position
+ if ( false === $decimals ) {
+
+ // Look for period
+ $period = strpos( $value, '.' );
+
+ // Period position, or 0
+ $decimals = ( false !== $period )
+ ? $period
+ : 0;
+ }
+
+ // Format to number of decimals
+ $formatted = number_format( (float) $value, (int) $decimals, '.', '' );
// Adjust for negative values
- $retval = $formatted * $negative_exponent;
+ $retval = ( $formatted * $negative_exponent );
// Return
return $retval;
}
+ /**
+ * Validate an integer value.
+ *
+ * This is used to validate an integer value before it is saved into any
+ * integer column in a database table.
+ *
+ * Uses: validate_numeric() to guard against non-numeric, invalid values
+ * being cast to a 1 when a fallback to $default is expected.
+ *
+ * @since 2.1.0
+ * @param int $value Default zero.
+ * @return int
+ */
+ public function validate_int( $value = 0 ) {
+ return (int) $this->validate_numeric( $value, false );
+ }
+
/**
* Validate a UUID.
*
@@ -876,88 +1292,123 @@ public function validate_uuid( $value = '' ) {
/** Table Helpers *********************************************************/
/**
- * Return a string representation of what this column's properties look like
- * in a MySQL.
+ * Return a string representation of this column's properties as part of
+ * the "CREATE" string of a Table.
*
- * @todo
- * @since 1.0.0
+ * @since 2.1.0
* @return string
*/
public function get_create_string() {
- // Default return val
- $retval = '';
+ // Create array
+ $create = array();
- // Bail if no name
+ // Name
if ( ! empty( $this->name ) ) {
- $retval .= $this->name;
+ $create[] = "`{$this->name}`";
}
// Type
if ( ! empty( $this->type ) ) {
- $retval .= " {$this->type}";
- }
- // Length
- if ( ! empty( $this->length ) ) {
- $retval .= '(' . $this->length . ')';
- }
+ // Lower looks nicer here for some reason...
+ $lower = strtolower( $this->type );
- // Unsigned
- if ( ! empty( $this->unsigned ) ) {
- $retval .= " unsigned";
- }
+ // Length
+ $create[] = ! empty( $this->length ) && is_numeric( $this->length )
+ ? "{$lower}({$this->length})"
+ : $lower;
+
+ // Binary column types
+ if ( $this->is_binary() ) {
+ $create[] = "CHARACTER SET binary";
+ $create[] = "COLLATE binary";
- // Zerofill
- if ( ! empty( $this->zerofill ) ) {
- // TBD
+ // Non-binary column types
+ } else {
+
+ // Encoding
+ if ( ! empty( $this->encoding ) ) {
+ $create[] = "CHARACTER SET {$this->encoding}";
+ }
+
+ // Collation
+ if ( ! empty( $this->collation ) ) {
+
+ // Binary text uses "_bin" collation
+ $create[] = ( ! empty( $this->binary ) && $this->is_text() )
+ ? "COLLATE {$this->collation}_bin"
+ : "COLLATE {$this->collation}";
+ }
+ }
}
- // Binary
- if ( ! empty( $this->binary ) ) {
- // TBD
+ /**
+ * Note: unsigned Decimals are deprecated in MySQL 8.0.17, and this will
+ * be changed to is_int() at a later date.
+ */
+ if ( $this->is_numeric() ) {
+
+ // Unsigned
+ if ( ! empty( $this->unsigned ) ) {
+ $create[] = 'unsigned';
+ }
+
+ // Zerofill
+ if ( ! empty( $this->zerofill ) ) {
+ $create[] = 'zerofill';
+ }
}
- // Allow null
- if ( ! empty( $this->allow_null ) ) {
- $retval .= " NOT NULL ";
+ // Disallow null
+ if ( false === $this->allow_null ) {
+ $create[] = 'not null';
}
- // Default
+ // Default supplied, so trust it (for now...)
if ( ! empty( $this->default ) ) {
- $retval .= " default '{$this->default}'";
+ $create[] = "default '{$this->default}'";
+
+ // allow_null with literal null defaults to null
+ } elseif ( ( true === $this->allow_null ) && ( null === $this->default ) ) {
+ $create[] = "default null";
- // A literal false means no default value
+ // Literal false means no default value
} elseif ( false !== $this->default ) {
- // Numeric
+ // Numeric (ints and decimals)
if ( $this->is_numeric() ) {
- $retval .= " default '0'";
- } elseif ( $this->is_type( 'datetime' ) ) {
- $retval .= " default '0000-00-00 00:00:00'";
+
+ // Default "0" if _not_ autoincrementing (primary)
+ if ( ! $this->is_extra( 'AUTO_INCREMENT' ) ) {
+ $create[] = "default '0'";
+ }
+
+ // Datetime or Timestamp
+ } elseif ( $this->is_type( array( 'datetime', 'timestamp' ) ) ) {
+
+ // Using the CURRENT_TIMESTAMP constant
+ if ( $this->is_extra( 'ON UPDATE CURRENT_TIMESTAMP' ) ) {
+ $create[] = "ON UPDATE current_timestamp()";
+
+ // @todo NO_ZERO_DATE
+ } elseif ( $this->is_type( 'datetime' ) ) {
+ $create[] = "default '0000-00-00 00:00:00'";
+ }
+
+ // All string types (texts and blobs)
} else {
- $retval .= " default ''";
+ $create[] = "default ''";
}
}
// Extra
if ( ! empty( $this->extra ) ) {
- $retval .= " {$this->extra}";
- }
-
- // Encoding
- if ( ! empty( $this->encoding ) ) {
-
- } else {
-
+ $create[] = strtoupper( $this->extra );
}
- // Collation
- if ( ! empty( $this->collation ) ) {
-
- } else {
-
- }
+ // Format return value from create array
+ $retval = implode( ' ', $create );
// Return the create string
return $retval;
diff --git a/src/Database/Queries/Compare.php b/src/Database/Queries/Compare.php
index 2e4b352..1853aa0 100644
--- a/src/Database/Queries/Compare.php
+++ b/src/Database/Queries/Compare.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Compare
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database\Queries;
// Exit if accessed directly
diff --git a/src/Database/Queries/Date.php b/src/Database/Queries/Date.php
index e246906..8d13dff 100644
--- a/src/Database/Queries/Date.php
+++ b/src/Database/Queries/Date.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Date
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database\Queries;
// Exit if accessed directly
@@ -65,7 +68,7 @@ class Date extends Base {
* The value comparison operator. Can be changed via the query arguments.
*
* @since 1.0.0
- * @var array
+ * @var string
*/
public $compare = '=';
@@ -73,7 +76,7 @@ class Date extends Base {
* The start of week operator. Can be changed via the query arguments.
*
* @since 1.1.0
- * @var array
+ * @var int
*/
public $start_of_week = 0;
@@ -152,12 +155,13 @@ class Date extends Base {
'AND'
);
-
/**
+ * Name of the database table to query
+ *
* @since 2.0.2
- * @var string|null Table name
+ * @var string
*/
- public $table_name = null;
+ public $table_name = '';
/**
* Constructor.
@@ -186,7 +190,7 @@ class Date extends Base {
* @type array ...$0 {
* Optional. An array of first-order clause parameters, or another fully-formed date query.
*
- * @type string|array $before {
+ * @type array|string $before {
* Optional. Date to retrieve posts before. Accepts `strtotime()`-compatible string,
* or array of 'year', 'month', 'day' values.
*
@@ -196,7 +200,7 @@ class Date extends Base {
* @type string $day Optional when passing array.The day of the month.
* Default (string:empty)|(array:1). Accepts numbers 1-31.
* }
- * @type string|array $after {
+ * @type array|string $after {
* Optional. Date to retrieve posts after. Accepts `strtotime()`-compatible string,
* or array of 'year', 'month', 'day' values.
*
@@ -367,7 +371,7 @@ protected function is_first_order_clause( $query = array() ) {
*
* @param array $query A date query or a date subquery.
*
- * @return string The current unix timestamp.
+ * @return int The current unix timestamp.
*/
public function get_now( $query = array() ) {
@@ -395,8 +399,9 @@ public function get_column( $query = array() ) {
? esc_sql( $this->validate_column( $query['column'] ) )
: $this->column;
- if (!empty($this->table_name)) {
- $retval = $this->table_name . '.' . $retval;
+ // Maybe concat table name & column
+ if ( ! empty( $this->table_name ) ) {
+ $retval = "{$this->table_name}.{$retval}";
}
return $retval;
@@ -446,7 +451,7 @@ public function get_relation( $query = array() ) {
*
* @param array $query A date query or a date subquery.
*
- * @return string The comparison operator.
+ * @return int The comparison operator.
*/
public function get_start_of_week( $query = array() ) {
@@ -513,7 +518,7 @@ public function validate_date_values( $date_query = array() ) {
$_year = $date_query['year'];
}
- $max_days_of_year = gmdate( 'z', gmmktime( 0, 0, 0, 12, 31, $_year ) ) + 1;
+ $max_days_of_year = (int) gmdate( 'z', gmmktime( 0, 0, 0, 12, 31, $_year ) ) + 1;
// Otherwise we use the max of 366 (leap-year)
} else {
@@ -654,21 +659,23 @@ public function validate_column( $column = '' ) {
*
* @since 1.0.0
*
- * @return string MySQL WHERE clauses.
+ * @return array MySQL WHERE clauses.
*/
public function get_sql( $type, $primary_table, $primary_id_column, $context = null ) {
- $this->table_name = $this->sanitize_table_name( $primary_table );
- $sql = $this->get_sql_clauses();
+
+ // Table name is the primary table
+ $this->table_name = $this->sanitize_table_name( $primary_table );
+ $sql = $this->get_sql_clauses();
/**
* Filters the date query clauses.
*
* @since 1.0.0
*
- * @param string $sql Clauses of the date query.
- * @param Date $this The Date query instance.
+ * @param array $sql Clauses of the date query.
+ * @param Date $instance The Date query instance.
*/
- return apply_filters( 'get_date_sql', $sql, $this );
+ return (array) apply_filters( 'get_date_sql', $sql, $this );
}
/**
@@ -693,7 +700,7 @@ protected function get_sql_clauses() {
$sql['where'] = ' AND ' . $sql['where'];
}
- return apply_filters( 'get_date_sql_clauses', $sql, $this );
+ return (array) apply_filters( 'get_date_sql_clauses', $sql, $this );
}
/**
@@ -785,7 +792,7 @@ protected function get_sql_for_query( $query = array(), $depth = 0 ) {
}
// Filter and return
- return apply_filters( 'get_date_sql_for_query', $sql, $query, $depth, $this );
+ return (array) apply_filters( 'get_date_sql_for_query', $sql, $query, $depth, $this );
}
/**
@@ -805,6 +812,9 @@ protected function get_sql_for_query( $query = array(), $depth = 0 ) {
*/
protected function get_sql_for_clause( $query = array(), $parent_query = array() ) {
+ // Get the database interface
+ $db = $this->get_db();
+
// The sub-parts of a $where part.
$where_parts = array();
@@ -826,11 +836,11 @@ protected function get_sql_for_clause( $query = array(), $parent_query = array()
// Range queries.
if ( ! empty( $query['after'] ) ) {
- $where_parts[] = $this->get_db()->prepare( "{$column} {$gt} %s", $this->build_mysql_datetime( $query['after'], ! $inclusive, $now ) );
+ $where_parts[] = $db->prepare( "{$column} {$gt} %s", $this->build_mysql_datetime( $query['after'], ! $inclusive, $now ) );
}
if ( ! empty( $query['before'] ) ) {
- $where_parts[] = $this->get_db()->prepare( "{$column} {$lt} %s", $this->build_mysql_datetime( $query['before'], $inclusive, $now ) );
+ $where_parts[] = $db->prepare( "{$column} {$lt} %s", $this->build_mysql_datetime( $query['before'], $inclusive, $now ) );
}
// Specific value queries.
@@ -905,9 +915,9 @@ protected function get_sql_for_clause( $query = array(), $parent_query = array()
* @since 1.0.0
*
* @param string $compare The compare operator to use
- * @param string|array $value The value
+ * @param array|int|string $value The value
*
- * @return string|false|int The value to be used in SQL or false on error.
+ * @return string|bool|int The value to be used in SQL or false on error.
*/
public function build_numeric_value( $compare = '=', $value = null ) {
@@ -964,12 +974,16 @@ public function build_numeric_value( $compare = '=', $value = null ) {
* @since 1.0.0
*
* @param string $compare The compare operator to use
- * @param string|array $value The value
+ * @param array|string $value The value
*
* @return string|false|int The value to be used in SQL or false on error.
*/
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 );
@@ -982,25 +996,25 @@ public function build_value( $compare = '=', $value = null ) {
case 'IN':
case 'NOT IN':
$compare_string = '(' . substr( str_repeat( ',%s', count( $value ) ), 1 ) . ')';
- $where = $this->get_db()->prepare( $compare_string, $value );
+ $where = $db->prepare( $compare_string, $value );
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$value = array_slice( $value, 0, 2 );
- $where = $this->get_db()->prepare( '%s AND %s', $value );
+ $where = $db->prepare( '%s AND %s', $value );
break;
case 'LIKE':
case 'NOT LIKE':
- $value = '%' . $this->get_db()->esc_like( $value ) . '%';
- $where = $this->get_db()->prepare( '%s', $value );
+ $value = '%' . $db->esc_like( $value ) . '%';
+ $where = $db->prepare( '%s', $value );
break;
// EXISTS with a value is interpreted as '='.
case 'EXISTS':
$compare = '=';
- $where = $this->get_db()->prepare( '%s', $value );
+ $where = $db->prepare( '%s', $value );
break;
// 'value' is ignored for NOT EXISTS.
@@ -1009,7 +1023,7 @@ public function build_value( $compare = '=', $value = null ) {
break;
default:
- $where = $this->get_db()->prepare( '%s', $value );
+ $where = $db->prepare( '%s', $value );
break;
}
@@ -1025,12 +1039,12 @@ public function build_value( $compare = '=', $value = null ) {
*
* @since 1.0.0
*
- * @param string|array $datetime An array of parameters or a strtotime() string
- * @param bool $default_to_max Whether to round up incomplete dates. Supported by values
- * of $datetime that are arrays, or string values that are a
- * subset of MySQL date format ('Y', 'Y-m', 'Y-m-d', 'Y-m-d H:i').
- * Default: false.
- * @param string|int $now The current unix timestamp.
+ * @param array|int|string $datetime An array of parameters or a strtotime() string
+ * @param bool $default_to_max Whether to round up incomplete dates. Supported by values
+ * of $datetime that are arrays, or string values that are a
+ * subset of MySQL date format ('Y', 'Y-m', 'Y-m-d', 'Y-m-d H:i').
+ * Default: false.
+ * @param string|int $now The current unix timestamp.
*
* @return string|false A MySQL format date/time or false on failure
*/
@@ -1225,6 +1239,9 @@ public function build_time_query( $column, $compare, $hour = null, $minute = nul
return false;
}
+ // Get the database interface
+ $db = $this->get_db();
+
// Complex combined queries aren't supported for multi-value queries
if ( in_array( $compare, $this->multi_value_keys, true ) ) {
$retval = array();
@@ -1294,7 +1311,7 @@ public function build_time_query( $column, $compare, $hour = null, $minute = nul
$query = "DATE_FORMAT( {$column}, %s ) {$compare} %f";
// Return the prepared SQL
- return $this->get_db()->prepare( $query, $format, $time );
+ return $db->prepare( $query, $format, $time );
}
/**
diff --git a/src/Database/Queries/Meta.php b/src/Database/Queries/Meta.php
index 8ae6705..a1ff0e7 100644
--- a/src/Database/Queries/Meta.php
+++ b/src/Database/Queries/Meta.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Meta
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.1.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database\Queries;
// Exit if accessed directly
diff --git a/src/Database/Query.php b/src/Database/Query.php
index caf59c5..d3044fc 100644
--- a/src/Database/Query.php
+++ b/src/Database/Query.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Query
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -22,30 +25,6 @@
* @since 1.0.0
*
* @see Query::__construct() for accepted arguments.
- *
- * @property string $prefix
- * @property string $table_name
- * @property string $table_alias
- * @property string $table_schema
- * @property string $item_name
- * @property string $item_name_plural
- * @property string $item_shape
- * @property string $cache_group
- * @property int $last_changed
- * @property array $columns
- * @property array $query_clauses
- * @property array $request_clauses
- * @property Queries\Meta $meta_query
- * @property Queries\Date $date_query
- * @property Queries\Compare $compare_query
- * @property array $query_vars
- * @property array $query_var_originals
- * @property array $query_var_defaults
- * @property string $query_var_default_value
- * @property array $items
- * @property int $found_items
- * @property int $max_num_pages
- * @property string $request
*/
class Query extends Base {
@@ -77,7 +56,7 @@ class Query extends Base {
* @since 1.0.0
* @var string
*/
- protected $table_schema = '\\BerlinDB\\Database\\Schema';
+ protected $table_schema = __NAMESPACE__ . '\\Schema';
/** Item ******************************************************************/
@@ -86,43 +65,44 @@ class Query extends Base {
*
* Use underscores between words. I.E. "term_relationship"
*
- * This is used to automatically generate action hooks.
+ * This is used to automatically generate hook names.
*
* @since 1.0.0
* @var string
*/
- protected $item_name = '';
+ protected $item_name = 'item';
/**
* Plural version for a group of items.
*
* Use underscores between words. I.E. "term_relationships"
*
- * This is used to automatically generate action hooks.
+ * This is used to automatically generate hook names.
*
* @since 1.0.0
* @var string
*/
- protected $item_name_plural = '';
+ protected $item_name_plural = 'items';
/**
* Name of class used to turn IDs into first-class objects.
*
- * This is used when looping through return values to guarantee their shape.
+ * This is used when looping through return values to guarantee that objects
+ * are the expected class.
*
* @since 1.0.0
* @var mixed
*/
- protected $item_shape = '\\BerlinDB\\Database\\Row';
+ protected $item_shape = __NAMESPACE__ . '\\Row';
- /**
- * Name of class used to turn IDs into first-class objects for the current request.
- *
- * This is used when looping through return values to guarantee their shape.
- *
- * @var mixed
- */
- protected $current_item_shape;
+ /**
+ * Name of class used to turn IDs into first-class objects for the current request.
+ *
+ * This is used when looping through return values to guarantee their shape.
+ *
+ * @var mixed
+ */
+ protected $current_item_shape = '';
/** Cache *****************************************************************/
@@ -142,19 +122,22 @@ class Query extends Base {
* The last updated time.
*
* @since 1.0.0
- * @var int
+ * @var string
*/
- protected $last_changed = 0;
+ protected $last_changed = '';
- /** Columns ***************************************************************/
+ /** Schema *************************************************************/
/**
- * Array of all database column objects.
+ * Schema object.
*
- * @since 1.0.0
- * @var array
+ * A collection of Column and Index objects. Set to private so that it is
+ * not touched directly until this can be vetted and opened up.
+ *
+ * @since 2.1.0
+ * @var Schema
*/
- protected $columns = array();
+ private $schema = null;
/** Clauses ***************************************************************/
@@ -164,53 +147,15 @@ class Query extends Base {
* @since 1.0.0
* @var array
*/
- protected $query_clauses = array(
- 'select' => '',
- 'from' => '',
- 'where' => array(),
- 'groupby' => '',
- 'orderby' => '',
- 'limits' => ''
- );
+ protected $query_clauses = array();
/**
- * Request clauses.
+ * SQL request clauses.
*
* @since 1.0.0
* @var array
*/
- protected $request_clauses = array(
- 'select' => '',
- 'from' => '',
- 'where' => '',
- 'groupby' => '',
- 'orderby' => '',
- 'limits' => ''
- );
-
- /**
- * Meta query container.
- *
- * @since 1.0.0
- * @var object|Queries\Meta
- */
- protected $meta_query = false;
-
- /**
- * Date query container.
- *
- * @since 1.0.0
- * @var object|Queries\Date
- */
- protected $date_query = false;
-
- /**
- * Compare query container.
- *
- * @since 1.0.0
- * @var object|Queries\Compare
- */
- protected $compare_query = false;
+ protected $request_clauses = array();
/** Query Variables *******************************************************/
@@ -248,6 +193,8 @@ class Query extends Base {
protected $query_var_defaults = array();
/**
+ * Random default value for all query vars.
+ *
* This private variable temporarily holds onto a random string used as the
* default query var value. This is used internally when performing
* comparisons, and allows for querying by falsy values.
@@ -257,18 +204,21 @@ class Query extends Base {
*/
protected $query_var_default_value = '';
- /** Results ***************************************************************/
-
/**
- * List of items located by the query.
+ * Query var parsers.
*
- * @since 1.0.0
+ * An array of special classes used to parse Magic $query_vars into
+ * $query_clauses.
+ *
+ * @since 2.1.0
* @var array
*/
- public $items = array();
+ protected $query_var_parsers = array();
+
+ /** Results ***************************************************************/
/**
- * The amount of found items for the current query.
+ * The total number of items found by the SQL query.
*
* @since 1.0.0
* @var int
@@ -284,13 +234,21 @@ class Query extends Base {
protected $max_num_pages = 0;
/**
- * SQL for database query.
+ * The final SQL string generated by this class.
*
* @since 1.0.0
* @var string
*/
protected $request = '';
+ /**
+ * Array of items retrieved by the SQL query.
+ *
+ * @since 1.0.0
+ * @var array|int
+ */
+ public $items = array();
+
/** Methods ***************************************************************/
/**
@@ -298,43 +256,39 @@ class Query extends Base {
*
* @since 1.0.0
*
- * @param string|array $query {
+ * @param array|string $query {
* Optional. Array or query string of item query parameters.
* Default empty.
*
- * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs)
- * or empty (returns an array of complete item objects). Default empty.
- * To do a date query against a field, append the field name with _query
- * @type bool $count Whether to return a item count (true) or array of item objects.
- * Default false.
- * @type int $number Limit number of items to retrieve. Use 0 for no limit.
- * Default 100.
- * @type int $offset Number of items to offset the query. Used to build LIMIT clause.
- * Default 0.
- * @type bool $no_found_rows Whether to disable the `SQL_CALC_FOUND_ROWS` query.
- * Default true.
- * @type string|array $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause.
- * Default '', to primary column ID.
- * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'.
- * Default 'DESC'.
- * @type string $search Search term(s) to retrieve matching items for.
- * Default empty.
- * @type array $search_columns Array of column names to be searched.
- * Default empty array.
- * @type bool $update_item_cache Whether to prime the cache for found items.
- * Default false.
- * @type bool $update_meta_cache Whether to prime the meta cache for found items.
- * Default false.
+ * @type string $fields Site fields to return. Accepts 'ids' (returns an array of item IDs)
+ * or empty (returns an array of complete item objects). Default empty.
+ * To do a date query against a field, append the field name with _query
+ * @type bool $count Return an item count (true) or array of item objects.
+ * Default false.
+ * @type int $number Limit number of items to retrieve. Use 0 for no limit.
+ * Default 100.
+ * @type int $offset Number of items to offset the query. Used to build LIMIT clause.
+ * Default 0.
+ * @type bool $no_found_rows Disable the separate COUNT(*) query.
+ * Default true.
+ * @type string $orderby Accepts false, an empty array, or 'none' to disable `ORDER BY` clause.
+ * Default '', to primary column ID.
+ * @type string $order How to order retrieved items. Accepts 'ASC', 'DESC'.
+ * Default 'DESC'.
+ * @type string $search Search term(s) to retrieve matching items for.
+ * Default empty.
+ * @type array $search_columns Array of column names to be searched.
+ * Default empty array.
+ * @type bool $update_item_cache Prime the cache for found items.
+ * Default false.
+ * @type bool $update_meta_cache Prime the meta cache for found items.
+ * Default false.
* }
*/
public function __construct( $query = array() ) {
// Setup
- $this->set_alias();
- $this->set_prefix();
- $this->set_columns();
- $this->set_item_shape();
- $this->set_query_var_defaults();
+ $this->setup();
// Maybe execute a query if arguments were passed
if ( ! empty( $query ) ) {
@@ -342,6 +296,24 @@ public function __construct( $query = array() ) {
}
}
+ /**
+ * 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
+ */
+ public function setup() {
+ $this->set_alias();
+ $this->set_prefixes();
+ $this->set_schema();
+ $this->set_item_shape();
+ $this->set_query_var_parsers();
+ $this->set_query_var_defaults();
+ $this->set_query_clause_defaults();
+ }
+
/**
* Queries the database and retrieves items or counts.
*
@@ -350,8 +322,8 @@ public function __construct( $query = array() ) {
*
* @since 1.0.0
*
- * @param string|array $query Array or URL query string of parameters.
- * @return array|int List of items, or number of items when 'count' is passed as a query var.
+ * @param array|string $query Array or URL query string of parameters.
+ * @return array|int Array of items, or number of items when 'count' is passed as a query var.
*/
public function query( $query = array() ) {
$this->parse_query( $query );
@@ -362,9 +334,9 @@ public function query( $query = array() ) {
/** Private Setters *******************************************************/
/**
- * Set the time when items were last changed.
+ * Set up the time when items were last changed.
*
- * We set this locally to avoid inconsistencies between method calls.
+ * Avoids inconsistencies between method calls.
*
* @since 1.0.0
*/
@@ -386,38 +358,36 @@ private function set_alias() {
}
/**
- * Prefix table names, cache groups, and other things.
+ * Set up prefixes on:
+ * - table name
+ * - table alias
+ * - cache group
*
* This is to avoid conflicts with other plugins or themes that might be
- * doing their own things.
+ * using the global scope for data and cache storage.
*
- * @since 1.0.0
+ * @since 2.1.0
*/
- private function set_prefix() {
+ private function set_prefixes() {
$this->table_name = $this->apply_prefix( $this->table_name );
$this->table_alias = $this->apply_prefix( $this->table_alias );
$this->cache_group = $this->apply_prefix( $this->cache_group, '-' );
}
/**
- * Set columns objects.
+ * Set up the Schema.
*
- * @since 1.0.0
+ * @since 2.1.0
*/
- private function set_columns() {
+ private function set_schema() {
// Bail if no table schema
- if ( ! class_exists( $this->table_schema ) ) {
+ if ( empty( $this->table_schema ) || ! class_exists( $this->table_schema ) ) {
return;
}
// Invoke a new table schema class
- $schema = new $this->table_schema;
-
- // Maybe get the column objects
- if ( ! empty( $schema->columns ) ) {
- $this->columns = $schema->columns;
- }
+ $this->schema = new $this->table_schema;
}
/**
@@ -426,19 +396,66 @@ private function set_columns() {
* @since 1.0.0
*/
private function set_item_shape() {
+
+ // Item shape
if ( empty( $this->item_shape ) || ! class_exists( $this->item_shape ) ) {
$this->item_shape = __NAMESPACE__ . '\\Row';
}
+ // Current item during shaping (might be stdClass)
if ( empty( $this->current_item_shape ) || ! class_exists( $this->current_item_shape ) ) {
$this->current_item_shape = $this->item_shape;
}
}
+ /**
+ * Set query var parsers.
+ *
+ * @since 2.1.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'
+ );
+ }
+ }
+
+ /**
+ * Set defaults for query (and also request) clauses.
+ *
+ * @since 2.1.0
+ */
+ private function set_query_clause_defaults() {
+
+ // Default query clauses
+ $this->query_clauses = array(
+ 'explain' => '',
+ 'select' => '',
+ 'fields' => '',
+ 'count' => '',
+ 'from' => '',
+ 'join' => array(),
+ 'where' => array(),
+ 'groupby' => '',
+ 'orderby' => '',
+ 'limits' => ''
+ );
+
+ // Default request clauses are empty strings
+ $this->request_clauses = array_fill_keys(
+ array_keys( $this->query_clauses ),
+ ''
+ );
+ }
+
/**
* Set default query vars based on columns.
*
* @since 1.0.0
+ * @since 2.1.0
*/
private function set_query_var_defaults() {
@@ -452,147 +469,150 @@ private function set_query_var_defaults() {
// Default query variables
$this->query_var_defaults = array(
+
+ // Statements
+ 'explain' => false,
+ 'select' => '',
+
+ // Fields
'fields' => '',
+ 'groupby' => '',
+
+ // Boundaries
'number' => 100,
'offset' => '',
'orderby' => $primary,
'order' => 'DESC',
- 'groupby' => '',
+
+ // Search
'search' => '',
'search_columns' => array(),
+
+ // COUNT(*)
'count' => false,
- // Disable SQL_CALC_FOUND_ROWS?
+ // Disable row count
'no_found_rows' => true,
- // Queries
- 'meta_query' => null, // See Queries\Meta
- 'date_query' => null, // See Queries\Date
- 'compare_query' => null, // See Queries\Compare
-
// Caching
'update_item_cache' => true,
'update_meta_cache' => true
);
- // Bail if no columns
- if ( empty( $this->columns ) ) {
+ /** Column Names ******************************************************/
+
+ // All column names
+ $names = $this->get_column_names();
+
+ // Bail early if no columns
+ if ( empty( $names ) ) {
return;
}
- // Direct column names
- $names = wp_list_pluck( $this->columns, 'name' );
- foreach ( $names as $name ) {
- $this->query_var_defaults[ $name ] = $this->query_var_default_value;
- }
+ // Fill with default value
+ $defaults = array_fill_keys( $names, $this->query_var_default_value );
- // Possible ins
- $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' );
- foreach ( $possible_ins as $in ) {
- $key = "{$in}__in";
- $this->query_var_defaults[ $key ] = false;
- }
+ /** Specials **********************************************************/
- // Possible not ins
- $possible_not_ins = $this->get_columns( array( 'not_in' => true ), 'and', 'name' );
- foreach ( $possible_not_ins as $in ) {
- $key = "{$in}__not_in";
- $this->query_var_defaults[ $key ] = false;
- }
+ // Special column query attributes
+ $specials = array(
+ 'in' => '__in',
+ 'not_in' => '__not_in',
+ 'date_query' => '_query'
+ );
+
+ // Loop through specials
+ foreach ( $specials as $column => $suffix ) {
+
+ // Columns
+ $filter = array( $column => true );
+ $columns = $this->get_column_names( $filter );
- // Possible dates
- $possible_dates = $this->get_columns( array( 'date_query' => true ), 'and', 'name' );
- foreach ( $possible_dates as $date ) {
- $key = "{$date}_query";
- $this->query_var_defaults[ $key ] = false;
+ // Skip if no columns
+ if ( empty( $columns ) ) {
+ continue;
+ }
+
+ // Add defaults
+ foreach ( $columns as $name ) {
+ $defaults[] = "{$name}{$suffix}";
+ }
}
- }
- /**
- * Set the request clauses.
- *
- * @since 1.0.0
- *
- * @param array $clauses
- */
- private function set_request_clauses( $clauses = array() ) {
+ /** Query Objects *****************************************************/
+
+ // Loop through query var parsers
+ foreach ( array_keys( $this->query_var_parsers ) as $id ) {
- // Found rows
- $found_rows = empty( $this->query_vars['no_found_rows'] )
- ? 'SQL_CALC_FOUND_ROWS'
- : '';
+ // Set query key
+ $suffix = '_query';
+ $query_key = strtolower( $id ) . $suffix;
- // Fields
- $fields = ! empty( $clauses['fields'] )
- ? $clauses['fields']
- : '';
+ // Columns
+ $filter = array( $query_key => true );
+ $columns = $this->get_column_names( $filter );
- // Join
- $join = ! empty( $clauses['join'] )
- ? $clauses['join']
- : '';
+ // Skip if no columns
+ if ( empty( $columns ) ) {
+ continue;
+ }
- // Where
- $where = ! empty( $clauses['where'] )
- ? "WHERE {$clauses['where']}"
- : '';
+ // Add defaults
+ foreach ( $columns as $column ) {
+ $defaults[] = "{$name}{$suffix}";
+ }
+ }
- // Group by
- $groupby = ! empty( $clauses['groupby'] )
- ? "GROUP BY {$clauses['groupby']}"
- : '';
+ /** Defaults **********************************************************/
- // Order by
- $orderby = ! empty( $clauses['orderby'] )
- ? "ORDER BY {$clauses['orderby']}"
- : '';
+ // Fill default keys with default value
+ $default_values = array_fill_keys( $defaults, $this->query_var_default_value );
- // Limits
- $limits = ! empty( $clauses['limits'] )
- ? $clauses['limits']
- : '';
+ // Merge defaults
+ $this->query_var_defaults = array_merge( $this->query_var_defaults, $default_values );
+ }
- // Select & From
- $table = $this->get_table_name();
- $select = "SELECT {$found_rows} {$fields}";
- $from = "FROM {$table} {$this->table_alias} {$join}";
+ /**
+ * Set $query_clauses by parsing $query_vars.
+ *
+ * @since 2.1.0
+ */
+ private function set_query_clauses() {
+ $this->query_clauses = $this->parse_query_vars();
+ }
- // Put query into clauses array
- $this->request_clauses['select'] = $select;
- $this->request_clauses['from'] = $from;
- $this->request_clauses['where'] = $where;
- $this->request_clauses['groupby'] = $groupby;
- $this->request_clauses['orderby'] = $orderby;
- $this->request_clauses['limits'] = $limits;
+ /**
+ * Set the $request_clauses.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Uses parse_query_clauses() with support for new clauses.
+ */
+ private function set_request_clauses() {
+ $this->request_clauses = $this->parse_query_clauses();
}
/**
- * Set the request.
+ * Set the $request.
*
* @since 1.0.0
+ * @since 2.1.0 Uses parse_request_clauses() on $request_clauses.
*/
private function set_request() {
- $filtered = array_filter( $this->request_clauses );
- $clauses = array_map( 'trim', $filtered );
- $this->request = implode( ' ', $clauses );
+ $this->request = $this->parse_request_clauses();
}
/**
* Set items by mapping them through the single item callback.
*
* @since 1.0.0
+ * @since 2.1.0 Moved 'count' logic back into get_items().
* @param array $item_ids
*/
private function set_items( $item_ids = array() ) {
- // Bail if counting, to avoid shaping items
- if ( ! empty( $this->query_vars['count'] ) ) {
- $this->items = $item_ids;
- return;
- }
-
- // Cast to integers
- $item_ids = array_map( 'intval', $item_ids );
+ // Validate primary column values
+ $callback = array( $this, 'shape_item_id' );
+ $item_ids = array_map( $callback, $item_ids );
// Prime item caches
$this->prime_item_caches( $item_ids );
@@ -602,49 +622,80 @@ private function set_items( $item_ids = array() ) {
}
/**
- * Populates found_items and max_num_pages properties for the current query
+ * Populates found_items for the current query.
+ *
* if the limit clause was used.
*
* @since 1.0.0
+ * @since 2.1.0 Uses filter_found_items_query().
*
- * @param array $item_ids Optional array of item IDs
+ * @param mixed $item_ids Optional array of item IDs
*/
private function set_found_items( $item_ids = array() ) {
- // Items were not found
- if ( empty( $item_ids ) ) {
- return;
- }
-
- // Default to number of item IDs
- $this->found_items = count( (array) $item_ids );
+ /**
+ * Default to count of item IDs.
+ *
+ * This is relevant for any kind of query. Either it is literal item IDs
+ * or it is the number of results returned by a 'count' and 'groupby'
+ * query.
+ */
+ $retval = count( (array) $item_ids );
- // Count query
- if ( ! empty( $this->query_vars['count'] ) ) {
+ /**
+ * Count query.
+ *
+ * Possibly grouping results by some other columns.
+ */
+ if ( $this->get_query_var( 'count' ) ) {
// Not grouped
- if ( is_numeric( $item_ids ) && empty( $this->query_vars['groupby'] ) ) {
- $this->found_items = intval( $item_ids );
+ if ( is_numeric( $item_ids ) && ! $this->get_query_var( 'groupby' ) ) {
+ $retval = $item_ids;
}
- // Not a count query
- } elseif ( is_array( $item_ids ) && ( ! empty( $this->query_vars['number'] ) && empty( $this->query_vars['no_found_rows'] ) ) ) {
+ /**
+ * Maybe perform a second COUNT(*) query immediately if:
+ *
+ * - 'count' query var is not truthy
+ * - 'no_found_row' query var is not truthy
+ * - 'number' query var is not falsy
+ *
+ * This second query uses most of the previously parsed $request_clauses
+ * and overrides a few to correct the SQL syntax.
+ *
+ * @since 2.1.0 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' => '',
+ 'limits' => '',
+ 'orderby' => ''
+ ),
+ $this->request_clauses
+ );
+
+ // Parse the new clauses
+ $query = $this->parse_request_clauses( $r );
- /**
- * Filters the query used to retrieve found item count.
- *
- * @since 1.0.0
- *
- * @param string $found_items_query SQL query. Default 'SELECT FOUND_ROWS()'.
- * @param object $item_query The object instance.
- */
- $found_items_query = (string) apply_filters_ref_array( $this->apply_prefix( "found_{$this->item_name_plural}_query" ), array( 'SELECT FOUND_ROWS()', &$this ) );
+ // Filter the found items query
+ $query = $this->filter_found_items_query( $query );
+
+ // Get the database interface
+ $db = $this->get_db();
// Maybe query for found items
- if ( ! empty( $found_items_query ) ) {
- $this->found_items = (int) $this->get_db()->get_var( $found_items_query );
+ if ( ! empty( $query ) && ! empty( $db ) ) {
+ $retval = $db->get_var( $query );
}
}
+
+ // Set found items
+ $this->found_items = (int) $retval;
}
/** Public Setters ********************************************************/
@@ -652,7 +703,7 @@ private function set_found_items( $item_ids = array() ) {
/**
* Set a query var, to both defaults and request arrays.
*
- * This method is used to expose the private query_vars array to hooks,
+ * This method is used to expose the private $query_vars array to hooks,
* allowing them to manipulate query vars just-in-time.
*
* @since 1.0.0
@@ -674,54 +725,74 @@ public function set_query_var( $key = '', $value = '' ) {
* @return bool
*/
public function is_query_var_default( $key = '' ) {
- return (bool) ( $this->query_vars[ $key ] === $this->query_var_default_value );
+ return (bool) ( $this->get_query_var( $key ) === $this->query_var_default_value );
}
- /** Private Getters *******************************************************/
-
/**
- * Pass-through method to return a new Meta object.
- *
- * @since 1.0.0
- *
- * @param array $args See Queries\Meta
+ * Is a column valid?
*
- * @return Queries\Meta
+ * @since 2.1.0
+ * @param string $column_name
+ * @return bool
*/
- private function get_meta_query( $args = array() ) {
- return new Queries\Meta( $args );
+ private function is_valid_column( $column_name = '' ) {
+
+ // Bail if column name not valid string
+ if ( empty( $column_name ) || ! is_string( $column_name ) ) {
+ return false;
+ }
+
+ // Return if column exists
+ return (bool) $this->get_column_by( array( 'name' => $column_name ) );
}
+ /** Private Getters *******************************************************/
+
/**
- * Pass-through method to return a new Compare object.
- *
- * @since 1.0.0
- *
- * @param array $args See Queries\Compare
+ * Get a query variable.
*
- * @return Queries\Compare
+ * @since 2.1.0
+ * @param string $key
+ * @return mixed
*/
- private function get_compare_query( $args = array() ) {
- return new Queries\Compare( $args );
+ private function get_query_var( $key = '' ) {
+ return isset( $this->query_vars[ $key ] )
+ ? $this->query_vars[ $key ]
+ : null;
}
/**
- * Pass-through method to return a new Queries\Date object.
- *
- * @since 1.0.0
- *
- * @param array $args See Queries\Date
+ * Return a new query var parser object, if it exists.
*
- * @return Queries\Date
+ * @since 2.1.0
+ * @param string $query
+ * @param array $args
+ * @return object
*/
- private function get_date_query( $args = array() ) {
- return new Queries\Date( $args );
+ 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()
+ * 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
*
@@ -732,25 +803,39 @@ private function get_current_time() {
}
/**
- * Return the literal table name (with prefix) from the database interface.
+ * Return the table name.
+ *
+ * Prefixed by the $table_prefix global, or get_blog_prefix() if
+ * is_multisite().
*
* @since 1.0.0
*
* @return string
*/
private function get_table_name() {
- return $this->get_db()->{$this->table_name};
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Return SQL
+ return ! empty( $db )
+ ? $db->{$this->table_name}
+ : $this->table_name;
}
/**
* Return array of column names.
*
* @since 1.0.0
+ * @since 2.1.0 Pass $args and $operator to filter names.
+ * No longer calls array_flip().
*
+ * @param array $args Arguments to filter columns by.
+ * @param string $operator Optional. The logical operation to perform.
* @return array
*/
- private function get_column_names() {
- return array_flip( $this->get_columns( array(), 'and', 'name' ) );
+ private function get_column_names( $args = array(), $operator = 'and' ) {
+ return $this->get_columns( $args, $operator, 'name' );
}
/**
@@ -807,43 +892,146 @@ private function get_column_by( $args = array() ) {
/**
* Get columns from an array of arguments.
*
+ * Function arguments are passed into wp_filter_object_list() to filter the
+ * array of columns as needed.
+ *
* @since 1.0.0
+ * @since 2.1.0
*
- * @param array $args Arguments to filter columns by.
- * @param string $operator Optional. The logical operation to perform.
- * @param string $field Optional. A field from the object to place
- * instead of the entire object. Default false.
- * @return array Array of column.
+ * @static array $columns Local static copy of columns, abstracted to
+ * support different storage locations.
+ * @param array $args Arguments to filter columns by.
+ * @param string $operator Optional. The logical operation to perform.
+ * @param bool|string $field Optional. A field from the object to place
+ * instead of the entire object. Default false.
+ * @return array Array of columns.
*/
private function get_columns( $args = array(), $operator = 'and', $field = false ) {
+ // Default columns
+ $columns = array();
+
+ // Prefer to get Columns from Schema
+ if ( ! empty( $this->schema->columns ) ) {
+ $columns = $this->schema->columns;
+
+ // Legacy column parameter support (from 1.0.0)
+ } elseif ( ! empty( $this->columns ) ) {
+ $columns = $this->columns;
+ }
+
+ // Bail if no columns to filter
+ if ( empty( $columns ) ) {
+ return $columns;
+ }
+
// Filter columns
- $filter = wp_filter_object_list( $this->columns, $args, $operator, $field );
+ $retval = wp_filter_object_list( $columns, $args, $operator, $field );
- // Return column or false
- return ! empty( $filter )
- ? array_values( $filter )
+ // Return columns or empty array
+ return ! empty( $retval )
+ ? array_values( $retval )
: array();
}
+ /**
+ * Get a field from columns, by the intersection of key and values.
+ *
+ * This is used for retrieving an array of column fields by an array of
+ * other field values.
+ *
+ * Uses get_column_field() to allow passing of a default value.
+ *
+ * @since 2.1.0
+ * @param string $key Name of property to compare $values to.
+ * @param array $values Values to get a column by.
+ * @param string $field Field to get from a column.
+ * @param mixed $default Default to use if no field is set.
+ * @return array
+ */
+ private function get_columns_field_by( $key = '', $values = array(), $field = '', $default = false ) {
+
+ // Bail if no values
+ if ( empty( $values ) ) {
+ return array();
+ }
+
+ // Allow scalar values
+ if ( is_scalar( $values ) ) {
+ $values = array( $values );
+ }
+
+ // Maybe fallback to $key
+ if ( empty( $field ) ) {
+ $field = $key;
+ }
+
+ // Default return value
+ $retval = array();
+
+ // Get the column fields
+ foreach ( $values as $value ) {
+ $args = array( $key => $value );
+ $retval[] = $this->get_column_field( $args, $field, $default );
+ }
+
+ // Return fields of columns
+ return $retval;
+ }
+
+ /**
+ * Get a column name, possibly with the $table_alias append.
+ *
+ * @since 2.1.0
+ * @param string $column_name
+ * @param bool $alias
+ * @return string
+ */
+ private function get_column_name_aliased( $column_name = '', $alias = true ) {
+
+ // Default return value
+ $retval = $column_name;
+
+ /**
+ * Maybe append table alias.
+ *
+ * Also append a period, to separate it from the column name.
+ */
+ if ( true === $alias ) {
+ $retval = "{$this->table_alias}.{$column_name}";
+ }
+
+ // Return SQL
+ return $retval;
+ }
+
/**
* Get a single database row by any column and value, skipping cache.
*
* @since 1.0.0
+ * @since 2.1.0 Uses is_valid_column()
*
* @param string $column_name Name of database column
- * @param string $column_value Value to query for
+ * @param mixed $column_value Value to query for
* @return object|false False if empty/error, Object if successful
*/
private function get_item_raw( $column_name = '', $column_value = '' ) {
- // Bail if no name or value
- if ( empty( $column_name ) || empty( $column_value ) ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Bail if empty or non-scalar value
+ if ( empty( $column_value ) || ! is_scalar( $column_value ) ) {
return false;
}
- // Bail if values aren't query'able
- if ( ! is_string( $column_name ) || ! is_scalar( $column_value ) ) {
+ // Bail if invalid column
+ if ( ! $this->is_valid_column( $column_name ) ) {
return false;
}
@@ -853,8 +1041,8 @@ private function get_item_raw( $column_name = '', $column_value = '' ) {
// Query database
$query = "SELECT * FROM {$table} WHERE {$column_name} = {$pattern} LIMIT 1";
- $select = $this->get_db()->prepare( $query, $column_value );
- $result = $this->get_db()->get_row( $select );
+ $select = $db->prepare( $query, $column_value );
+ $result = $db->get_row( $select );
// Bail on failure
if ( ! $this->is_success( $result ) ) {
@@ -870,7 +1058,7 @@ private function get_item_raw( $column_name = '', $column_value = '' ) {
*
* @since 1.0.0
*
- * @return array|int List of items, or number of items when 'count' is passed as a query var.
+ * @return array|int Array of items, or number of items when 'count' is passed as a query var.
*/
private function get_items() {
@@ -879,17 +1067,14 @@ private function get_items() {
*
* @since 1.0.0
*
- * @param Query &$this Current instance of Query, passed by reference.
+ * @param Query &$this Current instance passed by reference.
*/
- do_action_ref_array( $this->apply_prefix( "pre_get_{$this->item_name_plural}" ), array( &$this ) );
-
- // Never limit, never update item/meta caches when counting
- if ( ! empty( $this->query_vars['count'] ) ) {
- $this->query_vars['number'] = false;
- $this->query_vars['no_found_rows'] = true;
- $this->query_vars['update_item_cache'] = false;
- $this->query_vars['update_meta_cache'] = false;
- }
+ do_action_ref_array(
+ $this->apply_prefix( "pre_get_{$this->item_name_plural}" ),
+ array(
+ &$this
+ )
+ );
// Check the cache
$cache_key = $this->get_cache_key();
@@ -897,15 +1082,17 @@ private function get_items() {
// No cache value
if ( false === $cache_value ) {
- $item_ids = $this->get_item_ids();
+
+ // Query for item IDs
+ $result = $this->get_item_ids();
// Set the number of found items
- $this->set_found_items( $item_ids );
+ $this->set_found_items( $result );
// Format the cached value
$cache_value = array(
- 'item_ids' => $item_ids,
- 'found_items' => intval( $this->found_items ),
+ 'item_ids' => $result,
+ 'found_items' => (int) $this->found_items,
);
// Add value to the cache
@@ -913,22 +1100,36 @@ private function get_items() {
// Value exists in cache
} else {
- $item_ids = $cache_value['item_ids'];
- $this->found_items = intval( $cache_value['found_items'] );
+ $result = $cache_value['item_ids'];
+ $this->found_items = (int) $cache_value['found_items'];
}
// Pagination
- if ( ! empty( $this->found_items ) && ! empty( $this->query_vars['number'] ) ) {
- $this->max_num_pages = ceil( $this->found_items / $this->query_vars['number'] );
+ if ( ! empty( $this->found_items ) ) {
+ $number = (int) $this->get_query_var( 'number' );
+
+ if ( ! empty( $number ) ) {
+ $this->max_num_pages = (int) ceil( $this->found_items / $number );
+ }
}
// Cast to int if not grouping counts
- if ( ! empty( $this->query_vars['count'] ) && empty( $this->query_vars['groupby'] ) ) {
- $item_ids = intval( $item_ids );
+ if ( $this->get_query_var( 'count' ) ) {
+
+ // Set items
+ $this->items = $result;
+
+ // Not grouping, so cast to int
+ if ( ! $this->get_query_var( 'groupby' ) ) {
+ $this->items = (int) $result;
+ }
+
+ // Return
+ return $this->items;
}
- // Set items from IDs
- $this->set_items( $item_ids );
+ // Set items from result
+ $this->set_items( $result );
// Return array of items
return $this->items;
@@ -938,194 +1139,155 @@ private function get_items() {
* Used internally to get a list of item IDs matching the query vars.
*
* @since 1.0.0
+ * @since 2.1.0 Uses wp_parse_list() instead of wp_parse_id_list()
*
- * @return int|array A single count of item IDs if a count query. An array
- * of item IDs if a full query.
+ * @return mixed An array of item IDs if a full query. A single count of
+ * item IDs if a count query.
*/
private function get_item_ids() {
- // Setup primary column, and parse the where clause
- $this->parse_where();
+ // Setup the query clauses
+ $this->set_query_clauses();
- // Order & Order By
- $order = $this->parse_order( $this->query_vars['order'] );
- $orderby = $this->get_order_by( $order );
+ // Setup request
+ $this->set_request_clauses();
+ $this->set_request();
- // Limit & Offset
- $limit = absint( $this->query_vars['number'] );
- $offset = absint( $this->query_vars['offset'] );
+ // Get the database interface
+ $db = $this->get_db();
- // Limits
- if ( ! empty( $limit ) ) {
- $limits = ! empty( $offset )
- ? "LIMIT {$offset}, {$limit}"
- : "LIMIT {$limit}";
- } else {
- $limits = '';
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return array();
}
- // Where & Join
- $where = implode( ' AND ', $this->query_clauses['where'] );
- $join = implode( ', ', $this->query_clauses['join'] );
+ // Return count
+ if ( $this->get_query_var( 'count' ) ) {
- // Group by
- $groupby = $this->parse_groupby( $this->query_vars['groupby'] );
+ // Get vars or results
+ $retval = ! $this->get_query_var( 'groupby' )
+ ? $db->get_var( $this->request )
+ : $db->get_results( $this->request, ARRAY_A );
- // Fields
- $fields = $this->parse_fields( $this->query_vars['fields'] );
+ // Return vars or results
+ return $retval;
+ }
- // Setup the query array (compact() is too opaque here)
- $query = array(
- 'fields' => $fields,
- 'join' => $join,
- 'where' => $where,
- 'orderby' => $orderby,
- 'limits' => $limits,
- 'groupby' => $groupby
- );
-
- /**
- * Filters the item query clauses.
- *
- * @since 1.0.0
- *
- * @param array $pieces A compacted array of item query clauses.
- * @param Query &$this Current instance passed by reference.
- */
- $clauses = (array) apply_filters_ref_array( $this->apply_prefix( "{$this->item_name_plural}_query_clauses" ), array( $query, &$this ) );
-
- // Setup request
- $this->set_request_clauses( $clauses );
- $this->set_request();
-
- // Return count
- if ( ! empty( $this->query_vars['count'] ) ) {
-
- // Get vars or results
- $retval = empty( $this->query_vars['groupby'] )
- ? $this->get_db()->get_var( $this->request )
- : $this->get_db()->get_results( $this->request, ARRAY_A );
-
- // Return vars or results
- return $retval;
- }
-
- // Get IDs
- $item_ids = $this->get_db()->get_col( $this->request );
+ // Get IDs
+ $item_ids = $db->get_col( $this->request );
// Return parsed IDs
- return wp_parse_id_list( $item_ids );
+ return wp_parse_list( $item_ids );
}
/**
- * Get the ORDERBY clause.
+ * Used internally to generate an SQL string for searching across multiple
+ * columns.
*
* @since 1.0.0
+ * @since 2.1.0 Bail early if parameters are empty.
*
- * @param string $order
- * @return string
+ * @param string $string Search string.
+ * @param array $column_names Columns to search.
+ * @return string Search SQL.
*/
- private function get_order_by( $order = '' ) {
-
- // Default orderby primary column
- $parsed = $this->parse_orderby();
- $orderby = "{$parsed} {$order}";
+ private function get_search_sql( $string = '', $column_names = array() ) {
- // Disable ORDER BY if counting, or: 'none', an empty array, or false.
- if ( ! empty( $this->query_vars['count'] ) || in_array( $this->query_vars['orderby'], array( 'none', array(), false ), true ) ) {
- $orderby = '';
+ // Bail if malformed string
+ if ( empty( $string ) || ! is_scalar( $string ) ) {
+ return '';
+ }
- // Ordering by something, so figure it out
- } elseif ( ! empty( $this->query_vars['orderby'] ) ) {
+ // Bail if malformed columns
+ if ( empty( $column_names ) || ! is_array( $column_names ) ) {
+ return '';
+ }
- // Array of keys, or comma separated
- $ordersby = is_array( $this->query_vars['orderby'] )
- ? $this->query_vars['orderby']
- : preg_split( '/[,\s]/', $this->query_vars['orderby'] );
+ // Get the database interface
+ $db = $this->get_db();
- $orderby_array = array();
- $possible_ins = $this->get_columns( array( 'in' => true ), 'and', 'name' );
- $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' );
-
- // Loop through possible order by's
- foreach ( $ordersby as $_key => $_value ) {
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return '';
+ }
- // Skip if empty
- if ( empty( $_value ) ) {
- continue;
- }
+ // Array or String
+ $like = ( false !== strpos( $string, '*' ) )
+ ? '%' . implode( '%', array_map( array( $db, 'esc_like' ), explode( '*', $string ) ) ) . '%'
+ : '%' . $db->esc_like( $string ) . '%';
- // Key is numeric
- if ( is_int( $_key ) ) {
- $_orderby = $_value;
- $_item = $order;
+ // Default array
+ $searches = array();
- // Key is string
- } else {
- $_orderby = $_key;
- $_item = $_value;
- }
+ // Build search SQL
+ foreach ( $column_names as $column ) {
+ $searches[] = $db->prepare( "{$column} LIKE %s", $like );
+ }
- // Skip if not sortable
- if ( ! in_array( $_value, $sortables, true ) ) {
- continue;
- }
+ // Concatinate
+ $values = implode( ' OR ', $searches );
+ $retval = '(' . $values . ')';
- // Parse orderby
- $parsed = $this->parse_orderby( $_orderby );
+ // Return the clause
+ return $retval;
+ }
- // Skip if empty
- if ( empty( $parsed ) ) {
- continue;
- }
+ /**
+ * 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
+ *
+ * @param string $column_name Column name.
+ * @param array|string $values Array of values.
+ * @param bool $wrap To wrap in parenthesis.
+ * @param string $pattern Pattern to prepare with.
+ *
+ * @return string Escaped/prepared SQL, possibly wrapped in parenthesis.
+ */
+ private function get_in_sql( $column_name = '', $values = array(), $wrap = true, $pattern = '' ) {
- // Set if __in
- if ( in_array( $_orderby, $possible_ins, true ) ) {
- $orderby_array[] = "{$parsed} {$order}";
- continue;
- }
+ // Bail if no values or invalid column
+ if ( empty( $values ) || ! $this->is_valid_column( $column_name ) ) {
+ return '';
+ }
- // Append parsed orderby to array
- $orderby_array[] = $parsed . ' ' . $this->parse_order( $_item );
- }
+ // Get the database interface
+ $db = $this->get_db();
- // Only set if valid orderby
- if ( ! empty( $orderby_array ) ) {
- $orderby = implode( ', ', $orderby_array );
- }
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return '';
}
- // Return parsed orderby
- return $orderby;
- }
+ // Fallback to column pattern
+ if ( empty( $pattern ) || ! is_string( $pattern ) ) {
+ $pattern = $this->get_column_field( array( 'name' => $column_name ), 'pattern', '%s' );
+ }
- /**
- * Used internally to generate an SQL string for searching across multiple
- * columns.
- *
- * @since 1.0.0
- *
- * @param string $string Search string.
- * @param array $columns Columns to search.
- * @return string Search SQL.
- */
- private function get_search_sql( $string = '', $columns = array() ) {
+ // Fill an array of patterns to match the number of values
+ $count = count( $values );
+ $patterns = array_fill( 0, $count, $pattern );
- // Array or String
- $like = ( false !== strpos( $string, '*' ) )
- ? '%' . implode( '%', array_map( array( $this->get_db(), 'esc_like' ), explode( '*', $string ) ) ) . '%'
- : '%' . $this->get_db()->esc_like( $string ) . '%';
+ // Escape & prepare
+ $sql = implode( ', ', $patterns );
+ $values = $db->_escape( $values ); // May quote strings
+ $retval = $db->prepare( $sql, $values ); // Catches quoted strings
- // Default array
- $searches = array();
+ // Set return value to empty string if prepare() returns falsy
+ if ( empty( $retval ) ) {
+ $retval = '';
+ }
- // Build search SQL
- foreach ( $columns as $column ) {
- $searches[] = $this->get_db()->prepare( "{$column} LIKE %s", $like );
+ // Wrap them in parenthesis
+ if ( true === $wrap ) {
+ $retval = "({$retval})";
}
- // Return the clause
- return '(' . implode( ' OR ', $searches ) . ')';
+ // Return in SQL
+ return $retval;
}
/** Private Parsers *******************************************************/
@@ -1134,396 +1296,986 @@ private function get_search_sql( $string = '', $columns = array() ) {
* Parses arguments passed to the item query with default query parameters.
*
* @since 1.0.0
+ * @since 2.1.0 Forces some $query_vars if counting
*
- * @see Query::__construct()
- *
- * @param string|array $query Array or string of Query arguments.
+ * @param array|string $query
*/
private function parse_query( $query = array() ) {
- // Setup the query_vars_original var
+ // Setup the $query_vars_original var
$this->query_var_originals = wp_parse_args( $query );
- // Setup the query_vars parsed var
+ // Setup the $query_vars parsed var
$this->query_vars = wp_parse_args(
$this->query_var_originals,
$this->query_var_defaults
);
+ // If counting, override some other $query_vars
+ if ( $this->get_query_var( 'count' ) ) {
+ $this->query_vars['number'] = false;
+ $this->query_vars['fields'] = '';
+ $this->query_vars['orderby'] = '';
+ $this->query_vars['no_found_rows'] = true;
+ $this->query_vars['update_item_cache'] = false;
+ $this->query_vars['update_meta_cache'] = false;
+ }
+
/**
* Fires after the item query vars have been parsed.
*
* @since 1.0.0
*
- * @param Query &$this The Query instance (passed by reference).
+ * @param Query &$this Current instance passed by reference.
*/
- do_action_ref_array( $this->apply_prefix( "parse_{$this->item_name_plural}_query" ), array( &$this ) );
+ do_action_ref_array(
+ $this->apply_prefix( "parse_{$this->item_name_plural}_query" ),
+ array(
+ &$this
+ )
+ );
}
/**
- * Parse the where clauses for all known columns.
+ * Parse all of the $query_vars.
*
- * @todo split this method into smaller parts
+ * Optionally accepts an array of custom $query_vars that can be used
+ * instead of the default ones.
*
- * @since 1.0.0
+ * Calls filter_query_clauses() on the return value.
+ *
+ * @since 2.1.0
+ * @param array $query_vars Optional. Default empty array.
+ * Fallback to Query::query_vars.
+ * @return array Query clauses, parsed from Query vars.
*/
- private function parse_where() {
+ private function parse_query_vars( $query_vars = array() ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $query_vars ) && ! empty( $this->query_vars ) ) {
+ $query_vars = $this->query_vars;
+ }
+
+ // Parse arguments
+ $r = wp_parse_args( $query_vars );
+
+ // Parse $query_vars
+ $where_join = $this->parse_where_join( $r );
+
+ // Parse all clauses
+ $clauses = array(
+ 'explain' => $this->parse_explain( $r['explain'] ),
+ 'select' => $this->parse_select(),
+ 'fields' => $this->parse_fields( $r['fields'], $r['count'], $r['groupby'] ),
+ 'count' => $this->parse_count( $r['count'], $r['groupby'] ),
+ 'from' => $this->parse_from(),
+ 'join' => $this->parse_join_clause( $where_join['join'] ),
+ 'where' => $this->parse_where_clause( $where_join['where'] ),
+ 'groupby' => $this->parse_groupby( $r['groupby'], 'GROUP BY ' ),
+ 'orderby' => $this->parse_orderby( $r['orderby'], $r['order'], 'ORDER BY ' ),
+ 'limits' => $this->parse_limits( $r['number'], $r['offset'] )
+ );
+
+ // Return clauses
+ return $this->filter_query_clauses( $clauses );
+ }
+
+ /**
+ * Parse the 'where' and 'join' $query_vars for all known columns.
+ *
+ * @since 2.1.0
+ *
+ * @param array $args Query vars
+ * @return array Array of 'where' and 'join' clauses.
+ */
+ private function parse_where_join( $args = array() ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $args ) && ! empty( $this->query_vars ) ) {
+ $args = $this->query_vars;
+ }
+
+ // Parse arguments
+ $r = wp_parse_args( $args );
+
+ // 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 );
+ }
+
+ // Pluck join/where from results
+ $join = wp_list_pluck( $results, 'join' );
+ $where = wp_list_pluck( $results, 'where' );
+
+ // Set join/where subclauses to merged results
+ return array(
+ 'join' => call_user_func_array( 'array_merge', $join ),
+ 'where' => call_user_func_array( 'array_merge', $where )
+ );
+ }
+
+ /**
+ * Parse join/where subclauses for all columns.
+ *
+ * Used by parse_where_join().
+ *
+ * @since 2.1.0
+ * @return array
+ */
+ private function parse_where_columns( $query_vars = array() ) {
// Defaults
- $where = $join = $searchable = $date_query = array();
+ $retval = array(
+ 'join' => array(),
+ 'where' => array()
+ );
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return $retval;
+ }
+
+ // 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 ( $this->columns as $column ) {
+ foreach ( $all_columns as $column ) {
- // Maybe add name to searchable array
- if ( true === $column->searchable ) {
- $searchable[] = $column->name;
- }
+ // 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 ( ! $this->is_query_var_default( $column->name ) ) {
+ if ( false !== $column->by ) {
- // Array (unprepared)
- if ( is_array( $this->query_vars[ $column->name ] ) ) {
- $where_id = "'" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $column->name ] ) ) . "'";
- $statement = "{$this->table_alias}.{$column->name} IN ({$where_id})";
+ // Parse query variable
+ $where_id = $name;
+ $values = $this->parse_query_var( $query_vars, $where_id );
- // Add to where array
- $where[ $column->name ] = $statement;
+ // Parse item for direct clause.
+ if ( false !== $values ) {
- // Numeric/String/Float (prepared)
- } else {
- $pattern = $this->get_column_field( array( 'name' => $column->name ), 'pattern', '%s' );
- $where_id = $this->query_vars[ $column->name ];
- $statement = "{$this->table_alias}.{$column->name} = {$pattern}";
+ // Convert single item arrays to literal column comparisons
+ if ( 1 === count( $values ) ) {
+ $statement = "{$aliased} = {$pattern}";
+ $column_value = reset( $values );
+ $where[ $where_id ] = $db->prepare( $statement, $column_value );
- // Add to where array
- $where[ $column->name ] = $this->get_db()->prepare( $statement, $where_id );
+ // Implode
+ } else {
+ $where_id = "{$where_id}__in";
+ $in_values = $this->get_in_sql( $name, $values, true, $pattern );
+ $where[ $where_id ] = "{$aliased} IN {$in_values}";
+ }
}
}
// __in
if ( true === $column->in ) {
- $where_id = "{$column->name}__in";
+
+ // Parse query var
+ $where_id = "{$name}__in";
+ $values = $this->parse_query_var( $query_vars, $where_id );
// Parse item for an IN clause.
- if ( isset( $this->query_vars[ $where_id ] ) && is_array( $this->query_vars[ $where_id ] ) ) {
+ if ( false !== $values ) {
// Convert single item arrays to literal column comparisons
- if ( 1 === count( $this->query_vars[ $where_id ] ) ) {
- $column_value = reset( $this->query_vars[ $where_id ] );
- $statement = "{$this->table_alias}.{$column->name} = %s";
-
- $where[ $column->name ] = $this->get_db()->prepare( $statement, $column_value );
+ if ( 1 === count( $values ) ) {
+ $statement = "{$aliased} = {$pattern}";
+ $where_id = $name;
+ $column_value = reset( $values );
+ $where[ $where_id ] = $db->prepare( $statement, $column_value );
// Implode
} else {
- $where[ $where_id ] = "{$this->table_alias}.{$column->name} IN ( '" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $where_id ] ) ) . "' )";
+ $in_values = $this->get_in_sql( $name, $values, true, $pattern );
+ $where[ $where_id ] = "{$aliased} IN {$in_values}";
}
}
}
// __not_in
if ( true === $column->not_in ) {
- $where_id = "{$column->name}__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 ( isset( $this->query_vars[ $where_id ] ) && is_array( $this->query_vars[ $where_id ] ) ) {
+ if ( false !== $values ) {
// Convert single item arrays to literal column comparisons
- if ( 1 === count( $this->query_vars[ $where_id ] ) ) {
- $column_value = reset( $this->query_vars[ $where_id ] );
- $statement = "{$this->table_alias}.{$column->name} != %s";
-
- $where[ $column->name ] = $this->get_db()->prepare( $statement, $column_value );
+ if ( 1 === count( $values ) ) {
+ $statement = "{$aliased} != {$pattern}";
+ $where_id = $name;
+ $column_value = reset( $values );
+ $where[ $where_id ] = $db->prepare( $statement, $column_value );
// Implode
} else {
- $where[ $where_id ] = "{$this->table_alias}.{$column->name} NOT IN ( '" . implode( "', '", $this->get_db()->_escape( $this->query_vars[ $where_id ] ) ) . "' )";
+ $in_values = $this->get_in_sql( $name, $values, true, $pattern );
+ $where[ $where_id ] = "{$aliased} NOT IN {$in_values}";
}
}
}
// date_query
if ( true === $column->date_query ) {
- $where_id = "{$column->name}_query";
- $column_date = $this->query_vars[ $where_id ];
+ $where_id = "{$name}_query";
+ $column_date = $this->parse_query_var( $query_vars, $where_id );
// Parse item
- if ( ! empty( $column_date ) ) {
+ 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
+ * @return array
+ */
+ private function parse_where_parsers( $query_vars = array() ) {
+
+ // Bail if no query var parsers
+ if ( empty( $this->query_var_parsers ) ) {
+ return array(
+ 'join' => array(),
+ 'where' => 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
+ );
+
+ // Default values
+ $join = $where = array();
+
+ // Loop through parsers
+ foreach ( $parsers as $id ) {
+
+ // Skip
+ if ( empty( $id ) ) {
+ continue;
+ }
+
+ // Build the key
+ $key = strtolower( $id ) . '_query';
+
+ // Skip if no query vars
+ if ( empty( $query_vars[ $key ] ) || ! is_array( $query_vars[ $key ] ) ) {
+ continue;
+ }
+
+ // Add table alias to primary clause if not already set
+ if ( empty( $query_vars[ $key ][ 'alias'] ) ) {
+ $query_vars[ $key ][ 'alias'] = $args['table_alias'];
+ }
+
+ // Try to get the query var parser
+ $parser = $this->get_query_var_parser( $id, $query_vars[ $key ] );
+
+ // Skip if no query var parser
+ if ( empty( $parser ) ) {
+ continue;
+ }
+
+ // Default no subclauses
+ $subclauses = false;
+
+ // Set the key
+ $this->{$key} = $parser;
+
+ // Set the callback
+ $callback = array( $this->{$key}, 'get_sql' );
+
+ // Try to get the SQL subclauses
+ if ( is_callable( $callback ) ) {
+ $subclauses = call_user_func( $callback, array(
+ $args['meta_type'],
+ $args['primary_table'],
+ $args['primary_column'],
+ $args['query']
+ ) );
+ }
+
+ // Skip if no SQL subclauses
+ if ( false === $subclauses ) {
+ continue;
+ }
+
+ // Set join
+ if ( ! empty( $subclauses['join'] ) ) {
+ $join[ $key ] = $subclauses['join'];
+ }
+
+ // Set where (removing " AND " from subclauses)
+ if ( ! empty( $subclauses['where'] ) ) {
+ $where[ $key ] = preg_replace( '/^\s*AND\s*/', '', $subclauses['where'] );
+ }
+ }
+
+ // Return join/where subclauses
+ return array(
+ 'join' => $join,
+ 'where' => $where
+ );
+ }
+
+ /**
+ * Parse a single query variable value.
+ *
+ * @since 2.1.0
+ *
+ * @param array $query_vars
+ * @param string $key
+ *
+ * @return int|string|array False if not set or default.
+ * Value if object or array.
+ * Attempts to parse a comma-separated string of
+ * possible keys or numbers.
+ */
+ private function parse_query_var( $query_vars = array(), $key = '' ) {
+
+ // Bail if no query vars exist for that ID
+ if ( ! isset( $query_vars[ $key ] ) ) {
+ return false;
+ }
+
+ // Get the value
+ $value = $query_vars[ $key ];
+
+ // Bail if equal to the exact default random value
+ if ( $value === $this->query_var_default_value ) {
+ return false;
+ }
+
+ /**
+ * Early return objects, arrays, numerics, integers, or bools.
+ *
+ * These values assume the caller knew what it was doing, and simply
+ * pass themselves through as "parsed" without any extra handling.
+ */
+ if (
+ is_object( $value )
+ ||
+ is_array( $value )
+ ||
+ is_int( $value )
+ ||
+ is_numeric( $value )
+ ||
+ is_bool( $value )
+ ) {
+ return array( $value );
+ }
+
+ /**
+ * Attempt to determine if a string contains a comma separated list of
+ * values that should be split into an array of values for an __in type
+ * of query.
+ */
+ if ( is_string( $value ) ) {
+
+ // Bail if string is over 100 chars long
+ if ( strlen( $value ) > 100 ) {
+ return $value;
+ }
+
+ // Contains comma?
+ $comma = strpos( $value, ',' );
+
+ // Bail if no comma
+ if ( false === $comma ) {
+ return array( $value );
+ }
+
+ // Contains space?
+ $space = strpos( $value, ' ' );
+
+ // Bail if space is before comma
+ if ( $space < $comma ) {
+ return array( $value );
+ }
+
+ // Bail if first comma is more than 20 letters in
+ if ( $comma >= 20 ) {
+ return array( $value );
+ }
+
+ // Split by comma (and maybe spaces)
+ return preg_split( '#,\s*#', $value, -1, PREG_SPLIT_NO_EMPTY );
+ }
+
+ // Pass the value through
+ return array( $value );
+ }
+
+ /**
+ * Parse if query to be EXPLAIN'ed.
+ *
+ * @since 2.1.0
+ * @param bool $explain Default false. True to EXPLAIN.
+ * @return string
+ */
+ private function parse_explain( $explain = false ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $explain ) ) {
+ $explain = $this->get_query_var( 'explain' );
+ }
+
+ // Default return value
+ $retval = '';
+
+ // Maybe explaining
+ if ( ! empty( $explain ) ) {
+ $retval = 'EXPLAIN';
+ }
+
+ // Return SQL
+ return $retval;
+ }
+
+ /**
+ * Parse the "SELECT" part of the SQL.
+ *
+ * @since 2.1.0
+ * @return string Default "SELECT".
+ */
+ private function parse_select() {
+ return 'SELECT';
+ }
+
+ /**
+ * Parse which fields to query for.
+ *
+ * If making a 'count' request, this will return either an empty string or
+ * the same columns that are being used for the "GROUP BY" to avoid errors.
+ *
+ * If not counting, this always only includes the Primary column to more
+ * predictably hit the cache, but that may change in a future version.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Moved COUNT() SQL to parse_count() and uses parse_groupby()
+ * when counting to satisfy MySQL 8 and higher.
+ *
+ * @param string[] $fields
+ * @param bool $count
+ * @param string[] $groupby
+ * @param bool $alias
+ * @return string
+ */
+ private function parse_fields( $fields = '', $count = false, $groupby = '', $alias = true ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $count ) ) {
+ $count = $this->get_query_var( 'count' );
+ }
+
+ // Default return value
+ $retval = '';
+
+ // Counting, so use groupby
+ if ( ! empty( $count ) ) {
+
+ // Use groupby instead
+ if ( ! empty( $groupby ) ) {
+ $retval = $this->parse_groupby( $groupby, '', $alias );
+ }
+
+ // Not counting, so use primary column
+ } else {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $fields ) ) {
+ $fields = $this->get_query_var( 'fields' );
+ }
+
+ // Get the primary column name
+ $primary = $this->get_primary_column_name();
+
+ // Default return value
+ $retval = $this->get_column_name_aliased( $primary, $alias );
+ }
+
+ // Return fields
+ return $retval;
+ }
+
+ /**
+ * Parse if counting.
+ *
+ * When counting with groups, parse_fields() will return the required SQL to
+ * prevent errors.
+ *
+ * @since 2.1.0
+ * @param bool $count
+ * @param string $groupby
+ * @param string $name
+ * @param bool $alias
+ * @return string
+ */
+ private function parse_count( $count = false, $groupby = '', $name = 'count', $alias = true ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $count ) ) {
+ $count = $this->get_query_var( 'count' );
+ }
+
+ // Bail if not counting
+ if ( empty( $count ) ) {
+ return '';
+ }
+
+ // Default return value
+ $retval = 'COUNT(*)';
+
+ // Check for "GROUP BY"
+ $groupby_names = $this->parse_groupby( $groupby, '', $alias );
+
+ // Reformat if grouping counts together
+ if ( ! empty( $groupby_names ) ) {
+ $retval = ", {$retval} as {$name}";
+ }
+
+ // Return SQL
+ return $retval;
+ }
+
+ /**
+ * Parse which table to query and whether to follow it with an alias.
+ *
+ * @since 2.1.0
+ * @param string $table Optional. Default empty string.
+ * Fallback to get_table_name().
+ * @param string $alias Optional. Default empty string.
+ * Fallback to $table_alias.
+ * @return string
+ */
+ private function parse_from( $table = '', $alias = '' ) {
- // Default arguments
- $defaults = array(
- 'column' => "{$this->table_alias}.{$column->name}",
- 'before' => $column_date,
- 'inclusive' => true
- );
+ // Maybe fallback to get_table_name()
+ if ( empty( $table ) ) {
+ $table = $this->get_table_name();
+ }
- // Default date query
- if ( is_string( $column_date ) ) {
- $date_query[] = $defaults;
+ // Maybe fallback to $table_alias
+ if ( empty( $alias ) ) {
+ $alias = $this->table_alias;
+ }
- // Array query var
- } elseif ( is_array( $column_date ) ) {
+ // Return
+ return "FROM {$table} {$alias}";
+ }
- // Auto-fill column if empty
- if ( empty( $column_date['column'] ) ) {
- $column_date['column'] = $defaults['column'];
- }
+ /**
+ * Parses and sanitizes the 'groupby' keys passed into the item query.
+ *
+ * @since 1.0.0
+ *
+ * @param string $groupby
+ * @param string $before
+ * @param bool $alias
+ * @return string
+ */
+ private function parse_groupby( $groupby = '', $before = '', $alias = true ) {
- // Add clause to date query
- $date_query[] = $column_date;
- }
- }
- }
+ // Maybe fallback to $query_vars
+ if ( empty( $groupby ) ) {
+ $groupby = $this->get_query_var( 'groupby' );
}
- // Maybe search if columns are searchable.
- if ( ! empty( $searchable ) && strlen( $this->query_vars['search'] ) ) {
- $search_columns = array();
-
- // Intersect against known searchable columns
- if ( ! empty( $this->query_vars['search_columns'] ) ) {
- $search_columns = array_intersect(
- $this->query_vars['search_columns'],
- $searchable
- );
- }
+ // Bail if empty
+ if ( empty( $groupby ) ) {
+ return '';
+ }
- // Default to all searchable columns
- if ( empty( $search_columns ) ) {
- $search_columns = $searchable;
- }
+ // Maybe cast to array
+ if ( ! is_array( $groupby ) ) {
+ $groupby = (array) $groupby;
+ }
- /**
- * Filters the columns to search in a Query search.
- *
- * @since 1.0.0
- *
- * @param array $search_columns Array of column names to be searched.
- * @param string $search Text being searched.
- * @param object $this The current Query instance.
- */
- $search_columns = (array) apply_filters( $this->apply_prefix( "{$this->item_name_plural}_search_columns" ), $search_columns, $this->query_vars['search'], $this );
+ // Get the intersection of allowed column names to groupby columns
+ $intersect = $this->get_columns_field_by( 'name', $groupby );
- // Add search query clause
- $where['search'] = $this->get_search_sql( $this->query_vars['search'], $search_columns );
+ // Bail if invalid columns
+ if ( empty( $intersect ) ) {
+ return '';
}
- /** Query Classes *****************************************************/
+ // Column names array
+ $names = array();
- // Get the primary column name
- $primary = $this->get_primary_column_name();
+ // Maybe prepend table alias to key
+ foreach ( $intersect as $key ) {
+ $names[] = $this->get_column_name_aliased( $key, $alias );
+ }
- // Get the meta table
- $table = $this->get_meta_type();
+ // Bail if nothing to groupby
+ if ( empty( $names ) && ! empty( $before ) ) {
+ return '';
+ }
- // Set the " AND " regex pattern
- $and = '/^\s*AND\s*/';
+ // Format column names
+ $retval = implode( ',', $names );
- // Maybe perform a meta query.
- $meta_query = $this->query_vars['meta_query'];
- if ( ! empty( $meta_query ) && is_array( $meta_query ) ) {
- $this->meta_query = $this->get_meta_query( $meta_query );
- $clauses = $this->meta_query->get_sql( $table, $this->table_alias, $primary, $this );
+ // Return columns
+ return implode( ' ', array( $before, $retval ) ) ;
+ }
- // Not all objects have meta, so make sure this one exists
- if ( false !== $clauses ) {
+ /**
+ * 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
+ *
+ * @param string $orderby
+ * @param string $order
+ * @param string $before
+ * @param bool $alias
+ * @return string
+ */
+ private function parse_orderby( $orderby = '', $order = '', $before = '', $alias = true ) {
- // Set join
- if ( ! empty( $clauses['join'] ) ) {
- $join['meta_query'] = $clauses['join'];
- }
+ // Maybe fallback to $query_vars
+ if ( empty( $orderby ) ) {
+ $orderby = $this->get_query_var( 'orderby' );
+ }
- // Set where
- if ( ! empty( $clauses['where'] ) ) {
+ // Bail if counting
+ if ( $this->get_query_var( 'count' ) ) {
+ return '';
+ }
- // Remove " AND " from query query where clause
- $where['meta_query'] = preg_replace( $and, '', $clauses['where'] );
- }
- }
+ // Bail if $orderby is a value that could cancel ordering
+ if ( in_array( $orderby, array( 'none', array(), false, null ), true ) ) {
+ return '';
}
- // Maybe perform a compare query.
- $compare_query = $this->query_vars['compare_query'];
- if ( ! empty( $compare_query ) && is_array( $compare_query ) ) {
- $this->compare_query = $this->get_compare_query( $compare_query );
- $clauses = $this->compare_query->get_sql( $table, $this->table_alias, $primary, $this );
+ // Default return value
+ $retval = '';
- // Not all objects can compare, so make sure this one exists
- if ( false !== $clauses ) {
+ // Fallback to default orderby & order
+ if ( empty( $orderby ) ) {
+ $parsed = $this->parse_single_orderby( $orderby, $alias );
+ $order = $this->parse_order( $order );
+ $retval = "{$parsed} {$order}";
- // Set join
- if ( ! empty( $clauses['join'] ) ) {
- $join['compare_query'] = $clauses['join'];
- }
+ // Ordering by something, so figure it out
+ } else {
- // Set where
- if ( ! empty( $clauses['where'] ) ) {
+ // Cast orderby as an array
+ $ordersby = (array) $orderby;
- // Remove " AND " from query where clause.
- $where['compare_query'] = preg_replace( $and, '', $clauses['where'] );
- }
+ // Fill if numeric
+ if ( wp_is_numeric_array( $ordersby ) ) {
+ $ordersby = array_fill_keys( $ordersby, $order );
}
- }
- // Only do a date query with an array
- $date_query = ! empty( $date_query )
- ? $date_query
- : $this->query_vars['date_query'];
+ // Default return value
+ $orderby_array = array();
- // Maybe perform a date query
- if ( ! empty( $date_query ) && is_array( $date_query ) ) {
- $this->date_query = $this->get_date_query( $date_query );
- $clauses = $this->date_query->get_sql( $this->table_name, $this->table_alias, $primary, $this );
+ // Loop through orderby's
+ foreach ( $ordersby as $key => $value ) {
- // Not all objects are dates, so make sure this one exists
- if ( false !== $clauses ) {
+ // Parse orderby
+ $parsed = $this->parse_single_orderby( $key, $alias );
- // Set join
- if ( ! empty( $clauses['join'] ) ) {
- $join['date_query'] = $clauses['join'];
+ // Skip if empty
+ if ( empty( $parsed ) ) {
+ continue;
}
- // Set where
- if ( ! empty( $clauses['where'] ) ) {
+ // Append parsed orderby to array
+ $orderby_array[] = $parsed . ' ' . $this->parse_order( $value );
+ }
- // Remove " AND " from query where clause.
- $where['date_query'] = preg_replace( $and, '', $clauses['where'] );
- }
+ // Only set if valid orderby
+ if ( ! empty( $orderby_array ) ) {
+ $retval = implode( ', ', $orderby_array );
}
}
- // Set where and join clauses, removing possible empties
- $this->query_clauses['where'] = array_filter( $where );
- $this->query_clauses['join'] = array_filter( $join );
+ // Bail if nothing to orderby
+ if ( empty( $retval ) && ! empty( $before ) ) {
+ return '';
+ }
+
+ // Return parsed orderby
+ return implode( ' ', array( $before, $retval ) );
}
/**
- * Parse which fields to query for.
+ * Parse all of the where clauses.
*
- * @since 1.0.0
- *
- * @param string $fields
- * @param bool $alias
- * @return string
+ * @since 2.1.0
+ * @param array $where
+ * @return string A single SQL statement.
*/
- private function parse_fields( $fields = '', $alias = true ) {
+ private function parse_where_clause( $where = array() ) {
- // Get the primary column name
- $primary = $this->get_primary_column_name();
-
- // Default return value
- $retval = ( true === $alias )
- ? "{$this->table_alias}.{$primary}"
- : $primary;
+ // Bail if no where
+ if ( empty( $where ) ) {
+ return '';
+ }
- // No fields
- if ( empty( $fields ) && ! empty( $this->query_vars['count'] ) ) {
+ // Return SQL
+ return 'WHERE ' . implode( ' AND ', $where );
+ }
- // Possible fields to group by
- $groupby_names = $this->parse_groupby( $this->query_vars['groupby'], $alias );
- $groupby_names = ! empty( $groupby_names )
- ? "{$groupby_names}"
- : '';
+ /**
+ * Parse all of the join clauses.
+ *
+ * @since 2.1.0
+ * @param array $join
+ * @return string A single SQL statement.
+ */
+ private function parse_join_clause( $join = array() ) {
- // Group by or total count
- $retval = ! empty( $groupby_names )
- ? "{$groupby_names}, COUNT(*) as count"
- : 'COUNT(*)';
+ // Bail if no join
+ if ( empty( $join ) ) {
+ return '';
}
- // Return fields (or COUNT)
- return $retval;
+ // Return SQL
+ return implode( ', ', $join );
}
/**
- * Parses and sanitizes the 'groupby' keys passed into the item query.
- *
- * @since 1.0.0
+ * Parse all of the SQL query clauses.
*
- * @param string $groupby
- * @param bool $alias
- * @return string
+ * @since 2.1.0
+ * @param array $clauses
+ * @return array
*/
- private function parse_groupby( $groupby = '', $alias = true ) {
+ private function parse_query_clauses( $clauses = array() ) {
- // Bail if empty
- if ( empty( $groupby ) ) {
- return '';
+ // Maybe fallback to $query_clauses
+ if ( empty( $clauses ) && ! empty( $this->query_clauses ) ) {
+ $clauses = $this->query_clauses;
}
- // Sanitize groupby columns
- $groupby = (array) array_map( 'sanitize_key', (array) $groupby );
+ // Default return value
+ $retval = wp_parse_args( $clauses );
+
+ // Return array of clauses
+ return $retval;
+ }
- // Re'flip column names back around
- $columns = array_flip( $this->get_column_names() );
+ /**
+ * Parse all SQL $request_clauses into a single SQL query string.
+ *
+ * @since 2.1.0
+ * @param array $clauses
+ * @return string A single SQL statement.
+ */
+ private function parse_request_clauses( $clauses = array() ) {
- // Get the intersection of allowed column names to groupby columns
- $intersect = array_intersect( $groupby, $columns );
+ // Maybe fallback to $request_clauses
+ if ( empty( $clauses ) && ! empty( $this->request_clauses ) ) {
+ $clauses = $this->request_clauses;
+ }
- // Bail if invalid column
- if ( empty( $intersect ) ) {
+ // Bail if empty clauses
+ if ( empty( $clauses ) ) {
return '';
}
+ // Remove empties
+ $filtered = array_filter( $clauses );
+ $retval = array_map( 'trim', $filtered );
+
+ // Return SQL
+ return implode( ' ', $retval );
+ }
+
+ /**
+ * Parses the 'number' and 'offset' keys passed to the item query.
+ *
+ * @since 2.1.0
+ *
+ * @param int $number
+ * @param int $offset
+ * @return string
+ */
+ private function parse_limits( $number = 0, $offset = 0 ) {
+
// Default return value
- $retval = array();
+ $retval = '';
- // Maybe prepend table alias to key
- foreach ( $intersect as $key ) {
- $retval[] = ( true === $alias )
- ? "{$this->table_alias}.{$key}"
- : $key;
+ // No negative numbers
+ $limit = absint( $number );
+ $offset = absint( $offset );
+
+ // Only limit & offset if not limit empty
+ if ( ! empty( $limit ) ) {
+ $retval = ! empty( $offset )
+ ? "LIMIT {$offset}, {$limit}"
+ : "LIMIT {$limit}";
}
- // Separate sanitized columns
- return implode( ',', array_values( $retval ) );
+ // Return
+ return $retval;
}
/**
- * Parses and sanitizes 'orderby' keys passed to the item query.
+ * Parses and sanitizes a single 'orderby' key passed to the item query.
+ *
+ * This method assumes that $orderby is a valid Column name.
*
* @since 1.0.0
+ * @since 2.1.0 Uses get_in_sql()
*
* @param string $orderby Field for the items to be ordered by.
- * @return string|false Value to used in the ORDER clause. False otherwise.
+ * @param bool $alias Whether to append the table alias.
+ * @return string Value to used in the ORDER BY clause.
*/
- private function parse_orderby( $orderby = '' ) {
+ private function parse_single_orderby( $orderby = '', $alias = true ) {
- // Get the primary column name
- $primary = $this->get_primary_column_name();
+ // Fallback to primary column
+ if ( empty( $orderby ) ) {
+ $orderby = $this->get_primary_column_name();
+ }
// Default return value
- $parsed = "{$this->table_alias}.{$primary}";
+ $retval = '';
- // Default to primary column
- if ( empty( $orderby ) ) {
- $orderby = $primary;
- }
+ // Get possible columns an $orderby can belong to
+ $ins = $this->get_columns( array( 'in' => true ), 'and', 'name' );
+ $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' );
- // __in
+ // __in column
if ( false !== strstr( $orderby, '__in' ) ) {
- $column_name = str_replace( '__in', '', $orderby );
- $column = $this->get_column_by( array( 'name' => $column_name ) );
- $item_in = $column->is_numeric()
- ? implode( ',', array_map( 'absint', $this->query_vars[ $orderby ] ) )
- : implode( ',', $this->query_vars[ $orderby ] );
-
- $parsed = "FIELD( {$this->table_alias}.{$column->name}, {$item_in} )";
- // Specific column
- } else {
+ // Get column name from $orderby clause
+ $column_name = str_replace( '__in', '', $orderby );
- // Orderby is a literal, sortable column name
- $sortables = $this->get_columns( array( 'sortable' => true ), 'and', 'name' );
- if ( in_array( $orderby, $sortables, true ) ) {
- $parsed = "{$this->table_alias}.{$orderby}";
+ // Get values if valid column
+ if ( in_array( $column_name, $ins, true ) ) {
+ $values = $this->get_query_var( $orderby );
+ $item_in = $this->get_in_sql( $column_name, $values, false );
+ $aliased = $this->get_column_name_aliased( $column_name, $alias );
+ $retval = "FIELD( {$aliased}, {$item_in} )";
}
+
+ // Specific sortable column
+ } elseif ( in_array( $orderby, $sortables, true ) ) {
+ $retval = $this->get_column_name_aliased( $orderby, $alias );
}
- // Return parsed value
- return $parsed;
+ // Return SQL
+ return $retval;
}
/**
@@ -1531,11 +2283,12 @@ private function parse_orderby( $orderby = '' ) {
* necessary.
*
* @since 1.0.0
+ * @since 2.1.0 Default to 'DESC'
*
* @param string $order The 'order' query variable.
* @return string The sanitized 'order' query variable.
*/
- private function parse_order( $order = '' ) {
+ private function parse_order( $order = 'DESC' ) {
// Bail if malformed
if ( empty( $order ) || ! is_string( $order ) ) {
@@ -1550,24 +2303,61 @@ private function parse_order( $order = '' ) {
/** Private Shapers *******************************************************/
+ /**
+ * Shape an item from the database into the type of object it always wanted
+ * to be when it grew up.
+ *
+ * @since 1.0.0
+ *
+ * @param mixed ID of item, or row from database
+ * @return mixed False on error, Object of single-object class type on success
+ */
+ private function shape_item( $item = 0 ) {
+
+ // Get the item from an ID
+ if ( is_numeric( $item ) ) {
+ $item = $this->get_item( $item );
+ }
+
+ // Return the item if it's already shaped
+ if ( $item instanceof $this->current_item_shape ) {
+ return $item;
+ }
+
+ // Shape the item as needed
+ $item = ! empty( $this->current_item_shape )
+ ? new $this->current_item_shape( $item )
+ : (object) $item;
+
+ // Return the item object
+ return $item;
+ }
+
/**
* Shape items into their most relevant objects.
*
* This will try to use item_shape, but will fallback to a private
* method for querying and caching items.
*
- * If using the `fields` parameter, results will have unique shapes based on
- * exactly what was requested.
+ * If using the "fields" query_var, results will be an array of stdClass
+ * objects with keys based on fields.
*
* @since 1.0.0
+ * @since 2.1.0 Added $fields parameter.
*
- * @param array $items
+ * @param array $items Array of items to shape.
+ * @param array $fields Fields to get from items.
* @return array
*/
- private function shape_items( $items = array() ) {
+ private function shape_items( $items = array(), $fields = array() ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $fields ) ) {
+ $fields = $this->get_query_var( 'fields' );
+ }
// Force to stdClass if querying for fields
- if ( ! empty( $this->query_vars['fields'] ) ) {
+ if ( ! empty( $fields ) ) {
$this->current_item_shape = 'stdClass';
} else {
$this->current_item_shape = $this->item_shape;
@@ -1576,104 +2366,131 @@ private function shape_items( $items = array() ) {
// Default return value
$retval = array();
- // Use foreach because it's faster than array_map()
+ // Loop through items and get each item individually
if ( ! empty( $items ) ) {
foreach ( $items as $item ) {
$retval[] = $this->get_item( $item );
}
}
- /**
- * Filters the object query results.
- *
- * Looks like `edd_get_customers`
- *
- * @since 1.0.0
- *
- * @param array $retval An array of items.
- * @param object &$this Current instance of Query, passed by reference.
- */
- $retval = (array) apply_filters_ref_array( $this->apply_prefix( "the_{$this->item_name_plural}" ), array( $retval, &$this ) );
+ // Filter the items
+ $retval = $this->filter_items( $retval );
- // Return filtered results
- return ! empty( $this->query_vars['fields'] )
- ? $this->get_item_fields( $retval )
- : $retval;
+ // Maybe return specific fields
+ if ( ! empty( $fields ) ) {
+ $retval = $this->get_item_fields( $retval, $fields );
+ }
+
+ // Return shaped items
+ return $retval;
}
/**
- * Get specific item fields based on query_vars['fields'].
+ * Validate the primary column value of an item.
+ *
+ * Accepts an object, array, or numeric value.
*
* @since 1.0.0
+ * @since 2.1.0 Uses validate_item_field()
*
- * @param array $items
- * @return array
+ * @param array|object|scalar $item
+ * @return int|string
*/
- private function get_item_fields( $items = array() ) {
+ private function shape_item_id( $item = 0 ) {
+
+ // Default return value
+ $retval = $item;
// Get the primary column name
$primary = $this->get_primary_column_name();
- // Get the query var fields
- $fields = $this->query_vars['fields'];
+ // Object item
+ if ( is_object( $item ) && isset( $item->{$primary} ) ) {
+ $retval = $item->{$primary};
- // Strings need to be single columns
- if ( is_string( $fields ) ) {
- $field = sanitize_key( $fields );
- $items = ( 'ids' === $fields )
- ? wp_list_pluck( $items, $primary )
- : wp_list_pluck( $items, $field, $primary );
+ // Array item
+ } elseif ( is_array( $item ) && isset( $item[ $primary ] ) ) {
+ $retval = $item[ $primary ];
+ }
- // Arrays could be anything
- } elseif ( is_array( $fields ) ) {
- $new_items = array();
- $fields = array_flip( $fields );
+ // Return the validated item ID
+ return $this->validate_item_field( $retval, $primary );
+ }
- // Loop through items and pluck out the fields
- foreach ( $items as $item_id => $item ) {
- $new_items[ $item_id ] = (object) array_intersect_key( (array) $item, $fields );
- }
+ /**
+ * Validate a single field of an item.
+ *
+ * Calls Column::validate() on the column.
+ *
+ * @since 2.1.0
+ * @param mixed $value Value to validate.
+ * @param string $column_name Name of column.
+ * @return mixed A validated value
+ */
+ private function validate_item_field( $value = '', $column_name = '' ) {
+
+ // Get the column
+ $column = $this->get_column_by( array( 'name' => $column_name ) );
- // Set the items and unset the new items
- $items = $new_items;
- unset( $new_items );
+ // Bail if no column found
+ if ( empty( $column ) ) {
+ return false;
}
- // Return the item, possibly reduced
- return $items;
+ // Validate
+ return $column->validate( $value );
}
/**
- * Shape an item ID from an object, array, or numeric value.
+ * Get specific fields from an array of items.
*
* @since 1.0.0
+ * @since 2.1.0 Bails early if empty $fields.
*
- * @param mixed $item
- * @return int
+ * @param array $items Array of items to get fields from.
+ * @param array $fields Fields to get from items.
+ * @return array
*/
- private function shape_item_id( $item = 0 ) {
+ private function get_item_fields( $items = array(), $fields = array() ) {
+
+ // Maybe fallback to $query_vars
+ if ( empty( $fields ) ) {
+ $fields = $this->get_query_var( 'fields' );
+ }
+
+ // Bail if no fields to get
+ if ( empty( $fields ) ) {
+ return $items;
+ }
+
+ // Maybe cast to array
+ if ( ! is_array( $fields ) ) {
+ $fields = (array) $fields;
+ }
// Default return value
- $retval = 0;
+ $retval = $items;
// Get the primary column name
$primary = $this->get_primary_column_name();
- // Numeric item ID
- if ( is_numeric( $item ) ) {
- $retval = $item;
+ // 'ids' is numerically keyed
+ if ( ( 1 === count( $fields ) ) && ( 'ids' === $fields[0] ) ) {
+ $retval = wp_list_pluck( $items, $primary );
- // Object item
- } elseif ( is_object( $item ) && isset( $item->{$primary} ) ) {
- $retval = $item->{$primary};
+ // Get fields from items
+ } else {
+ $retval = array();
+ $fields = array_flip( $fields );
- // Array item
- } elseif ( is_array( $item ) && isset( $item[ $primary ] ) ) {
- $retval = $item[ $primary ];
+ // Loop through items and pluck out the fields
+ foreach ( $items as $item ) {
+ $retval[ $item->{$primary} ] = (object) array_intersect_key( (array) $item, $fields );
+ }
}
- // Return the item ID
- return absint( $retval );
+ // Return the item fields
+ return $retval;
}
/** Queries ***************************************************************/
@@ -1710,7 +2527,7 @@ public function get_item( $item_id = 0 ) {
* Get a single database row by any column and value, possibly from cache.
*
* Take care to only use this method on columns with unique values,
- * preferably with a cache group for that column. See: get_item().
+ * preferably with a cache group for that column.
*
* @since 1.0.0
*
@@ -1720,32 +2537,19 @@ public function get_item( $item_id = 0 ) {
*/
public function get_item_by( $column_name = '', $column_value = '' ) {
- // Default return value
- $retval = false;
-
- // Bail if no key or value
- if ( empty( $column_name ) || empty( $column_value ) ) {
- return $retval;
- }
-
- // Bail if name is not a string
- if ( ! is_string( $column_name ) ) {
- return $retval;
- }
-
- // Bail if value is not scalar (null values also not allowed)
- if ( ! is_scalar( $column_value ) ) {
- return $retval;
+ // Bail if empty or non-scalar value
+ if ( empty( $column_value ) || ! is_scalar( $column_value ) ) {
+ return false;
}
- // Get all of the column names
- $columns = $this->get_column_names();
-
// Bail if column does not exist
- if ( ! isset( $columns[ $column_name ] ) ) {
- return $retval;
+ if ( ! $this->is_valid_column( $column_name ) ) {
+ return false;
}
+ // Default return value
+ $retval = false;
+
// Get all of the cache groups
$groups = $this->get_cache_groups();
@@ -1786,6 +2590,14 @@ public function get_item_by( $column_name = '', $column_value = '' ) {
*/
public function add_item( $data = array() ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
// Get the primary column name
$primary = $this->get_primary_column_name();
@@ -1815,8 +2627,8 @@ public function add_item( $data = array() ) {
unset( $item[ $primary ] );
}
- // Cut out non-keys for meta
- $columns = $this->get_column_names();
+ // Slice data that has columns, and cut out non-keys for meta
+ $columns = array_flip( $this->get_column_names() );
$data = array_merge( $item, $data );
$meta = array_diff_key( $data, $columns );
$save = array_intersect_key( $data, $columns );
@@ -1841,35 +2653,42 @@ public function add_item( $data = array() ) {
$save[ $modified->name ] = $time;
}
- // Try to add
- $table = $this->get_table_name();
+ // Reduce & validate
$reduce = $this->reduce_item( 'insert', $save );
$save = $this->validate_item( $reduce );
- $result = ! empty( $save )
- ? $this->get_db()->insert( $table, $save )
- : false;
+
+ // Default return value
+ $retval = false;
+
+ // Try to save
+ if ( ! empty( $save ) ) {
+ $table = $this->get_table_name();
+ $names = array_keys( $save );
+ $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' );
+ $retval = $db->insert( $table, $save, $save_format );
+ }
// Bail on failure
- if ( ! $this->is_success( $result ) ) {
+ if ( ! $this->is_success( $retval ) ) {
return false;
}
// Get the new item ID
- $item_id = $this->get_db()->insert_id;
+ $retval = $db->insert_id;
// Maybe save meta keys
if ( ! empty( $meta ) ) {
- $this->save_extra_item_meta( $item_id, $meta );
+ $this->save_extra_item_meta( $retval, $meta );
}
// Update item cache(s)
- $this->update_item_cache( $item_id );
+ $this->update_item_cache( $retval );
// Transition item data
- $this->transition_item( $save, array(), $item_id );
+ $this->transition_item( $retval, $save, array() );
- // Return result
- return $item_id;
+ // Return
+ return $retval;
}
/**
@@ -1877,7 +2696,7 @@ public function add_item( $data = array() ) {
*
* @since 1.1.0
*
- * @param int $item_id
+ * @param int|string $item_id
* @param array $data
* @return int|false Item ID if successful, false if not
*/
@@ -1886,6 +2705,9 @@ public function copy_item( $item_id = 0, $data = array() ) {
// Get the primary column name
$primary = $this->get_primary_column_name();
+ // Shape the primary item ID
+ $item_id = $this->shape_item_id( $item_id );
+
// Get item by ID (from database, not cache)
$item = $this->get_item_raw( $primary, $item_id );
@@ -1905,7 +2727,7 @@ public function copy_item( $item_id = 0, $data = array() ) {
// Unset the primary key
unset( $save[ $primary ] );
- // Return result
+ // Return result of add_item()
return $this->add_item( $save );
}
@@ -1914,12 +2736,20 @@ public function copy_item( $item_id = 0, $data = array() ) {
*
* @since 1.0.0
*
- * @param int $item_id
+ * @param int|string $item_id
* @param array $data
* @return bool
*/
public function update_item( $item_id = 0, $data = array() ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
// Bail early if no data to update
if ( empty( $data ) ) {
return false;
@@ -1954,7 +2784,7 @@ public function update_item( $item_id = 0, $data = array() ) {
);
// Slice data that has columns, and cut out non-keys for meta
- $columns = $this->get_column_names();
+ $columns = array_flip( $this->get_column_names() );
$data = array_diff_assoc( $data, $item );
$meta = array_diff_key( $data, $columns );
$save = array_intersect_key( $data, $columns );
@@ -1975,17 +2805,25 @@ public function update_item( $item_id = 0, $data = array() ) {
$save[ $modified->name ] = $this->get_current_time();
}
- // Try to update
- $table = $this->get_table_name();
+ // Reduce & validate
$reduce = $this->reduce_item( 'update', $save );
$save = $this->validate_item( $reduce );
- $where = array( $primary => $item_id );
- $result = ! empty( $save )
- ? $this->get_db()->update( $table, $save, $where )
- : false;
+
+ // Default return value
+ $retval = false;
+
+ // Try to update
+ if ( ! empty( $save ) ) {
+ $table = $this->get_table_name();
+ $where = array( $primary => $item_id );
+ $names = array_keys( $save );
+ $save_format = $this->get_columns_field_by( 'name', $names, 'pattern', '%s' );
+ $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' );
+ $retval = $db->update( $table, $save, $where, $save_format, $where_format );
+ }
// Bail on failure
- if ( ! $this->is_success( $result ) ) {
+ if ( ! $this->is_success( $retval ) ) {
return false;
}
@@ -1993,10 +2831,10 @@ public function update_item( $item_id = 0, $data = array() ) {
$this->update_item_cache( $item_id );
// Transition item data
- $this->transition_item( $save, $item, $item_id );
+ $this->transition_item( $item_id, $save, $item );
- // Return result
- return $result;
+ // Return
+ return $retval;
}
/**
@@ -2004,11 +2842,19 @@ public function update_item( $item_id = 0, $data = array() ) {
*
* @since 1.0.0
*
- * @param int $item_id
+ * @param int|string $item_id
* @return bool
*/
public function delete_item( $item_id = 0 ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
// Shape the item ID
$item_id = $this->shape_item_id( $item_id );
@@ -2037,12 +2883,13 @@ public function delete_item( $item_id = 0 ) {
}
// Try to delete
- $table = $this->get_table_name();
- $where = array( $primary => $item_id );
- $result = $this->get_db()->delete( $table, $where );
+ $table = $this->get_table_name();
+ $where = array( $primary => $item_id );
+ $where_format = $this->get_columns_field_by( 'name', $primary, 'pattern', '%s' );
+ $retval = $db->delete( $table, $where, $where_format );
// Bail on failure
- if ( ! $this->is_success( $result ) ) {
+ if ( ! $this->is_success( $retval ) ) {
return false;
}
@@ -2050,53 +2897,22 @@ public function delete_item( $item_id = 0 ) {
$this->delete_all_item_meta( $item_id );
$this->clean_item_cache( $item );
- // Return result
- return $result;
- }
-
- /**
- * Filter an item before it is inserted of updated in the database.
- *
- * This method is public to allow subclasses to perform JIT manipulation
- * of the parameters passed into it.
- *
- * @since 1.0.0
- *
- * @param array $item
- * @return array
- */
- public function filter_item( $item = array() ) {
- return (array) apply_filters_ref_array( $this->apply_prefix( "filter_{$this->item_name}_item" ), array( $item, &$this ) );
- }
-
- /**
- * Shape an item from the database into the type of object it always wanted
- * to be when it grew up.
- *
- * @since 1.0.0
- *
- * @param mixed ID of item, or row from database
- * @return mixed False on error, Object of single-object class type on success
- */
- private function shape_item( $item = 0 ) {
-
- // Get the item from an ID
- if ( is_numeric( $item ) ) {
- $item = $this->get_item( $item );
- }
-
- // Return the item if it's already shaped
- if ( $item instanceof $this->current_item_shape ) {
- return $item;
- }
-
- // Shape the item as needed
- $item = ! empty( $this->current_item_shape )
- ? new $this->current_item_shape( $item )
- : (object) $item;
+ /**
+ * Fires after an object has been deleted.
+ *
+ * @since 1.0.0
+ *
+ * @param int $item_id The ID of the item that was deleted.
+ * @param bool $result Whether the item was successfully deleted.
+ */
+ do_action(
+ $this->apply_prefix( "{$this->item_name}_deleted" ),
+ $item_id,
+ $retval
+ );
- // Return the item object
- return $item;
+ // Return
+ return $retval;
}
/**
@@ -2114,41 +2930,9 @@ private function validate_item( $item = array() ) {
return $item;
}
- // Loop through item attributes
+ // Validate all item fields
foreach ( $item as $key => $value ) {
-
- // Get the column
- $column = $this->get_column_by( array( 'name' => $key ) );
-
- // Null value is special for all item keys
- if ( is_null( $value ) ) {
-
- // Bail if null is not allowed
- if ( false === $column->allow_null ) {
- return false;
- }
-
- // Attempt to validate
- } elseif ( ! empty( $column->validate ) && is_callable( $column->validate ) ) {
- $validated = call_user_func( $column->validate, $value );
-
- // Bail if error
- if ( is_wp_error( $validated ) ) {
- return false;
- }
-
- // Update the value
- $item[ $key ] = $validated;
-
- /**
- * Fallback to using the raw value.
- *
- * Note: This may change at a later date, so do not rely on this.
- * Please always validate all data.
- */
- } else {
- $item[ $key ] = $value;
- }
+ $item[ $key ] = $this->validate_item_field( $value, $key );
}
// Return the validated item
@@ -2204,28 +2988,30 @@ private function reduce_item( $method = 'update', $item = array() ) {
}
/**
- * Return an item comprised of all default values.
+ * Return an item comprised of all Column names as keys and their defaults
+ * as values.
*
- * This is used by `add_item()` to populate known default values, to ensure
- * new item data is always what we expect it to be.
+ * This is used by `add_item()` to get an array of default item values that
+ * can be compared against, to determine if any values need to be saved into
+ * meta data instead.
*
* @since 1.0.0
+ * @since 2.1.0 Uses array_combine()
*
+ * @param array $args Default empty array. Parsed & passed into get_columns().
* @return array
*/
- private function default_item() {
+ private function default_item( $args = array() ) {
- // Default return value
- $retval = array();
+ // Parse arguments
+ $r = wp_parse_args( $args );
// Get the column names and their defaults
- $names = $this->get_columns( array(), 'and', 'name' );
- $defaults = $this->get_columns( array(), 'and', 'default' );
+ $names = $this->get_columns( $r, 'and', 'name' );
+ $defaults = $this->get_columns( $r, 'and', 'default' );
- // Put together an item using default values
- foreach ( $names as $key => $name ) {
- $retval[ $name ] = $defaults[ $key ];
- }
+ // Combine them
+ $retval = array_combine( $names, $defaults );
// Return
return $retval;
@@ -2239,12 +3025,11 @@ private function default_item() {
*
* @since 1.0.0
*
+ * @param int|string $item_id
* @param array $new_data
* @param array $old_data
- * @param int $item_id
- * @return array
*/
- private function transition_item( $new_data = array(), $old_data = array(), $item_id = 0 ) {
+ private function transition_item( $item_id = 0, $new_data = array(), $old_data = array() ) {
// Look for transition columns
$columns = $this->get_columns( array( 'transition' => true ), 'and', 'name' );
@@ -2312,10 +3097,10 @@ private function transition_item( $new_data = array(), $old_data = array(), $ite
*
* @since 1.0.0
*
- * @param int $item_id
- * @param string $meta_key
- * @param string $meta_value
- * @param string $unique
+ * @param int|string $item_id
+ * @param string $meta_key
+ * @param string $meta_value
+ * @param bool $unique
* @return int|false The meta ID on success, false on failure.
*/
protected function add_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $unique = false ) {
@@ -2345,9 +3130,9 @@ protected function add_item_meta( $item_id = 0, $meta_key = '', $meta_value = ''
*
* @since 1.0.0
*
- * @param int $item_id
- * @param string $meta_key
- * @param bool $single
+ * @param int|string $item_id
+ * @param string $meta_key
+ * @param bool $single
* @return mixed Single metadata value, or array of values
*/
protected function get_item_meta( $item_id = 0, $meta_key = '', $single = false ) {
@@ -2377,10 +3162,10 @@ protected function get_item_meta( $item_id = 0, $meta_key = '', $single = false
*
* @since 1.0.0
*
- * @param int $item_id
- * @param string $meta_key
- * @param string $meta_value
- * @param string $prev_value
+ * @param int|string $item_id
+ * @param string $meta_key
+ * @param string $meta_value
+ * @param string $prev_value
* @return bool True on successful update, false on failure.
*/
protected function update_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $prev_value = '' ) {
@@ -2410,10 +3195,10 @@ protected function update_item_meta( $item_id = 0, $meta_key = '', $meta_value =
*
* @since 1.0.0
*
- * @param int $item_id
- * @param string $meta_key
- * @param string $meta_value
- * @param string $delete_all
+ * @param int|string $item_id
+ * @param string $meta_key
+ * @param string $meta_value
+ * @param bool $delete_all
* @return bool True on successful delete, false on failure.
*/
protected function delete_item_meta( $item_id = 0, $meta_key = '', $meta_value = '', $delete_all = false ) {
@@ -2461,7 +3246,8 @@ private function get_registered_meta_keys( $object_subtype = '' ) {
*
* @since 1.0.0
*
- * @param array $meta
+ * @param int|string $item_id
+ * @param array $meta
*/
private function save_extra_item_meta( $item_id = 0, $meta = array() ) {
@@ -2500,10 +3286,18 @@ private function save_extra_item_meta( $item_id = 0, $meta = array() ) {
*
* @since 1.0.0
*
- * @param int $item_id
+ * @param int|string $item_id
*/
private function delete_all_item_meta( $item_id = 0 ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return;
+ }
+
// Shape the item ID
$item_id = $this->shape_item_id( $item_id );
@@ -2524,12 +3318,13 @@ private function delete_all_item_meta( $item_id = 0 ) {
$primary = $this->get_primary_column_name();
// Guess the item ID column for the meta table
- $item_id_column = $this->apply_prefix( "{$this->item_name}_{$primary}" );
+ $item_id_column = $this->apply_prefix( "{$this->item_name}_{$primary}" );
+ $item_id_pattern = $this->get_column_field( array( 'name' => $primary ), 'pattern', '%s' );
// Get meta IDs
- $query = "SELECT meta_id FROM {$table} WHERE {$item_id_column} = %d";
- $prepared = $this->get_db()->prepare( $query, $item_id );
- $meta_ids = $this->get_db()->get_col( $prepared );
+ $query = "SELECT meta_id FROM {$table} WHERE {$item_id_column} = {$item_id_pattern}";
+ $prepared = $db->prepare( $query, $item_id );
+ $meta_ids = $db->get_col( $prepared );
// Bail if no meta IDs to delete
if ( empty( $meta_ids ) ) {
@@ -2548,30 +3343,28 @@ private function delete_all_item_meta( $item_id = 0 ) {
/**
* Get the meta table for this query.
*
- * Forked from WordPress\_get_meta_table() so it can be more accurately
- * predicted in a future iteration and default to returning false.
- *
* @since 1.0.0
+ * @since 2.1.0 Minor refactor to improve readability.
*
- * @return mixed Table name if exists, False if not
+ * @return bool|string Table name if exists, False if not.
*/
private function get_meta_table_name() {
- // Get the meta-type
- $type = $this->get_meta_type();
+ // Get the meta type
+ $type = $this->get_meta_type();
- // Append "meta" to end of meta-type
- $table_name = "{$type}meta";
+ // Append "meta" to end of meta type
+ $table = "{$type}meta";
// Variable'ize the database interface, to use inside empty()
- $db = $this->get_db();
+ $db = $this->get_db();
// If not empty, return table name
- if ( ! empty( $db->{$table_name} ) ) {
- return $db->{$table_name};
+ if ( ! empty( $db->{$table} ) ) {
+ return $db->{$table};
}
- // Default return false
+ // Return
return false;
}
@@ -2579,7 +3372,7 @@ private function get_meta_table_name() {
* Get the meta type for this query.
*
* This method exists to reduce some duplication for now. Future iterations
- * will likely use Column::relationships to
+ * will likely use Column::relationships to more reliably predict this.
*
* @since 1.1.0
*
@@ -2592,25 +3385,26 @@ private function get_meta_type() {
/** Cache *****************************************************************/
/**
- * Get cache key from query_vars and query_var_defaults.
+ * Get cache key from $query_vars and $query_var_defaults.
*
* @since 1.0.0
*
+ * @param string $group
* @return string
*/
private function get_cache_key( $group = '' ) {
- // Slice query vars
+ // Slice $query_vars by default keys
$slice = wp_array_slice_assoc( $this->query_vars, array_keys( $this->query_var_defaults ) );
- // Unset `fields` so it does not effect the cache key
+ // Unset "fields" so it does not effect the cache key
unset( $slice['fields'] );
// Setup key & last_changed
$key = md5( serialize( $slice ) );
$last_changed = $this->get_last_changed_cache( $group );
- // Concatenate and return cache key
+ // Return the concatenated cache key
return "get_{$this->item_name_plural}:{$key}:{$last_changed}";
}
@@ -2674,8 +3468,7 @@ private function get_cache_groups() {
}
/**
- * Maybe prime item & item-meta caches by querying 1 time for all un-cached
- * items.
+ * Maybe prime item & item-meta caches.
*
* Accepts a single ID, or an array of IDs.
*
@@ -2683,7 +3476,11 @@ private function get_cache_groups() {
* after an item is inserted in the database, but before items have been
* "shaped" into proper objects, so object properties may not be set yet.
*
+ * Queries the database 1 time for all non-cached item objects and 1 time
+ * for all non-cached item meta.
+ *
* @since 1.0.0
+ * @since 2.1.0 Uses get_meta_table_name() to
*
* @param array $item_ids
* @param bool $force
@@ -2692,6 +3489,14 @@ private function get_cache_groups() {
*/
private function prime_item_caches( $item_ids = array(), $force = false ) {
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
// Bail if no items to cache
if ( empty( $item_ids ) ) {
return false;
@@ -2700,36 +3505,55 @@ private function prime_item_caches( $item_ids = array(), $force = false ) {
// Accepts single values, so cast to array
$item_ids = (array) $item_ids;
- // Update item caches
- if ( ! empty( $force ) || ! empty( $this->query_vars['update_item_cache'] ) ) {
+ /**
+ * Update item caches.
+ *
+ * Uses get_non_cached_ids() to remove item IDs that already exist in
+ * in the cache, then performs direct database query for the remaining
+ * IDs, and caches them.
+ */
+ if ( ! empty( $force ) || $this->get_query_var( 'update_item_cache' ) ) {
// Look for non-cached IDs
$ids = $this->get_non_cached_ids( $item_ids, $this->cache_group );
- // Bail if IDs are cached
- if ( empty( $ids ) ) {
- return false;
- }
+ // Proceed if non-cached IDs exist
+ if ( ! empty( $ids ) ) {
- // Get query parts
- $table = $this->get_table_name();
- $primary = $this->get_primary_column_name();
+ // Get query parts
+ $table = $this->get_table_name();
+ $primary = $this->get_primary_column_name();
+ $ids = $this->get_in_sql( $primary, $ids );
- // Query database
- $query = "SELECT * FROM {$table} WHERE {$primary} IN (%s)";
- $ids = implode( ',', array_map( 'absint', $ids ) );
- $prepare = sprintf( $query, $ids );
- $results = $this->get_db()->get_results( $prepare );
+ // Query database
+ $query = "SELECT * FROM {$table} WHERE {$primary} IN %s";
+ $prepare = sprintf( $query, $ids );
+ $results = $db->get_results( $prepare );
- // Update item cache(s)
- $this->update_item_cache( $results );
+ // Update item cache(s)
+ $this->update_item_cache( $results );
+ }
}
- // Update meta data caches
- if ( ! empty( $this->query_vars['update_meta_cache'] ) ) {
- $singular = rtrim( $this->table_name, 's' ); // sic
- update_meta_cache( $singular, $item_ids );
+ /**
+ * Update meta data caches.
+ *
+ * Uses update_meta_cache() because it politely handles all of the
+ * non-cached ID logic. This allows us to use the original (and likely
+ * larger) $item_ids array instead of $ids, thus ensuring the everything
+ * is cached according to our expectations.
+ */
+ if ( ! empty( $force ) || $this->get_query_var( 'update_meta_cache' ) ) {
+
+ // Proceed if meta table exists
+ if ( $this->get_meta_table_name() ) {
+ $meta_type = $this->get_meta_type();
+ update_meta_cache( $meta_type, $item_ids );
+ }
}
+
+ // Return true because something was cached
+ return true;
}
/**
@@ -2742,19 +3566,24 @@ private function prime_item_caches( $item_ids = array(), $force = false ) {
* querying for it again. It's just safer this way.
*
* @since 1.0.0
+ * @since 2.1.0 Uses shape_item_id() if $items is scalar
*
- * @param array $items
+ * @param int|object|array $items Primary ID if int. Row if object. Array
+ * of objects if array.
*/
private function update_item_cache( $items = array() ) {
// Maybe query for single item
- if ( is_numeric( $items ) ) {
+ if ( is_scalar( $items ) ) {
// Get the primary column name
$primary = $this->get_primary_column_name();
+ // Shape the primary item ID
+ $item_id = $this->shape_item_id( $items );
+
// Get item by ID (from database, not cache)
- $items = $this->get_item_raw( $primary, $items );
+ $items = $this->get_item_raw( $primary, $item_id );
}
// Bail if no items to cache
@@ -2838,6 +3667,8 @@ private function clean_item_cache( $items = array() ) {
// Update last changed
$this->update_last_changed_cache();
+
+ return true;
}
/**
@@ -2886,28 +3717,26 @@ 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
*
- * @param array $item_ids Array of item IDs
+ * @param array $item_ids Array of shaped item IDs
* @param string $group Cache group. Defaults to $this->cache_group
*
* @return array
*/
private function get_non_cached_ids( $item_ids = array(), $group = '' ) {
- // Default return value
- $retval = array();
-
// Bail if no item IDs
if ( empty( $item_ids ) ) {
- return $retval;
+ return array();
}
+ // Default return value
+ $retval = array();
+
// Loop through item IDs
foreach ( $item_ids as $id ) {
- // Shape the item ID
- $id = $this->shape_item_id( $id );
-
// Add to return value if not cached
if ( false === $this->cache_get( $id, $group ) ) {
$retval[] = $id;
@@ -2952,9 +3781,9 @@ private function cache_add( $key = '', $value = '', $group = '', $expire = 0 ) {
*
* @since 1.0.0
*
- * @param string $key Cache key.
- * @param string $group Cache group. Defaults to $this->cache_group
- * @param bool $force
+ * @param int|string $key Cache key.
+ * @param string $group Cache group. Defaults to $this->cache_group
+ * @param bool $force
*/
private function cache_get( $key = '', $group = '', $force = false ) {
@@ -3029,10 +3858,152 @@ private function cache_delete( $key = '', $group = '' ) {
wp_cache_delete( $key, $group );
}
+ /** Filters ***************************************************************/
+
+ /**
+ * Filter an item before it is inserted or updated in the database.
+ *
+ * @since 2.1.0
+ *
+ * @param array $item The item data.
+ * @return array
+ */
+ public function filter_item( $item = array() ) {
+
+ /**
+ * Filters an item before it is inserted or updated.
+ *
+ * @since 1.0.0
+ *
+ * @param array $item The item as an array.
+ * @param Query &$this Current instance passed by reference.
+ */
+ return (array) apply_filters_ref_array(
+ $this->apply_prefix( "filter_{$this->item_name}_item" ),
+ array(
+ $item,
+ &$this
+ )
+ );
+ }
+
+ /**
+ * Filter all shaped items after they are retrieved from the database.
+ *
+ * @since 2.1.0
+ *
+ * @param array $items The item data.
+ * @return array
+ */
+ public function filter_items( $items = array() ) {
+
+ /**
+ * Filters the object query results after they have been shaped.
+ *
+ * @since 1.0.0
+ *
+ * @param array $retval An array of items.
+ * @param Query &$this Current instance passed by reference.
+ */
+ return (array) apply_filters_ref_array(
+ $this->apply_prefix( "the_{$this->item_name_plural}" ),
+ array(
+ $items,
+ &$this
+ )
+ );
+ }
+
+ /**
+ * Filter the found items query.
+ *
+ * @since 2.1.0
+ * @param string $sql
+ * @return string
+ */
+ public function filter_found_items_query( $sql = '' ) {
+
+ /**
+ * Filters the query used to retrieve the found item count.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Supports MySQL 8 by removing FOUND_ROWS() and uses
+ * $request_clauses instead.
+ *
+ * @param string $query SQL query.
+ * @param Query &$this Current instance passed by reference.
+ */
+ return (string) apply_filters_ref_array(
+ $this->apply_prefix( "found_{$this->item_name_plural}_query" ),
+ array(
+ $sql,
+ &$this
+ )
+ );
+ }
+
+ /**
+ * Filter the query clauses before they are parsed into a SQL string.
+ *
+ * @since 2.1.0
+ *
+ * @param array $clauses All of the SQL query clauses.
+ * @return array
+ */
+ public function filter_query_clauses( $clauses = array() ) {
+
+ /**
+ * Filters the item query clauses.
+ *
+ * @since 1.0.0
+ *
+ * @param array $clauses An array of query clauses.
+ * @param Query &$this Current instance passed by reference.
+ */
+ return (array) apply_filters_ref_array(
+ $this->apply_prefix( "{$this->item_name_plural}_query_clauses" ),
+ array(
+ $clauses,
+ &$this
+ )
+ );
+ }
+
+ /**
+ * Filters the columns to search by.
+ *
+ * @since 2.1.0
+ *
+ * @param array $search_columns All of the columns to search.
+ * @return array
+ */
+ public function filter_search_columns( $search_columns = array() ) {
+
+ /**
+ * Filters the columns to search by.
+ *
+ * @since 1.0.0
+ * @since 2.1.0 Uses apply_filters_ref_array() instead of apply_filters()
+ *
+ * @param array $search_columns Array of column names to be searched.
+ * @param Query &$this Current instance passed by reference.
+ */
+ return (array) apply_filters_ref_array(
+ $this->apply_prefix( "{$this->item_name_plural}_search_columns" ),
+ array(
+ $search_columns,
+ &$this
+ )
+ );
+ }
+
+ /** General ***************************************************************/
+
/**
* Fetch raw results directly from the database.
*
* @since 1.0.0
+ * @since 2.1.0 Uses query()
*
* @param array $cols Columns for `SELECT`.
* @param array $where_cols Where clauses. Each key-value pair in the array
@@ -3054,117 +4025,17 @@ private function cache_delete( $key = '', $group = '' ) {
*/
public function get_results( $cols = array(), $where_cols = array(), $limit = 25, $offset = null, $output = OBJECT ) {
- // Bail if no columns have been passed
- if ( empty( $cols ) ) {
- return null;
- }
-
- // Fetch all the columns for the table being queried
- $column_names = $this->get_column_names();
-
- // Ensure valid column names have been passed for the `SELECT` clause
- foreach ( $cols as $index => $column ) {
- if ( ! array_key_exists( $column, $column_names ) ) {
- unset( $cols[ $index ] );
- }
- }
-
- // Columns to retrieve
- $columns = implode( ',', $cols );
-
- // Get the table name
- $table = $this->get_table_name();
-
- // Setup base query
- $query = implode( ' ', array(
- "SELECT",
- $columns,
- "FROM {$table} {$this->table_alias}",
- "WHERE 1=1"
+ // Parse arguments
+ $r = wp_parse_args( $where_cols, array(
+ 'fields' => $cols,
+ 'number' => $limit,
+ 'offset' => $offset,
+ 'output' => $output,
+ 'update_item_cache' => false,
+ 'update_meta_cache' => false,
) );
- // Ensure valid columns have been passed for the `WHERE` clause
- if ( ! empty( $where_cols ) ) {
-
- // Get keys from where columns
- $columns = array_keys( $where_cols );
-
- // Loop through columns and unset any invalid names
- foreach ( $columns as $index => $column ) {
- if ( ! array_key_exists( $column, $column_names ) ) {
- unset( $where_cols[ $index ] );
- }
- }
-
- // Parse WHERE clauses
- foreach ( $where_cols as $column => $compare ) {
-
- // Basic WHERE clause
- if ( ! is_array( $compare ) ) {
- $pattern = $this->get_column_field( array( 'name' => $column ), 'pattern', '%s' );
- $statement = " AND {$this->table_alias}.{$column} = {$pattern} ";
- $query .= $this->get_db()->prepare( $statement, $compare );
-
- // More complex WHERE clause
- } else {
- $value = isset( $compare['value'] )
- ? $compare['value']
- : false;
-
- // Skip if a value was not provided
- if ( false === $value ) {
- continue;
- }
-
- // Default compare clause to equals
- $compare_clause = isset( $compare['compare_query'] )
- ? trim( strtoupper( $compare['compare_query'] ) )
- : '=';
-
- // Array (unprepared)
- if ( is_array( $compare['value'] ) ) {
-
- // Default to IN if clause not specified
- if ( ! in_array( $compare_clause, array( 'IN', 'NOT IN', 'BETWEEN' ), true ) ) {
- $compare_clause = 'IN';
- }
-
- // Parse & escape for IN and NOT IN
- if ( 'IN' === $compare_clause || 'NOT IN' === $compare_clause ) {
- $value = "('" . implode( "','", $this->get_db()->_escape( $compare['value'] ) ) . "')";
-
- // Parse & escape for BETWEEN
- } elseif ( is_array( $value ) && 2 === count( $value ) && 'BETWEEN' === $compare_clause ) {
- $_this = $this->get_db()->_escape( $value[0] );
- $_that = $this->get_db()->_escape( $value[1] );
- $value = " {$_this} AND {$_that} ";
- }
- }
-
- // Add WHERE clause
- $query .= " AND {$this->table_alias}.{$column} {$compare_clause} {$value} ";
- }
- }
- }
-
- // Maybe set an offset
- if ( ! empty( $offset ) ) {
- $values = explode( ',', $offset );
- $values = array_filter( $values, 'intval' );
- $offset = implode( ',', $values );
- $query .= " OFFSET {$offset} ";
- }
-
- // Maybe set a limit
- if ( ! empty( $limit ) && ( $limit > 0 ) ) {
- $limit = intval( $limit );
- $query .= " LIMIT {$limit} ";
- }
-
- // Execute query
- $results = $this->get_db()->get_results( $query, $output );
-
- // Return results
- return $results;
+ // Get items
+ return $this->query( $r );
}
}
diff --git a/src/Database/Row.php b/src/Database/Row.php
index 99c7852..c94fca6 100644
--- a/src/Database/Row.php
+++ b/src/Database/Row.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Row
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -33,7 +36,7 @@ class Row extends Base {
*
* @since 1.0.0
*
- * @param mixed Null by default, Array/Object if not
+ * @param mixed $item Null by default, Array/Object if not
*/
public function __construct( $item = null ) {
if ( ! empty( $item ) ) {
diff --git a/src/Database/Schema.php b/src/Database/Schema.php
index 80a7c99..00ab54d 100644
--- a/src/Database/Schema.php
+++ b/src/Database/Schema.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Schema
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -21,11 +24,32 @@
* including global tables for multisite, and users tables.
*
* @since 1.0.0
+ * @since 2.1.0 Added variables for Column & Index
*/
class Schema extends Base {
+ /** Item Types ************************************************************/
+
+ /**
+ * Schema Column class.
+ *
+ * @since 2.1.0
+ * @var string
+ */
+ protected $column = __NAMESPACE__ . '\\Column';
+
/**
- * Array of database column objects to turn into Column.
+ * Schema Index class.
+ *
+ * @since 2.1.0
+ * @var string
+ */
+ protected $index = __NAMESPACE__ . '\\Index';
+
+ /** Item Objects **********************************************************/
+
+ /**
+ * Array of database Column objects.
*
* @since 1.0.0
* @var array
@@ -33,56 +57,266 @@ class Schema extends Base {
protected $columns = array();
/**
- * Invoke new column objects based on array of column data.
+ * Array of database Index objects.
+ *
+ * @since 2.1.0
+ * @var array
+ */
+ protected $indexes = array();
+
+ /** Public Methods ********************************************************/
+
+ /**
+ * Setup the Schema object, and parse any arguments passed in.
*
* @since 1.0.0
*/
- public function __construct() {
+ public function __construct( $args = array() ) {
- // Bail if no columns
- if ( empty( $this->columns ) || ! is_array( $this->columns ) ) {
- return;
+ // Setup the Schema
+ $this->setup();
+
+ // Parse arguments if not empty
+ if ( ! empty( $args ) ) {
+ $this->parse_args( $args );
}
+ }
- // Juggle original columns array
- $columns = $this->columns;
- $this->columns = array();
+ /**
+ * Setup the class variables.
+ *
+ * This method includes legacy support for Schema objects that predefined
+ * their array of Columns. This approach will not be removed, as it was the
+ * only way to register Columns in all versions before 2.1.0.
+ *
+ * @since 2.1.0
+ */
+ public function setup() {
- // Loop through columns and create objects from them
- foreach ( $columns as $column ) {
- if ( is_array( $column ) ) {
- $this->columns[] = new Column( $column );
- } elseif ( $column instanceof Column ) {
- $this->columns[] = $column;
- }
+ // Legacy support for pre-set $columns array
+ if ( ! empty( $this->columns ) && is_array( $this->columns ) ) {
+ $this->setup_items( 'columns', $this->column, $this->columns );
+ }
+
+ // Legacy support for pre-set $indexes array
+ if ( ! empty( $this->indexes ) && is_array( $this->indexes ) ) {
+ $this->setup_items( 'indexes', $this->index, $this->indexes );
}
}
/**
- * Return the schema in string form.
+ * Parse all of the arguments.
*
- * @since 1.0.0
+ * @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.
*
- * @return string Calls get_create_string() on every column.
+ * @since 2.1.0
+ * @param string $type The type of items to clear.
*/
- protected function to_string() {
+ public function clear( $type = '' ) {
+
+ // Clearing specific
+ if ( ! empty( $type ) ) {
+ $this->{$type} = array();
+
+ // Clearing everything
+ } else {
+ $this->columns = array();
+ $this->indexes = array();
+ }
+ }
+
+ /**
+ * Add an item to a specific items array.
+ *
+ * @since 2.1.0
+ * @param string $type Item type to add.
+ * @param string $class Class to shape item into.
+ * @param array|object $data Data to pass into class constructor.
+ * @return object|false
+ */
+ public function add_item( $type = 'column', $class = 'Column', $data = array() ) {
// Default return value
- $retval = '';
+ $retval = false;
+
+ // Bail if no data to add
+ if ( empty( $data ) ) {
+ return false;
+ }
+
+ // Array
+ if ( is_array( $data ) ) {
+ $retval = new $class( $data );
- // Bail if no columns to convert
- if ( empty( $this->columns ) ) {
- return $retval;
+ // Object
+ } elseif ( $data instanceof $class ) {
+ $retval = $data;
}
- // Loop through columns...
- foreach ( $this->columns as $column_info ) {
- if ( method_exists( $column_info, 'get_create_string' ) ) {
- $retval .= '\n' . $column_info->get_create_string() . ', ';
+ // Bail if no item to add
+ if ( empty( $retval ) ) {
+ return false;
+ }
+
+ // Add item to array
+ $this->{$type}[] = $retval;
+
+ // Return the item
+ return $retval;
+ }
+
+ /**
+ * Return the SQL used for all items in a "CREATE TABLE" query.
+ *
+ * This does not include the "CREATE TABLE" directive itself, and is only
+ * used to generate the SQL inside of that kind of query.
+ *
+ * @since 2.1.0
+ * @return string
+ */
+ public function get_create_table_string() {
+
+ // Get strings
+ $strings = array(
+ $this->get_items_create_string( 'columns' ),
+ $this->get_items_create_string( 'indexes' )
+ );
+
+ // Format
+ $retval = implode( ",\n", array_filter( $strings ) );
+
+ // Return
+ return $retval;
+ }
+
+ /** Private Helpers *******************************************************/
+
+ /**
+ * Parse all item types.
+ *
+ * This simply calls setup() after all arguments have been parsed.
+ * A future version of setup() may require this method to change.
+ *
+ * @since 2.1.0
+ */
+ private function parse_item_types() {
+ $this->setup();
+ }
+
+ /**
+ * Setup an array of items.
+ *
+ * @since 2.1.0
+ * @param string $type Type of items to setup.
+ * @param string $class Class to use to create objects.
+ * @param array $values Array of values to convert to objects.
+ * @return array Array of items that were setup.
+ */
+ private function setup_items( $type = 'columns', $class = 'Column', $values = array() ) {
+
+ // Bail if no items
+ if ( empty( $this->{$type} ) || ! is_array( $this->{$type} ) ) {
+ return array();
+ }
+
+ // Bail if no class
+ if ( empty( $class ) || ! class_exists( $class ) ) {
+ return array();
+ }
+
+ // Clear items for type
+ $this->clear( $type );
+
+ // Bail if no values
+ if ( empty( $values ) || ! is_array( $values ) ) {
+ return array();
+ }
+
+ // Loop through values and create objects from them
+ foreach ( $values as $item ) {
+ $this->add_item( $type, $class, $item );
+ }
+
+ // Return the items
+ return $this->{$type};
+ }
+
+ /**
+ * Return the SQL for an item type used in a "CREATE TABLE" query.
+ *
+ * @since 2.1.0
+ * @param string $type Type of item.
+ * @return string Calls get_create_string() on every item.
+ */
+ private function get_items_create_string( $type = 'columns' ) {
+
+ // Bail if no items to get strings from
+ if ( empty( $this->{$type} ) || ! is_array( $this->{$type} ) ) {
+ return '';
+ }
+
+ // Default return value
+ $retval = '';
+
+ // Improve readability
+ $indent = ' ';
+
+ // Default strings
+ $strings = array();
+
+ // Loop through items...
+ foreach ( $this->{$type} as $item ) {
+ if ( method_exists( $item, 'get_create_string' ) ) {
+ $strings[] = $indent . $item->get_create_string();
}
}
- // Return the string
+ // Format
+ $retval = implode( ",\n", $strings );
+
+ // Return the SQL
return $retval;
}
+
+ /** Deprecated ************************************************************/
+
+ /**
+ * Return the columns in string form.
+ *
+ * This method was deprecated in 2.1.0 because in previous versions it only
+ * included Columns and did not include Indexes.
+ *
+ * @since 1.0.0
+ * @deprecated 2.1.0
+ * @return string
+ */
+ protected function to_string() {
+ return $this->get_items_create_string( 'columns' );
+ }
}
diff --git a/src/Database/Table.php b/src/Database/Table.php
index ce1e362..e0dcd80 100644
--- a/src/Database/Table.php
+++ b/src/Database/Table.php
@@ -4,10 +4,13 @@
*
* @package Database
* @subpackage Table
- * @copyright Copyright (c) 2021
+ * @copyright 2021-2022 - JJJ and all BerlinDB contributors
* @license https://opensource.org/licenses/MIT MIT
* @since 1.0.0
*/
+
+declare( strict_types = 1 );
+
namespace BerlinDB\Database;
// Exit if accessed directly
@@ -120,6 +123,17 @@ abstract class Table extends Base {
*/
protected $charset_collation = '';
+ /**
+ * Typically empty; probably ignore.
+ *
+ * By default, tables do not have comments. This is unused by any other
+ * relative code, but you can include less than 1024 characters here.
+ *
+ * @since 2.1.0
+ * @var string
+ */
+ protected $comment = '';
+
/**
* Key => value array of versions => methods.
*
@@ -137,7 +151,7 @@ abstract class Table extends Base {
*/
public function __construct() {
- // Setup the database table
+ // Setup this database table
$this->setup();
// Bail if setup failed
@@ -145,7 +159,7 @@ public function __construct() {
return;
}
- // Add the table to the database interface
+ // Add table to the database interface
$this->set_db_interface();
// Set the database schema
@@ -212,7 +226,7 @@ public function switch_blog( $site_id = 0 ) {
/** Public Helpers ********************************************************/
/**
- * Maybe upgrade the database table. Handles creation & schema changes.
+ * Maybe upgrade this database table. Handles creation & schema changes.
*
* Hooked to the `admin_init` action.
*
@@ -259,7 +273,7 @@ public function needs_upgrade( $version = false ) {
// Get the current database version
$this->get_db_version();
- // Is the database table up to date?
+ // Is this database table up to date?
$is_current = version_compare( $this->db_version, $version, '>=' );
// Return false if current, true if out of date
@@ -305,7 +319,7 @@ public function get_version() {
/**
* Install a database table
*
- * Creates the table and sets the version information if successful.
+ * Create table and set the version if successful.
*
* @since 1.0.0
*/
@@ -323,8 +337,9 @@ public function install() {
/**
* Uninstall a database table
*
- * Drops the table and deletes the version information if successful and/or
- * the table does not exist anymore.
+ * Drops table and deletes the version information if successful.
+ *
+ * If the table does not exist, the version will still be deleted.
*
* @since 1.0.0
*/
@@ -342,7 +357,7 @@ public function uninstall() {
/** Public Management *****************************************************/
/**
- * Check if table already exists.
+ * Check if table exists.
*
* @since 1.0.0
*
@@ -359,21 +374,53 @@ public function exists() {
}
// Query statement to check if table exists.
- $query = "SHOW TABLES LIKE %s";
+ $sql = "SHOW TABLES LIKE %s";
$like = $db->esc_like( $this->table_name );
- $prepared = $db->prepare( $query, $like );
+ $prepared = $db->prepare( $sql, $like );
$result = $db->get_var( $prepared );
// Does the table exist?
return $this->is_success( $result );
}
+ /**
+ * Get status of table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/show-table-status.html
+ *
+ * @since 2.1.0
+ *
+ * @return object
+ */
+ public function status() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "SHOW TABLE STATUS LIKE %s";
+ $like = $db->esc_like( $this->table_name );
+ $prepared = $db->prepare( $sql, $like );
+ $query = (array) $db->get_results( $prepared );
+ $result = end( $query );
+
+ // Does the table exist?
+ return $this->is_success( $result )
+ ? $result
+ : false;
+ }
+
/**
* Get columns from table.
*
* @since 1.2.0
*
- * @return array
+ * @return mixed Array on success, False on failure
*/
public function columns() {
@@ -386,8 +433,8 @@ public function columns() {
}
// Query statement
- $query = "SHOW FULL COLUMNS FROM {$this->table_name}";
- $result = $db->get_results( $query );
+ $sql = "SHOW FULL COLUMNS FROM {$this->table_name}";
+ $result = $db->get_results( $sql );
// Return the results
return $this->is_success( $result )
@@ -396,7 +443,7 @@ public function columns() {
}
/**
- * Create the table.
+ * Create this database table.
*
* @since 1.0.0
*
@@ -412,8 +459,26 @@ public function create() {
return false;
}
+ // Bail if schema not initialized (tables need at least 1 column)
+ if ( empty( $this->schema ) ) {
+ return false;
+ }
+
+ // Required parts
+ $sql = array(
+ 'CREATE TABLE',
+ $this->table_name,
+ "( {$this->schema} )",
+ $this->charset_collation,
+ );
+
+ // Maybe append comment
+ if ( ! empty( $this->comment ) ) {
+ $sql[] = "COMMENT='{$this->comment}'";
+ }
+
// Query statement
- $query = "CREATE TABLE {$this->table_name} ( {$this->schema} ) {$this->charset_collation}";
+ $query = implode( ' ', array_filter( $sql ) );
$result = $db->query( $query );
// Was the table created?
@@ -421,7 +486,7 @@ public function create() {
}
/**
- * Drop the database table.
+ * Drop this database table.
*
* @since 1.0.0
*
@@ -438,15 +503,15 @@ public function drop() {
}
// Query statement
- $query = "DROP TABLE {$this->table_name}";
- $result = $db->query( $query );
+ $sql = "DROP TABLE {$this->table_name}";
+ $result = $db->query( $sql );
// Did the table get dropped?
return $this->is_success( $result );
}
/**
- * Truncate the database table.
+ * Truncate this database table.
*
* @since 1.0.0
*
@@ -463,15 +528,15 @@ public function truncate() {
}
// Query statement
- $query = "TRUNCATE TABLE {$this->table_name}";
- $result = $db->query( $query );
+ $sql = "TRUNCATE TABLE {$this->table_name}";
+ $result = $db->query( $sql );
// Did the table get truncated?
return $this->is_success( $result );
}
/**
- * Delete all items from the database table.
+ * Delete all items from this database table.
*
* @since 1.0.0
*
@@ -488,8 +553,8 @@ public function delete_all() {
}
// Query statement
- $query = "DELETE FROM {$this->table_name}";
- $result = $db->query( $query );
+ $sql = "DELETE FROM {$this->table_name}";
+ $result = $db->query( $sql );
// Return the results
return $result;
@@ -502,7 +567,7 @@ public function delete_all() {
*
* @since 1.1.0
*
- * @param string $new_table_name The name of the new table, without prefix
+ * @param string $new_table_name The name of the new table, no prefix
*
* @return bool
*/
@@ -526,8 +591,8 @@ public function _clone( $new_table_name = '' ) {
// Query statement
$table = $this->apply_prefix( $table_name );
- $query = "CREATE TABLE {$table} LIKE {$this->table_name}";
- $result = $db->query( $query );
+ $sql = "CREATE TABLE {$table} LIKE {$this->table_name}";
+ $result = $db->query( $sql );
// Did the table get cloned?
return $this->is_success( $result );
@@ -540,7 +605,7 @@ public function _clone( $new_table_name = '' ) {
*
* @since 1.1.0
*
- * @param string $new_table_name The name of the new table, without prefix
+ * @param string $new_table_name The name of the new table, no prefix
*
* @return bool
*/
@@ -564,15 +629,15 @@ public function copy( $new_table_name = '' ) {
// Query statement
$table = $this->apply_prefix( $table_name );
- $query = "INSERT INTO {$table} SELECT * FROM {$this->table_name}";
- $result = $db->query( $query );
+ $sql = "INSERT INTO {$table} SELECT * FROM {$this->table_name}";
+ $result = $db->query( $sql );
// Did the table get copied?
return $this->is_success( $result );
}
/**
- * Count the number of items in the database table.
+ * Count the number of items in this database table.
*
* @since 1.0.0
*
@@ -589,19 +654,56 @@ public function count() {
}
// Query statement
- $query = "SELECT COUNT(*) FROM {$this->table_name}";
- $result = $db->get_var( $query );
+ $sql = "SELECT COUNT(*) FROM {$this->table_name}";
+ $result = $db->get_var( $sql );
- // Query success/fail
+ // 0 on error/empty, number of rows on success
return intval( $result );
}
+ /**
+ * Rename this database table.
+ *
+ * @since 2.1.0
+ *
+ * @param string $new_table_name The new name of the current table, no prefix
+ *
+ * @return bool
+ */
+ public function rename( $new_table_name = '' ) {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Sanitize the new table name
+ $table_name = $this->sanitize_table_name( $new_table_name );
+
+ // Bail if new table name is invalid
+ if ( empty( $table_name ) ) {
+ return false;
+ }
+
+ // Query statement
+ $table = $this->apply_prefix( $table_name );
+ $sql = "RENAME TABLE {$this->table_name} TO {$table}";
+ $result = $db->query( $sql );
+
+ // Did the table get renamed?
+ return $this->is_success( $result );
+ }
+
/**
* Check if column already exists.
*
* @since 1.0.0
+ * @since 2.1.0 Uses sanitize_column_name().
*
- * @param string $name Value
+ * @param string $name Column name to check.
*
* @return bool
*/
@@ -616,9 +718,10 @@ public function column_exists( $name = '' ) {
}
// Query statement
- $query = "SHOW COLUMNS FROM {$this->table_name} LIKE %s";
+ $sql = "SHOW COLUMNS FROM {$this->table_name} LIKE %s";
+ $name = $this->sanitize_column_name( $name );
$like = $db->esc_like( $name );
- $prepared = $db->prepare( $query, $like );
+ $prepared = $db->prepare( $sql, $like );
$result = $db->query( $prepared );
// Does the column exist?
@@ -629,9 +732,10 @@ public function column_exists( $name = '' ) {
* Check if index already exists.
*
* @since 1.0.0
+ * @since 2.1.0 Uses sanitize_column_name().
*
- * @param string $name Value
- * @param string $column Column name
+ * @param string $name Index name to check.
+ * @param string $column Column name to compare.
*
* @return bool
*/
@@ -651,15 +755,169 @@ public function index_exists( $name = '', $column = 'Key_name' ) {
}
// Query statement
- $query = "SHOW INDEXES FROM {$this->table_name} WHERE {$column} LIKE %s";
+ $sql = "SHOW INDEXES FROM {$this->table_name} WHERE {$column} LIKE %s";
+ $name = $this->sanitize_column_name( $name );
$like = $db->esc_like( $name );
- $prepared = $db->prepare( $query, $like );
+ $prepared = $db->prepare( $sql, $like );
$result = $db->query( $prepared );
// Does the index exist?
return $this->is_success( $result );
}
+ /** Repair ****************************************************************/
+
+ /**
+ * Analyze this database table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html
+ *
+ * @since 2.1.0
+ *
+ * @return bool|string
+ */
+ public function analyze() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "ANALYZE TABLE {$this->table_name}";
+ $query = (array) $db->get_results( $sql );
+ $result = end( $query );
+
+ // Return message text
+ return ! empty( $result->Msg_text )
+ ? $result->Msg_text
+ : false;
+ }
+
+ /**
+ * Check this database table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/check-table.html
+ *
+ * @since 2.1.0
+ *
+ * @return bool|string
+ */
+ public function check() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "CHECK TABLE {$this->table_name}";
+ $query = (array) $db->get_results( $sql );
+ $result = end( $query );
+
+ // Return message text
+ return ! empty( $result->Msg_text )
+ ? $result->Msg_text
+ : false;
+ }
+
+ /**
+ * Get the Checksum this database table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/checksum-table.html
+ *
+ * @since 2.1.0
+ *
+ * @return bool|string
+ */
+ public function checksum() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "CHECKSUM TABLE {$this->table_name}";
+ $query = (array) $db->get_results( $sql );
+ $result = end( $query );
+
+ // Return checksum
+ return ! empty( $result->Checksum )
+ ? $result->Checksum
+ : false;
+ }
+
+ /**
+ * Optimize this database table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/optimize-table.html
+ *
+ * @since 2.1.0
+ *
+ * @return bool|string
+ */
+ public function optimize() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "OPTIMIZE TABLE {$this->table_name}";
+ $query = (array) $db->get_results( $sql );
+ $result = end( $query );
+
+ // Return message text
+ return ! empty( $result->Msg_text )
+ ? $result->Msg_text
+ : false;
+ }
+
+ /**
+ * Repair this database table.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.0/en/repair-table.html
+ * Note: Not supported by InnoDB, the default engine in MySQL 8 and higher.
+ *
+ * @since 2.1.0
+ *
+ * @return bool|string
+ */
+ public function repair() {
+
+ // Get the database interface
+ $db = $this->get_db();
+
+ // Bail if no database interface is available
+ if ( empty( $db ) ) {
+ return false;
+ }
+
+ // Query statement
+ $sql = "REPAIR TABLE {$this->table_name}";
+ $query = (array) $db->get_results( $sql );
+ $result = end( $query );
+
+ // Return message text
+ return ! empty( $result->Msg_text )
+ ? $result->Msg_text
+ : false;
+ }
+
/** Upgrades **************************************************************/
/**
@@ -789,10 +1047,10 @@ private function setup() {
return;
}
- // Sanitize the database table name
+ // Sanitize this database table name
$this->name = $this->sanitize_table_name( $this->name );
- // Bail if database table name was garbage
+ // Bail if database table name sanitization failed
if ( false === $this->name ) {
return;
}
@@ -826,10 +1084,10 @@ private function setup() {
*/
private function set_db_interface() {
- // Get the database once, to avoid duplicate function calls
+ // Get the database interface
$db = $this->get_db();
- // Bail if no database
+ // Bail if no database interface is available
if ( empty( $db ) ) {
return;
}
@@ -845,7 +1103,7 @@ private function set_db_interface() {
$tables = 'tables';
}
- // Set the table prefix and prefix the table name
+ // Set table prefix and prefix table name
$this->table_prefix = $db->get_blog_prefix( $site_id );
// Get the prefixed table name
@@ -859,7 +1117,7 @@ private function set_db_interface() {
$db->{$tables} = array();
}
- // Add the table to the global table array
+ // Add table to the global table array
$db->{$tables}[] = $this->prefixed_name;
// Charset
@@ -874,7 +1132,7 @@ private function set_db_interface() {
}
/**
- * Set the database version for the table.
+ * Set table version in the database.
*
* @since 1.0.0
*
@@ -897,18 +1155,18 @@ private function set_db_version( $version = '' ) {
}
/**
- * Get the table version from the database.
+ * Get table version from the database.
*
* @since 1.0.0
*/
private function get_db_version() {
$this->db_version = $this->is_global()
- ? get_network_option( get_main_network_id(), $this->db_version_key, false )
- : get_option( $this->db_version_key, false );
+ ? get_network_option( get_main_network_id(), $this->db_version_key, 1 )
+ : get_option( $this->db_version_key, 1 );
}
/**
- * Delete the table version from the database.
+ * Delete table version from the database.
*
* @since 1.0.0
*/
@@ -959,7 +1217,7 @@ function_exists( '_manually_load_plugin' );
* @return bool
*/
private function is_global() {
- return ( true === $this->global );
+ return $this->global;
}
/**