Builder

Class Builder


Source

File: src/Events/Custom_Tables/V1/Models/Builder.php

class Builder {

	/**
	 * @var int Flag to validate the Builder::upsert was an insert. Note - This is dependent on whether the MySQL
	 *      CLIENT_FOUND_ROWS flag is set or not.
	 */
	const UPSERT_DID_INSERT = 1;

	/**
	 * @var int Flag to validate the Builder::upsert was an update. Note - This is dependent on whether the MySQL
	 *      CLIENT_FOUND_ROWS flag is set or not.
	 */
	const UPSERT_DID_UPDATE = 2;

	/**
	 * @var int Flag to validate the Builder::upsert made no changes. Note - This is dependent on whether the MySQL
	 *      CLIENT_FOUND_ROWS flag is set or not.
	 */
	const UPSERT_DID_NOT_CHANGE = 0;

	/**
	 * A class-wide query execution toggle that will prevent the execution
	 * of SQL queries across all instances of the the Builder.
	 *
	 * @since 6.0.0
	 *
	 * @var bool
	 */
	private static $class_execute_queries = true;

	/**
	 * The size of the batch the Builder should use to fetch
	 * Models in unbound query methods like `find_all`.
	 *
	 * Set statically to affect any instance of the Builder.
	 *
	 * @since 6.0.0
	 *
	 * @var int
	 */
	protected $batch_size = 1000;

	/**
	 * The type of output that should be used to format the result set elements.
	 *
	 * @since 6.0.0
	 *
	 * @var string
	 */
	private $output_format;

	/**
	 * An instance to the Model that is using this builder class.
	 *
	 * @since 6.0.0
	 *
	 * @var Model model The model that is using this class.
	 */
	private $model;

	/**
	 * Base operation running in SQL; by default is a `SELECT *` statement
	 *
	 * @since 6.0.0
	 *
	 * @var string operation
	 */
	private $operation = 'SELECT *';

	/**
	 * Group of all the different where clauses combined with an AND boolean.
	 *
	 * @since 6.0.0
	 *
	 * @var string[] wheres
	 */
	private $wheres = [];

	/**
	 * Variable holding the value used to limit the results from the Query.
	 *
	 * @since 6.0.0
	 *
	 * @var  int|null limit
	 */
	private $limit;

	/**
	 * Variable holding the value used to offset the results from the Query.
	 *
	 * @since 6.0.0
	 *
	 * @var  int|null $offset
	 */
	private $offset;

	/**
	 * Variable holding the values of order used to construct the SQL query.
	 *
	 * @since 6.0.0
	 *
	 * @var array<string,mixed> order
	 */
	private $order = [];

	/**
	 * Flag to indicate the moment the builder class has an invalid query.
	 *
	 * @since 6.0.0
	 *
	 * @var bool invalid
	 */
	private $invalid = false;

	/**
	 * List of all the valid operators to use when running comparisons with SQL.
	 *
	 * @since 6.0.0
	 *
	 * @var string[] operators
	 */
	public $operators = [
		'=',
		'<',
		'>',
		'<=',
		'>=',
		'!=',
		'<>',
	];

	/**
	 * List of all the queries executed by this instance of the builder class.
	 *
	 * @since 6.0.0
	 *
	 * @var array<string> An array of multiple queries.
	 */
	private $queries = [];

	/**
	 * If the queries should be executed or not against the Database.
	 *
	 * @since 6.0.0
	 *
	 * @var bool execute_queries
	 */
	private $execute_queries = true;

	/**
	 * An array with all the available inner joins.
	 *
	 * @since 6.0.0
	 *
	 * @var array<array<string>> joins
	 */
	private $joins = [];

	/**
	 * Builder constructor.
	 *
	 * @since 6.0.0
	 *
	 * @param Model $model The model using this builder.
	 */
	public function __construct( Model $model ) {
		$this->model = $model;
	}

	/**
	 * Sets the class-wide queries execution toggle that will enable or
	 * disable the execution of queries overriding the per-instance value of the
	 * `$execute_queries` flag.
	 *
	 * @since 6.0.0
	 *
	 * @param bool $class_execute_queries Whether to enable or disable the execution
	 *                                    of queries class-wide.
	 *
	 * @see Builder::enable_query_execution() to set the flag on a per-instance basis
	 */
	public static function class_enable_query_execution( $class_execute_queries ) {
		self::$class_execute_queries = $class_execute_queries;
	}

	/**
	 * Get an instance to this builder class.
	 *
	 * @since 6.0.0
	 *
	 * @return $this An instance to this builder class.
	 */
	public function builder_instance() {
		return $this;
	}

	/**
	 * If this builder class has invalid queries or not.
	 *
	 * @since 6.0.0
	 *
	 * @return bool True if this builder has an invalid query, false otherwise.
	 */
	public function has_invalid_queries() {
		return $this->invalid;
	}

	/**
	 * Returns an array of strings with all the SQL queries generated by this builder class.
	 *
	 * @since 6.0.0
	 *
	 * @return array<string> An array of the current Builder queries.
	 */
	public function queries() {
		return $this->queries;
	}

	/**
	 * Method to enable query execution or not.
	 *
	 * @since 6.0.0
	 *
	 * @param bool $execute_queries If the query should be executed or not against the Database.
	 */
	public function enable_query_execution( $execute_queries = true ) {
		$this->execute_queries = $execute_queries;

		return $this;
	}

