diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b5a75..2ad084a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. -## [unreleased] Unreleased +## [3.0.0] 2025-09-24 + +* Feature - Introduces stricter column and indexes definitions. This is NOT a backwards compatible change. Read the migration guide in docs/migrating-from-v2-to-v3.md. + +[3.0.0]: https://github.com/stellarwp/schema/releases/tag/3.0.0 ## [2.0.1] 2025-07-18 @@ -16,7 +20,6 @@ Feature - Bump di52 to 4.0.1 and all other deps. * Tweak - Add @throws tags from the [stellarwp/db](https://github.com/stellarwp/db) library and better generics. - ## [1.1.8] 2025-01-10 * Feature - Introduce truncate method which does what the empty_table method was doing. Update empty_table to actually empty the table instead of truncating it. diff --git a/docs/migrating-from-v2-to-v3.md b/docs/migrating-from-v2-to-v3.md new file mode 100644 index 0000000..b876174 --- /dev/null +++ b/docs/migrating-from-v2-to-v3.md @@ -0,0 +1,35 @@ +# Migrating from V2 to V3 + +## Overview + +Version 3 introduces several important changes: + +- Stricter column and indexes definitions +- Enhanced query methods available through extending `StellarWP\Schema\Tables\Contracts\Table` + +## Migration Steps + +### 1. Update Method Visibility + +For classes extending `StellarWP\Schema\Tables\Contracts\Table`: + +- Convert the `get_definition` method from `protected` to `public` + +If in your implementation you chose to directly implement the renamed interface `StellarWP\Schema\Tables\Contracts\Schema_Interface`, you will need to implement the new interface `StellarWP\Schema\Tables\Contracts\Table_Interface` instead. + +We strongly recommend extending the provided abstract instead. + +### 2. Implement Schema History + +In your table class: + +- Add the static method `get_schema_history` +- Optionally keep or remove the `get_definition` method + +### 3. Define Schema History + +The `get_schema_history` method must: + +- Return an array of callables +- Each callable should return a `StellarWP\Schema\Tables\Contracts\Table_Schema_Interface` object +- Include at least one entry for your current schema version diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d65ae9f..e485880 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -20,7 +20,6 @@ parameters: level: 5 inferPrivatePropertyTypeFromConstructor: true reportUnmatchedIgnoredErrors: false - checkGenericClassInNonGenericObjectType: false # Paths to be analyzed. paths: diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 9f33691..bacd1c5 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -3,10 +3,8 @@ namespace StellarWP\Schema; use StellarWP\Schema\Config; -use StellarWP\Schema\Fields; -use StellarWP\Schema\Fields\Contracts\Schema_Interface as Field_Schema_Interface; use StellarWP\Schema\Tables; -use StellarWP\Schema\Tables\Contracts\Schema_Interface as Table_Schema_Interface; +use StellarWP\Schema\Tables\Contracts\Table_Interface as Table_Schema_Interface; use StellarWP\Schema\Tables\Filters\Group_FilterIterator; use WP_CLI; @@ -37,7 +35,7 @@ public function __construct( $db = null, $container = null ) { } /** - * Whether all the custom tables exist or not. Does not check custom fields. + * Whether all the custom tables exist or not. * * Note: the method will return `false` if even one table is missing. * @@ -47,7 +45,7 @@ public function __construct( $db = null, $container = null ) { * * @param string|null $group An optional group name to restrict the check to. * - * @return bool Whether all custom tables exist or not. Does not check custom fields. + * @return bool Whether all custom tables exist or not. */ public function all_tables_exist( $group = null ) { $table_schemas = $this->get_registered_table_schemas(); @@ -64,7 +62,6 @@ public function all_tables_exist( $group = null ) { $result = $this->db::get_col( 'SHOW TABLES' ); foreach ( $table_schemas as $table_schema ) { if ( ! in_array( $table_schema::table_name(), $result, true ) ) { - return false; } } @@ -108,35 +105,6 @@ public function down() { * @since 1.0.0 */ do_action( 'stellarwp_post_drop_tables' ); - - /** - * Runs before the custom fields are dropped. - * - * @since 1.0.0 - */ - do_action( 'stellarwp_pre_drop_fields' ); - - $field_schemas = $this->get_registered_field_schemas(); - - /** - * Filters the fields to be dropped. - * - * @since 1.0.0 - * - * @param \Iterator $field_classes A list of Field_Schema_Interface objects that will have their fields dropped. - */ - $field_schemas = apply_filters( 'stellarwp_fields_to_drop', $field_schemas ); - - foreach ( $field_schemas as $field_schema ) { - $field_schema->drop(); - } - - /** - * Runs after the custom tables have been dropped by The Events Calendar. - * - * @since 1.0.0 - */ - do_action( 'stellarwp_post_drop_fields' ); } /** @@ -153,17 +121,6 @@ public function empty_custom_tables() { } } - /** - * Get the registered field handlers. - * - * @since 1.0.0 - * - * @return Fields\Collection - */ - public function get_registered_field_schemas(): Fields\Collection { - return $this->container->get( Fields\Collection::class ); - } - /** * Get the md5 hash of all the registered schemas classes with their versions. * diff --git a/src/Schema/Collections/Collection.php b/src/Schema/Collections/Collection.php new file mode 100644 index 0000000..a70a31f --- /dev/null +++ b/src/Schema/Collections/Collection.php @@ -0,0 +1,200 @@ + $value ) { + $this->set( (string) $offset, $value ); + } + } + + /** + * Sets a value in the collection. + * + * @since 3.0.0 + * + * @param string $offset The offset to set. + * @param mixed $value The value to set. + */ + protected function set( string $offset, $value ): void { + $this->resources[ $offset ] = $value; + } + + /** + * @inheritDoc + */ + #[ReturnTypeWillChange] + public function current() { + return current( $this->resources ); + } + + /** + * @inheritDoc + */ + public function key(): ?string { + return (string) key( $this->resources ); + } + + /** + * @inheritDoc + */ + public function next(): void { + next( $this->resources ); + } + + /** + * @inheritDoc + * + * @param string $offset The offset to check. + * + * @return bool + */ + public function offsetExists( $offset ): bool { + return array_key_exists( $offset, $this->resources ); + } + + /** + * @inheritDoc + * + * @param string $offset The offset to get. + * + * @return ?mixed + */ + #[ReturnTypeWillChange] + public function offsetGet( $offset ) { + return $this->resources[ $offset ] ?? null; + } + + /** + * @inheritDoc + * + * @param string $offset The offset to set. + * @param mixed $value The value to set. + */ + public function offsetSet( $offset, $value ): void { + if ( ! $offset ) { + $offset = (string) count( $this->resources ); + } + $this->set( $offset, $value ); + } + + /** + * @inheritDoc + * + * @param string $offset The offset to unset. + */ + public function offsetUnset( $offset ): void { + unset( $this->resources[ $offset ] ); + } + + /** + * @inheritDoc + */ + public function rewind(): void { + reset( $this->resources ); + } + + /** + * @inheritDoc + */ + public function valid(): bool { + return key( $this->resources ) !== null; + } + + /** + * @inheritDoc + */ + public function count(): int { + return count( $this->resources ); + } + + /** + * Returns the collection as an array. + * + * @since 3.0.0 + * + * @return array + */ + public function jsonSerialize(): array { + return $this->resources; + } + + /** + * Maps the collection to an array. + * + * @since 3.0.0 + * + * @param callable $callback The callback to map the collection to an array. + * + * @return self + */ + public function map( callable $callback ): self { + return new static( array_map( $callback, $this->resources ) ); + } + + /** + * Filters the collection. + * + * @since 3.0.0 + * + * @param callable $callback The callback to filter the collection. + * + * @return self + */ + public function filter( callable $callback ): self { + return new static( array_filter( $this->resources, $callback ) ); + } + + /** + * Gets a resource from the collection. + * + * @since 3.0.0 + * + * @param string $key The key to get. + * + * @return ?mixed + */ + #[ReturnTypeWillChange] + public function get( string $key ) { + return $this->offsetGet( $key ); + } +} diff --git a/src/Schema/Collections/Column_Collection.php b/src/Schema/Collections/Column_Collection.php new file mode 100644 index 0000000..13c2b5f --- /dev/null +++ b/src/Schema/Collections/Column_Collection.php @@ -0,0 +1,122 @@ + + */ + protected array $resources = []; + + /** + * Sets a value in the collection. + * + * @since 3.0.0 + * + * @param string $offset The offset to set. + * @param Column $value The value to set. + */ + protected function set( string $offset, $value ): void { + $this->resources[ $offset ] = $value; + } + + /** + * @inheritDoc + */ + public function current(): Column { + return current( $this->resources ); + } + + /** + * @inheritDoc + * + * @param string $offset The offset to get. + * + * @return ?Column + */ + public function offsetGet( $offset ): ?Column { + return $this->resources[ $offset ] ?? null; + } + + /** + * @inheritDoc + * + * @param string $offset The offset to set. + * @param Column $value The value to set. + */ + public function offsetSet( $offset, $value ): void { + if ( ! $offset ) { + $offset = (string) count( $this->resources ); + } + $this->set( $offset, $value ); + } + + /** + * Gets a resource from the collection. + * + * @since 3.0.0 + * + * @param string $key The key to get. + * + * @return ?Column + */ + public function get( string $key ): ?Column { + foreach ( $this->resources as $column ) { + if ( $column->get_name() === $key ) { + return $column; + } + } + + return null; + } + + /** + * Gets the names from the collection. + * + * @since 3.0.0 + * + * @return array + */ + public function get_names(): array { + return array_map( + function ( Column $column ) { + return $column->get_name(); + }, + $this->resources + ); + } + + /** + * Gets the indexes from the collection. + * + * @since 3.0.0 + * + * @return array + */ + public function get_indexes(): array { + return array_filter( $this->resources, fn ( Column $column ) => $column->is_index() ); + } +} diff --git a/src/Schema/Collections/Index_Collection.php b/src/Schema/Collections/Index_Collection.php new file mode 100644 index 0000000..6b515f6 --- /dev/null +++ b/src/Schema/Collections/Index_Collection.php @@ -0,0 +1,88 @@ + + */ + protected array $resources = []; + + /** + * Sets a value in the collection. + * + * @since 3.0.0 + * + * @param string $offset The offset to set. + * @param Index $value The value to set. + */ + protected function set( string $offset, $value ): void { + $this->resources[ $offset ] = $value; + } + + /** + * @inheritDoc + */ + public function current(): Index { + return current( $this->resources ); + } + + /** + * @inheritDoc + * + * @param string $offset The offset to get. + * + * @return ?Index + */ + public function offsetGet( $offset ): ?Index { + return $this->resources[ $offset ] ?? null; + } + + /** + * @inheritDoc + * + * @param string $offset The offset to set. + * @param Index $value The value to set. + */ + public function offsetSet( $offset, $value ): void { + if ( ! $offset ) { + $offset = (string) count( $this->resources ); + } + $this->set( $offset, $value ); + } + + /** + * Gets a resource from the collection. + * + * @since 3.0.0 + * + * @param string $key The key to get. + * + * @return ?Index + */ + public function get( string $key ): ?Index { + return $this->offsetGet( $key ); + } +} diff --git a/src/Schema/Columns/Column_Types.php b/src/Schema/Columns/Column_Types.php new file mode 100644 index 0000000..2280c05 --- /dev/null +++ b/src/Schema/Columns/Column_Types.php @@ -0,0 +1,475 @@ +name = $name; + } + + /** + * Get the type of the column. + * + * @return string The type of the column. + */ + public function get_type(): string { + return $this->type; + } + + /** + * Get the PHP type of the column. + * + * @return string The PHP type of the column. + */ + public function get_php_type(): string { + return $this->php_type; + } + + /** + * Get the name of the column. + * + * @return string The name of the column. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Get the nullable of the column. + * + * @return bool Whether the column can be null. + */ + public function get_nullable(): bool { + return $this->nullable; + } + + /** + * Get the default of the column. + * + * @return mixed The default value of the column. + */ + public function get_default() { + return $this->default; + } + + /** + * Get the searchable of the column. + * + * @return bool Whether the column is searchable. + */ + public function is_searchable(): bool { + return $this->searchable; + } + + /** + * Get the on update value of the column. + * + * @return ?string The on update value of the column. + */ + public function get_on_update(): ?string { + return $this->on_update; + } + + /** + * Set the type of the column. + * + * @param string $type The type of the column. + * + * @return self + * + * @throws InvalidArgumentException If the type is not valid. + */ + public function set_type( string $type ): self { + if ( ! in_array( $type, $this->get_supported_column_types(), true ) ) { + throw new InvalidArgumentException( 'Invalid column type `' . $type . '` for class `' . get_class( $this ) . '`.' ); + } + $this->type = $type; + return $this; + } + + /** + * Set the PHP type of the column. + * + * @param string $php_type The PHP type of the column. + * + * @return self + * + * @throws InvalidArgumentException If the PHP type is not valid. + */ + public function set_php_type( string $php_type ): self { + if ( ! in_array( $php_type, $this->get_supported_php_types(), true ) ) { + throw new InvalidArgumentException( 'Invalid PHP type `' . $php_type . '` for class `' . get_class( $this ) . '`.' ); + } + $this->php_type = $php_type; + return $this; + } + + /** + * Set the name of the column. + * + * @param string $name The name of the column. + * + * @return self + */ + public function set_name( string $name ): self { + $this->name = $name; + return $this; + } + + /** + * Set the default of the column. + * + * @param mixed $default The default value of the column. + * + * @return self + */ + public function set_default( $default ): self { + $this->default = $default; + return $this; + } + + /** + * Set the nullable of the column. + * + * @param bool $nullable Whether the column can be null. + * + * @return self + */ + public function set_nullable( bool $nullable ): self { + $this->nullable = $nullable; + return $this; + } + + /** + * Set the searchable of the column. + * + * @param bool $searchable Whether the column is searchable. + * + * @return self + */ + public function set_searchable( bool $searchable ): self { + $this->searchable = $searchable; + return $this; + } + + /** + * Set the on update value of the column. + * + * @param ?string $on_update The on update value of the column. + * + * @return self + */ + public function set_on_update( ?string $on_update ): self { + $this->on_update = $on_update; + return $this; + } + + /** + * Get the definition of the column. + * + * @return array The definition of the column. + */ + public function get_definition(): array { + $sql = "`{$this->get_name()}` {$this->get_type()}"; + + if ( $this instanceof Lengthable && $this instanceof Precisionable ) { + $sql .= "({$this->get_length()}, {$this->get_precision()})"; + } elseif ( $this instanceof Lengthable ) { + $sql .= "({$this->get_length()})"; + } + + if ( $this instanceof Signable && ! $this->get_signed() ) { + $sql .= ' UNSIGNED'; + } + + $sql .= $this->get_nullable() ? ' NULL' : ' NOT NULL'; + + if ( $this instanceof Auto_Incrementable && $this->get_auto_increment() ) { + $sql .= ' AUTO_INCREMENT'; + } + + if ( $this->get_default() ) { + $default = $this->get_default(); + $sql .= ' DEFAULT ' . ( in_array( $default, self::SQL_RESERVED_DEFAULTS, true ) || in_array( $this->get_type(), [ PHP_Types::INT, PHP_Types::BOOL, PHP_Types::FLOAT ], true ) ? $default : "'{$default}'" ); + } + + if ( $this->get_on_update() ) { + $sql .= ' ON UPDATE ' . $this->get_on_update(); + } + + $index_sql = ''; + + if ( $this->is_index() ) { + if ( $this->is_primary_key() ) { + $index_sql = 'PRIMARY KEY'; + } elseif ( $this->is_unique() ) { + $index_sql = 'UNIQUE KEY `' . $this->get_name() . '`'; + } elseif ( $this->is_index() ) { + $index_sql = 'INDEX `' . $this->get_name() . '`'; + } + $index_sql = "{$index_sql} ({$this->get_name()})"; + } + + return [ $sql, $index_sql ]; + } + + /** + * Get the supported column types. + * + * @return string[] The supported column types. + */ + protected function get_supported_column_types(): array { + return Column_Types::SUPPORTED; + } + + /** + * Get the supported PHP types. + * + * @return string[] The supported PHP types. + */ + protected function get_supported_php_types(): array { + return PHP_Types::SUPPORTED; + } +} diff --git a/src/Schema/Columns/Contracts/Column_Interface.php b/src/Schema/Columns/Contracts/Column_Interface.php new file mode 100644 index 0000000..7a3e8fb --- /dev/null +++ b/src/Schema/Columns/Contracts/Column_Interface.php @@ -0,0 +1,146 @@ +default && $this->get_type() !== Column_Types::TIMESTAMP ) { + throw new InvalidArgumentException( 'CURRENT_TIMESTAMP is not a valid default for a non timestamp column until MySQL 5.6.5. Please use a timestamp column instead.' ); + } + + return $this->default; + } + + /** + * Get the supported column types. + * + * @return string[] The supported column types. + */ + protected function get_supported_column_types(): array { + return Column_Types::SUPPORTED_DATETIME; + } + + /** + * Get the supported PHP types. + * + * @return string[] The supported PHP types. + */ + protected function get_supported_php_types(): array { + return [ + PHP_Types::DATETIME + ]; + } +} diff --git a/src/Schema/Columns/Float_Column.php b/src/Schema/Columns/Float_Column.php new file mode 100644 index 0000000..c3a55f5 --- /dev/null +++ b/src/Schema/Columns/Float_Column.php @@ -0,0 +1,154 @@ +precision; + } + + /** + * Get the signed of the column. + * + * @return bool Whether the column is signed. + */ + public function get_signed(): bool { + return $this->signed; + } + + /** + * Get the length of the column. + * + * @return int The length of the column. + */ + public function get_length(): int { + return $this->length; + } + + /** + * Set the precision of the column. + * + * @param int $precision The precision of the column. + * + * @return self + */ + public function set_precision( int $precision ): self { + $this->precision = $precision; + return $this; + } + + /** + * Set the signed of the column. + * + * @param bool $signed Whether the column is signed. + * + * @return self + */ + public function set_signed( bool $signed ): self { + $this->signed = $signed; + return $this; + } + + /** + * Set the length of the column. + * + * @param int $length The length of the column. + * + * @return self + */ + public function set_length( int $length ): self { + $this->length = $length; + return $this; + } + + /** + * Get the supported column types. + * + * @return string[] The supported column types. + */ + protected function get_supported_column_types(): array { + return Column_Types::SUPPORTED_FLOAT; + } + + /** + * Get the supported PHP types. + * + * @return string[] The supported PHP types. + */ + protected function get_supported_php_types(): array { + return [ + PHP_Types::FLOAT, + ]; + } +} diff --git a/src/Schema/Columns/ID.php b/src/Schema/Columns/ID.php new file mode 100644 index 0000000..d74dee5 --- /dev/null +++ b/src/Schema/Columns/ID.php @@ -0,0 +1,42 @@ +auto_increment; + } + + /** + * Get the signed of the column. + * + * @return bool Whether the column is signed. + */ + public function get_signed(): bool { + return $this->signed; + } + + /** + * Get the length of the column. + * + * @return int The length of the column. + */ + public function get_length(): int { + return $this->length; + } + + /** + * Set the auto increment of the column. + * + * @param bool $auto_increment Whether the column is auto increment. + * + * @return self + */ + public function set_auto_increment( bool $auto_increment ): self { + $this->auto_increment = $auto_increment; + $this->set_is_primary_key( true ); + return $this; + } + + /** + * Set the signed of the column. + * + * @param bool $signed Whether the column is signed. + * + * @return self + */ + public function set_signed( bool $signed ): self { + $this->signed = $signed; + return $this; + } + + /** + * Set the length of the column. + * + * @param int $length The length of the column. + * + * @return self + */ + public function set_length( int $length ): self { + $this->length = $length; + return $this; + } + + /** + * Get the supported column types. + * + * @return string[] The supported column types. + */ + protected function get_supported_column_types(): array { + return Column_Types::SUPPORTED_INTEGER; + } + + /** + * Get the supported PHP types. + * + * @return string[] The supported PHP types. + */ + protected function get_supported_php_types(): array { + return [ + PHP_Types::INT, + PHP_Types::BOOL, + ]; + } +} diff --git a/src/Schema/Columns/Last_Changed.php b/src/Schema/Columns/Last_Changed.php new file mode 100644 index 0000000..e887a97 --- /dev/null +++ b/src/Schema/Columns/Last_Changed.php @@ -0,0 +1,37 @@ +length, ! $this->is_index() ? 255 : 191 ), 1 ); + } + + /** + * Set the length of the column. + * + * @param int $length The length of the column. + * + * @return self + */ + public function set_length( int $length ): self { + $this->length = $length; + return $this; + } + + /** + * Get the supported column types. + * + * @return string[] The supported column types. + */ + protected function get_supported_column_types(): array { + return Column_Types::SUPPORTED_STRING; + } + + /** + * Get the supported PHP types. + * + * @return string[] The supported PHP types. + */ + protected function get_supported_php_types(): array { + return [ + PHP_Types::STRING, + PHP_Types::JSON, + ]; + } +} diff --git a/src/Schema/Columns/Text_Column.php b/src/Schema/Columns/Text_Column.php new file mode 100644 index 0000000..8505707 --- /dev/null +++ b/src/Schema/Columns/Text_Column.php @@ -0,0 +1,81 @@ + - */ - private $groups = []; - - /** - * Collection of fields. - * - * @var array - */ - private $fields = []; - - /** - * Adds a table to the collection. - * - * @since 1.0.0 - * - * @param Schema_Interface $field Field instance. - * - * @return mixed - */ - public function add( Schema_Interface $field ) { - $this->offsetSet( $field::get_schema_slug(), $field ); - - $this->register_group( $field ); - - return $this->offsetGet( $field::get_schema_slug() ); - } - - /** - * @inheritDoc - */ - public function count(): int { - return count( $this->fields ); - } - - /** - * @inheritDoc - */ - #[\ReturnTypeWillChange] - public function current() { - return current( $this->fields ); - } - - /** - * Alias method for offsetGet. - * - * @since 1.0.0 - * - * @param string $key Field slug. - * - * @return Schema_Interface - */ - public function get( string $key ): Schema_Interface { - return $this->offsetGet( $key ); - } - - /** - * Gets fields by table. - * - * @since 1.0.0 - * - * @param array|string $tables Tables to filter fields by. - * @param \Iterator $iterator Optional. Iterator to filter. - * - * @return Filters\Table_FilterIterator - */ - public function get_by_table( $tables, $iterator = null ): Filters\Table_FilterIterator { - return new Filters\Table_FilterIterator( $tables, $iterator ?: $this ); - } - - /** - * @inheritDoc - */ - public function key(): string { - return key( $this->fields ); - } - - /** - * @inheritDoc - */ - public function next(): void { - next( $this->fields ); - } - - /** - * @inheritDoc - */ - public function offsetExists( $offset ): bool { - return isset( $this->fields[ $offset ] ); - } - - /** - * @inheritDoc - */ - #[\ReturnTypeWillChange] - public function offsetGet( $offset ) { - return $this->fields[ $offset ]; - } - - /** - * @inheritDoc - */ - #[\ReturnTypeWillChange] - public function offsetSet( $offset, $value ): void { - $this->fields[ $offset ] = $value; - } - - /** - * @inheritDoc - */ - #[\ReturnTypeWillChange] - public function offsetUnset( $offset ): void { - unset( $this->fields[ $offset ] ); - } - - /** - * Registers a group in the group array for the given table. - * - * @param Schema_Interface $field Field instance. - */ - private function register_group( $field ) { - $group = $field->group_name(); - - if ( ! isset( $this->groups[ $group ] ) ) { - $this->groups[ $group ] = $group; - } - } - - /** - * Helper function for removing a table from the collection. - * - * @since 1.0.0 - * - * @param string $name Table name. - */ - public function remove( $name ): void { - $this->offsetUnset( $name ); - } - - /** - * @inheritDoc - */ - public function rewind(): void { - reset( $this->fields ); - } - - /** - * Sets a table in the collection. - * - * @since 1.0.0 - * - * @param string $name Field name. - * @param Schema_Interface $field Field instance. - * - * @return mixed - */ - public function set( $name, Schema_Interface $field ) { - $this->offsetSet( $name, $field ); - - $this->register_group( $field ); - - return $this->offsetGet( $name ); - } - - /** - * @inheritDoc - */ - public function valid(): bool { - return key( $this->fields ) !== null; - } -} diff --git a/src/Schema/Fields/Contracts/Field.php b/src/Schema/Fields/Contracts/Field.php deleted file mode 100644 index 6a2aa41..0000000 --- a/src/Schema/Fields/Contracts/Field.php +++ /dev/null @@ -1,279 +0,0 @@ - The db class. - */ - protected $db; - - /** - * @since 1.0.0 - * - * @var string The slug used to identify the custom field alterations. - */ - protected static $schema_slug = ''; - - /** - * @since 1.0.0 - * - * @var array Custom fields defined in this field schema. - */ - protected $fields = []; - - /** - * @var string The organizational group this field set belongs to. - */ - protected static $group = ''; - - /** - * Constructor. - * - * @since 1.0.0 - * - * @param class-string<\StellarWP\DB\DB>|null $db StellarWP\DB object. - * @param object $container The container to use. - */ - public function __construct( $db = null, $container = null ) { - $this->db = $db ?: Config::get_db(); - $this->container = $container ?: Config::get_container(); - } - - /** - * {@inheritdoc} - */ - public function after_update( array $results ) { - // No-op by default. - return $results; - } - - /** - * Gets the base table name. - * - * @since 1.0.0 - * - * @return string - */ - public static function base_table_name() { - return static::$base_table_name; - } - - /** - * {@inheritdoc} - */ - public function drop() { - if ( ! $this->exists() ) { - return false; - } - - $schema_slug = static::get_schema_slug(); - - /** - * Allows for the enabling of field removal. - * - * Defaults to false. - * - * @since 1.0.0 - * - * @param bool $can_drop_field Whether or not the field schema can be dropped. - * @param string $schema_slug The slug of the field schema. - */ - $can_drop_field = apply_filters( "stellarwp_drop_field_enabled_{$schema_slug}", false, $schema_slug ); - - /** - * Allows for the enabling of field removal. - * - * Defaults to false. - * - * @since 1.0.0 - * - * @param bool $can_drop_field Whether or not the field schema can be dropped. - * @param string $schema_slug The slug of the field schema. - */ - $can_drop_field = apply_filters( "stellarwp_drop_field_enabled", $can_drop_field, $schema_slug ); - - if ( ! $can_drop_field ) { - return false; - } - - /** - * Runs before the custom field is dropped. - * - * @since 1.0.0 - * - * @param string $schema_slug The schema slug. - * @param Schema_Interface $field_schema The field schema to be dropped. - */ - do_action( 'stellarwp_pre_drop_field', $schema_slug, $this ); - - $this_table = $this->table_schema()::table_name( true ); - $drop_columns = 'DROP COLUMN `' . implode( '`, DROP COLUMN `', $this->fields() ) . '`'; - - $results = $this->db::query( sprintf( "ALTER TABLE %s %s", $this_table, $drop_columns ) ); - - /** - * Runs after the custom field has been dropped. - * - * @since 1.0.0 - * - * @param string $schema_slug The schema slug. - * @param Schema_Interface $field_schema The field schema to be dropped. - */ - do_action( 'stellarwp_post_drop_field', $schema_slug, $this ); - - $this->table_schema()->sync_stored_version(); - - /** - * Runs after the custom field's table schema's version has been synchronized. - * - * @since 1.0.0 - * - * @param string $schema_slug The schema slug. - * @param Schema_Interface $field_schema The field schema to be dropped. - */ - do_action( 'stellarwp_post_drop_field_table_version_sync', $schema_slug, $this ); - - return $results; - } - - /** - * Returns whether a fields' schema definition exists in the table or not. - * - * @since 1.0.0 - * - * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. - * - * @return bool Whether a set of fields exists in the database or not. - */ - public function exists() { - $table_schema = $this->table_schema(); - - if ( $table_schema === null ) { - return false; - } - - $table_name = $table_schema::table_name( true ); - - $rows = $this->db::table( $this->db::raw( 'information_schema.statistics' ) ) - ->select( 'column_name' ) - ->whereRaw( 'WHERE TABLE_SCHEMA = DATABASE()' ) - ->where( 'TABLE_NAME', $table_name ) - ->getAll(); - - $fields = $this->fields(); - $rows = array_map( function ( $row ) { - return $row->column_name; - }, $rows ); - - foreach ( $fields as $field ) { - if ( ! in_array( $field, $rows, true ) ) { - - return false; - } - } - - return true; - } - - /** - * Fields being added to the table. - * - * @since 1.0.0 - * - * @return array - */ - public function fields() { - return (array) $this->fields; - } - - /** - * The base table name of the schema. - * - * @since 1.0.0 - */ - public static function get_schema_slug() { - return static::$schema_slug; - } - - /** - * {@inheritdoc} - */ - public function get_sql() { - return $this->get_definition(); - } - - /** - * {@inheritdoc} - */ - abstract protected function get_definition(); - - /** - * Gets the field schema's version. - * - * @since 1.0.0 - * - * @return string - */ - public function get_version(): string { - return static::get_schema_slug() . '-' . static::SCHEMA_VERSION; - } - - /** - * {@inheritdoc} - */ - public static function group_name() { - return static::$group; - } - - /** - * {@inheritdoc} - */ - public function table_schema() { - $tables = Schema::tables(); - $base_table_name = static::base_table_name(); - - if ( ! isset( $tables[ $base_table_name ] ) ) { - return null; - } - - return $tables->offsetGet( $base_table_name ); - } -} diff --git a/src/Schema/Fields/Contracts/Schema_Interface.php b/src/Schema/Fields/Contracts/Schema_Interface.php deleted file mode 100644 index 21bc1ea..0000000 --- a/src/Schema/Fields/Contracts/Schema_Interface.php +++ /dev/null @@ -1,83 +0,0 @@ - $results A map of results in the format - * returned by the `dbDelta` function. - * - * @return array A map of results in the format returned by - * the `dbDelta` function. - */ - public function after_update( array $results ); - - /** - * Drop the custom fields. - * - * @since 1.0.0 - * - * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. - * - * @return bool `true` if successful operation, `false` to indicate a failure. - */ - public function drop(); - - /** - * Gets the custom slug identifier that should identify this field schema. - * - * @since 1.0.0 - * - * @return string - */ - public static function get_schema_slug(); - - /** - * Returns the SQL to be injected into CREATE TABLE statement for the fields and indexes being created in the format supported - * by the `dbDelta` function. - * - * @since 1.0.0 - * - * @return string The table creation SQL for the fields and indexes being created, in the format supported - * by the `dbDelta` function. - */ - public function get_sql(); - - /** - * The organizational group this field schema belongs to. - * - * @since 1.0.0 - * - * @return string - */ - public static function group_name(); - - /** - * A reference to the table definition we are modifying with new fields. - * - * @since 1.0.0 - * - * @return Table|null - */ - public function table_schema(); -} diff --git a/src/Schema/Fields/Filters/Table_FilterIterator.php b/src/Schema/Fields/Filters/Table_FilterIterator.php deleted file mode 100644 index 2b3bb21..0000000 --- a/src/Schema/Fields/Filters/Table_FilterIterator.php +++ /dev/null @@ -1,44 +0,0 @@ - - */ - private $tables = []; - - /** - * Constructor. - * - * @since 1.0.0 - * - * @param string|array $tables Tables to filter. - * @param \Iterator $iterator Iterator to filter. - */ - public function __construct( $tables, \Iterator $iterator ) { - parent::__construct( $iterator ); - - $this->tables = (array) $tables; - } - - /** - * @inheritDoc - */ - public function accept(): bool { - $field = $this->getInnerIterator()->current(); - - return in_array( $field->base_table_name(), $this->tables, true ); - } - - /** - * @inheritDoc - */ - public function count(): int { - return iterator_count( $this->getInnerIterator() ); - } -} diff --git a/src/Schema/Indexes/Classic_Index.php b/src/Schema/Indexes/Classic_Index.php new file mode 100644 index 0000000..c4237c5 --- /dev/null +++ b/src/Schema/Indexes/Classic_Index.php @@ -0,0 +1,30 @@ +name = $name; + } + + /** + * Get the name of the index. + * + * @return string The name of the index. + */ + public function get_name(): string { + return self::TYPE_PRIMARY === $this->get_type() ? '' : $this->name; + } + + /** + * Set the name of the index. + * + * Name is optional for primary indexes, required for other indexes. + * + * @param string $name The name of the index. + * + * @return self + */ + public function set_name( string $name ): self { + $this->name = $name; + return $this; + } + + /** + * Get the columns of the index. + * + * @return string[] The columns of the index. + */ + public function get_columns(): array { + /** @var array */ + return empty( $this->columns ) ? [ $this->get_name() ] : $this->columns; + } + + /** + * Set the columns of the index. + * + * @param string ...$columns The columns of the index. + * + * @return self + */ + public function set_columns( string ...$columns ): self { + /** @var array $columns */ + $this->columns = $columns; + return $this; + } + + /** + * Get the type of the index. + * + * @return string The type of the index. + */ + public function get_type(): string { + return $this->type; + } + + /** + * Set the table name of the index. + * + * @param string $table_name The table name of the index. + * + * @return self + */ + public function set_table_name( string $table_name ): self { + $this->table_name = $table_name; + return $this; + } + + /** + * Get the table name of the index. + * + * @return string The table name of the index. + */ + public function get_table_name(): string { + return $this->table_name; + } + + /** + * Get the definition of the index. + * + * @return string The definition of the index. + */ + public function get_alter_table_with_index_definition(): string { + $type = $this->get_type(); + $name = esc_sql( $this->get_name() ); + switch ( $type ) { + case self::TYPE_INDEX: + $key = 'INDEX ' . $name; + break; + case self::TYPE_UNIQUE: + $key = 'UNIQUE KEY ' . $name; + break; + case self::TYPE_PRIMARY: + $key = 'PRIMARY KEY'; + break; + case self::TYPE_FULLTEXT: + $key = 'FULLTEXT INDEX ' . $name; + break; + default: + throw new InvalidArgumentException( "Invalid index type: {$type}" ); + } + + /** @var array $esc_columns */ + $esc_columns = array_map( 'esc_sql', $this->get_columns() ); + + $columns = implode( ', ', $esc_columns ); + + $table_name = esc_sql( $this->get_table_name() ); + + return "ALTER TABLE `{$table_name}` ADD {$key} ({$columns})"; + } +} diff --git a/src/Schema/Indexes/Contracts/Index.php b/src/Schema/Indexes/Contracts/Index.php new file mode 100644 index 0000000..e2825fe --- /dev/null +++ b/src/Schema/Indexes/Contracts/Index.php @@ -0,0 +1,117 @@ +add( $field ); - - // If we've already executed plugins_loaded, automatically add the field. - if ( did_action( 'plugins_loaded' ) ) { - $container->get( Builder::class )->up(); - } - - return $field; - } - - /** - * Register multiple field schemas. - * - * @since 1.0.0 - * - * @param array $fields Fields to register. - * - * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. - * - * @return Fields\Collection - */ - public static function fields( array $fields ) { - foreach ( $fields as $field ) { - static::field( $field ); - } - - return Schema::fields(); - } - - /** - * Removes a field from the register. - * - * @since 1.0.0 - * - * @param string|Fields\Contracts\Schema_Interface $field Field Schema class. - * - * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. - * - * @return Fields\Contracts\Schema_Interface - */ - public static function remove_field( $field ) { - Schema::init(); - - if ( is_string( $field ) ) { - $field = new $field(); - } - - // If we've already executed plugins_loaded, automatically remove the field. - if ( did_action( 'plugins_loaded' ) ) { - $field->drop(); - } - - Schema::fields()->remove( $field::get_schema_slug() ); - - return $field; - } - /** * Removes a table from the register. * * @since 1.0.0 * - * @param string|Tables\Contracts\Schema_Interface $table Table Schema class. + * @param string|Table_Interface $table Table Schema class. * * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. * - * @return Tables\Contracts\Schema_Interface + * @return Table_Interface */ - public static function remove_table( $table ) { + public static function remove_table( $table ): Table_Interface { Schema::init(); if ( is_string( $table ) ) { @@ -126,9 +49,9 @@ public static function remove_table( $table ) { * * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. * - * @return Tables\Contracts\Schema_Interface + * @return Table_Interface */ - public static function table( $table ) { + public static function table( $table ): Table_Interface { Schema::init(); if ( is_string( $table ) ) { @@ -156,9 +79,9 @@ public static function table( $table ) { * * @throws \StellarWP\DB\Database\Exceptions\DatabaseQueryException If the query fails. * - * @return Tables\Collection + * @return Collection */ - public static function tables( array $tables ) { + public static function tables( array $tables ): Collection { foreach ( $tables as $table ) { static::table( $table ); } diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index 45c2d0d..9243314 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -5,7 +5,7 @@ use StellarWP\Schema\Config; class Schema { - const VERSION = '1.1.0'; + const VERSION = '3.0.0'; /** * Container object. @@ -27,19 +27,6 @@ public static function builder() { return Config::get_container()->get( Builder::class ); } - /** - * Gets the field collection. - * - * @since 1.0.0 - * - * @return Fields\Collection - */ - public static function fields() { - static::init(); - - return Config::get_container()->get( Fields\Collection::class ); - } - /** * Initializes the service provider. * @@ -81,7 +68,6 @@ public function __construct( $container = null ) { public function register() { $this->container->singleton( static::class, $this ); $this->container->singleton( Builder::class ); - $this->container->singleton( Fields\Collection::class ); $this->container->singleton( Tables\Collection::class ); /** diff --git a/src/Schema/Tables/Collection.php b/src/Schema/Tables/Collection.php index 1f04fea..87d2310 100644 --- a/src/Schema/Tables/Collection.php +++ b/src/Schema/Tables/Collection.php @@ -1,10 +1,27 @@ offsetSet( $table::base_table_name(), $table ); $this->register_group( $table ); @@ -58,9 +75,9 @@ public function current() { * * @param string $key Table base name. * - * @return Schema_Interface + * @return Table_Interface */ - public function get( string $key ): Schema_Interface { + public function get( string $key ): Table_Interface { return $this->offsetGet( $key ); } @@ -137,7 +154,7 @@ public function offsetUnset( $offset ): void { /** * Registers a group in the group array for the given table. * - * @param Schema_Interface $table Table instance. + * @param Table_Interface $table Table instance. */ private function register_group( $table ) { $group = $table->group_name(); @@ -171,11 +188,11 @@ public function rewind(): void { * @since 1.0.0 * * @param string $name Table name. - * @param Schema_Interface $table Table instance. + * @param Table_Interface $table Table instance. * * @return mixed */ - public function set( $name, Schema_Interface $table ) { + public function set( $name, Table_Interface $table ) { $this->offsetSet( $name, $table ); $this->register_group( $table ); diff --git a/src/Schema/Tables/Contracts/Table.php b/src/Schema/Tables/Contracts/Table.php index 3ade812..99185ca 100644 --- a/src/Schema/Tables/Contracts/Table.php +++ b/src/Schema/Tables/Contracts/Table.php @@ -1,12 +1,51 @@ > get_all( int $batch_size = 50, string $where_clause = '', string $order_by = '' ) + * @method static bool|int insert( array $entry ) + * @method static bool update_single( array $entry ) + * @method static bool upsert( array $entry ) + * @method static bool|int insert_many( array $entries ) + * @method static bool delete( int $uid, string $column = '' ) + * @method static bool|int delete_many( array $ids, string $column = '', string $more_where = '' ) + * @method static int get_total_items( array $args = [] ) + * @method static bool update_many( array $entries ) + * @method static array paginate( array $args, int $per_page = 20, int $page = 1, array $columns = [ '*' ], string $join_table = '', string $join_condition = '', array $selectable_joined_columns = [], string $output = 'OBJECT' ) + * @method static mixed[] get_all_by( string $column, $value, string $operator = '=', int $limit = 50 ) + * @method static ?mixed get_first_by( string $column, $value ) + * @method static ?mixed get_by_id( $id ) + * @method static array operators() + * @method static mixed cast_value_based_on_type( string $type, $value ) + */ +abstract class Table implements Table_Interface { + use Custom_Table_Query_Methods; -abstract class Table implements Schema_Interface { /** * @var string|null The version number for this schema definition. */ @@ -27,11 +66,6 @@ abstract class Table implements Schema_Interface { */ protected $container; - /** - * @var \Iterator The filtered field collection that applies to this table. - */ - protected $field_schemas = null; - /** * @var string The organizational group this table belongs to. */ @@ -44,11 +78,6 @@ abstract class Table implements Schema_Interface { */ protected static $schema_slug; - /** - * @var string The field that uniquely identifies a row in the table. - */ - protected static $uid_column = ''; - /** * Ordered collection of table update methods. * @@ -72,10 +101,9 @@ public function __construct( $db = null, $container = null ) { } /** - * Allows extending classes that require it to run some methods - * immediately after the table creation or update. + * Add indexes after table creation. * - * @since 1.0.0 + * @since 3.0.0 * * @param array $results A map of results in the format * returned by the `dbDelta` function. @@ -84,10 +112,87 @@ public function __construct( $db = null, $container = null ) { * the `dbDelta` function. */ protected function after_update( array $results ) { - // No-op by default. + $indexes = static::get_current_schema()->get_indexes(); + if ( ! $indexes ) { + return $results; + } + + foreach ( $indexes as $index ) { + $this->check_and_add_index( $index ); + } + return $results; } + /** + * An array of all the columns in the table. + * + * @since 3.0.0 + * + * @return Column_Collection The columns of the table. + */ + public static function get_columns(): Column_Collection { + return static::get_current_schema()->get_columns(); + } + + /** + * An array of all the columns that are searchable. + * + * @since 3.0.0 + * + * @return Column_Collection The searchable columns of the table. + */ + public static function get_searchable_columns(): Column_Collection { + /** @var Column_Collection */ + return static::get_columns()->filter( fn ( Column $column ) => $column->is_searchable() ); + } + + /** + * Gets the current schema for the table. + * + * @since 3.0.0 + * + * @return Table_Schema_Interface The current schema for the table. + * + * @throws RuntimeException If the current schema version is not found in the schema history. + */ + public static function get_current_schema(): Table_Schema_Interface { + static $current_schema = null; + + if ( null !== $current_schema ) { + return $current_schema; + } + + $history = static::get_schema_history(); + + if ( empty( $history[ static::SCHEMA_VERSION ] ) ) { + throw new RuntimeException( 'The current schema version is not found in the schema history.' ); + } + + $current_schema = $history[ static::SCHEMA_VERSION ](); + + return $current_schema; + } + + /** + * Helper method to check and add an index to a table. + * + * @since 3.0.0 + * + * @param Index $index The index. + * + * @return void + */ + protected function check_and_add_index( Index $index ): void { + $index_name = esc_sql( $index->get_name() ); + + if ( $this->has_index( $index_name ) ) { + return; + } + + $this->db::query( $index->get_alter_table_with_index_definition() ); + } + /** * Archives the current stored version of the schema. */ @@ -154,13 +259,13 @@ public function drop() { $this_table = static::table_name( true ); /** - * Runs before the custom field is dropped. + * Runs before the custom table is dropped. * * @since 1.0.0 * - * @param string $base_table_name The base table name. - * @param string $table_name The full table name. - * @param Schema_Interface $table_schema The table schema to be dropped. + * @param string $base_table_name The base table name. + * @param string $table_name The full table name. + * @param Table_Interface $table_schema The table schema to be dropped. */ do_action( 'stellarwp_pre_drop_table', $base_table_name, $this_table, $this ); @@ -183,9 +288,9 @@ public function drop() { * * @since 1.0.0 * - * @param string $base_table_name The base table name. - * @param string $table_name The full table name. - * @param Schema_Interface $table_schema The table schema to be dropped. + * @param string $base_table_name The base table name. + * @param string $table_name The full table name. + * @param Table_Interface $table_schema The table schema to be dropped. */ do_action( 'stellarwp_post_drop_table', $base_table_name, $this_table, $this ); @@ -200,9 +305,9 @@ public function drop() { * * @since 1.0.0 * - * @param string $base_table_name The base table name. - * @param string $table_name The full table name. - * @param Schema_Interface $table_schema The table schema to be dropped. + * @param string $base_table_name The base table name. + * @param string $table_name The full table name. + * @param Table_Interface $table_schema The table schema to be dropped. */ do_action( 'stellarwp_post_drop_table_wpdb_update', $base_table_name, $this_table, $this ); @@ -272,23 +377,6 @@ public function exists() { return count( $this->db::get_col( $this->db::prepare( 'SHOW TABLES LIKE %s', $table_name ) ) ) === 1; } - /** - * Gets the defined fields schemas for the table. - * - * @since 1.0.0 - * - * @param bool $force Force a refresh of the field collection. - * - * @return \Iterator - */ - public function get_field_schemas( bool $force = false ) { - if ( $this->field_schemas === null || $force ) { - $this->field_schemas = $this->container->get( Fields\Collection::class )->get_by_table( static::base_table_name() ); - } - - return $this->field_schemas; - } - /** * {@inheritdoc} */ @@ -322,15 +410,7 @@ public function get_schema_version_option(): string { * {@inheritdoc} */ public function get_sql() { - $sql = $this->get_definition(); - - $field_schemas = $this->get_field_schemas(); - - foreach ( $field_schemas as $field_schema ) { - $sql = $this->inject_field_schema( $field_schema, $sql ); - } - - return $sql; + return $this->get_definition(); } /** @@ -359,25 +439,45 @@ public function get_stored_version() { * Returns the table creation SQL in the format supported * by the `dbDelta` function. * - * @since 1.0.0 + * @since 3.0.0 * * @return string The table creation SQL, in the format supported * by the `dbDelta` function. */ - abstract protected function get_definition(); + public function get_definition(): string { + global $wpdb; + $table_name = static::table_name( true ); + $charset_collate = $wpdb->get_charset_collate(); + + $columns = static::get_columns(); + + $columns_definitions = []; + $indexes_definitions = []; + foreach ( $columns as $column ) { + [ $column_definition, $index_definition ] = $column->get_definition(); + $columns_definitions[] = $column_definition; + $indexes_definitions[] = $index_definition; + } + + $indexes_definitions = array_filter( $indexes_definitions ); + + $indexes_sql = ! empty( $indexes_definitions ) ? implode( ',' . PHP_EOL, $indexes_definitions ) : ''; + $columns_sql = implode( ',' . PHP_EOL, $columns_definitions ); + + $columns_sql = $indexes_sql ? $columns_sql . ',' . PHP_EOL : $columns_sql; + + return " + CREATE TABLE `{$table_name}` ( + {$columns_sql}{$indexes_sql} + ) {$charset_collate}; + "; + } /** * {@inheritdoc} */ public function get_version(): string { - $field_versions = []; - $schema_fields = $this->get_field_schemas( true ); - - foreach ( $schema_fields as $field ) { - $field_versions[] = $field->get_version(); - } - - return static::SCHEMA_VERSION . ( $field_versions ? '-' . md5( implode( ':', $field_versions ) ) : '' ); + return static::SCHEMA_VERSION ; } /** @@ -403,6 +503,8 @@ public static function group_name() { public function has_index( $index, $table_name = null ) { $table_name = $table_name ?: static::table_name( true ); + $index = $index ?: 'PRIMARY'; + $count = $this->db::table( $this->db::raw( 'information_schema.statistics' ) ) ->whereRaw( 'WHERE TABLE_SCHEMA = DATABASE()' ) ->where( 'TABLE_NAME', $table_name ) @@ -412,58 +514,6 @@ public function has_index( $index, $table_name = null ) { return $count >= 1; } - /** - * Inject field schema definitions into the CREATE TABLE SQL. - * - * @since 1.0.0 - * - * @param Field_Schema_Interface $field_schema The field schema to inject. - * @param string $sql The CREATE TABLE SQL to inject into. - * - * @return string - */ - protected function inject_field_schema( Field_Schema_Interface $field_schema, $sql ): string { - $fields = trim( $field_schema->get_sql() ); - - // Inject any extra fields into the table's definition. - // phpcs:disable Squiz.Strings.ConcatenationSpacing.PaddingFound -- don't remove regex indentation - $find_first_index_regex = - '/' - . '(,' // 1) Final comma before indexes. - . '(\s*)' // 2) Capture whitespace before indexes. - . '(?=' // Followed by the indexes. - . '(?:' - . 'PRIMARY\s+KEY|(?:UNIQUE|FULLTEXT|SPACIAL)\s+(?:KEY|INDEX)|KEY|INDEX' - . ')' - . ')' - . '[^\n]+' // Followed by indice columns and names. - . '(?!' // Not followed by another index. - . '(?:' - . 'PRIMARY\s+KEY|(?:UNIQUE|FULLTEXT|SPACIAL)\s+(?:KEY|INDEX)|KEY|INDEX' - . ')' - . ')' - . ')' - .'/im'; // Case insensitive and multi-line. - - if ( preg_match( $find_first_index_regex, $sql ) ) { - // Inject additional fields before the indexes. - $sql = preg_replace( - $find_first_index_regex, - ",$2{$fields}$1", // $2 is the captured whitespace. $1 is the whitespace PLUS the first index after the last field. - $sql - ); - } else { - // Inject additional fields before the closing parenthesis of the CREATE TABLE statement. - $sql = preg_replace( - '/(? The primary columns for the table. + */ + public static function primary_columns(): array { + return static::get_current_schema()->get_primary_key()->get_columns(); } /** @@ -517,53 +579,83 @@ public function update() { // @phpstan-ignore-next-line require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - $sql = $this->get_sql(); - $field_schemas = $this->get_field_schemas(); - - /** - * Hookable action before the table schema has updated. - * - * @since 1.1.0 - * - * @param string $table_name The prefix-less table name. - * @param Table $table The table object. - * @param \Iterator $field_schemas An iterable collection of field schemas associated with this table. - */ - do_action( 'stellarwp_schema_table_before_updete', static::table_name(), $this, $field_schemas ); - - $results = (array) $this->db::delta( $sql ); - $this->archive_previous_version(); - $this->sync_stored_version(); - $results = $this->after_update( $results ); - - /** - * Hookable action before the field schemas have updated. - * - * @since 1.1.0 - * - * @param string $table_name The prefix-less table name. - * @param array $results The results of the schema update. - * @param Table $table The table object. - * @param \Iterator $field_schemas An iterable collection of field schemas associated with this table. - */ - do_action( 'stellarwp_schema_table_before_field_schema_updete', static::table_name(), $results, $this, $field_schemas ); - - foreach ( $field_schemas as $field_schema ) { - $results[] = $field_schema->after_update( $results ); + $sql = $this->get_sql(); + + $results = []; + + try { + /** + * Hookable action before the table schema has updated. + * + * @since 3.0.0 + * + * @param string $table_name The prefix-less table name. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_before_update_' . static::get_schema_slug(), static::table_name(), $this ); + + /** + * Hookable action before the table schema has updated. + * + * @since 3.0.0 + * + * @param string $table_name The prefix-less table name. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_before_update', static::table_name(), $this ); + + $results = (array) $this->db::delta( $sql ); + $this->archive_previous_version(); + $this->sync_stored_version(); + $results = $this->after_update( $results ); + + /** + * Hookable action after the table schema has updated. + * + * @since 3.0.0 + * + * @param string $table_name The prefix-less table name. + * @param array $results The results of the table schema updates. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_after_update_' . static::get_schema_slug(), static::table_name(), $results, $this ); + + /** + * Hookable action after the table schema has updated. + * + * @since 3.0.0 + * + * @param string $table_name The prefix-less table name. + * @param array $results The results of the table schema updates. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_after_update', static::table_name(), $results, $this ); + } catch ( Exception $e ) { + if ( ! has_action( 'stellarwp_schema_table_update_error_' . static::get_schema_slug() ) && ! has_action( 'stellarwp_schema_table_update_error' ) ) { + throw $e; + } + + /** + * Hookable action after the table schema has failed to update. + * + * @since 3.0.0 + * + * @param Exception $e The exception. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_update_error_' . static::get_schema_slug(), $e, $this ); + + /** + * Hookable action after the table schema has failed to update. + * + * @since 3.0.0 + * + * @param Exception $e The exception. + * @param Table $table The table object. + */ + do_action( 'stellarwp_schema_table_update_error', $e, $this ); } - /** - * Hookable action after the table schema has updated. - * - * @since 1.1.0 - * - * @param string $table_name The prefix-less table name. - * @param array $results The results of the table and field schema updates. - * @param Table $table The table object. - * @param \Iterator $field_schemas An iterable collection of field schemas associated with this table. - */ - do_action( 'stellarwp_schema_table_after_updete', static::table_name(), $results, $this, $field_schemas ); - return $results; } @@ -588,4 +680,11 @@ public function has_foreign_key( string $foreign_key, string $table_name = '' ): return $count >= 1; } + + /** + * {@inheritdoc} + */ + public static function transform_from_array( array $result_array ) { + return $result_array; + } } diff --git a/src/Schema/Tables/Contracts/Schema_Interface.php b/src/Schema/Tables/Contracts/Table_Interface.php similarity index 68% rename from src/Schema/Tables/Contracts/Schema_Interface.php rename to src/Schema/Tables/Contracts/Table_Interface.php index d4abf49..96da3c6 100644 --- a/src/Schema/Tables/Contracts/Schema_Interface.php +++ b/src/Schema/Tables/Contracts/Table_Interface.php @@ -8,6 +8,8 @@ */ namespace StellarWP\Schema\Tables\Contracts; +use StellarWP\Schema\Collections\Column_Collection; +use ReturnTypeWillChange; /** * Interface Schema_Interface @@ -16,7 +18,7 @@ * * @package StellarWP\Schema\Tables\Contracts */ -interface Schema_Interface { +interface Table_Interface { /** * Returns the custom table name. * @@ -26,6 +28,63 @@ interface Schema_Interface { */ public static function base_table_name(); + /** + * Gets the definition of the table. + * + * @since 3.0.0 + * + * @return string The definition of the table. + */ + public function get_definition(): string; + + /** + * Gets the columns of the table. + * + * @since 3.0.0 + * + * @return Column_Collection The columns of the table. + */ + public static function get_columns(): Column_Collection; + + /** + * Gets the searchable columns of the table. + * + * @since 3.0.0 + * + * @return Column_Collection The searchable columns of the table. + */ + public static function get_searchable_columns(): Column_Collection; + + /** + * Gets the schema history for the table. + * + * @since 3.0.0 + * + * @return array The schema history for the table. The key is the version and the value is a callable that returns a Table_Schema_Interface object. + */ + public static function get_schema_history(): array; + + /** + * Gets the schema of the table. + * + * @since 3.0.0 + * + * @return Table_Schema_Interface The schema of the table. + */ + public static function get_current_schema(): Table_Schema_Interface; + + /** + * Transforms a result array into a model. + * + * @since 3.0.0 + * + * @param array $result_array The result array. + * + * @return mixed The model. + */ + #[ReturnTypeWillChange] + public static function transform_from_array( array $result_array ); + /** * Drop the custom table. * diff --git a/src/Schema/Tables/Contracts/Table_Schema_Interface.php b/src/Schema/Tables/Contracts/Table_Schema_Interface.php new file mode 100644 index 0000000..c524b65 --- /dev/null +++ b/src/Schema/Tables/Contracts/Table_Schema_Interface.php @@ -0,0 +1,53 @@ +table_name = $table_name; + $this->columns = $columns; + + $filtered_indexes = $indexes ? $indexes->map( + function( Index $index ): Index { + $index->set_table_name( $this->get_table_name() ); + return $index; + } + ) : null; + + /** @var ?Index_Collection $filtered_indexes */ + $this->indexes = $filtered_indexes; + + $this->validate_columns(); + $this->validate_indexes(); + } + + /** + * Gets the name of the table. + * + * @return string The name of the table. + */ + public function get_table_name(): string { + return $this->table_name; + } + + /** + * Gets the columns of the table. + * + * @return Column_Collection The columns of the table. + */ + public function get_columns(): Column_Collection { + return $this->columns; + } + + /** + * Gets the indexes of the table. + * + * @return Index_Collection The indexes of the table. + */ + public function get_indexes(): ?Index_Collection { + return $this->indexes; + } + + public function get_primary_key(): ?Primary_Key { + return $this->primary_key; + } + + /** + * Validates the columns of the table. + * + * @return void + */ + protected function validate_columns(): void { + $columns = $this->get_columns(); + + $column_names = []; + + foreach ( $columns as $column ) { + if ( isset( $column_names[ $column->get_name() ] ) ) { + throw new RuntimeException( 'Column already exists.' ); + } + + $column_names[ $column->get_name() ] = $column->get_name(); + } + } + + /** + * Validates the indexes of the table. + * + * @return void + */ + protected function validate_indexes(): void { + $index_columns = $this->get_columns()->get_indexes(); + + $indexes = []; + $indexed_columns = []; + + foreach ( $index_columns as $index_column ) { + if ( $index_column->is_primary_key() ) { + if ( null !== $this->primary_key ) { + throw new RuntimeException( 'Primary key already set. Only one primary key per table is allowed.' ); + } + + /** @var Primary_Key $primary_key */ + $primary_key = ( new Primary_Key( $index_column->get_name() ) )->set_columns( $index_column->get_name() )->set_table_name( $this->get_table_name() ); + + $this->primary_key = $primary_key; + $indexes[ $index_column->get_name() ] = Index::TYPE_PRIMARY; + continue; + } + + $indexes[ $index_column->get_name() ] = $index_column->is_unique() ? Index::TYPE_UNIQUE : Index::TYPE_INDEX; + $indexed_columns[ $index_column->get_name() ] = $index_column->get_name(); + } + + $all_indexes = $this->get_indexes(); + if ( $all_indexes ) { + foreach ( $all_indexes as $index ) { + if ( isset( $indexes[ $index->get_name() ] ) ) { + throw new RuntimeException( 'Index already exists.' ); + } + + if ( Index::TYPE_PRIMARY === $index->get_type() ) { + if ( null !== $this->primary_key ) { + throw new RuntimeException( 'Primary key already set. Only one primary key per table is allowed.' ); + } + + /** @var Primary_Key $index */ + $this->primary_key = $index; + } + + $indexes[ $index->get_name() ] = $index->get_type(); + $indexed_columns[ $index->get_name() ] = $index->get_columns(); + } + } + + if ( array_values( $indexed_columns ) !== array_unique( array_values( $indexed_columns ), SORT_REGULAR ) ) { + throw new RuntimeException( 'Multiple indexes with the same column combinations.' ); + } + } +} diff --git a/src/Schema/Traits/Custom_Table_Query_Methods.php b/src/Schema/Traits/Custom_Table_Query_Methods.php new file mode 100644 index 0000000..7bf9252 --- /dev/null +++ b/src/Schema/Traits/Custom_Table_Query_Methods.php @@ -0,0 +1,877 @@ +> The rows from the table. + */ + protected static function fetch_all( int $batch_size = 50, string $output = OBJECT, string $where_clause = '', string $order_by = '' ): Generator { + $fetched = 0; + $total = null; + $offset = 0; + $database = Config::get_db(); + + do { + $primary_columns = static::primary_columns(); + + $order_by = $order_by ?: implode( ', ', array_map( fn( $column ) => "{$column} ASC", $primary_columns ) ); + + $query = $database::prepare( + "SELECT * FROM %i {$where_clause} ORDER BY {$order_by} LIMIT %d, %d", + static::table_name( true ), + $offset, + $batch_size + ); + + $batch = $database::get_results( + $query, + $output + ); + + // We need to get the total number of rows, only after the first batch. + $total ??= $database::get_var( $database::prepare( "SELECT COUNT(*) FROM %i {$where_clause}", static::table_name( true ) ) ); + $fetched += count( $batch ); + + $offset += $batch_size; + + yield from $batch; + } while ( $fetched < $total ); + } + + /** + * Fetches all the rows from the table using a batched query. + * + * @since 3.0.0 + * + * @param int $batch_size The number of rows to fetch per batch. + * @param string $where_clause The optional WHERE clause to use. + * @param string $order_by The optional ORDER BY clause to use. + * + * @return Generator> The rows from the table. + */ + public static function get_all( int $batch_size = 50, string $where_clause = '', string $order_by = '' ): Generator { + $batch = static::fetch_all( $batch_size, ARRAY_A, $where_clause, $order_by ); + + foreach ( $batch as $row ) { + yield static::transform_from_array( self::amend_value_types( $row ) ); + } + } + + /** + * Inserts a single row into the table. + * + * @since 3.0.0 + * + * @param array $entry The entry to insert. + * + * @return bool|int The number of rows affected, or `false` on failure. + */ + public static function insert( array $entry ) { + return static::insert_many( [ $entry ] ); + } + + /** + * Updates a single row in the table. + * + * @since 3.0.0 + * + * @param array $entry The entry to update. + * + * @return bool Whether the update was successful. + */ + public static function update_single( array $entry ): bool { + return static::update_many( [ $entry ] ); + } + + /** + * Inserts or updates a single row in the table. + * + * @since 3.0.0 + * + * @param array $entry The entry to upsert. + * + * @return bool Whether the upsert was successful. + */ + public static function upsert( array $entry ): bool { + $primary_columns = static::primary_columns(); + $primary_values = array_filter( array_intersect_key( $entry, array_flip( $primary_columns ) ) ); + + $is_update = count( $primary_values ) === count( $primary_columns ); + + return $is_update ? static::update_single( $entry ) : (bool) static::insert( $entry ); + } + + /** + * Inserts multiple rows into the table. + * + * @since 3.0.0 + * + * @param array $entries The entries to insert. + * + * @return bool|int The number of rows affected, or `false` on failure. + */ + public static function insert_many( array $entries ) { + [ $prepared_columns, $prepared_values ] = static::prepare_statements_values( $entries ); + + $database = Config::get_db(); + + return $database::query( + $database::prepare( + "INSERT INTO %i ({$prepared_columns}) VALUES {$prepared_values}", + static::table_name( true ), + ) + ); + } + + /** + * Deletes a single row from the table. + * + * @since 3.0.0 + * + * @param int $uid The ID of the row to delete. + * @param string $column The column to use for the delete query. + * + * @return bool Whether the delete was successful. + */ + public static function delete( int $uid, string $column = '' ): bool { + return (bool) static::delete_many( [ $uid ], $column ); + } + + /** + * Deletes multiple rows from the table. + * + * @since 3.0.0 + * + * @param array $ids The IDs of the rows to delete. + * @param string $column The column to use for the delete query. + * @param string $more_where The more WHERE clause to use for the delete query. + * + * @return bool|int The number of rows affected, or `false` on failure. + */ + public static function delete_many( array $ids, string $column = '', string $more_where = '' ) { + $ids = array_filter( + array_map( + fn( $id ) => is_numeric( $id ) ? (int) $id : "'{$id}'", + $ids + ) + ); + + if ( empty( $ids ) ) { + return false; + } + + $database = Config::get_db(); + $prepared_ids = implode( ', ', $ids ); + $column = $column ? + "{$column} IN ({$prepared_ids})" : + implode( + ' AND ', + array_map( + function ( $c ) use ( $prepared_ids ) { + return "{$c} IN ({$prepared_ids})"; + }, + static::primary_columns() + ) + ); + + return $database::query( + $database::prepare( + "DELETE FROM %i WHERE {$column} {$more_where}", + static::table_name( true ), + ) + ); + } + /** + * Prepares the statements and values for the insert and update queries. + * + * @since 3.0.0 + * + * @param array $entries The entries to prepare. + * + * @return array The prepared statements and values. + */ + protected static function prepare_statements_values( array $entries ): array { + $uid_column = static::uid_column(); + $entries = array_map( + function ( $entry ) use ( $uid_column ) { + unset( $entry[ $uid_column ] ); + return $entry; + }, + $entries + ); + + $columns = static::get_columns(); + + $entries = array_map( + function ( $entry ) use ( $columns ) { + foreach ( $columns as $column ) { + if ( ! isset( $entry[ $column->get_name() ] ) ) { + continue; + } + + switch ( $column->get_php_type() ) { + case PHP_Types::JSON: + $entry[ $column->get_name() ] = wp_json_encode( $entry[ $column->get_name() ] ); + break; + default: + break; + } + } + return $entry; + }, + $entries + ); + + $database = Config::get_db(); + $columns = array_keys( $entries[0] ); + $prepared_columns = implode( + ', ', + array_map( + static fn( string $column ) => "`$column`", + $columns + ) + ); + + $prepared_values = []; + foreach ( $entries as $row_index => $entry ) { + $prepared_values[ $row_index ] = []; + foreach ( $entry as $column => $value ) { + [ $prepared_value, $placeholder ] = self::prepare_value_for_query( $column, $value ); + $prepared_values[ $row_index ][] = $database::prepare( $placeholder, $prepared_value ); + } + } + + $prepared_values = implode( ', ', + array_map( + static fn ( array $entry ) => '(' . implode( ', ', $entry ) . ')', + $prepared_values + ) + ); + + return [ $prepared_columns, $prepared_values ]; + } + + /** + * Fetches all the rows from the table using a batched query and a WHERE clause. + * + * @since 3.0.0 + * + * @param string $where_clause The WHERE clause to use. + * @param int $batch_size The number of rows to fetch per batch. + * @param string $output The output type of the query, one of OBJECT, ARRAY_A, or ARRAY_N. + * @param string $order_by The optional ORDER BY clause to use. + * + * @return Generator> The rows from the table. + */ + protected static function fetch_all_where( string $where_clause, int $batch_size = 50, string $output = OBJECT, string $order_by = '' ): Generator { + return static::fetch_all( $batch_size, $output, $where_clause, $order_by ); + } + + /** + * Fetches the first row from the table using a WHERE clause. + * + * @since 3.0.0 + * + * @param string $where_clause The prepared WHERE clause to use. + * @param string $output The output type of the query, one of OBJECT, ARRAY_A, or ARRAY_N. + * + * @return array|object|null The row from the table, or `null` if no row was found. + */ + protected static function fetch_first_where( string $where_clause, string $output = OBJECT ) { + $database = Config::get_db(); + + return $database::get_row( + $database::prepare( + "SELECT * FROM %i {$where_clause} LIMIT 1", + static::table_name( true ) + ), + $output + ); + } + + /** + * Gets the total number of items in the table. + * + * @since 3.0.0 + * + * @param array $args The query arguments. + * + * @return int The total number of items in the table. + */ + public static function get_total_items( array $args = [] ): int { + $database = Config::get_db(); + $where = static::build_where_from_args( $args ); + + return (int) $database::get_var( + $database::prepare( + "SELECT COUNT(*) FROM %i a {$where}", + static::table_name( true ) + ) + ); + } + + /** + * Updates multiple rows into the table. + * + * @since 3.0.0 + * + * @param array $entries The entries to update. + * + * @return bool Whether the update was successful. + */ + public static function update_many( array $entries ): bool { + $uid_column = static::uid_column(); + + $database = Config::get_db(); + + $queries = []; + $columns = static::get_columns()->get_names(); + foreach ( $entries as $entry ) { + $uid = $entry[ $uid_column ] ?? ''; + + if ( ! $uid ) { + continue; + } + + $set_statement = []; + + foreach ( $entry as $column => $value ) { + if ( $column === $uid_column ) { + continue; + } + + if ( ! in_array( $column, $columns, true ) ) { + continue; + } + + if ( $value instanceof DateTimeInterface ) { + $value = $value->format( 'Y-m-d H:i:s' ); + } + + $set_statement[] = $database::prepare( "`{$column}` = %s", $value ); + } + + $set_statement = implode( ', ', $set_statement ); + + $queries[] = $database::prepare( + "UPDATE %i SET {$set_statement} WHERE {$uid_column} = %s;", + static::table_name( true ), + $uid + ); + } + + return (bool) $database::query( implode( '', $queries ) ); + } + + /** + * Method used to paginate the results of a query. + * + * Also supports joining another table. + * + * @since 3.0.0 + * + * @param array $args The query arguments. + * @param int $per_page The number of items to display per page. + * @param int $page The current page number. + * @param array $columns The columns to select. + * @param string $join_table The table to join. + * @param string $join_condition The condition to join on. + * @param array $selectable_joined_columns The columns from the joined table to select. + * @param string $output The output type of the query, one of OBJECT, ARRAY_A, or ARRAY_N. + * + * @return array The items. + * @throws InvalidArgumentException If the table to join is the same as the current table. + * If the join condition does not contain an equal sign. + * If the join condition does not contain valid columns. + */ + public static function paginate( array $args, int $per_page = 20, int $page = 1, array $columns = [ '*' ], string $join_table = '', string $join_condition = '', array $selectable_joined_columns = [], string $output = OBJECT ): array { + $is_join = (bool) $join_table; + + if ( $is_join && static::table_name( true ) === $join_table::table_name( true ) ) { + throw new InvalidArgumentException( 'The table to join must be different from the current table.' ); + } + + $per_page = min( max( 1, $per_page ), 200 ); + $page = max( 1, $page ); + + $offset = ( $page - 1 ) * $per_page; + $args_offset = $args['offset'] ?? $offset; + $offset = 1 === $page ? $args_offset : $offset; + + $orderby = $args['orderby'] ?? static::uid_column(); + $order = strtoupper( $args['order'] ?? 'ASC' ); + + if ( ! in_array( $orderby, static::get_columns()->get_names(), true ) ) { + $orderby = static::uid_column(); + } + + if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) { + $order = 'ASC'; + } + + $where = static::build_where_from_args( $args ); + + [ $join, $secondary_columns ] = $is_join ? static::get_join_parts( $join_table, $join_condition, $selectable_joined_columns ) : [ '', '' ]; + + $columns = implode( ', ', array_map( fn( $column ) => "a.{$column}", $columns ) ); + + /** + * Fires before the results of the query are fetched. + * + * @since 3.0.0 + * + * @param array $args The query arguments. + * @param class-string $class The class name. + */ + do_action( 'tec_common_custom_table_query_pre_results', $args, static::class ); + + $database = Config::get_db(); + + $results = $database::get_results( + $database::prepare( + "SELECT {$columns}{$secondary_columns} FROM %i a {$join} {$where} ORDER BY a.{$orderby} {$order} LIMIT %d, %d", + static::table_name( true ), + $offset, + $per_page + ), + $output + ); + + $results = array_map( fn( $result ) => static::transform_from_array( self::amend_value_types( $result ) ), $results ); + + /** + * Fires after the results of the query are fetched. + * + * @since 3.0.0 + * + * @param array $results The results of the query. + * @param array $args The query arguments. + * @param class-string $class The class name. + */ + do_action( 'tec_common_custom_table_query_post_results', $results, $args, static::class ); + + /** + * Filters the results of the query. + * + * @since 3.0.0 + * + * @param array $results The results of the query. + * @param array $args The query arguments. + * @param class-string $class The class name. + */ + return apply_filters( 'tec_common_custom_table_query_results', $results, $args, static::class ); + } + + /** + * Builds a WHERE clause from the provided arguments. + * + * @since 3.0.0 + * + * @param array $args The query arguments. + * + * @return string The WHERE clause. + */ + protected static function build_where_from_args( array $args = [] ): string { + $query_operator = strtoupper( $args['query_operator'] ?? 'AND' ); + + if ( ! in_array( $query_operator, [ 'AND', 'OR' ], true ) ) { + $query_operator = 'AND'; + } + + unset( $args['order'], $args['orderby'], $args['query_operator'], $args['offset'] ); + + $joined_prefix = 'a.'; + $database = Config::get_db(); + + $where = []; + + $search = $args['term'] ?? ''; + if ( $search ) { + $searchable_columns = static::get_searchable_columns(); + + $search_where = []; + + foreach ( $searchable_columns as $column ) { + $search_where[] = $database::prepare( "{$joined_prefix}{$column->get_name()} LIKE %s", '%' . $database::esc_like( $search ) . '%' ); + } + + if ( ! empty( $search_where ) ) { + $where[] = '(' . implode( ' OR ', $search_where ) . ')'; + } + } + + $columns = static::get_columns()->get_names(); + + foreach ( $args as $arg ) { + if ( ! is_array( $arg ) ) { + continue; + } + + if ( empty( $arg['column'] ) ) { + continue; + } + + if ( ! in_array( $arg['column'], $columns, true ) ) { + continue; + } + + if ( empty( $arg['value'] ) ) { + // We check that the column has any value then. + $arg['value'] = ''; + $arg['operator'] = '!='; + } + + if ( empty( $arg['operator'] ) ) { + $arg['operator'] = '='; + } + + // For anything else, you should build your own query! + if ( ! in_array( strtoupper( $arg['operator'] ), array_values( static::operators() ), true ) ) { + $arg['operator'] = '='; + } + + $column = $arg['column']; + $operator = strtoupper( $arg['operator'] ); + + [ $value, $placeholder ] = self::prepare_value_for_query( $column, $arg['value'] ); + + $database = Config::get_db(); + $query = "{$joined_prefix}{$column} {$operator} {$placeholder}"; + + if ( is_array( $value ) ) { + $where[] = $database::prepare( $query, ...$value ); + continue; + } + + $where[] = $database::prepare( $query, $value ); + } + + /** + * Filters the WHERE clause. + * + * @since 3.0.0 + * + * @param array $where The WHERE clause parts. + * @param array $args The query arguments. + * @param class-string $class The class name. + */ + $where = apply_filters( 'tec_common_custom_table_query_where', array_filter( $where ), $args, static::class ); + + if ( empty( $where ) ) { + return ''; + } + + return 'WHERE ' . implode( " {$query_operator} ", $where ); + } + + /** + * Gets the JOIN parts of the query. + * + * @since 3.0.0 + * + * @param string $join_table The table to join. + * @param string $join_condition The condition to join on. + * @param array $selectable_joined_columns The columns from the joined table to select. + * + * @return array The JOIN statement and the secondary columns to select. + * @throws InvalidArgumentException If the join condition does not contain an equal sign. + * If the join condition does not contain valid columns. + */ + protected static function get_join_parts( string $join_table, string $join_condition, array $selectable_joined_columns = [] ): array { + if ( ! strstr( $join_condition, '=' ) ) { + throw new InvalidArgumentException( 'The join condition must contain an equal sign.' ); + } + + $join_condition = array_map( 'trim', explode( '=', $join_condition, 2 ) ); + + $secondary_table_columns = $join_table::get_columns()->get_names(); + + $both_table_columns = array_merge( static::get_columns()->get_names(), $secondary_table_columns ); + + if ( ! in_array( $join_condition[0], $both_table_columns, true ) || ! in_array( $join_condition[1], $both_table_columns, true ) ) { + throw new InvalidArgumentException( 'The join condition must contain valid columns.' ); + } + + $join_condition = 'a.' . str_replace( [ 'a.', 'b.' ], '', $join_condition[0] ) . ' = b.' . str_replace( [ 'a.', 'b.' ], '', $join_condition[1] ); + + $clean_secondary_columns = []; + + foreach ( array_map( 'trim', $selectable_joined_columns ) as $column ) { + if ( ! in_array( $column, $secondary_table_columns, true ) ) { + continue; + } + + $clean_secondary_columns[] = 'b.' . $column; + } + + $database = Config::get_db(); + $clean_secondary_columns = $clean_secondary_columns ? ', ' . implode( ', ', $clean_secondary_columns ) : ''; + + return [ + $database::prepare( "JOIN %i b ON {$join_condition}", $join_table::table_name( true ) ), + $clean_secondary_columns, + ]; + } + + /** + * Gets all models by a column. + * + * @since 3.0.0 + * + * @param string $column The column to get the models by. + * @param mixed $value The value to get the models by. + * @param string $operator The operator to use. + * @param int $limit The limit of models to return. + * + * @return mixed[] The models, or an empty array if no models are found. + * + * @throws InvalidArgumentException If the column does not exist. + */ + public static function get_all_by( string $column, $value, string $operator = '=', int $limit = 50 ): ?array{ + [ $value, $placeholder ] = self::prepare_value_for_query( $column, $value ); + + $operator = strtoupper( $operator ); + + $database = Config::get_db(); + $results = []; + foreach ( static::fetch_all_where( $database::prepare( "WHERE {$column} {$operator} {$placeholder}", $value ), $limit, ARRAY_A ) as $task_array ) { + if ( empty( $task_array[ static::uid_column() ] ) ) { + continue; + } + + $results[] = static::transform_from_array( self::amend_value_types( $task_array ) ); + } + + return $results; + } + + /** + * Gets the first model by a column. + * + * @since 3.0.0 + * + * @param string $column The column to get the model by. + * @param mixed $value The value to get the model by. + * + * @return ?mixed The model, or `null` if no model is found. + * + * @throws InvalidArgumentException If the column does not exist. + */ + public static function get_first_by( string $column, $value ) { + [ $value, $placeholder ] = self::prepare_value_for_query( $column, $value ); + + $database = Config::get_db(); + $task_array = static::fetch_first_where( $database::prepare( "WHERE {$column} = {$placeholder}", $value ), ARRAY_A ); + + if ( empty( $task_array[ static::uid_column() ] ) ) { + return null; + } + + return static::transform_from_array( self::amend_value_types( $task_array ) ); + } + + /** + * Prepares a value for a query. + * + * @since 3.0.0 + * + * @param string $column The column to prepare the value for. + * @param mixed $value The value to prepare. + * + * @return array{0: mixed, 1: string} The prepared value and placeholder. + * + * @throws InvalidArgumentException If the column does not exist. + */ + private static function prepare_value_for_query( string $column, $value ): array { + $columns = static::get_columns(); + + /** @var ?Column $column */ + $column = $columns->get( $column ); + + if ( ! $column ) { + throw new InvalidArgumentException( "Column $column does not exist." ); + } + + $column_type = $column->get_php_type(); + + switch ( $column->get_php_type() ) { + case PHP_Types::INT: + case PHP_Types::BOOL: + $value = is_array( $value ) ? array_map( fn( $v ) => (int) $v, $value ) : (int) $value; + $placeholder = '%d'; + break; + case PHP_Types::STRING: + case PHP_Types::DATETIME: + $value = is_array( $value ) ? + array_map( fn( $v ) => $v instanceof DateTimeInterface ? $v->format( 'Y-m-d H:i:s' ) : (string) $v, $value ) : + ( $value instanceof DateTimeInterface ? $value->format( 'Y-m-d H:i:s' ) : (string) $value ); + $placeholder = '%s'; + break; + case PHP_Types::JSON: + $value = is_string( $value ) ? $value : wp_json_encode( $value ); + $placeholder = '%s'; + break; + case PHP_Types::FLOAT: + $value = is_array( $value ) ? array_map( fn( $v ) => (float) $v, $value ) : (float) $value; + $placeholder = '%f'; + break; + default: + throw new InvalidArgumentException( "Unsupported column type: $column_type." ); + } + + return [ $value, is_array( $value ) ? '(' . implode( ',', array_fill( 0, count( $value ), $placeholder ) ) . ')' : $placeholder ]; + } + + /** + * Gets a model by its ID. + * + * @since 3.0.0 + * + * @param int|string $id The ID. + * + * @return ?mixed The model, or null if not found. + */ + public static function get_by_id( $id ) { + return static::get_first_by( static::uid_column(), $id ); + } + + /** + * Gets the operators supported by the table. + * + * @since 3.0.0 + * + * @return array The operators supported by the table. + */ + public static function operators(): array { + return [ + 'eq' => '=', + 'neq' => '!=', + 'gt' => '>', + 'lt' => '<', + 'gte' => '>=', + 'lte' => '<=', + 'in' => 'IN', + 'not_in' => 'NOT IN', + ]; + } + + /** + * Amends the value types of the data. + * + * @since 3.0.0 + * + * @param array $data The data. + * + * @return array The amended data. + * + * @throws InvalidArgumentException If the column type is unsupported. + * @throws InvalidArgumentException If the datetime value format is invalid. + */ + private static function amend_value_types( array $data ): array { + $columns = static::get_columns(); + $column_names = $columns->get_names(); + foreach ( $data as $column => $value ) { + if ( ! in_array( $column, $column_names, true ) ) { + continue; + } + + $column_object = $columns->get( $column ); + + if ( $column_object->get_nullable() && null === $value ) { + continue; + } + + $column_php_type = $column_object->get_php_type(); + + $data[ $column ] = static::cast_value_based_on_type( $column_php_type, $value ); + } + + return $data; + } + + /** + * Casts a value based on the type. + * + * @since 3.0.0 + * + * @param string $type The type to cast the value to. + * @param mixed $value The value to cast. + * + * @return mixed The cast value. + */ + public static function cast_value_based_on_type( string $type, $value ) { + switch ( $type ) { + case PHP_Types::INT: + return (int) $value; + case PHP_Types::STRING: + return (string) $value; + case PHP_Types::FLOAT: + return (float) $value; + case PHP_Types::BOOL: + return (bool) $value; + case PHP_Types::JSON: + return is_string( $value ) ? (array) json_decode( $value, true ) : (array) $value; + case PHP_Types::DATETIME: + if ( $value instanceof DateTimeInterface ) { + return $value; + } + + try { + $instance = Config::get_container()->get( DateTimeInterface::class ); + } catch ( Exception $e ) { + $instance = DateTime::class; + } + + $new_value = $instance::createFromFormat( 'Y-m-d H:i:s', $value ); + + if ( $new_value instanceof DateTimeInterface ) { + return $new_value; + } + + $new_value = $instance::createFromFormat( 'Y-m-d', $value ); + + if ( ! $new_value instanceof DateTimeInterface ) { + throw new InvalidArgumentException( "Invalid datetime value format: {$value}." ); + } + + return $new_value; + default: + throw new InvalidArgumentException( "Unsupported column type: {$type}." ); + } + } +} diff --git a/src/Schema/Traits/Indexable.php b/src/Schema/Traits/Indexable.php new file mode 100644 index 0000000..b4d6f7f --- /dev/null +++ b/src/Schema/Traits/Indexable.php @@ -0,0 +1,138 @@ +is_index || $this->is_unique || $this->is_primary_key; + } + + /** + * Get whether the column is unique. + * + * @return bool Whether the column is unique. + */ + public function is_unique(): bool { + if ( ! $this instanceof Uniquable ) { + return false; + } + + return $this->is_unique; + } + + /** + * Get whether the column is a primary key. + * + * @return bool Whether the column is a primary key. + */ + public function is_primary_key(): bool { + if ( ! $this instanceof Primarable ) { + return false; + } + + return $this->is_primary_key; + } + + /** + * Set whether the column is an index. + * + * @param bool $is_index Whether the column is an index. + * + * @return Indexable_Contract + * + * @throws InvalidArgumentException If the column is not an index. + */ + public function set_is_index( bool $is_index ): Indexable_Contract { + if ( ! $this instanceof Indexable_Contract ) { + throw new InvalidArgumentException( 'The column is not an index.' ); + } + + $this->is_index = $is_index; + return $this; + } + + /** + * Set whether the column is unique. + * + * @param bool $is_unique Whether the column is unique. + * + * @return Uniquable + * + * @throws InvalidArgumentException If the column is not unique. + */ + public function set_is_unique( bool $is_unique ): Uniquable { + if ( ! $this instanceof Uniquable ) { + throw new InvalidArgumentException( 'The column is not unique.' ); + } + + $this->is_unique = $is_unique; + return $this; + } + + /** + * Set whether the column is a primary key. + * + * @param bool $is_primary_key Whether the column is a primary key. + * + * @return Primarable + * + * @throws InvalidArgumentException If the column is not a primary key. + */ + public function set_is_primary_key( bool $is_primary_key ): Primarable { + if ( ! $this instanceof Primarable ) { + throw new InvalidArgumentException( 'The column is not a primary key.' ); + } + + $this->is_primary_key = $is_primary_key; + return $this; + } +} diff --git a/tests/_support/Helper/SchemaTestCase.php b/tests/_support/Helper/SchemaTestCase.php index fa1c6ed..07e1edf 100644 --- a/tests/_support/Helper/SchemaTestCase.php +++ b/tests/_support/Helper/SchemaTestCase.php @@ -10,10 +10,10 @@ class SchemaTestCase extends Unit { protected $backupGlobals = false; - protected function setUp() { - // before - parent::setUp(); - + /** + * @before + */ + protected function set_Up() { Config::set_container( new Container() ); Config::set_db( DB::class ); diff --git a/tests/_support/Traits/Table_Fixtures.php b/tests/_support/Traits/Table_Fixtures.php index 7108c96..f86f086 100644 --- a/tests/_support/Traits/Table_Fixtures.php +++ b/tests/_support/Traits/Table_Fixtures.php @@ -4,9 +4,15 @@ use StellarWP\Schema\Activation; use StellarWP\Schema\Builder; +use StellarWP\Schema\Columns\ID; use StellarWP\Schema\Config; -use StellarWP\Schema\Fields\Contracts\Field; use StellarWP\Schema\Tables\Contracts\Table; +use StellarWP\Schema\Tables\Table_Schema; +use StellarWP\Schema\Columns\Contracts\Column; +use StellarWP\Schema\Columns\String_Column; +use StellarWP\Schema\Collections\Column_Collection; +use StellarWP\Schema\Columns\Integer_Column; +use StellarWP\Schema\Columns\Column_Types; trait Table_Fixtures { private function assert_custom_tables_exist() { @@ -54,24 +60,27 @@ public function get_modified_simple_table() { protected static $base_table_name = 'simple'; protected static $group = 'bork'; protected static $schema_slug = 'bork-simple'; - protected static $uid_column = 'id'; - protected function get_definition() { - global $wpdb; - $table_name = self::table_name( true ); - $charset_collate = $wpdb->get_charset_collate(); - - return " - CREATE TABLE `{$table_name}` ( - `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, - `name` varchar(25) NOT NULL, - `slug` varchar(25) NOT NULL, - `something` varchar(25) NOT NULL, - PRIMARY KEY (`id`), - KEY `slug` (`slug`), - KEY `something` (`something`) - ) {$charset_collate}; - "; + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) )->set_length( 11 )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 25 ); + $columns[] = ( new String_Column( 'slug' ) )->set_length( 25 )->set_is_index( true ); + $columns[] = ( new String_Column( 'something' ) )->set_length( 25 )->set_is_index( true ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; } }; @@ -88,22 +97,25 @@ public function get_simple_table() { protected static $base_table_name = 'simple'; protected static $group = 'bork'; protected static $schema_slug = 'bork-simple'; - protected static $uid_column = 'id'; - protected function get_definition() { - global $wpdb; - $table_name = self::table_name( true ); - $charset_collate = $wpdb->get_charset_collate(); - - return " - CREATE TABLE `{$table_name}` ( - `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, - `name` varchar(25) NOT NULL, - `slug` varchar(25) NOT NULL, - PRIMARY KEY (`id`), - KEY `slug` (`slug`) - ) {$charset_collate}; - "; + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) )->set_length( 11 )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 25 ); + $columns[] = ( new String_Column( 'slug' ) )->set_length( 25 )->set_is_index( true ); + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; } }; @@ -120,22 +132,25 @@ public function get_simple_table_alt_group(): Table { protected static $base_table_name = 'simple-alt'; protected static $group = 'test'; protected static $schema_slug = 'bork-simple-alt'; - protected static $uid_column = 'id'; - protected function get_definition(): string { - global $wpdb; - $table_name = self::table_name(); - $charset_collate = $wpdb->get_charset_collate(); - - return " - CREATE TABLE `$table_name` ( - `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, - `name` varchar(25) NOT NULL, - `slug` varchar(25) NOT NULL, - PRIMARY KEY (`id`), - KEY `slug` (`slug`) - ) $charset_collate; - "; + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) )->set_length( 11 )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 25 ); + $columns[] = ( new String_Column( 'slug' ) )->set_length( 25 )->set_is_index( true ); + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; } }; } @@ -150,49 +165,29 @@ public function get_indexless_table() { protected static $base_table_name = 'noindex'; protected static $group = 'bork'; protected static $schema_slug = 'bork-noindex'; - protected static $uid_column = 'id'; - - protected function get_definition() { - global $wpdb; - $table_name = self::table_name( true ); - $charset_collate = $wpdb->get_charset_collate(); - - return " - CREATE TABLE `{$table_name}` ( - `id` int(11) UNSIGNED NOT NULL, - `name` varchar(25) NOT NULL, - `slug` varchar(25) NOT NULL - ) {$charset_collate}; - "; - } - }; - return $table; - } + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); - /** - * Get a simple table field class. - */ - public function get_simple_table_field() { - $field = new class extends Field { - const SCHEMA_VERSION = '1.0.0'; + $columns[] = ( new Integer_Column( 'id' ) )->set_length( 11 )->set_signed( false )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 25 ); + $columns[] = ( new String_Column( 'slug' ) )->set_length( 25 ); + return new Table_Schema( $table_name, $columns ); + }; - protected static $base_table_name = 'simple'; - protected static $schema_slug = 'simple-bork'; - - protected $fields = [ - 'bork', - ]; + return [ + static::SCHEMA_VERSION => $callable, + ]; + } - protected function get_definition() { - return " - `bork` int(11) UNSIGNED NOT NULL, - KEY `bork` (`bork`) - "; + public static function transform_from_array( array $result_array ) { + return $result_array; } }; - return $field; + return $table; } public function get_foreign_key_table() { @@ -202,20 +197,21 @@ public function get_foreign_key_table() { protected static $base_table_name = 'foreignkey'; protected static $group = 'bork'; protected static $schema_slug = 'bork-with-foreignkey'; - protected static $uid_column = 'id'; - protected function get_definition() { - global $wpdb; - $table_name = self::table_name( true ); - $charset_collate = $wpdb->get_charset_collate(); - - return " - CREATE TABLE `{$table_name}` ( - `id` int(11) UNSIGNED NOT NULL, - `name` varchar(25) NOT NULL, - `simple_id` int(11) UNSIGNED NOT NULL - ) {$charset_collate}; - "; + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new Integer_Column( 'id' ) )->set_length( 11 )->set_signed( false )->set_type( Column_Types::INT ); + $columns[] = ( new String_Column( 'name' ) )->set_length( 25 ); + $columns[] = ( new Integer_Column( 'simple_id' ) )->set_length( 11 )->set_signed( false )->set_type( Column_Types::INT ); + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; } protected function after_update( array $results ) { @@ -224,7 +220,7 @@ protected function after_update( array $results ) { } global $wpdb; - $table_name = $this->table_name(); + $table_name = static::table_name(); $simple_table = $wpdb->prefix . 'simple'; $updated = $wpdb->query( "ALTER TABLE $table_name ADD FOREIGN KEY (simple_id) REFERENCES $simple_table(id)" ); @@ -236,6 +232,10 @@ protected function after_update( array $results ) { return $results; } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } }; return $table; diff --git a/tests/wpunit/BuilderTest.php b/tests/wpunit/BuilderTest.php index 4880d29..394096c 100644 --- a/tests/wpunit/BuilderTest.php +++ b/tests/wpunit/BuilderTest.php @@ -2,20 +2,38 @@ namespace StellarWP\Schema\Tests; -use StellarWP\Schema\Tables\Contracts\Schema_Interface as Table_Schema_Interface; +use StellarWP\Schema\Tables\Contracts\Table_Interface as Table_Schema_Interface; use StellarWP\Schema\Register; use StellarWP\Schema\Schema; use StellarWP\Schema\Tests\Traits\Table_Fixtures; - +use StellarWP\Schema\Collections\Column_Collection; +use StellarWP\Schema\Tables\Table_Schema; + +/** + * Class BuilderTest + * + * @since 3.0.0 + * + * @package StellarWP\Schema\Tests + */ class BuilderTest extends SchemaTestCase { use Table_Fixtures; + /** + * @return array List of tables in this database. + */ + public function get_tables() { + global $wpdb; + + return $wpdb->get_col( 'SHOW TABLES' ); + } + /** * @param string $table Table name. * * @return array List of fields for this table. */ - public function get_table_fields( $table ) { + public function get_table_columns( $table ) { global $wpdb; $q = 'select `column_name` from information_schema.columns where table_schema = database() @@ -27,15 +45,6 @@ public function get_table_fields( $table ) { }, $rows ); } - /** - * @return array List of tables in this database. - */ - public function get_tables() { - global $wpdb; - - return $wpdb->get_col( 'SHOW TABLES' ); - } - /** * Should tables create/destroy properly. * @@ -77,104 +86,17 @@ public function should_update_table_when_version_changes() { $this->assertContains( $table::table_name( true ), $tables ); $this->assertTrue( $builder->all_tables_exist() ); - $rows = $this->get_table_fields( $table::table_name( true ) ); + $rows = $this->get_table_columns( $table::table_name( true ) ); $this->assertNotContains( 'something', $rows ); Register::table( $modded_table ); - $rows = $this->get_table_fields( $table::table_name( true ) ); + $rows = $this->get_table_columns( $table::table_name( true ) ); $this->assertContains( 'something', $rows ); $builder->down(); } - /** - * Should fields create/destroy properly. - * - * @test - */ - public function should_up_down_field_schema() { - $builder = Schema::builder(); - $table_schema = $this->get_simple_table(); - $field_schema = $this->get_simple_table_field(); - - Register::table( $table_schema ); - Register::field( $field_schema ); - - // Validate expected state. - $rows = $this->get_table_fields( $field_schema->table_schema()::table_name( true ) ); - - foreach ( $field_schema->fields() as $field ) { - $this->assertContains( $field, $rows ); - } - - add_filter( 'stellarwp_schema_table_drop_simple', '__return_false' ); - - // Bring down with dropping disabled (default). - $builder->down(); - - // Validate expected state. - $rows = $this->get_table_fields( $field_schema->table_schema()::table_name( true ) ); - - foreach ( $field_schema->fields() as $field ) { - $this->assertContains( $field, $rows ); - } - - add_filter( 'stellarwp_drop_field_enabled', '__return_true' ); - - // Bring down with dropping enabled. - $builder->down(); - - remove_filter( 'stellarwp_schema_table_drop_simple', '__return_false' ); - remove_filter( 'stellarwp_drop_field_enabled', '__return_true' ); - - // Validate expected state. - $rows = $this->get_table_fields( $field_schema->table_schema()::table_name( true ) ); - - foreach ( $field_schema->fields() as $field ) { - $this->assertNotContains( $field, $rows ); - } - - // Clean up. - $builder->down(); - } - - /** - * Tests the `exists` function finds the fields properly. - * - * @test - */ - public function should_field_exists() { - $builder = Schema::builder(); - $table_schema = $this->get_simple_table(); - $field_schema = $this->get_simple_table_field(); - - Register::table( $table_schema ); - Register::field( $field_schema ); - - $this->assertTrue( $field_schema->exists() ); - - // Keep our table - validate the field changes. - add_filter( 'stellarwp_schema_table_drop_simple', '__return_false' ); - - // Bring down with dropping disabled (default). - $builder->down(); - $this->assertTrue( $field_schema->exists() ); - - add_filter( 'stellarwp_drop_field_enabled', '__return_true' ); - - // Bring down with dropping enabled. - $builder->down(); - - remove_filter( 'stellarwp_schema_table_drop_simple', '__return_false' ); - remove_filter( 'stellarwp_drop_field_enabled', '__return_true' ); - - $this->assertFalse( $field_schema->exists() ); - - // Cleanup. - $builder->down(); - } - /** * The state of the stored version should be stored and removed when we up/down the schema. * @@ -183,7 +105,6 @@ public function should_field_exists() { public function should_sync_version() { $builder = Schema::builder(); $table_schema = $this->get_simple_table(); - $field_schema = $this->get_simple_table_field(); Register::table( $table_schema ); @@ -197,7 +118,6 @@ public function should_sync_version() { $this->assertNotEquals( $table_schema->get_version(), $table_version ); Register::table( $table_schema ); - Register::field( $field_schema ); // Is version there? $table_version = get_option( $table_schema->get_schema_version_option() ); @@ -249,6 +169,26 @@ public static function get_schema_slug() { return 'fodz'; } + public function get_definition(): string { + return ''; + } + + public static function get_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_searchable_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_schema_history(): array { + return []; + } + + public static function get_current_schema(): Table_Schema { + return new Table_Schema( 'fodz', new Column_Collection() ); + } + public function get_sql() {} public function get_version(): string { @@ -270,12 +210,36 @@ public static function uid_column() {} public function update() { return []; } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } }; $klutz_table = new class implements Table_Schema_Interface { public static function base_table_name() { return 'klutz'; } + public function get_definition(): string { + return ''; + } + + public static function get_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_searchable_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_schema_history(): array { + return []; + } + + public static function get_current_schema(): Table_Schema { + return new Table_Schema( 'klutz', new Column_Collection() ); + } + public function drop() {} public function empty_table() {} @@ -309,6 +273,10 @@ public static function uid_column() {} public function update() { return []; } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } }; $zorps_table = new class implements Table_Schema_Interface { public static function base_table_name() { @@ -327,6 +295,26 @@ public static function get_schema_slug() { return 'zorps'; } + public function get_definition(): string { + return ''; + } + + public static function get_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_searchable_columns(): Column_Collection { + return new Column_Collection(); + } + + public static function get_schema_history(): array { + return []; + } + + public static function get_current_schema(): Table_Schema { + return new Table_Schema( 'zorps', new Column_Collection() ); + } + public function get_sql() {} public function get_version(): string { @@ -348,6 +336,10 @@ public static function uid_column() {} public function update() { return []; } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } }; $builder = Schema::builder(); diff --git a/tests/wpunit/RegisterTest.php b/tests/wpunit/RegisterTest.php index b2655a4..82c2957 100644 --- a/tests/wpunit/RegisterTest.php +++ b/tests/wpunit/RegisterTest.php @@ -2,8 +2,6 @@ namespace StellarWP\Schema\Tests; -use StellarWP\Schema\Config; -use StellarWP\Schema\Fields; use StellarWP\Schema\Register; use StellarWP\Schema\Schema; use StellarWP\Schema\Tests\Traits\Table_Fixtures; @@ -21,34 +19,6 @@ public function drop_tables() { $this->get_foreign_key_table()->drop(); } - /** - * Registered fields should exist in the collection - * - * @test - */ - public function it_should_have_fields_in_collection_when_added_individually() { - $field_1 = $this->get_simple_table_field(); - - Register::field( $field_1 ); - - $this->assertArrayHasKey( $field_1::get_schema_slug(), Config::get_container()->get( Fields\Collection::class ) ); - } - - /** - * Batch registered tables should exist in the collection - * - * @test - */ - public function it_should_have_fields_in_collection_when_batch_added() { - $field_1 = $this->get_simple_table_field(); - - Register::fields( [ - $field_1, - ]); - - $this->assertArrayHasKey( $field_1::get_schema_slug(), Config::get_container()->get( Fields\Collection::class ) ); - } - /** * Registered tables should exist in the collection * @@ -149,21 +119,4 @@ public function it_should_remove_tables() { $this->assertArrayNotHasKey( $table_1::base_table_name(), Schema::tables() ); } - - /** - * Registered fields should be removed from the collection. - * - * @test - */ - public function it_should_remove_fields() { - $field_1 = $this->get_simple_table_field(); - - Register::field( $field_1 ); - - $this->assertArrayHasKey( $field_1::get_schema_slug(), Schema::fields() ); - - Register::remove_field( $field_1 ); - - $this->assertArrayNotHasKey( $field_1::get_schema_slug(), Schema::fields() ); - } } diff --git a/tests/wpunit/Tables/ComplexTableTest.php b/tests/wpunit/Tables/ComplexTableTest.php new file mode 100644 index 0000000..341cb96 --- /dev/null +++ b/tests/wpunit/Tables/ComplexTableTest.php @@ -0,0 +1,1209 @@ +get_comprehensive_table()->drop(); + $this->get_indexed_table()->drop(); + $this->get_timestamp_table()->drop(); + $this->get_created_at_table()->drop(); + $this->get_updated_at_table()->drop(); + } + + /** + * Get a table with all column types. + */ + public function get_comprehensive_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'comprehensive_columns'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-comprehensive'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Primary key with auto increment + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::BIGINT ) + ->set_auto_increment( true ); + + // Integer types + $columns[] = ( new Integer_Column( 'tinyint_col' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 3 ) + ->set_signed( false ) + ->set_default( 0 ); + + $columns[] = ( new Integer_Column( 'smallint_col' ) ) + ->set_type( Column_Types::SMALLINT ) + ->set_length( 5 ) + ->set_signed( true ) + ->set_nullable( true ); + + $columns[] = ( new Integer_Column( 'mediumint_col' ) ) + ->set_type( Column_Types::MEDIUMINT ) + ->set_length( 8 ) + ->set_default( 100 ); + + $columns[] = ( new Integer_Column( 'int_col' ) ) + ->set_type( Column_Types::INT ) + ->set_length( 11 ) + ->set_signed( true ) + ->set_is_index( true ); + + $columns[] = ( new Integer_Column( 'bigint_col' ) ) + ->set_type( Column_Types::BIGINT ) + ->set_length( 20 ) + ->set_signed( false ); + + // Float types + // For FLOAT(10,2) - 10 total digits, 2 decimal places + $columns[] = ( new Float_Column( 'float_col' ) ) + ->set_type( Column_Types::FLOAT ) + ->set_length( 10 ) + ->set_precision( 2 ) + ->set_default( 0.0 ); + + // For DECIMAL(15,4) - 15 total digits, 4 decimal places + $columns[] = ( new Float_Column( 'decimal_col' ) ) + ->set_type( Column_Types::DECIMAL ) + ->set_length( 15 ) + ->set_precision( 4 ) + ->set_nullable( true ); + + // For DOUBLE(22,8) - 22 total digits, 8 decimal places + $columns[] = ( new Float_Column( 'double_col' ) ) + ->set_type( Column_Types::DOUBLE ) + ->set_length( 22 ) + ->set_precision( 8 ); + + // String types + $columns[] = ( new String_Column( 'char_col' ) ) + ->set_type( Column_Types::CHAR ) + ->set_length( 10 ) + ->set_default( 'DEFAULT' ); + + $columns[] = ( new String_Column( 'varchar_col' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ) + ->set_searchable( true ) + ->set_is_unique( true ); + + // Text types + $columns[] = ( new Text_Column( 'tinytext_col' ) ) + ->set_type( Column_Types::TINYTEXT ); + + $columns[] = ( new Text_Column( 'text_col' ) ) + ->set_type( Column_Types::TEXT ) + ->set_nullable( true ); + + $columns[] = ( new Text_Column( 'mediumtext_col' ) ) + ->set_type( Column_Types::MEDIUMTEXT ); + + $columns[] = ( new Text_Column( 'longtext_col' ) ) + ->set_type( Column_Types::LONGTEXT ); + + // Datetime types + $columns[] = ( new Datetime_Column( 'date_col' ) ) + ->set_type( Column_Types::DATE ) + ->set_nullable( true ); + + $columns[] = ( new Datetime_Column( 'datetime_col' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_default( '0000-00-00 00:00:00' ); + + $columns[] = new Last_Changed( 'last_changed' ); + + // Boolean column + $columns[] = ( new Integer_Column( 'is_active' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 1 ) + ->set_default( 1 ) + ->set_php_type( PHP_Types::BOOL ); + + // JSON column (stored as text) + $columns[] = ( new Text_Column( 'json_data' ) ) + ->set_type( Column_Types::TEXT ) + ->set_php_type( PHP_Types::JSON ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } + + /** + * Get a table with all index types. + */ + public function get_indexed_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'indexed_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-indexed'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Primary key + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::INT ) + ->set_auto_increment( true ); + + // Columns for various indexes + $columns[] = ( new String_Column( 'unique_email' ) ) + ->set_length( 255 ) + ->set_is_unique( true ); + + $columns[] = ( new String_Column( 'indexed_slug' ) ) + ->set_length( 200 ) + ->set_is_index( true ); + + $columns[] = ( new Integer_Column( 'user_id' ) ) + ->set_type( Column_Types::INT ) + ->set_length( 11 ) + ->set_is_index( true ); + + $columns[] = ( new String_Column( 'category' ) ) + ->set_length( 100 ); + + $columns[] = ( new String_Column( 'tag' ) ) + ->set_length( 100 ); + + $columns[] = ( new Text_Column( 'searchable_content' ) ) + ->set_type( Column_Types::TEXT ); + + $columns[] = ( new String_Column( 'title' ) ) + ->set_length( 255 ); + + $columns[] = ( new Text_Column( 'description' ) ) + ->set_type( Column_Types::TEXT ); + + $columns[] = ( new Integer_Column( 'status' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 1 ) + ->set_default( 1 ); + + $columns[] = ( new Datetime_Column( 'published_at' ) ) + ->set_type( Column_Types::DATETIME ); + + // Define additional indexes + $indexes = new Index_Collection(); + + // Composite index + $indexes[] = ( new Classic_Index( 'idx_category_tag' ) ) + ->set_columns( 'category', 'tag' ); + + // Another composite with different order + $indexes[] = ( new Classic_Index( 'idx_status_published' ) ) + ->set_columns( 'status', 'published_at' ); + + // Unique composite key + $indexes[] = ( new Unique_Key( 'uk_user_category' ) ) + ->set_columns( 'user_id', 'category' ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } + + /** + * Get a table with timestamp column that auto-updates (MySQL 5.5 compatible - only one CURRENT_TIMESTAMP). + */ + public function get_timestamp_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'timestamp_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-timestamp'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::INT ); + + $columns[] = ( new String_Column( 'title' ) ) + ->set_length( 255 ); + + $columns[] = ( new Datetime_Column( 'timestamp_col' ) ) + ->set_type( Column_Types::TIMESTAMP ) + ->set_default( 'CURRENT_TIMESTAMP' ) + ->set_on_update( 'CURRENT_TIMESTAMP' ); + + $columns[] = ( new Datetime_Column( 'created_date' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_default( '0000-00-00 00:00:00' ); + + $columns[] = ( new Datetime_Column( 'updated_date' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_nullable( true ) + ->set_default( 'NULL' ); + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } + + /** + * Get a table with Created_At column. + */ + public function get_created_at_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'created_at_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-created-at'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::INT ) + ->set_auto_increment( true ); + + $columns[] = ( new String_Column( 'name' ) ) + ->set_length( 100 ); + + // Created_At column + $columns[] = new Created_At( 'created_at' ); + + // Regular datetime for comparison + $columns[] = ( new Datetime_Column( 'other_date' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_nullable( true ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } + + /** + * Get a table with Updated_At column. + */ + public function get_updated_at_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'updated_at_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-updated-at'; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::INT ); + + $columns[] = ( new String_Column( 'content' ) ) + ->set_length( 255 ); + + $columns[] = new Updated_At( 'updated_at' ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + + public static function transform_from_array( array $result_array ) { + return $result_array; + } + }; + } + + /** + * Test comprehensive table creation and structure. + * + * @test + */ + public function should_create_comprehensive_table() { + $table = $this->get_comprehensive_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + } + + /** + * Test indexed table creation and structure. + * + * @test + */ + public function should_create_indexed_table() { + $table = $this->get_indexed_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify indexes exist + $this->assertTrue( $table->has_index( 'indexed_slug' ) ); + $this->assertTrue( $table->has_index( 'user_id' ) ); + $this->assertTrue( $table->has_index( 'unique_email' ) ); + $this->assertTrue( $table->has_index( 'idx_category_tag' ) ); + $this->assertTrue( $table->has_index( 'idx_status_published' ) ); + $this->assertTrue( $table->has_index( 'uk_user_category' ) ); + } + + /** + * Test data insertion and retrieval with proper types. + * + * @test + */ + public function should_insert_and_retrieve_data_with_correct_types() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert test data + $data = [ + 'tinyint_col' => 127, + 'smallint_col' => -1000, + 'mediumint_col' => 50000, + 'int_col' => 2147483647, + 'bigint_col' => '9223372036854775807', + 'float_col' => 123.45, + 'decimal_col' => '1234.5678', + 'double_col' => 123456.78901234, + 'char_col' => 'FIXED', + 'varchar_col' => 'Variable length string', + 'tinytext_col' => 'Tiny text content', + 'text_col' => 'Regular text content with more data', + 'mediumtext_col' => str_repeat( 'Medium text ', 100 ), + 'longtext_col' => str_repeat( 'Long text content ', 1000 ), + 'date_col' => '2024-01-15', + 'datetime_col' => '2024-01-15 14:30:00', + 'is_active' => 1, + 'json_data' => json_encode( [ 'key' => 'value', 'nested' => [ 'data' => true ] ] ), + ]; + + $inserted = $wpdb->insert( $table_name, $data ); + $this->assertNotFalse( $inserted ); + + $insert_id = $wpdb->insert_id; + $this->assertGreaterThan( 0, $insert_id ); + + // Retrieve and verify data + $result = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $result ); + + // Verify integer types + $this->assertSame( $insert_id, (int) $result['id'] ); + $this->assertSame( 127, (int) $result['tinyint_col'] ); + $this->assertSame( -1000, (int) $result['smallint_col'] ); + $this->assertSame( 50000, (int) $result['mediumint_col'] ); + $this->assertSame( 2147483647, (int) $result['int_col'] ); + $this->assertEquals( '9223372036854775807', $result['bigint_col'] ); + + // Verify float types + $this->assertEqualsWithDelta( 123.45, (float) $result['float_col'], 0.01 ); + $this->assertEqualsWithDelta( 1234.5678, (float) $result['decimal_col'], 0.0001 ); + $this->assertEqualsWithDelta( 123456.78901234, (float) $result['double_col'], 0.00001 ); + + // Verify string types + $this->assertEquals( 'FIXED', trim( $result['char_col'] ) ); + $this->assertEquals( 'Variable length string', $result['varchar_col'] ); + + // Verify text types + $this->assertEquals( 'Tiny text content', $result['tinytext_col'] ); + $this->assertEquals( 'Regular text content with more data', $result['text_col'] ); + $this->assertStringContainsString( 'Medium text', $result['mediumtext_col'] ); + $this->assertStringContainsString( 'Long text content', $result['longtext_col'] ); + + // Verify datetime types + $this->assertEquals( '2024-01-15', $result['date_col']->format( 'Y-m-d' ) ); + $this->assertEquals( '2024-01-15 14:30:00', $result['datetime_col']->format( 'Y-m-d H:i:s' ) ); + + // Verify special columns - only last_changed is in comprehensive table + $this->assertNotNull( $result['last_changed'] ); + $this->assertInstanceOf( DateTime::class, $result['last_changed'] ); + + // Verify boolean transformation + $this->assertIsBool( $result['is_active'] ); + $this->assertTrue( $result['is_active'] ); + + // Verify JSON transformation + $this->assertIsArray( $result['json_data'] ); + $this->assertEquals( 'value', $result['json_data']['key'] ); + $this->assertTrue( $result['json_data']['nested']['data'] ); + } + + /** + * Test nullable columns. + * + * @test + */ + public function should_handle_nullable_columns() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert with NULL values + $data = [ + 'smallint_col' => null, + 'decimal_col' => null, + 'text_col' => null, + 'date_col' => null, + 'varchar_col' => 'required_unique_' . time(), + 'is_active' => 0, + 'json_data' => '{}', + ]; + + $inserted = $wpdb->insert( $table_name, $data ); + $this->assertNotFalse( $inserted ); + + $insert_id = $wpdb->insert_id; + + $result = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Verify NULL values + $this->assertNull( $result['smallint_col'] ); + $this->assertNull( $result['decimal_col'] ); + $this->assertNull( $result['text_col'] ); + $this->assertNull( $result['date_col'] ); + } + + /** + * Test default values. + * + * @test + */ + public function should_use_default_values() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert minimal data to test defaults + $data = [ + 'varchar_col' => 'test_defaults_' . time(), + 'json_data' => '{}', + ]; + + $inserted = $wpdb->insert( $table_name, $data ); + $this->assertNotFalse( $inserted ); + + $insert_id = $wpdb->insert_id; + + $result = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Verify default values + $this->assertEquals( 0, (int) $result['tinyint_col'] ); + $this->assertEquals( 100, (int) $result['mediumint_col'] ); + $this->assertEquals( 0, (float) $result['float_col'] ); + $this->assertEquals( 'DEFAULT', trim( $result['char_col'] ) ); + $this->assertEquals( 1, (int) $result['is_active'] ); + $this->assertEquals( '0000-00-00 00:00:00', $result['datetime_col'] ); + } + + /** + * Test unique constraints. + * + * @test + */ + public function should_enforce_unique_constraints() { + global $wpdb; + + $table = $this->get_indexed_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert first record + $data1 = [ + 'unique_email' => 'test@example.com', + 'indexed_slug' => 'test-slug', + 'user_id' => 1, + 'category' => 'category1', + 'tag' => 'tag1', + 'title' => 'Test Title', + 'description' => 'Test Description', + 'searchable_content' => 'Searchable content here', + ]; + + $inserted1 = $wpdb->insert( $table_name, $data1 ); + $this->assertNotFalse( $inserted1 ); + + // Try to insert duplicate unique_email + $data2 = $data1; + $data2['indexed_slug'] = 'different-slug'; + + $wpdb->suppress_errors( true ); + $inserted2 = $wpdb->insert( $table_name, $data2 ); + $wpdb->suppress_errors( false ); + + $this->assertFalse( $inserted2 ); + + // Try to insert duplicate composite unique key + $data3 = [ + 'unique_email' => 'another@example.com', + 'indexed_slug' => 'another-slug', + 'user_id' => 1, + 'category' => 'category1', + 'tag' => 'tag2', + 'title' => 'Another Title', + 'description' => 'Another Description', + 'searchable_content' => 'More searchable content', + ]; + + $wpdb->suppress_errors( true ); + $inserted3 = $wpdb->insert( $table_name, $data3 ); + $wpdb->suppress_errors( false ); + + $this->assertFalse( $inserted3 ); + } + + /** + * Test composite index queries. + * + * @test + */ + public function should_use_composite_indexes_efficiently() { + global $wpdb; + + $table = $this->get_indexed_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert test data + for ( $i = 1; $i <= 10; $i++ ) { + $data = [ + 'unique_email' => "user$i@example.com", + 'indexed_slug' => "slug-$i", + 'user_id' => $i, + 'category' => 'category' . ( $i % 3 ), + 'tag' => 'tag' . ( $i % 2 ), + 'title' => "Title $i", + 'description' => "Description $i", + 'searchable_content' => "Content $i", + 'status' => $i % 2, + 'published_at' => date( 'Y-m-d H:i:s', strtotime( "+$i days" ) ), + ]; + $wpdb->insert( $table_name, $data ); + } + + // Query using composite index + $query = $wpdb->prepare( + "SELECT * FROM $table_name WHERE category = %s AND tag = %s", + 'category0', + 'tag0' + ); + + $results = $wpdb->get_results( $query, ARRAY_A ); + $this->assertNotEmpty( $results ); + + // Query using another composite index + $query2 = $wpdb->prepare( + "SELECT * FROM $table_name WHERE status = %d AND published_at > %s", + 1, + date( 'Y-m-d H:i:s' ) + ); + + $results2 = $wpdb->get_results( $query2, ARRAY_A ); + $this->assertNotEmpty( $results2 ); + } + + /** + * Test timestamp auto-update functionality (MySQL 5.5 compatible). + * + * @test + */ + public function should_auto_update_timestamp_column() { + global $wpdb; + + $table = $this->get_timestamp_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert initial data + $data = [ + 'title' => 'Test Title', + 'created_date' => '2024-01-01 10:00:00', + ]; + + $wpdb->insert( $table_name, $data ); + $insert_id = $wpdb->insert_id; + + // Get initial timestamps + $initial = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + $initial_timestamp = $initial['timestamp_col']; + + // Wait a moment + sleep( 1 ); + + // Update the record + $wpdb->update( + $table_name, + [ 'title' => 'Updated Title' ], + [ 'id' => $insert_id ] + ); + + // Get updated timestamps + $updated = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Verify timestamp was updated + $this->assertNotEquals( $initial_timestamp, $updated['timestamp_col'] ); + } + + /** + * Test Created_At special column. + * + * @test + */ + public function should_handle_created_at_column() { + global $wpdb; + + $table = $this->get_created_at_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert data + $data = [ + 'name' => 'Test Name', + ]; + + $wpdb->insert( $table_name, $data ); + $insert_id = $wpdb->insert_id; + + // Get the record + $result = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Verify created_at was set + $this->assertNotNull( $result['created_at'] ); + $this->assertNotEquals( '0000-00-00 00:00:00', $result['created_at'] ); + + $created_at_initial = $result['created_at']; + + // Wait and update + sleep( 1 ); + + $wpdb->update( + $table_name, + [ 'name' => 'Updated Name' ], + [ 'id' => $insert_id ] + ); + + // Get updated record + $updated = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Created_at should not change on update + $this->assertEquals( $created_at_initial, $updated['created_at'] ); + } + + /** + * Test Updated_At special column. + * + * @test + */ + public function should_handle_updated_at() { + global $wpdb; + + $table = $this->get_updated_at_table(); + Register::table( $table ); + + $table_name = $table->table_name(); + + // Insert data + $data = [ + 'content' => 'Initial Content', + ]; + + $wpdb->insert( $table_name, $data ); + $insert_id = $wpdb->insert_id; + + // Get the initial record + $initial = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Both columns should have values + $this->assertNull( $initial['updated_at'] ); + + $initial_updated_at = $initial['updated_at']; + + $wpdb->update( + $table_name, + [ 'content' => 'Updated Content' ], + [ 'id' => $insert_id ] + ); + + // Get updated record + $updated = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), + ARRAY_A + ); + + // Both should be updated + $this->assertNotEquals( $initial_updated_at, $updated['updated_at'] ); + } + + /** + * Test table creation for all timestamp tables. + * + * @test + */ + public function should_create_timestamp_tables() { + $timestamp_table = $this->get_timestamp_table(); + $created_at_table = $this->get_created_at_table(); + $updated_at_table = $this->get_updated_at_table(); + + Register::table( $timestamp_table ); + Register::table( $created_at_table ); + Register::table( $updated_at_table ); + + $this->assertTrue( $timestamp_table->exists() ); + $this->assertTrue( $created_at_table->exists() ); + $this->assertTrue( $updated_at_table->exists() ); + } + + /** + * Test data type transformations through Schema API methods. + * + * @test + */ + public function should_transform_data_types_through_api() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert test data directly + $data = [ + 'tinyint_col' => 1, + 'smallint_col' => 100, + 'mediumint_col' => 1000, + 'int_col' => 123456, + 'bigint_col' => '9876543210', + 'float_col' => 99.99, + 'decimal_col' => '456.789', + 'double_col' => 3.14159265359, + 'char_col' => 'CHAR', + 'varchar_col' => 'api_test_' . time(), + 'tinytext_col' => 'Tiny API text', + 'text_col' => 'Regular API text content', + 'mediumtext_col' => 'Medium API text content', + 'longtext_col' => 'Long API text content', + 'date_col' => '2024-03-15', + 'datetime_col' => '2024-03-15 10:30:00', + 'is_active' => 1, + 'json_data' => json_encode( [ 'api' => true, 'version' => 3, 'features' => [ 'type_safety', 'transformations' ] ] ), + ]; + + $wpdb->insert( $table::table_name(), $data ); + $insert_id = $wpdb->insert_id; + + // Test get_by_id method + $result = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $result ); + $this->assertIsArray( $result ); + + // Verify integer types are properly cast + $this->assertIsInt( $result['id'] ); + $this->assertIsInt( $result['tinyint_col'] ); + $this->assertIsInt( $result['smallint_col'] ); + $this->assertIsInt( $result['mediumint_col'] ); + $this->assertIsInt( $result['int_col'] ); + $this->assertEquals( 123456, $result['int_col'] ); + + // Bigint remains as string to avoid precision loss + $this->assertEquals( '9876543210', $result['bigint_col'] ); + + // Verify float types + $this->assertIsFloat( $result['float_col'] ); + $this->assertIsFloat( $result['decimal_col'] ); + $this->assertIsFloat( $result['double_col'] ); + $this->assertEqualsWithDelta( 99.99, $result['float_col'], 0.01 ); + $this->assertEqualsWithDelta( 456.789, $result['decimal_col'], 0.001 ); + + // Verify string types + $this->assertIsString( $result['char_col'] ); + $this->assertIsString( $result['varchar_col'] ); + $this->assertIsString( $result['tinytext_col'] ); + $this->assertIsString( $result['text_col'] ); + + // Verify boolean transformation + $this->assertIsBool( $result['is_active'] ); + $this->assertTrue( $result['is_active'] ); + + // Verify JSON transformation + $this->assertIsArray( $result['json_data'] ); + $this->assertTrue( $result['json_data']['api'] ); + $this->assertEquals( 3, $result['json_data']['version'] ); + $this->assertContains( 'type_safety', $result['json_data']['features'] ); + } + + /** + * Test get_first_by method with type transformations. + * + * @test + */ + public function should_transform_types_with_get_first_by() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert multiple records + $unique_varchar = 'first_by_test_' . time(); + $data = [ + 'varchar_col' => $unique_varchar, + 'int_col' => 42, + 'float_col' => 3.14, + 'is_active' => 0, + 'json_data' => json_encode( [ 'method' => 'get_first_by', 'test' => true ] ), + ]; + + $wpdb->insert( $table::table_name(), $data ); + + // Test get_first_by + $result = $table::get_first_by( 'varchar_col', $unique_varchar ); + + $this->assertNotNull( $result ); + + // Verify type transformations + $this->assertIsInt( $result['int_col'] ); + $this->assertEquals( 42, $result['int_col'] ); + + $this->assertIsFloat( $result['float_col'] ); + $this->assertEqualsWithDelta( 3.14, $result['float_col'], 0.01 ); + + $this->assertIsBool( $result['is_active'] ); + $this->assertFalse( $result['is_active'] ); + + $this->assertIsArray( $result['json_data'] ); + $this->assertEquals( 'get_first_by', $result['json_data']['method'] ); + $this->assertTrue( $result['json_data']['test'] ); + } + + /** + * Test get_all_by method with type transformations. + * + * @test + */ + public function should_transform_types_with_get_all_by() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert multiple records with same int_col value + $shared_int_value = 999; + for ( $i = 1; $i <= 3; $i++ ) { + $data = [ + 'varchar_col' => 'get_all_test_' . $i . '_' . time(), + 'int_col' => $shared_int_value, + 'float_col' => $i * 1.5, + 'is_active' => $i % 2, + 'json_data' => json_encode( [ 'index' => $i, 'batch' => 'test' ] ), + ]; + $wpdb->insert( $table::table_name(), $data ); + } + + // Test get_all_by + $results = $table::get_all_by( 'int_col', $shared_int_value ); + + $this->assertIsArray( $results ); + $this->assertGreaterThanOrEqual( 3, count( $results ) ); + + foreach ( $results as $index => $result ) { + // Verify each result has proper type transformations + $this->assertIsInt( $result['int_col'] ); + $this->assertEquals( $shared_int_value, $result['int_col'] ); + + $this->assertIsFloat( $result['float_col'] ); + + $this->assertIsBool( $result['is_active'] ); + + $this->assertIsArray( $result['json_data'] ); + $this->assertArrayHasKey( 'index', $result['json_data'] ); + $this->assertEquals( 'test', $result['json_data']['batch'] ); + } + } + + /** + * Test paginate method with type transformations. + * + * @test + */ + public function should_transform_types_with_paginate() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert test records for pagination + $base_time = time(); + for ( $i = 1; $i <= 5; $i++ ) { + $data = [ + 'varchar_col' => 'paginate_test_' . $i . '_' . $base_time, + 'int_col' => $i * 10, + 'float_col' => $i * 2.5, + 'is_active' => $i <= 3 ? 1 : 0, + 'json_data' => json_encode( [ 'page_item' => $i, 'total' => 5 ] ), + ]; + $wpdb->insert( $table::table_name(), $data ); + } + + // Test paginate with search and filters + $args = [ + 'term' => 'paginate_test', + [ + 'column' => 'is_active', + 'value' => 1, + 'operator' => '=', + ], + ]; + + $results = $table::paginate( $args, 10, 1, [ '*' ], '', '', [], ARRAY_A ); + + $this->assertIsArray( $results ); + $this->assertNotEmpty( $results ); + + foreach ( $results as $result ) { + // Verify type transformations in paginated results + $this->assertIsInt( $result['int_col'] ); + $this->assertIsFloat( $result['float_col'] ); + $this->assertIsBool( $result['is_active'] ); + $this->assertTrue( $result['is_active'] ); // We filtered for active only + + $this->assertIsArray( $result['json_data'] ); + $this->assertArrayHasKey( 'page_item', $result['json_data'] ); + $this->assertEquals( 5, $result['json_data']['total'] ); + } + } + + /** + * Test fetch_all generator with type transformations. + * + * @test + */ + public function should_transform_types_with_fetch_all() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert a few test records + $base_time = time(); + for ( $i = 1; $i <= 2; $i++ ) { + $data = [ + 'varchar_col' => 'fetch_all_test_' . $i . '_' . $base_time, + 'int_col' => $i * 100, + 'float_col' => $i * 0.5, + 'is_active' => 1, + 'json_data' => json_encode( [ 'fetched' => true, 'order' => $i ] ), + ]; + $wpdb->insert( $table::table_name(), $data ); + } + + // Test fetch_all_where + $count = 0; + + foreach ( $table::get_all_by( 'varchar_col', 'fetch_all_test_%' . $base_time, 'LIKE' ) as $row ) { + // Note: fetch_all returns raw data without transformation + // We need to manually transform it + $transformed = $table::transform_from_array( $row ); + + // Verify the transformation worked + $this->assertIsInt( $transformed['int_col'] ); + $this->assertIsBool( $transformed['is_active'] ); + $this->assertIsArray( $transformed['json_data'] ); + $this->assertTrue( $transformed['json_data']['fetched'] ); + + $count++; + } + + $this->assertGreaterThanOrEqual( 2, $count ); + } + + /** + * Test nullable column handling through API. + * + * @test + */ + public function should_handle_null_values_in_api() { + global $wpdb; + + $table = $this->get_comprehensive_table(); + Register::table( $table ); + + // Insert record with NULL values + $unique_varchar = 'null_test_' . time(); + $data = [ + 'varchar_col' => $unique_varchar, + 'smallint_col' => null, + 'decimal_col' => null, + 'text_col' => null, + 'date_col' => null, + 'json_data' => '{}', + ]; + + $wpdb->insert( $table::table_name(), $data ); + + // Retrieve through API + $result = $table::get_first_by( 'varchar_col', $unique_varchar ); + + $this->assertNotNull( $result ); + + // Verify NULL values are preserved + $this->assertNull( $result['smallint_col'] ); + $this->assertNull( $result['decimal_col'] ); + $this->assertNull( $result['text_col'] ); + $this->assertNull( $result['date_col'] ); + + // Non-nullable fields should have their defaults + $this->assertIsInt( $result['tinyint_col'] ); + $this->assertEquals( 0, $result['tinyint_col'] ); + } + + /** + * Test special column types through API. + * + * @test + */ + public function should_handle_special_columns_through_api() { + global $wpdb; + + $table = $this->get_updated_at_table(); + Register::table( $table ); + + // Insert record + $content = 'Special columns test ' . time(); + $table::insert( [ + 'content' => $content, + ] ); + + // Retrieve through API + $result = $table::get_first_by( 'content', $content ); + + $this->assertNotNull( $result ); + $this->assertEquals( $content, $result['content'] ); + + // Updated_at should be null on insert + $this->assertNull( $result['updated_at'] ); + + // Update the record + sleep( 1 ); + $wpdb->update( + $table::table_name(), + [ 'content' => $content . ' updated' ], + [ 'id' => $result['id'] ] + ); + + // Retrieve again + $updated = $table::get_by_id( $result['id'] ); + + // Now updated_at should have a value + $this->assertInstanceOf( DateTime::class, $updated['updated_at'] ); + } +}