	/**
	 * Sets the size of the batch the Builder should use to fetch models in unbound query methods like `find_all`.
	 *
	 * @since 6.0.0
	 *
	 * @param int $size    The size of the batch the Builder should use to fetch
	 *                     Models in unbound query methods like `find_all`.
	 *
	 * @return Builder The instance to the current class.
	 */
	public function set_batch_size( $size = 100 ) {
		$this->batch_size = $size;

		return $this;
	}

	/**
	 * Insert a new row or update one if already exists.
	 *
	 * @since 6.0.0
	 *
	 * @param array<string>            $unique_by A list of columns that are marked as UNIQUE on the database.
	 * @param array<string,mixed>|null $data      The data to be inserted or updated into the table.
	 *
	 * @return false|int The rows affected flag or false on failure.
	 */
	public function upsert( array $unique_by, array $data = null ) {
		if ( empty( $unique_by ) ) {
			throw new InvalidArgumentException( 'A series of unique column needs to be specified.' );
		}

		// If no input was provided use the model as input.
		if ( $data === null ) {
			$model = $this->set_data_to_model();
			$model->validate();
		} else {
			if ( empty( $data ) ) {
				return false;
			}

			$columns = array_keys( $data );
			// Make sure the required key is part of the data to be inserted in.
			foreach ( $unique_by as $column ) {
				if ( ! in_array( $column, $columns, true ) ) {
					throw new InvalidArgumentException( "The column '{$column}' must be part of the data array" );
				}
			}

			$model = $this->set_data_to_model( $data );
			$model->validate( array_keys( $data ) );
		}

		if ( $model->is_invalid() ) {
			do_action( 'tribe_log', 'error', implode( ' : ', $model->errors() ), [
				'method' => __METHOD__,
				'line'   => __LINE__,
				'model'  => get_class( $model )
			] );

			return false;
		}

		list( $formatted_data, $format ) = $model->format();

		// No data to be inserted.
		if ( empty( $formatted_data ) ) {
			return false;
		}

		$placeholder_values = $this->create_placeholders( $formatted_data, $format );

		if ( empty( $placeholder_values ) ) {
			return false;
		}

		global $wpdb;

		$update_sql   = [];
		$update_value = [];
		foreach ( $formatted_data as $column => $value ) {
			if ( in_array( $column, $unique_by, true ) ) {
				continue;
			}
			$value_placeholder = isset( $format[ $column ] ) ? $format[ $column ] : '%s';
			$update_sql[]      = "{$column}={$value_placeholder}";
			$update_value[]    = $value;
		}
		$update_assignment_list = $wpdb->prepare( implode( ', ', $update_sql ), ...$update_value );

		$columns = implode( ',', array_keys( $formatted_data ) );

		$SQL = "INSERT INTO {$wpdb->prefix}{$this->model->table_name()} ($columns) VALUES($placeholder_values) ON DUPLICATE KEY update {$update_assignment_list}";
		$SQL = $wpdb->prepare( $SQL, ...$this->create_replacements_values( $formatted_data ) );

		$this->queries[] = $SQL;

		if ( $this->execute_queries && self::$class_execute_queries ) {
			/*
			 * Depending on the db implementation, it could not run updates and return `0`.
			 * We need to make sure it does not return exactly boolean `false`.
			 */
			$result = $wpdb->query( $SQL );
			if ( $result === false ) {
				do_action( 'tribe_log', 'debug', 'Builder: upsert query failure.', [
					'source' => __CLASS__ . ' ' . __METHOD__ . ' ' . __LINE__,
					'trace'  => debug_backtrace( 2, 5 )
				] );
			}

			return $result;
		}

		return 0;
	}

	/**
	 * Add operation to insert new records inside of the table that is used from the current model. A single entry can
	 * be set here as an array of key => value pairs, where the key is the column being saved and the value is the
	 * value intended to be saved.
	 *
	 * A bulk insert is also supported, only rows of the same size can be inserted, an array of arrays representing
	 * each
	 * the column to be inserted, all rows should be the same length of columns and values as the rest of the rows
	 * inside of the data, otherwise the operation is won't complete.
	 *
	 * @since 6.0.0
	 *
	 * @param array<string, mixed>|array<array<string,mixed> $data The data that is being inserted.
	 *
	 * @return int The number of affected rows.
	 */
	public function insert( array $data ) {
		// No data or operation was inserted.
		if ( empty( $data ) ) {
			return 0;
		}

		// If the first element is not an array make sure to wrap it around an array.
		if ( ! is_array( reset( $data ) ) ) {
			$data = [ $data ];
		}

		// @todo make this filterable?
		$insert_batch_size = $this->batch_size;
		$result            = 0;
		global $wpdb;

		$wpdb->suppress_errors( true );

		do {
			$this_batch_data = array_splice( $data, 0, $insert_batch_size );
			$validated       = $this->validate_rows( $this_batch_data );
			if ( empty( $validated['columns'] ) || empty( $validated['placeholders'] ) || empty( $validated['values'] ) ) {
				return 0;
			}

			$columns      = $validated['columns'];
			$placeholders = $validated['placeholders'];

			$SQL             = "INSERT INTO {$wpdb->prefix}{$this->model->table_name()} ($columns) VALUES $placeholders";
			$SQL             = $wpdb->prepare( $SQL, ...$validated['values'] );
			$this->queries[] = $SQL;
			if ( $this->execute_queries ) {
				$query_result = $wpdb->query( $SQL );
				$result       += (int) $query_result;
			}
			// Log our errors.
			if ( $query_result === false && $wpdb->last_error ) {
				do_action( 'tribe_log',
					'error',
					"ORM Builder mysql error while performing insert on {$this->model->table_name()}.", [
						'source'      => __METHOD__ . ':' . __LINE__,
						'mysql error' => $wpdb->last_error,
					] );
			}
		} while ( count( $data ) );

		$wpdb->suppress_errors( false );

		return $result;
	}

	/**
	 * Perform updates against a model that already exists on the database.
	 *
	 *
	 * @since 6.0.0
	 *
	 * @param array|null $data    If the data is null the data of the model would be used to set an update, otherwise
	 *                            an array of `column => value` are used to construct the series of updates to perform
	 *                            against this model.
	 *
	 * @return bool|int False if the operation was unsuccessfully
	 */
	public function update( array $data = null ) {
		// Invalid on a where clause or previous value.
		if ( $this->invalid ) {
			return false;
		}

		if ( $data === null ) {
			$model = $this->set_data_to_model();
			$model->validate();
		} else {
			if ( empty( $data ) ) {
				return false;
			}

			$model = $this->set_data_to_model( $data );
			$model->validate( array_keys( $data ) );
		}

		if ( $model->is_invalid() ) {
			$this->invalid = true;

			return false;
		}

		list( $formatted_data, $format ) = $model->format();

		if ( empty( $formatted_data ) ) {
			return false;
		}

		$columns             = [];
		$replacements_values = [];
		foreach ( $formatted_data as $column => $value ) {
			if ( array_key_exists( $column, $format ) ) {
				$columns[]             = "`{$column}` = {$format[$column]}";
				$replacements_values[] = $value;
				continue;
			}

			if ( $value === null ) {
				$columns[] = "`{$column}` = NULL";
			}
		}

		global $wpdb;

		$this->operation = "UPDATE {$wpdb->prefix}{$this->model->table_name()}";

		$pieces = [
			$this->operation,
			"SET " . $wpdb->prepare( implode( ', ', $columns ), $replacements_values ),
		];

		$where = $this->get_where_clause();

		if ( $where !== '' ) {
			$pieces[] = $where;
		}

		$SQL = implode( "\n", $pieces );

		$this->queries[] = $SQL;

		return $this->execute_queries ? $wpdb->query( $SQL ) : false;
	}

	/**
	 * Run a delete operation against an existing model if the model has not been persisted on the DB the operation
	 * will fail.
	 *
	 * @since 6.0.0
	 *
	 * @return int The number of affected rows.
	 */
	public function delete() {
		$this->operation = 'DELETE';

		global $wpdb;
		$SQL = $this->get_sql();

		// If the query is invalid, don't delete anything.
		if ( $this->invalid ) {
			return 0;
		}

		$this->queries[] = $SQL;
		$result          = $this->execute_queries ? $wpdb->query( $SQL ) : false;

		// If an error happen or no row was updated by the query above.
		if ( $result === false || (int) $result === 0 ) {
			return 0;
		}

		$this->model->reset();

		return absint( $result );
	}

	/**
	 * Find an instance of the model in the database using a specific value and column if no column is specified
	 * the primary key is used.
	 *
	 * @since 6.0.0
	 *
	 * @param mixed|array<mixed> $value  The value, or values, of the column we are looking for.
	 * @param string|null        $column The name of the column used to compare against, primary key if not defined.
	 *
	 * @return Model|null Returns a single record where if the model is found, `null` otherwise.
	 */
	public function find( $value, $column = null ) {
		$column = null === $column ? $this->model->primary_key_name() : $column;

		return $this->where( $column, $value )->first();
	}

	/**
	 * Get an array of models that match with the criteria provided.
	 *
	 * @since 6.0.0
	 *
	 * @param string       $column    The column name to look for.
	 * @param array<mixed> $in_values An array of values to test against the database.
	 *
	 * @return Builder
	 */
	public function where_in( $column, array $in_values = [] ) {
		$result = $this->prepare_list_of_values( $column, $in_values );

		if ( $this->invalid || empty( $result ) ) {
			return $this;
		}

		if ( empty ( $result['placeholders'] ) || empty( $result['values'] ) ) {
			return $this;
		}

		global $wpdb;

		$placeholders   = implode( ',', $result['placeholders'] );
		$this->wheres[] = $wpdb->prepare( "(`{$column}` IN ({$placeholders}))", $result['values'] );

		return $this;
	}

	/**
	 * Append a new "NOT IN()" clause to the builder of where clauses.
	 *
	 * @since 6.0.0
	 *
	 * @param string       $column        The name of the column to compare against.
	 * @param array<mixed> $not_in_values The list of values used in the comparison.
	 *
	 * @return $this
	 */
	public function where_not_in( $column, array $not_in_values = [] ) {
		$result = $this->prepare_list_of_values( $column, $not_in_values );

		if ( $this->invalid || empty( $result ) ) {
			return $this;
		}

		if ( empty ( $result['placeholders'] ) || empty( $result['values'] ) ) {
			return $this;
		}

		global $wpdb;

		$placeholders   = implode( ',', $result['placeholders'] );
		$this->wheres[] = $wpdb->prepare( "(`{$column}` NOT IN ({$placeholders}))", $result['values'] );

		return $this;
	}

	/**
	 * Prepare, sanitize and validate a list of values against the model validators.
	 *
	 * @since 6.0.0
	 *
	 * @param string       $column      The name of the column we are validating.
	 * @param array<mixed> $list_values An array with the values that are compared against this model column.
	 *
	 * @return array<array<string>, array<mixed>> An associative array with the placeholders and values generated from
	 *                              the validations.
	 */
	private function prepare_list_of_values( $column, $list_values ) {
		$placeholders  = [];
		$values        = [];
		$this->invalid = false;

		foreach ( $list_values as $value ) {
			$model = $this->set_data_to_model( [ $column => $value ] );
			if ( ! $model->enable_single_validation( $column )->validate( [ $column ] ) ) {
				$this->invalid = true;
				continue;
			}
			list( $data, $format ) = $model->format();
			if ( empty( $data ) || empty( $data[ $column ] ) ) {
				$this->invalid = true;
				continue;
			}
			if ( array_key_exists( $column, $format ) ) {
				$placeholders[] = $format[ $column ];
				$values[]       = $data[ $column ];
				continue;
			}

			if ( $data[ $column ] === null ) {
				$placeholders[] = $column;
				$values[]       = 'NULL';
			}
		}

		return compact( 'placeholders', 'values' );
	}

	/**
	 * Checks the value and columns requested for a GET operation on the
	 * Model to make sure they are coherent and valid.
	 *
	 * @since 6.0.0
	 *
	 * @param mixed|array<mixed> $value  The value, or values, of the column we are looking for.
	 * @param string|null        $column The name of the column used to compare against, primary key if not defined.
	 *
	 * @return array<mixed>|false Either an array containing the column, data and format, in this order; or `false` to
	 *                            indicate the value and column are not coherent and valid. The data and format values
	 *                            will be array if the input `$value` is an array.
	 */
	private function check_find_value_column( $value, $column = null ) {
		$column        = null === $column ? $this->model->primary_key_name() : $column;
		$data_buffer   = [];
		$format_buffer = [];

		foreach ( (array) $value as $val ) {
			$model = $this->set_data_to_model( [ $column => $val ] );

			if ( ! $model->validate( [ $column ] ) ) {
				return false;
			}

			list( $data, $format ) = $model->format();

			if ( empty( $data ) || empty( $data[ $column ] ) || empty( $format ) || empty( $format[ $column ] ) ) {
				return false;
			}

			$data_buffer[]   = $data;
			$format_buffer[] = $format;
		}

		$data   = is_array( $value ) ? $data_buffer : reset( $data_buffer );
		$format = is_array( $value ) ? $format_buffer : reset( $format_buffer );

		return [ $column, $data, $format ];
	}

	/**
	 * Finds all the Model instances matching a set of values for a column.
	 *
	 * The method will query the database for matching Models in batches of fixed size
	 * that will be hidden from the client code.
	 *
	 * @since 6.0.0
	 *
	 * @param mixed|array<mixed> $value     The value, or values, to find the matches for.
	 * @param string|null        $column    The column to search the Models by, or `null` to use the Model
	 *                                      primary column.
	 *
	 * @return Generator<Model>|null A generator that will return all matching Model instances
	 *                               hiding the batched query logic.
	 */
	public function find_all( $value, $column = null ) {
		if ( false === $column_data_format = $this->check_find_value_column( $value, $column ) ) {
			// Nothing to return.
			return;
		}

		$this->query = '';

		list( $column, $data, $format ) = $column_data_format;

		$operator = is_array( $value ) ? 'IN' : '=';
		$compare  = is_array( $value ) ? implode( ',', array_column( $format, $column ) ) : $format[ $column ];
		$data     = is_array( $value ) ? array_column( $data, $column ) : $data;
		$orderBy  = ! empty( $this->order ) ? 'ORDER BY `' . $this->order['column'] . '` ' . $this->order['order'] : '';

		global $wpdb;
		$SQL = "SELECT * FROM {$wpdb->prefix}{$this->model->table_name()} WHERE `{$column}` {$operator} ({$compare}) {$orderBy} LIMIT %d";

		$batch_size    = min( absint( $this->batch_size ), 5000 );
		$semi_prepared = $wpdb->prepare( $SQL, array_merge( (array) $data, [ $batch_size ] ) );
		$model_class   = get_class( $this->model );
		// Start with no results.
		$results = [];
		$offset  = 0;
		$found   = 0;
		do {
			if ( empty( $results ) ) {
				// Run a fetch if we're out of results to return, maybe get some results.
				$results = $wpdb->get_results( $semi_prepared . " OFFSET {$offset}", ARRAY_A );
				$offset  += $batch_size;
				$found   = count( $results );
				$results = array_reverse( $results );
			}

			// Get a result from the fetch.
			$result = array_pop( $results );

			if ( null === $result ) {
				// No more results.
				break;
			}

			// Yield a model instance.
			yield new $model_class( $result );
		} while ( $found > 0 );

		// We're done.
		return;
	}

	/**
	 * Limit the results from a query to a single result and return the first instance if available otherwise null.
	 *
	 * @since 6.0.0
	 *
	 * @return Model|array|null The requested model in the required format, or `null` if the model could not be found.
	 */
	public function first() {
		$results = $this->limit( 1 )->get();

		if ( empty( $results ) ) {
			return null;
		}

		$result = reset( $results );

		switch ( $this->output_format ) {
			case OBJECT:
			default:
				return $result instanceof $this->model ? $result : null;
			case ARRAY_N:
				return is_array( $result ) ? array_values( $result ) : null;
			case ARRAY_A:
				return is_array( $result ) ? $result : null;
		}
	}

	/**
	 * Execute a COUNT() call against the DB using the provided query elements.
	 *
	 * @since 6.0.0
	 *
	 * @param string|null $column_name The name of the column used for the count, '*` otherwise.
	 *
	 * @return int
	 */
	public function count( $column_name = null ) {
		if ( $this->invalid ) {
			return 0;
		}

		global $wpdb;

		if ( $column_name === null ) {
			$this->operation = 'SELECT COUNT(*)';
		} else {
			$this->operation = $wpdb->prepare( 'SELECT COUNT(%s)', $column_name );
		}

		// If the query is invalid, don't return a single result.
		if ( $this->invalid ) {
			return 0;
		}

		$SQL             = $this->get_sql();
		$this->queries[] = $SQL;

		if ( $this->execute_queries ) {
			return (int) $wpdb->get_var( $SQL );
		}

		return 0;
	}

	/**
	 * Execute an EXISTS() call using the created query as subquery of the EXISTS function.
	 *
	 * @since 6.0.0
	 * @return bool True If the query has at least 1 result available, false otherwise.
	 */
	public function exists() {
		if ( $this->invalid ) {
			return false;
		}

		global $wpdb;

		$this->operation = 'SELECT *';

		// If the query is invalid, don't return a single result.
		if ( $this->invalid ) {
			return false;
		}

		$subquery = $this->get_sql();

		$SQL             = "SELECT * FROM `{$wpdb->prefix}{$this->model->table_name()}` WHERE EXISTS ($subquery)";
		$this->queries[] = $SQL;

		if ( $this->execute_queries ) {
			return (bool) $wpdb->get_var( $SQL );
		}

		return false;
	}

	/**
	 * Create a join clause with the single builder method.
	 *
	 * @since 6.0.0
	 *
	 * @param string $table_name           The name of the table to join.
	 * @param string $left_column          The field on the table to join.
	 * @param string $current_model_column The field on the current model to join against with.
	 *
	 * @return $this
	 */
	public function join( $table_name, $left_column, $current_model_column ) {
		if ( $this->invalid ) {
			return $this;
		}

		global $wpdb;

		$parts = [
			"JOIN `{$table_name}`",
			"ON `{$wpdb->prefix}{$this->model->table_name()}`.$current_model_column = `{$table_name}`.$left_column",
		];

		$this->joins[] = $parts;

		return $this;
	}

	/**
	 * Select all the rows that match with the query.
	 *
	 * @since 6.0.0
	 * @return Model[]
	 */
	public function get() {
		global $wpdb;

		$this->operation = 'SELECT *';

		// If the query is invalid, don't return a single result.
		if ( $this->invalid ) {
			return [];
		}

		$SQL             = $this->get_sql();
		$this->queries[] = $SQL;
		$results         = [];

		if ( $this->execute_queries ) {
			$results = $wpdb->get_results(
				$SQL,
				ARRAY_A
			);
		}

		if ( ARRAY_A === $this->output_format ) {
			return $results;
		}

		if ( ARRAY_N === $this->output_format ) {
			return array_map( 'array_values', $results );
		}

		return $this->create_collection( $results );
	}

	/**
	 * Get all the pieces of the SQL constructed to used against the DB.
	 *
	 * @since 6.0.0
	 * @return string
	 */
	public function get_sql() {
		// If this query is already invalid return an empty string.
		if ( $this->invalid ) {
			return '';
		}

		global $wpdb;
		$pieces = [
			$this->operation,
			"FROM `{$wpdb->prefix}{$this->model->table_name()}`",
		];

		foreach ( $this->joins as $joins ) {
			foreach ( $joins as $line ) {
				$pieces[] = $line;
			}
		}

		$where = $this->get_where_clause();

		if ( $where !== '' ) {
			$pieces[] = $where;
		}

		if ( ! empty( $this->order ) ) {
			$pieces[] = 'ORDER BY `' . $this->order['column'] . '` ' . $this->order['order'];
		}

		if ( isset( $this->limit ) ) {
			$pieces[] = $wpdb->prepare( "LIMIT %d", (int) $this->limit );
		}

		if ( isset( $this->offset ) ) {
			$pieces[] = $wpdb->prepare( "OFFSET %d", (int) $this->offset );
		}

		return implode( "\n", $pieces );
	}

	/**
	 * Get the SQL with the where clauses, uses the `where_in` first if specified along side with a  where clause
	 * if no where clause is specified the `primary_key` of the model is used to construct a where clause.
	 *
	 * @since 6.0.0
	 *
	 * @return string An empty string if the WHERE clause is invalid, or a valid SQL with the where clause.
	 */
	private function get_where_clause() {
		if ( ! empty( $this->wheres ) ) {
			return "WHERE " . implode( ' AND ', $this->wheres );
		}

		// Add a where clause with the primary key of the model if no where was specified.
		$pk = $this->model->primary_key_name();
		if ( isset( $this->model->{$pk} ) ) {
			$this->wheres = [];

			$this->where( $pk, $this->model->{$pk} );

			if ( empty( $this->wheres ) ) {
				$this->invalid = true;

				return '';
			}

			return "WHERE " . implode( ' AND ', $this->wheres );
		}

		return '';
	}

	/**
	 * Add the available where clauses on the model.
	 *
	 * @since 6.0.0
	 *
	 * @param string      $column   The name of the column
	 * @param string|null $operator The operator to use against to compare or the value
	 * @param string|null $value    The value to compare against with.
	 *
	 * @return $this
	 */
	public function where( $column, $operator = null, $value = null ) {
		$this->invalid = false;

		// If only 2 arguments are provided use the second argument as the value and assume the operator is "="
		if ( func_num_args() === 2 ) {
			$value    = $operator;
			$operator = '=';
		}

		if ( $this->invalid_operator( $operator ) ) {
			$this->invalid = true;

			return $this;
		}

		$model = $this->set_data_to_model( [ $column => $value ] );

		if ( ! $model->enable_single_validation( $column )->validate( [ $column ] ) ) {
			$this->invalid = true;

			return $this;
		}

		list( $data, $format ) = $model->format();

		if ( empty( $data ) || ! array_key_exists( $column, $data ) ) {
			$this->invalid = true;

			return $this;
		}

		if ( array_key_exists( $column, $format ) ) {
			global $wpdb;
			$format = $format[ $column ];

			$this->wheres[] = $wpdb->prepare( "(`{$column}` {$operator} {$format})", $data[ $column ] );

			return $this;
		}

		if ( $value === null ) {
			$this->wheres[] = "(`{$column}` {$operator} NULL)";
		}

		return $this;
	}

	/**
	 * Detect if the operator is allowed or not.
	 *
	 * @since 6.0.0
	 *
	 * @param string $operator The operator to compare against with.
	 *
	 * @return bool If the operator is invalid or not.
	 */
	private function invalid_operator( $operator ) {
		return ! in_array( $operator, $this->operators, true );
	}

	/**
	 * Allow to define the clause for order by on the Query.
	 *
	 * @since 6.0.0
	 *
	 * @param string|null $column The name of the column to order by, if not provided fallback to the primary key name
	 * @param string      $order  The type of order for the results.
	 *
	 * @return $this
	 */
	public function order_by( $column = null, $order = 'ASC' ) {
		if ( in_array( strtoupper( $order ), [ 'ASC', 'DESC' ], true ) ) {
			$this->order = [
				'column' => null === $column ? $this->model->primary_key_name() : $column,
				'order'  => $order,
			];
		}

		return $this;
	}

	/**
	 * Set the limit for the current Query.
	 *
	 * @since 6.0.0
	 *
	 * @param int $limit The limit to apply to the current query.
	 *
	 * @return $this Instance to the current class.
	 */
	public function limit( $limit ) {
		$limit = (int) $limit;

		if ( $limit >= 0 ) {
			$this->limit = $limit;
		}

		return $this;
	}

	/**
	 * Set the offset for the current query.
	 *
	 * @since 6.0.0
	 *
	 * @param int $offset The offset applied to the current query.
	 *
	 * @return $this Instance to the current class.
	 */
	public function offset( $offset ) {
		$this->offset = max( 0, (int) $offset );

		return $this;
	}

	/**
	 * Create an array with a list of placeholders to be replaced, if the column has been defined in the format
	 * we just use the format specified on the format array, if the format has not been specified, check if the
	 * value was set as `null and if so define "NULL" as the placeholder value when passing it to the `$wpdb->prepare`
	 * call.
	 *
	 * @since 6.0.0
	 *
	 * @param array<string, mixed> $data   An associative array where the keys represent the columns and the value the
	 *                                     expected value to be saved.
	 * @param array<string,string> $format An array with the key as the column and the value as the format of the
	 *                                     column.
	 *
	 * @return string
	 */
	private function create_placeholders( array $data, array $format ) {
		$placeholder_values = [];
		foreach ( $data as $column => $value ) {
			if ( array_key_exists( $column, $format ) ) {
				$placeholder_values[] = $format[ $column ];
				continue;
			}

			if ( $value === null ) {
				$placeholder_values[] = "NULL";
				continue;
			}
		}

		// Not the same number of columns to be inserted.
		if ( count( $placeholder_values ) !== count( array_keys( $data ) ) ) {
			return '';
		}

		return implode( ',', $placeholder_values );
	}

	/**
	 * Create an array with all the raw values that are going to be replaced, `null` values are skipped
	 * as those should be inserted directly as part of the `values() list instead of using `$wpdb->prepare`.
	 *
	 * @since 6.0.0
	 *
	 * @param array $data
	 *
	 * @return array
	 */
	private function create_replacements_values( array $data ) {
		$values = [];
		foreach ( $data as $column => $value ) {
			if ( $value === null ) {
				continue;
			}
			$values[] = $value;
		}

		return $values;
	}

	/**
	 * Validate a series of rows inside of a nested array.
	 *
	 * @since 6.0.0
	 *
	 * @param array<array<string, mixed>> $data The data to be validated.
	 *
	 * @return array<string, mixed> An array with the columns, placeholders and values to be inserted.
	 */
	private function validate_rows( array $data ) {
		$columns     = '';
		$formatting  = [];
		$list_values = [];

		foreach ( $data as $row ) {
			$model = $this->set_data_to_model( $row );
			$model->validate();
			if ( $model->is_invalid() ) {
				continue;
			}

			list( $values, $format ) = $model->format();

			if ( empty( $values ) || empty( $format ) ) {
				continue;
			}

			// Just set the columns values the first time.
			if ( empty( $columns ) ) {
				$keys    = array_map(
					static function ( $column ) {
						return "`{$column}`";
					},
					array_keys( $values )
				);
				$columns = implode( ',', $keys );
			}

			// Ignore all values that were set as NULL.
			$values_to_be_inserted = array_filter(
				array_values( $values ),
				static function ( $value ) {
					return $value !== null;
				}
			);

			// Append all the values, into a single array to flat the values into a single array.
			array_push( $list_values, ...$values_to_be_inserted );

			$pieces = [];
			foreach ( $values as $column => $value ) {
				if ( array_key_exists( $column, $format ) ) {
					$pieces[] = $format[ $column ];
					continue;
				}

				if ( $value === null ) {
					$pieces[] = 'NULL';
					continue;
				}
			}
			$formatting[] = '(' . implode( ',', $pieces ) . ')';
		}

		return [
			'columns'      => $columns,
			'placeholders' => implode( ',', $formatting ),
			'values'       => $list_values,
		];
	}

	/**
	 * If an instance already exists refresh the values by querying the same value against the DB.
	 *
	 * @since 6.0.0
	 * @return Model
	 */
	public function refresh() {
		$pk = $this->model->primary_key_name();
		if ( ! isset( $this->model->{$pk} ) ) {
			return $this->model;
		}

		$model = $this->find( $this->model->{$pk}, $pk );

		if ( $model === null ) {
			$this->model->reset();
		}

		foreach ( $model->to_array() as $column => $value ) {
			$this->model->{$column} = $value;
		}
	}

	/**
	 * Setup the dynamic properties of a model using an array.
	 *
	 * @since 6.0.0
	 *
	 * @param array<string, mixed> $data An array that is going to be used to setup the data of a model.
	 *
	 * @return   Model  An instance of a model
	 */
	private function set_data_to_model( array $data = [] ) {
		$data        = array_merge( $this->model->to_array(), $data );
		$model_class = get_class( $this->model );

		return new $model_class( $data );
	}

	/**
	 * Create an array of model instances to get the benefits of a model.
	 *
	 * @since 6.0.0
	 *
	 * @param array|null $raw The result from a `$wpdb->get_results` call.
	 *
	 * @return array<Model> An array with the models with the raw results.
	 */
	private function create_collection( array $raw = null ) {
		if ( $raw === null ) {
			return [];
		}

		$model_class = get_class( $this->model );

		$results = [];

		foreach ( $raw as $result ) {
			$results[] = new $model_class( $result );
		}

		return $results;
	}

	/**
	 * Adds a raw WHERE clause to the SQL statement being built.
	 *
	 * @since 6.0.0
	 * @param string $query   The SQL clause to be prepared using the `wpdb::prepare()`
	 *                        method and placeholder format.
	 * @param mixed  ...$args A set of arguments that should be used to prepare the SQL
	 *                        statement.
	 *
	 * @return $this A reference to the query builder object, for chaining purposes.
	 */
	public function where_raw( $query, ...$args ) {
		global $wpdb;
		$this->wheres[] = '(' . $wpdb->prepare( $query, ...$args ) . ')';

		return $this;
	}

	/**
	 * Sets the output format that should be used to format the result(s) of a SELECT
	 * Model query.
	 *
	 * @since 6.0.0
	 *
	 * @param string $output One of `OBJECT`, `ARRAY_A` or `ARRAY_N`. Note that `OBJECT`
	 *                       will build and return instances of the Model.
	 *
	 * @return $this A reference to the query builder object, for chaining purposes.
	 */
	public function output( $output = OBJECT ) {
		if ( ! in_array( $output, [ OBJECT, ARRAY_A, ARRAY_N ], true ) ) {
			throw new InvalidArgumentException( 'Output not supported, use one of ARRAY_A, ARRAY_N or OBJECT.' );
		}

		$this->output_format = $output;

		return $this;
	}

	/**
	 * Fetches all the matching results for the query.
	 *
	 * The method will handle querying the database in batches, running bound queries
	 * to support unbound fetching.
	 *
	 * @since 6.0.0
	 *
	 * @return Generator<Model|array> A generator of either this Model instances or arrays, depending on
	 *                                the selected output format.
	 */
	public function all() {
		$query_offset   = (int) $this->offset;
		$query_limit    = $this->limit ?: PHP_INT_MAX;
		$running_offset = $query_offset;
		$running_limit  = $query_limit;
		$running_tally  = 0;

		do {
			$this->limit    = min( $this->batch_size, $running_limit );
			$this->offset   = $running_offset;
			$running_limit  -= $this->batch_size;
			$running_offset += $this->batch_size;
			$batch_results  = $this->get();
			$found          = count( $batch_results );
			foreach ( $batch_results as $batch_result ) {
				// Yields with a set key to avoid calls to `iterator_to_array` overriding the values on each pass.
				yield $running_tally ++ => $batch_result;
			}
		} while ( $found === $this->batch_size );
	}

	/**
	 * Bulk updates instances of the Model.
	 *
	 * Since MySQL does not come with a bulk update feature, this code will actually
	 * delete the exising model entries and re-insert them, by primary key, using the
	 * updated data.
	 *
	 * @since 6.0.0
	 * @param array<Model>|array<array<string,mixed>> $models Either a list of Model
	 *                                                        instances to update, or a
	 *                                                        set of models in array format.
	 *
	 * @return int The number of updated rows.
	 */
	public function upsert_set( array $models = [] ) {
		if ( ! count( $models ) ) {
			return 0;
		}

		global $wpdb;
		$table          = $wpdb->prefix . $this->model->table_name();
		$primary_key    = $this->model->primary_key_name();
		$expected_count = count( $models );
		$keys           = wp_list_pluck( $models, $primary_key );

		$deleted = 0;
		do {
			$batch         = array_splice( $keys, 0, $this->batch_size );
			$keys_interval = implode( ',', array_map( 'absint', $batch ) );
			$deleted       += $wpdb->query( "DELETE FROM {$table} WHERE {$primary_key} IN ({$keys_interval})" );
		} while ( count( $keys ) );

		if ( $deleted !== $expected_count ) {
			// There might be legit reasons, like another process running on the same table, but let's log it.
			do_action( 'tribe_log', 'warning', 'Mismatching number of deletions.', [
				'source'      => __CLASS__,
				'slug'        => 'delete-in-upsert-set',
				'table'       => $table,
				'primary_key' => $primary_key,
				'expected'    => $expected_count,
				'deleted'     => $inserted,
			] );
		}

		$updates = $models;
		// Here we make the assumptions the models will not be mixed bag, but either all arrays or all Models.
		if ( ! is_array( reset( $models ) ) ) {
			$updates = array_map( static function ( Model $model ) {
				return $model->to_array();
			}, $models );
		}

		$inserted = $this->insert( $updates );

		if ( $inserted !== $expected_count ) {
			// There might be legit reasons, like another process running on the same table, but let's log it.
			do_action( 'tribe_log', 'warning', 'Mismatching number of insertions.', [
				'source'      => __CLASS__,
				'slug'        => 'delete-in-upsert-set',
				'table'       => $table,
				'primary_key' => $primary_key,
				'expected'    => $expected_count,
				'inserted'    => $inserted,
			] );
		}

		return $inserted;
	}

	/**
	 * Gets the results and plucks a field from each.
	 *
	 * @since 6.0.1
	 *
	 * @param string $field The field to pluck.
	 *
	 * @return array The plucked values.
	 */
	public function pluck( string $field ): array {
		return wp_list_pluck( $this->get(), $field );
	}

	/**
	 * Maps from the results of the query to a new array using the callback.
	 *
	 * @since 6.0.1
	 *
	 * @param callable $callback The callback to use to map the results.
	 *
	 * @return array The mapped results.
	 */
	public function map( callable $callback ): array {
		return array_map( $callback, $this->get() );
	}
}

Top ↑

Changelog

Changelog
Version Description
6.0.0 Introduced.

Top ↑

Methods

  • __construct — Builder constructor.
  • all — Fetches all the matching results for the query.
  • builder_instance — Get an instance to this builder class.
  • class_enable_query_execution — Sets the class-wide queries execution toggle that will enable or disable the execution of queries overriding the per-instance value of the `$execute_queries` flag.
  • count — Execute a COUNT() call against the DB using the provided query elements.
  • delete — Run a delete operation against an existing model if the model has not been persisted on the DB the operation will fail.
  • enable_query_execution — Method to enable query execution or not.
  • exists — Execute an EXISTS() call using the created query as subquery of the EXISTS function.
  • find — Find an instance of the model in the database using a specific value and column if no column is specified the primary key is used.
  • find_all — Finds all the Model instances matching a set of values for a column.
  • first — Limit the results from a query to a single result and return the first instance if available otherwise null.
  • generate_cache_key — Generates a cache key for this particular model instance.
  • get — Select all the rows that match with the query.
  • get_sql — Get all the pieces of the SQL constructed to used against the DB.
  • has_invalid_queries — If this builder class has invalid queries or not.
  • insert — Add operation to insert new records inside of the table that is used from the current model. A single entry can be set here as an array of key => value pairs, where the key is the column being saved and the value is the value intended to be saved.
  • join — Create a join clause with the single builder method.
  • limit — Set the limit for the current Query.
  • map — Maps from the results of the query to a new array using the callback.
  • offset — Set the offset for the current query.
  • order_by — Allow to define the clause for order by on the Query.
  • output — Sets the output format that should be used to format the result(s) of a SELECT Model query.
  • pluck — Gets the results and plucks a field from each.
  • queries — Returns an array of strings with all the SQL queries generated by this builder class.
  • refresh — If an instance already exists refresh the values by querying the same value against the DB.
  • set_batch_size — Sets the size of the batch the Builder should use to fetch models in unbound query methods like `find_all`.
  • update — Perform updates against a model that already exists on the database.
  • upsert — Insert a new row or update one if already exists.
  • upsert_set — Bulk updates instances of the Model.
  • where — Add the available where clauses on the model.
  • where_in — Get an array of models that match with the criteria provided.
  • where_not_in — Append a new "NOT IN()" clause to the builder of where clauses.
  • where_raw — Adds a raw WHERE clause to the SQL statement being built.