diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad084a..40306ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [3.1.0] 2025-09-30 + +* Feature - Introduce new column types: `Blob_Column`, `Binary_Column`, and `Boolean_Column`. +* Feature - Introduce new PHP type: Blob. Blob will be stored as a base64 encoded string. +* Tweak - Update string based columns to have the ability to become primary keys. Those columns include: char, varchar, binary and varbinary. + +[3.1.0]: https://github.com/stellarwp/schema/releases/tag/3.1.0 + ## [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. diff --git a/README.md b/README.md index 796b689..a4904de 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ The examples will be using: * `Boom\Shakalaka\` as the namespace prefix, though will sometimes be referenced as `PREFIX\` for the purpose of brevity in the docs. * `BOOM_SHAKALAKA_` as the constant prefix. +## What's new in 3.0.0 + +Version 3.0.0 introduces major new features and breaking changes: + +- **Type-safe Column Definitions**: Define table columns using strongly-typed classes (`Integer_Column`, `String_Column`, `Float_Column`, etc.) instead of raw SQL +- **Index Management**: Create and manage indexes with dedicated classes (`Primary_Key`, `Unique_Key`, `Classic_Index`, `Fulltext_Index`) +- **Schema History**: Track and manage schema changes over time with the `get_schema_history()` method +- **Enhanced Query Methods**: Access built-in CRUD operations through the `Custom_Table_Query_Methods` trait +- **Improved Type Safety**: Automatic type casting and validation for PHP/MySQL data transformation + +**Note**: Version 3.0.0 is NOT backwards compatible with 2.x. See the [migration guide](docs/migrating-from-v2-to-v3.md) for upgrading from v2 to v3. + ## Getting started For a full understanding of what is available in this library and how to use it, definitely read through the full [documentation](#documentation). But for folks that want to get rolling with the basics quickly, try out the following. @@ -55,15 +67,22 @@ Config::set_container( $container ); Config::set_db( DB::class ); ``` -### Creating a table +### Creating a table (v3.0+) Let's say you want a new custom table called `sandwiches` (with the default WP prefix, it'd be `wp_sandwiches`). You'll need a class file for the table. For the sake of this example, we'll be assuming this class is going into a `Tables/` directory and is reachable via the `Boom\Shakalaka\Tables` namespace. +**Version 3.0** introduces a new, type-safe way to define tables using Column and Index classes: + ```php get_charset_collate(); - - return " - CREATE TABLE `{$table_name}` ( - `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT, - `name` varchar(50) NOT NULL, - PRIMARY KEY (`id`) - ) {$charset_collate}; - "; + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Define an auto-incrementing ID column + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::INT ) + ->set_auto_increment( true ); + + // Define a varchar column for the name + $columns[] = ( new String_Column( 'name' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ); + + return new Table_Schema( $table_name, $columns ); + }, + ]; } } ``` +You can still use the `get_definition()` method for backwards compatibility, but the new Column/Index system is recommended for all new tables. You must switch the visibility of the `get_definition()` method to `public` and still implement the `get_schema_history()` method, however. + Here's what the properties and method mean: * `$base_table_name`: The name of the table without the prefix. * `$group`: The group that the table belongs to - this is for organizational purposes. * `$schema_slug`: An identifier for the table. This is used in storing your table's schema version in `wp_options`. -* `$uid_column`: The name of the column that is used to uniquely identify each row. -* `get_definition()`: This should return the base SQL definition used to create your `sandwiches` table. To get the full SQL (with any field schemas included), you can call `get_sql()`! +* `get_schema_history()`: Returns an array of callables keyed by version number. Each callable returns a `Table_Schema` object defining columns and indexes for that version. + +**Key features of the new v3 system:** + +* **Type-safe columns**: Use `Integer_Column`, `String_Column`, `Float_Column`, `Text_Column`, `Datetime_Column`, and specialized columns like `ID`, `Created_At`, `Updated_At` +* **Fluent API**: Chain methods like `set_length()`, `set_default()`, `set_nullable()`, `set_auto_increment()` +* **Index support**: Define indexes with `Classic_Index`, `Unique_Key`, `Primary_Key`, `Fulltext_Index` +* **Automatic type casting**: Values are automatically cast between PHP and MySQL types ### Registering the table @@ -155,6 +186,7 @@ Here's some more advanced documentation to get you rolling on using this library 1. [Deregistering fields](/docs/schemas-field.md#deregistering-fields) 1. [Field collection](/docs/schemas-field.md#field-collection) 1. [Publicly accessible methods](/docs/schemas-field.md#publicly-accessible-methods) +1. [Migrating from v2 to v3](/docs/migrating-from-v2-to-v3.md) - **Important for existing users!** 1. [Automated testing](/docs/automated-testing.md) ## Acknowledgements diff --git a/docs/columns.md b/docs/columns.md new file mode 100644 index 0000000..882fe34 --- /dev/null +++ b/docs/columns.md @@ -0,0 +1,325 @@ +# Column System (v3.0+) + +Version 3.0.0 introduces a powerful type-safe column definition system. Instead of writing raw SQL, you can now define table columns using strongly-typed PHP classes that provide automatic type casting, validation, and a fluent API. + +## Available Column Types + +### Base Column Classes + +- **`Integer_Column`** - For integer types (TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT) +- **`Float_Column`** - For floating point types (FLOAT, DOUBLE, DECIMAL) +- **`String_Column`** - For fixed and variable length strings (CHAR, VARCHAR) +- **`Text_Column`** - For text types (TINYTEXT, TEXT, MEDIUMTEXT, LONGTEXT) +- **`Datetime_Column`** - For date and time types (DATE, DATETIME, TIMESTAMP) +- **`Boolean_Column`** - For boolean values (stored as TINYINT(1)) +- **`Blob_Column`** - For binary data (TINYBLOB, BLOB, MEDIUMBLOB, LONGBLOB) +- **`Binary_Column`** - For fixed-length binary data (BINARY, VARBINARY) + +### Specialized Column Classes + +- **`ID`** - Pre-configured auto-incrementing primary key +- **`Referenced_ID`** - Foreign key reference to another table +- **`Created_At`** - Timestamp column with automatic CURRENT_TIMESTAMP default +- **`Updated_At`** - Timestamp column that updates on row changes +- **`Last_Changed`** - Alias for `Updated_At` + +## Column Types Enum + +Use the `Column_Types` class to specify MySQL column types: + +```php +use StellarWP\Schema\Columns\Column_Types; + +// Integer types +Column_Types::TINYINT +Column_Types::SMALLINT +Column_Types::MEDIUMINT +Column_Types::INT +Column_Types::BIGINT + +// Float types +Column_Types::FLOAT +Column_Types::DOUBLE +Column_Types::DECIMAL + +// String types +Column_Types::CHAR +Column_Types::VARCHAR + +// Text types +Column_Types::TINYTEXT +Column_Types::TEXT +Column_Types::MEDIUMTEXT +Column_Types::LONGTEXT + +// Date/Time types +Column_Types::DATE +Column_Types::DATETIME +Column_Types::TIMESTAMP + +// Binary types +Column_Types::BINARY +Column_Types::VARBINARY +Column_Types::TINYBLOB +Column_Types::BLOB +Column_Types::MEDIUMBLOB +Column_Types::LONGBLOB + +// Other types +Column_Types::BOOLEAN +Column_Types::JSON +``` + +## Common Column Methods + +All column classes support a fluent API with these methods: + +### Required Methods + +```php +// Set the column name. +$column = new Integer_Column( 'user_id' ); +``` + +### Type Configuration + +```php +// Set the MySQL column type. +->set_type( Column_Types::INT ) + +// Set the length/size. +->set_length( 11 ) + +// Set precision for float columns (decimal places). +->set_precision( 2 ) // For DECIMAL(10,2). +``` + +### Nullability and Defaults + +```php +// Allow NULL values (default is NOT NULL). +->set_nullable( true ) + +// Set a default value. +->set_default( 0 ) +->set_default( 'pending' ) +->set_default( 'CURRENT_TIMESTAMP' ) // MySQL function. +``` + +### Signing (Integer/Float only) + +```php +// Set signed/unsigned (default varies by type). +->set_signed( false ) // UNSIGNED. +->set_signed( true ) // SIGNED. +``` + +### Auto Increment (Integer only) + +```php +// Enable auto-increment. +->set_auto_increment( true ) +``` + +### Indexes + +```php +// Mark column as indexed. +->set_is_index( true ) + +// Mark column as unique. +->set_is_unique( true ) + +// Mark column as primary key. +->set_is_primary( true ) +``` + +### Searchability + +```php +// Mark column as searchable (used by query methods). +->set_searchable( true ) +``` + +## PHP Type Mapping + +Columns automatically map between PHP and MySQL types: + +| Column Class | MySQL Type | PHP Type | +|-------------|-----------|---------| +| `Integer_Column` | INT, BIGINT, etc. | `int` | +| `Float_Column` | FLOAT, DOUBLE, DECIMAL | `float` | +| `String_Column` | VARCHAR, CHAR | `string` | +| `Text_Column` | TEXT, LONGTEXT, etc. | `string` | +| `Datetime_Column` | DATETIME, TIMESTAMP | `DateTimeInterface` | +| `Boolean_Column` | TINYINT(1) | `bool` | +| `Blob_Column` | BLOB | `string` (base64) | + +## Examples + +### Basic Integer Column + +```php +$columns[] = ( new Integer_Column( 'user_id' ) ) + ->set_type( Column_Types::BIGINT ) + ->set_length( 20 ) + ->set_signed( false ); +``` + +### String Column with Default + +```php +$columns[] = ( new String_Column( 'status' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ) + ->set_default( 'pending' ); +``` + +### Nullable Text Column + +```php +$columns[] = ( new Text_Column( 'notes' ) ) + ->set_type( Column_Types::TEXT ) + ->set_nullable( true ); +``` + +### Decimal Price Column + +```php +$columns[] = ( new Float_Column( 'price' ) ) + ->set_type( Column_Types::DECIMAL ) + ->set_length( 10 ) // Total digits + ->set_precision( 2 ) // Decimal places + ->set_default( 0.00 ); +``` + +### Auto-incrementing ID + +```php +$columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::BIGINT ) + ->set_auto_increment( true ); +``` + +### Timestamp Columns + +```php +// Created timestamp +$columns[] = new Created_At( 'created_at' ); + +// Updated timestamp +$columns[] = new Updated_At( 'updated_at' ); +``` + +### Searchable Column + +```php +$columns[] = ( new String_Column( 'title' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 200 ) + ->set_searchable( true ); +``` + +### Foreign Key Reference + +```php +$columns[] = ( new Referenced_ID( 'event_id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::BIGINT ); +``` + +### Boolean Column + +```php +$columns[] = ( new Boolean_Column( 'is_active' ) ) + ->set_default( true ); +``` + +### JSON Column + +```php +$columns[] = ( new Text_Column( 'metadata' ) ) + ->set_type( Column_Types::JSON ) + ->set_nullable( true ); +``` + +## Complete Example + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Primary key. + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_auto_increment( true ); + + // Foreign key. + $columns[] = ( new Referenced_ID( 'user_id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::BIGINT ); + + // String fields. + $columns[] = ( new String_Column( 'title' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ) + ->set_searchable( true ); + + $columns[] = ( new String_Column( 'status' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 20 ) + ->set_default( 'draft' ) + ->set_is_index( true ); + + // Text field. + $columns[] = ( new Text_Column( 'content' ) ) + ->set_type( Column_Types::LONGTEXT ) + ->set_searchable( true ); + + // Numeric fields. + $columns[] = ( new Integer_Column( 'views' ) ) + ->set_type( Column_Types::INT ) + ->set_default( 0 ); + + $columns[] = ( new Float_Column( 'rating' ) ) + ->set_type( Column_Types::DECIMAL ) + ->set_length( 3 ) + ->set_precision( 2 ) + ->set_default( 0.00 ); + + // Boolean. + $columns[] = ( new Boolean_Column( 'is_published' ) ) + ->set_default( false ); + + // Timestamps. + $columns[] = new Created_At( 'created_at' ); + $columns[] = new Updated_At( 'updated_at' ); + + return new Table_Schema( $table_name, $columns ); + }, + ]; +} +``` + +## Benefits of the Column System + +1. **Type Safety**: Catch errors at development time instead of runtime +2. **Auto Type Casting**: Values are automatically converted between PHP and MySQL types +3. **Fluent API**: Readable, chainable method calls +4. **Validation**: Column definitions are validated when the table is created +5. **IDE Support**: Full autocomplete and type hints +6. **Query Methods**: Enables built-in CRUD operations on your tables +7. **Searchability**: Mark columns as searchable for automatic full-text search +8. **Migration Friendly**: Easy to version and track schema changes + +## See Also + +- [Index System](indexes.md) +- [Table Schemas](schemas-table.md) +- [Query Methods](query-methods.md) +- [Migrating from v2 to v3](migrating-from-v2-to-v3.md) \ No newline at end of file diff --git a/docs/indexes.md b/docs/indexes.md new file mode 100644 index 0000000..1395348 --- /dev/null +++ b/docs/indexes.md @@ -0,0 +1,370 @@ +# Index System (v3.0+) + +Version 3.0.0 introduces a type-safe index management system. Define indexes using dedicated PHP classes instead of raw SQL for better type safety and validation. + +## Available Index Types + +### `Primary_Key` + +Defines the primary key for the table. Primary keys uniquely identify each row and must contain non-NULL values. + +```php +use StellarWP\Schema\Indexes\Primary_Key; + +// Single column primary key. +$indexes[] = new Primary_Key( [ 'id' ] ); + +// Composite primary key. +$indexes[] = new Primary_Key( [ 'event_id', 'ticket_id' ] ); +``` + +### `Unique_Key` + +Ensures that all values in the indexed columns are unique across the table. + +```php +use StellarWP\Schema\Indexes\Unique_Key; + +// Single column unique constraint. +$indexes[] = new Unique_Key( [ 'email' ] ); + +// Composite unique constraint. +$indexes[] = new Unique_Key( [ 'user_id', 'event_id' ] ); +``` + +### `Classic_Index` + +A standard index that speeds up lookups on the specified columns. Does not enforce uniqueness. + +```php +use StellarWP\Schema\Indexes\Classic_Index; + +// Single column index. +$indexes[] = new Classic_Index( [ 'status' ] ); + +// Composite index. +$indexes[] = new Classic_Index( [ 'user_id', 'created_at' ] ); +``` + +### `Fulltext_Index` + +Enables full-text searching on text columns. Useful for content search functionality. + +```php +use StellarWP\Schema\Indexes\Fulltext_Index; + +// Single column fulltext. +$indexes[] = new Fulltext_Index( [ 'content' ] ); + +// Multiple columns fulltext. +$indexes[] = new Fulltext_Index( [ 'title', 'content', 'excerpt' ] ); +``` + +## Creating Indexes + +Indexes are defined within the `get_schema_history()` method and added to an `Index_Collection`: + +```php +use StellarWP\Schema\Collections\Index_Collection; + +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + // ... define columns ... + + // Define indexes + $indexes = new Index_Collection(); + + $indexes[] = new Primary_Key( [ 'id' ] ); + $indexes[] = new Classic_Index( [ 'status' ] ); + $indexes[] = new Unique_Key( [ 'email' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +## Index Naming + +The library automatically generates index names based on the index type and columns: + +- **Primary Key**: `PRIMARY` +- **Unique Key**: `unique_{column1}_{column2}` +- **Classic Index**: `index_{column1}_{column2}` +- **Fulltext Index**: `fulltext_{column1}_{column2}` + +## Composite Indexes + +All index types support multiple columns (composite indexes): + +```php +// Composite classic index. +$indexes[] = new Classic_Index( [ 'user_id', 'status', 'created_at' ] ); + +// Composite unique key. +$indexes[] = new Unique_Key( [ 'external_id', 'source' ] ); +``` + +**Important**: The order of columns in a composite index matters for query optimization. Place the most selective columns first. + +## Examples + +### E-commerce Orders Table + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_auto_increment( true ); + + $columns[] = ( new String_Column( 'order_number' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ); + + $columns[] = ( new Referenced_ID( 'customer_id' ) ) + ->set_type( Column_Types::BIGINT ); + + $columns[] = ( new String_Column( 'status' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 20 ); + + $columns[] = ( new Float_Column( 'total' ) ) + ->set_type( Column_Types::DECIMAL ) + ->set_length( 10 ) + ->set_precision( 2 ); + + $columns[] = new Created_At( 'created_at' ); + + // Indexes for common queries. + $indexes = new Index_Collection(); + + // Unique order numbers. + $indexes[] = new Unique_Key( [ 'order_number' ] ); + + // Fast lookups by customer. + $indexes[] = new Classic_Index( [ 'customer_id' ] ); + + // Fast lookups by status. + $indexes[] = new Classic_Index( [ 'status' ] ); + + // Efficient date range queries. + $indexes[] = new Classic_Index( [ 'created_at' ] ); + + // Composite index for customer orders by status. + $indexes[] = new Classic_Index( [ 'customer_id', 'status' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +### Blog Posts with Fulltext Search + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_auto_increment( true ); + + $columns[] = ( new String_Column( 'title' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ); + + $columns[] = ( new Text_Column( 'content' ) ) + ->set_type( Column_Types::LONGTEXT ); + + $columns[] = ( new String_Column( 'slug' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ); + + $columns[] = ( new String_Column( 'status' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 20 ); + + $indexes = new Index_Collection(); + + // Unique slugs for URL routing. + $indexes[] = new Unique_Key( [ 'slug' ] ); + + // Full-text search on title and content. + $indexes[] = new Fulltext_Index( [ 'title', 'content' ] ); + + // Fast filtering by status. + $indexes[] = new Classic_Index( [ 'status' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +### User Sessions Table + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Using session_id as primary key (not auto-increment). + $columns[] = ( new String_Column( 'session_id' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 128 ); + + $columns[] = ( new Referenced_ID( 'user_id' ) ) + ->set_type( Column_Types::BIGINT ) + ->set_nullable( true ); + + $columns[] = ( new Text_Column( 'data' ) ) + ->set_type( Column_Types::TEXT ); + + $columns[] = ( new Datetime_Column( 'expires_at' ) ) + ->set_type( Column_Types::DATETIME ); + + $indexes = new Index_Collection(); + + // String-based primary key. + $indexes[] = new Primary_Key( [ 'session_id' ] ); + + // Lookup sessions by user. + $indexes[] = new Classic_Index( [ 'user_id' ] ); + + // Efficiently clean up expired sessions. + $indexes[] = new Classic_Index( [ 'expires_at' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +### Many-to-Many Relationship Table + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new Referenced_ID( 'event_id' ) ) + ->set_type( Column_Types::BIGINT ); + + $columns[] = ( new Referenced_ID( 'venue_id' ) ) + ->set_type( Column_Types::BIGINT ); + + $columns[] = new Created_At( 'created_at' ); + + $indexes = new Index_Collection(); + + // Composite primary key (no auto-increment needed). + $indexes[] = new Primary_Key( [ 'event_id', 'venue_id' ] ); + + // Fast reverse lookups. + $indexes[] = new Classic_Index( [ 'venue_id' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +## Index Best Practices + +### When to Use Indexes + +✅ **DO** add indexes for: +- Primary keys (required) +- Foreign keys used in JOINs +- Columns used in WHERE clauses +- Columns used in ORDER BY +- Unique constraints (email, username, etc.) +- Full-text search columns + +❌ **DON'T** add indexes for: +- Small tables (< 1000 rows) +- Columns with low cardinality (few unique values) +- Columns that are rarely queried +- Every column (over-indexing slows down writes) + +### Composite Index Tips + +1. **Order matters**: Put the most selective column first +2. **Leftmost prefix**: MySQL can use partial composite indexes from left to right +3. **Cover common queries**: Design indexes around your most frequent query patterns + +```php +// Good: User queries often filter by status first, then date. +$indexes[] = new Classic_Index( [ 'status', 'created_at' ] ); + +// Also good: Allows queries on just 'status' OR 'status' + 'created_at'. +// MySQL can use the leftmost prefix. +``` + +### Performance Considerations + +- **Read vs Write**: Indexes speed up reads but slow down writes +- **Index size**: More indexes = more disk space and memory usage +- **Maintenance**: Indexes need to be rebuilt/optimized periodically + +## Migration Example + +Adding an index to an existing table: + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + // Original schema. + $columns = new Column_Collection(); + $columns[] = ( new ID( 'id' ) )->set_auto_increment( true ); + $columns[] = ( new String_Column( 'email' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ); + + return new Table_Schema( $table_name, $columns ); + }, + '1.1.0' => function() use ( $table_name ) { + // Add unique constraint on email in version 1.1.0. + $columns = new Column_Collection(); + $columns[] = ( new ID( 'id' ) )->set_auto_increment( true ); + $columns[] = ( new String_Column( 'email' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ); + + $indexes = new Index_Collection(); + $indexes[] = new Unique_Key( [ 'email' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; +} +``` + +## See Also + +- [Column System](columns.md) +- [Table Schemas](schemas-table.md) +- [Query Methods](query-methods.md) +- [Migrating from v2 to v3](migrating-from-v2-to-v3.md) \ No newline at end of file diff --git a/docs/query-methods.md b/docs/query-methods.md new file mode 100644 index 0000000..3b2e9a7 --- /dev/null +++ b/docs/query-methods.md @@ -0,0 +1,536 @@ +# Query Methods (v3.0+) + +Version 3.0.0 introduces built-in CRUD (Create, Read, Update, Delete) operations through the `Custom_Table_Query_Methods` trait. When you define tables using the new Column system, you automatically get access to these powerful query methods with automatic type casting and validation. + +## Overview + +All query methods: +- **Type-safe**: Automatically cast values between PHP and MySQL types +- **Validated**: Ensure column names exist and values are appropriate +- **Convenient**: No need to write raw SQL for common operations +- **Batched**: Support bulk operations where appropriate + +## Insert Operations + +### `::insert( array $entry )` + +Insert a single row into the table. + +**Parameters:** +- `$entry` - Associative array of column => value pairs + +**Returns:** Number of rows affected, or `false` on failure + +**Example:** +```php +$result = Sandwiches::insert( [ + 'name' => 'Club Sandwich', + 'type' => 'classic', + 'price_cents' => 1299, + 'is_active' => true, +] ); + +if ( $result ) { + // Success - $result contains the insert ID for auto-increment columns. +} +``` + +### `::insert_many( array $entries )` + +Insert multiple rows in a single query (more efficient than multiple insert calls). + +**Parameters:** +- `$entries` - Array of associative arrays + +**Returns:** Number of rows affected, or `false` on failure + +**Example:** +```php +$result = Sandwiches::insert_many( [ + [ + 'name' => 'BLT', + 'type' => 'classic', + 'price_cents' => 899, + ], + [ + 'name' => 'Reuben', + 'type' => 'hot', + 'price_cents' => 1099, + ], + [ + 'name' => 'Veggie Wrap', + 'type' => 'healthy', + 'price_cents' => 799, + ], +] ); +``` + +## Update Operations + +### `::update_single( array $entry )` + +Update a single row. The entry must include the primary key column(s). + +**Parameters:** +- `$entry` - Associative array including primary key and fields to update + +**Returns:** `true` on success, `false` on failure + +**Example:** +```php +$result = Sandwiches::update_single( [ + 'id' => 42, + 'price_cents' => 1499, // Increase price + 'updated_at' => new DateTime(), +] ); +``` + +### `::update_many( array $entries )` + +Update multiple rows in a transaction. All updates succeed or all fail. + +**Parameters:** +- `$entries` - Array of associative arrays, each including primary key + +**Returns:** `true` if all updates succeeded, `false` if any failed + +**Example:** +```php +$result = Sandwiches::update_many( [ + [ 'id' => 1, 'is_active' => true ], + [ 'id' => 2, 'is_active' => true ], + [ 'id' => 3, 'is_active' => false ], +] ); +``` + +### `::upsert( array $entry )` + +Insert a new row or update existing based on primary key presence. + +**Parameters:** +- `$entry` - Associative array (with or without primary key) + +**Returns:** `true` on success, `false` on failure + +**Example:** +```php +// Will insert if no ID, update if ID exists +$result = Sandwiches::upsert( [ + 'id' => $maybe_existing_id ?? null, + 'name' => 'Turkey Club', + 'price_cents' => 1199, +] ); +``` + +## Delete Operations + +### `::delete( int|string $uid, string $column = '' )` + +Delete a single row by its unique identifier. + +**Parameters:** +- `$uid` - The ID value +- `$column` - Optional column name (defaults to primary key) + +**Returns:** `true` on success, `false` on failure + +**Example:** +```php +// Delete by primary key +$result = Sandwiches::delete( 42 ); + +// Delete by custom column +$result = Sandwiches::delete( 'abc123', 'external_id' ); +``` + +### `::delete_many( array $ids, string $column = '', string $more_where = '' )` + +Delete multiple rows in a single query. + +**Parameters:** +- `$ids` - Array of ID values +- `$column` - Optional column name (defaults to primary key) +- `$more_where` - Additional WHERE conditions + +**Returns:** Number of rows deleted, or `false` on failure + +**Example:** +```php +// Delete multiple sandwiches +$result = Sandwiches::delete_many( [ 1, 5, 12, 15 ] ); + +// Delete with additional condition +$result = Sandwiches::delete_many( + [ 1, 2, 3 ], + 'id', + 'AND is_active = 0' +); +``` + +## Read Operations + +### `::get_by_id( int|string $id )` + +Fetch a single row by its primary key. + +**Returns:** Object/array representing the row, or `null` if not found + +**Example:** +```php +$sandwich = Sandwiches::get_by_id( 42 ); + +if ( $sandwich ) { + echo $sandwich['name']; // Values are automatically type-cast. + echo $sandwich['price_cents']; // int. + echo $sandwich['is_active']; // bool. +} +``` + +### `::get_first_by( string $column, mixed $value )` + +Fetch the first row matching a column value. + +**Parameters:** +- `$column` - Column name to search +- `$value` - Value to match + +**Returns:** Row data or `null` if not found + +**Example:** +```php +$sandwich = Sandwiches::get_first_by( 'name', 'Club Sandwich' ); +$sandwich = Sandwiches::get_first_by( 'type', 'classic' ); +``` + +### `::get_all_by( string $column, mixed $value, string $operator = '=', int $limit = 50 )` + +Fetch all rows matching a column value. + +**Parameters:** +- `$column` - Column name +- `$value` - Value to match +- `$operator` - Comparison operator (=, !=, >, <, >=, <=, IN, NOT IN) +- `$limit` - Maximum rows to return + +**Returns:** Array of rows + +**Example:** +```php +// Get all classic sandwiches. +$classics = Sandwiches::get_all_by( 'type', 'classic' ); + +// Get all sandwiches under $10. +$affordable = Sandwiches::get_all_by( 'price_cents', 1000, '<' ); + +// Get specific sandwiches. +$selection = Sandwiches::get_all_by( 'id', [ 1, 5, 10 ], 'IN' ); +``` + +### `::get_all( int $batch_size = 50, string $where_clause = '', string $order_by = '' )` + +Generator that yields all rows in batches. Memory efficient for large datasets. + +**Parameters:** +- `$batch_size` - Number of rows per batch +- `$where_clause` - Optional WHERE clause +- `$order_by` - Optional ORDER BY clause + +**Returns:** Generator yielding rows + +**Example:** +```php +// Process all sandwiches without loading all into memory. +foreach ( Sandwiches::get_all( 100 ) as $sandwich ) { + // Process each sandwich. + process_sandwich( $sandwich ); +} + +// With conditions. +foreach ( Sandwiches::get_all( 50, 'WHERE is_active = 1', 'created_at DESC' ) as $sandwich ) { + echo $sandwich['name'] . "\n"; +} +``` + +### `::paginate( array $args, int $per_page = 20, int $page = 1, array $columns = ['*'], ... )` + +Advanced paginated query with filtering, sorting, and optional joins. + +**Parameters:** +- `$args` - Query arguments (see below) +- `$per_page` - Items per page (max 200) +- `$page` - Current page number +- `$columns` - Columns to select +- `$join_table` - Optional table to join +- `$join_condition` - Join condition +- `$selectable_joined_columns` - Columns from joined table + +**Query Arguments:** +```php +$args = [ + 'term' => 'search term', // Search in searchable columns. + 'orderby' => 'created_at', // Column to sort by. + 'order' => 'DESC', // ASC or DESC. + 'offset' => 0, // Starting offset. + 'query_operator' => 'AND', // AND or OR. + + // Column filters. + [ + 'column' => 'status', + 'value' => 'active', + 'operator' => '=', // =, !=, >, <, >=, <=, IN, NOT IN. + ], + [ + 'column' => 'price_cents', + 'value' => 1000, + 'operator' => '<', + ], +]; +``` + +**Returns:** Array of rows + +**Example:** +```php +// Basic pagination. +$sandwiches = Sandwiches::paginate( [], 20, 1 ); + +// With search. +$results = Sandwiches::paginate( + [ 'term' => 'turkey' ], + 20, + 1 +); + +// Complex filtering. +$results = Sandwiches::paginate( + [ + 'orderby' => 'price_cents', + 'order' => 'ASC', + [ + 'column' => 'type', + 'value' => 'classic', + 'operator' => '=', + ], + [ + 'column' => 'price_cents', + 'value' => 1500, + 'operator' => '<', + ], + ], + 20, + 1 +); + +// With JOIN. +$results = Sandwiches::paginate( + $args, + 20, + 1, + [ '*' ], // Columns from main table. + Ingredients::class, // Join table. + 'sandwich_id = id', // Join condition. + [ 'name', 'quantity' ] // Columns from joined table. +); +``` + +### `::get_total_items( array $args = [] )` + +Count total rows matching the given filters. + +**Parameters:** +- `$args` - Same format as `paginate()` + +**Returns:** Integer count + +**Example:** +```php +$total = Sandwiches::get_total_items(); + +$active_count = Sandwiches::get_total_items( [ + [ + 'column' => 'is_active', + 'value' => true, + 'operator' => '=', + ], +] ); +``` + +## Type Casting + +All query methods automatically handle type conversion: + +```php +// DateTime objects are converted to MySQL format. +Sandwiches::insert( [ + 'name' => 'BLT', + 'created_at' => new DateTime(), // Becomes '2025-09-30 12:00:00'. +] ); + +// Retrieved DateTimes are converted back. +$sandwich = Sandwiches::get_by_id( 1 ); +$sandwich['created_at']; // DateTimeInterface object. + +// Booleans work seamlessly. +Sandwiches::insert( [ + 'name' => 'Club', + 'is_active' => true, // Stored as 1. +] ); + +$sandwich = Sandwiches::get_by_id( 1 ); +$sandwich['is_active']; // true (bool). + +// JSON columns. +Sandwiches::insert( [ + 'name' => 'Veggie', + 'metadata' => [ 'calories' => 350, 'vegan' => true ], // JSON encoded. +] ); + +$sandwich = Sandwiches::get_by_id( 1 ); +$sandwich['metadata']; // array. +``` + +## Complete Example + +```php + 123, + 'order_number' => 'ORD-2025-001', + 'status' => 'pending', + 'total' => 49.99, + 'created_at' => new DateTime(), +] ); + +// Update order status. +Orders::update_single( [ + 'id' => $order_id, + 'status' => 'processing', + 'updated_at' => new DateTime(), +] ); + +// Get order by ID. +$order = Orders::get_by_id( $order_id ); + +// Get all pending orders. +$pending = Orders::get_all_by( 'status', 'pending' ); + +// Paginate orders with filtering. +$recent_orders = Orders::paginate( + [ + 'orderby' => 'created_at', + 'order' => 'DESC', + [ + 'column' => 'status', + 'value' => [ 'pending', 'processing' ], + 'operator' => 'IN', + ], + ], + 25, // per page. + 1 // page number. +); + +// Search orders. +$search_results = Orders::paginate( + [ 'term' => 'john doe' ], // Searches searchable columns. + 25, + 1 +); + +// Get total count. +$total_pending = Orders::get_total_items( [ + [ + 'column' => 'status', + 'value' => 'pending', + 'operator' => '=', + ], +] ); + +// Bulk update. +Orders::update_many( [ + [ 'id' => 1, 'status' => 'shipped' ], + [ 'id' => 2, 'status' => 'shipped' ], + [ 'id' => 3, 'status' => 'shipped' ], +] ); + +// Delete old orders. +Orders::delete_many( [ 10, 11, 12 ] ); +``` + +## Custom Transform Method + +You can implement a `transform_from_array()` method to convert row data into custom objects: + +```php +class Orders extends Table { + // ... + + public static function transform_from_array( array $data ) { + // Return a custom object instead of array. + return new Order_Model( $data ); + } +} + +// Now query methods return Order_Model objects. +$order = Orders::get_by_id( 1 ); // Order_Model instance. +$orders = Orders::get_all_by( 'status', 'pending' ); // Array of Order_Model. +``` + +## Hooks and Filters + +Query methods provide hooks for customization: + +```php +// Before query execution. +add_action( 'tec_common_custom_table_query_pre_results', function( $args, $class ) { + // Modify args, log, etc. +}, 10, 2 ); + +// After query execution. +add_action( 'tec_common_custom_table_query_post_results', function( $results, $args, $class ) { + // Log, analyze, etc. +}, 10, 3 ); + +// Filter results. +add_filter( 'tec_common_custom_table_query_results', function( $results, $args, $class ) { + // Modify results. + return $results; +}, 10, 3 ); + +// Filter WHERE clause. +add_filter( 'tec_common_custom_table_query_where', function( $where, $args, $class ) { + // Add custom WHERE conditions. + return $where; +}, 10, 3 ); +``` + +## Performance Tips + +1. **Batch Operations**: Use `insert_many()` and `update_many()` for bulk operations +2. **Pagination**: Use `paginate()` instead of loading all rows +3. **Generators**: Use `get_all()` for memory-efficient processing of large datasets +4. **Indexes**: Ensure columns used in filters have appropriate indexes +5. **Select Specific Columns**: Pass specific columns to `paginate()` instead of `*` + +## See Also + +- [Column System](columns.md) +- [Index System](indexes.md) +- [Table Schemas](schemas-table.md) +- [Migrating from v2 to v3](migrating-from-v2-to-v3.md) \ No newline at end of file diff --git a/docs/schemas-field-example.md b/docs/schemas-field-example.md deleted file mode 100644 index 65c52b1..0000000 --- a/docs/schemas-field-example.md +++ /dev/null @@ -1,75 +0,0 @@ -# Example field schema class - -Here is an example field schema: - -```php -prefix` applied) that this field schema is associated with. With the above example in mind, the value can be accessed using `PREFIX\Fields\SandwichesPro::base_table_name()`. - -### `protected static $group` - -This value allows you to group field schemas together so you can do interesting things with them programmatically. There's no explicit use for groups out of the box other than providing some tooling for finding field schemas within a group. - -### `protected static $schema_slug` - -This is the slug of this field schema and is used as the index for the `PREFIX\StellarWP\Schema::fields()` collection as well as a prefix to the version number for this field schema. - -### `protected function get_definition()` - -This is the base definition of the fields and indices that the fields schema will be injecting. In isolation, this is not valid SQL, but it should be valid SQL within a `CREATE TABLE` statement. diff --git a/docs/schemas-field.md b/docs/schemas-field.md deleted file mode 100644 index a866ade..0000000 --- a/docs/schemas-field.md +++ /dev/null @@ -1,218 +0,0 @@ -# Field schemas - -Table schema classes hold all of the building blocks for getting a custom table to be defined and managed by this library. Table schemas should have their base definition declared in the `::get_definition()` method, however, it is important to note that the final definition SQL (fetched via `::get_sql()`) can be influenced by registered Field Schemas. - -Check out an [example field schema](schemas-field-example.md) and get a look at the minimally required properties and methods. - -## Versioning - -Field schema versions are used to augment the resulting version number of the table schema to which the field schema is associated. For more details around how table schema versions are built and stored, see the documentation for [table schemas](schemas-table.md). - -## Registering fields - -Field schemas need to be registered for them to be implemented. Additionally, all field schemas _must_ have a corresponding table schema that is registered. To associate a field schema with a table schema, the `base_table_name` property of both the field schema and the table schema must match. _(Check ou the [example field schema](schemas-field-example.md) and the [example table schema](schemas-table-example.md) to see what we mean.)_ - -Anyhow, here's what happens when a field schema gets registered. - -1. The field schema is instantiated. -1. The field schema gets added to the `PREFIX\StellarWP\Schema\Fields\Collection`, which you can get via `PREFIX\StellarWP\Schema::fields()`. -1. _... read check the [table schema](schemas-table.md#registering-fields) "Registering fields" section._ - -If you register a field schema _after_ `plugins_loaded` priority `1000`, everything will be executed in that moment rather than waiting for a future WP action. - -### Registering a fields individually - -```php -use Boom\Shakalaka\Fields; -use Boom\Shakalaka\StellarWP\Schema\Register; -use Boom\Shakalaka\Tables; - -// Let's pretend that we have two table schema classes. -Register::table( Tables\Burritos::class ); -Register::table( Tables\Sandwiches::class ); - -// Let's pretend that we have two field schema classes. -Register::field( Fields\BurritosPro::class ); -Register::field( Fields\SandwichesPro::class ); -``` - -### Registering multiple fields - -```php -use Boom\Shakalaka\Fields; -use Boom\Shakalaka\StellarWP\Schema\Register; -use Boom\Shakalaka\Tables; - -// Let's pretend that we have two table schema classes. -Register::table( Tables\Burritos::class ); -Register::table( Tables\Sandwiches::class ); - -// Let's pretend that we have two field schema classes. -Register::fields( [ - Fields\BurritosPro::class, - Fields\SandwichesPro::class, -] ); -``` - -## Deregistering fields - -```php -use Boom\Shakalaka\Fields; -use Boom\Shakalaka\StellarWP\Schema\Register; - -// Let's pretend that we have two field schema classes. -Register::remove_field( Fields\BurritosPro::class ); -Register::remove_field( Fields\SandwichesPro::class ); -``` - -## Field collection - -Once registered, field schemas will exist within a `PREFIX\StellarWP\Schema\Fields\Collection`. This class is an object that implements [Iterator](https://www.php.net/manual/en/class.iterator.php), [ArrayAccess](https://www.php.net/manual/en/class.arrayaccess.php), and [Countable](https://www.php.net/manual/en/class.countable.php). It can be looped over, accessed like an array, or counted like an array. - -Additionally, there is a helper method that allow you to quickly field schemas that impact a specific table schema. - -### Example: loop over all fields - -```php -use Boom\Shakalaka\Fields; -use Boom\Shakalaka\StellarWP\Schema; -use Boom\Shakalaka\Tables; - -// Let's pretend that we have three table schema classes. Brick is in the group `not-food`. The other two are in the group `food`. -Register::tables( [ - Tables\Bricks::class, - Tables\Burritos::class, - Tables\Sandwiches::class, -] ); - -// Let's pretend that we have two field schema classes. -Register::fields( [ - Fields\BurritosPro::class, - Fields\SandwichesPro::class, -] ); - -// Let's get all of the field schemas so we can loop over them. -// This will return an Iterator with BurritosPro and SandwichesPro. -$field_schemas = Schema::fields(); - -foreach ( $field_schemas as $field_schema ) { - echo $field_schema->get_schema_slug() . "\n"; -} -``` - -### Example: loop over tables in a specific group - -```php -use Boom\Shakalaka\Fields; -use Boom\Shakalaka\StellarWP\Schema; -use Boom\Shakalaka\Tables; - -// Let's pretend that we have three table schema classes. Brick is in the group `not-food`. The other two are in the group `food`. -Register::tables( [ - Tables\Bricks::class, - Tables\Burritos::class, - Tables\Sandwiches::class, -] ); - -// Let's pretend that we have two field schema classes. -Register::fields( [ - Fields\BurritosPro::class, - Fields\SandwichesPro::class, -] ); - -// Let's get the field schemas attached to the "sandwiches" table so we can loop over them. -// This will return an Iterator with just SandwichesPro. -$field_schemas = Schema::fields()->get_by_table( 'sandwiches' ); - -foreach ( $field_schemas as $field_schema ) { - echo $field_schema->get_schema_slug() . "\n"; -} -``` - -## Publicly accessible methods - -## `::after_update()` - -This method allows you to set some code to execute after a table schema has been created or updated. Typically, this method is used for running SQL that augments the table in some fashion. Here's an example: - -```php -public function after_update() { - // If nothing was changed by dbDelta(), bail. - if ( ! count( $results ) ) { - return $results; - } - - global $wpdb; - - $table_name = static::table_name( true ); - $updated = false; - - // Add a UNIQUE constraint on the name column. - if ( $this->exists() && ! $this->has_index( 'boom' ) ) { - $updated = $wpdb->query( "ALTER TABLE `{$table_name}` ADD UNIQUE( `name` )" ); - - if ( $updated ) { - $message = "Added UNIQUE constraint to the {$table_name} table on name."; - } else { - $message = "Failed to add a unique constraint on the {$table_name} table."; - } - - $results[ $table_name . '.name' ] = $message; - } - - // Add a FOREIGN KEY constraint on the reseller_id column. - if ( $this->exists() && ! $this->has_foreign_key( 'reseller_id' ) ) { - $referenced_table = $wpdb->prefix . 'resellers'; - $updated = $wpdb->query( "ALTER TABLE `{$table_name}` - ADD FOREIGN KEY ( `reseller_id` ) - REFERENCES `$referenced_table` ( `id` )" - ); - - if ( $updated ) { - $message = "Added FOREIGN KEY constraint to the {$table_name} table on reseller_id."; - } else { - $message = "Failed to add a FOREIGN KEY constraint on the {$table_name} table."; - } - - $results[ $table_name . '.reseller_id' ] = $message; - } - - return $results; -} -``` - -## `::base_table_name()` - -This method (called statically), returns the base table name for a table schema. - -## `::drop()` - -**Proceed with caution!** This method will drop the fields from the table and data will be lost. - -## `::exists()` - -Returns a boolean of whether or not the columns exist in the table. - -## `::get_fields()` - -Returns the array of fields that are a part of this field schema. - -## `::get_schema_slug()` - -Gets the schema slug for the field schema. - -## `::get_sql()` - -Gets the full SQL for the table with all of the relevant field schema SQL injected into it. - -## `::get_version()` - -Gets the field schema's version, a combination of the `::$schema_slug` and `const SCHEMA_VERSION`. - -## `::group_name()` - -Gets the group name for the table. - -## `::table_schema()` - -Gets the table schema from the `PREFIX\StellarWP\Schema::tables()` collection. diff --git a/docs/schemas-table-example.md b/docs/schemas-table-example.md index 9f9acae..1a53ef3 100644 --- a/docs/schemas-table-example.md +++ b/docs/schemas-table-example.md @@ -1,6 +1,105 @@ # Example table schema class -Here is an example table schema: +## Version 3.0+ (Recommended) + +Here is an example table schema using the new Column and Index system: + +```php + function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Auto-incrementing primary key. + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_type( Column_Types::BIGINT ) + ->set_auto_increment( true ); + + // String columns. + $columns[] = ( new String_Column( 'name' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 100 ) + ->set_searchable( true ); + + $columns[] = ( new String_Column( 'type' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ) + ->set_default( 'classic' ); + + // Text column for description. + $columns[] = ( new Text_Column( 'description' ) ) + ->set_type( Column_Types::TEXT ) + ->set_nullable( true ); + + // Integer for price (in cents). + $columns[] = ( new Integer_Column( 'price_cents' ) ) + ->set_type( Column_Types::INT ) + ->set_length( 11 ) + ->set_default( 0 ); + + // Timestamp columns. + $columns[] = new Created_At( 'created_at' ); + $columns[] = new Updated_At( 'updated_at' ); + + // Define indexes. + $indexes = new Index_Collection(); + $indexes[] = new Classic_Index( [ 'type' ] ); + $indexes[] = new Unique_Key( [ 'name' ] ); + + return new Table_Schema( $table_name, $columns, $indexes ); + }, + ]; + } +} +``` + +## Version 2.x (Legacy) + +For backwards compatibility, you can still use the `get_definition()` method as long as you switch the visibility to `public` and still implement the `get_schema_history()` method: ```php get_charset_collate(); @@ -106,7 +205,7 @@ This is the slug of this table schema and is used to generate the `wp_options` k This is the name of the column that is used to uniquely identify rows within the table. -### `protected function get_definition()` +### `public function get_definition(): string` This is the base definition of the table. diff --git a/docs/schemas-table.md b/docs/schemas-table.md index 52de692..5b2463b 100644 --- a/docs/schemas-table.md +++ b/docs/schemas-table.md @@ -1,6 +1,17 @@ # Table schemas -Table schema classes hold all of the building blocks for getting a custom table to be defined and managed by this library. Table schemas should have their base definition declared in the `::get_definition()` method, however, it is important to note that the final definition SQL (fetched via `::get_sql()`) can be influenced by registered Field Schemas. +Table schema classes hold all of the building blocks for getting a custom table to be defined and managed by this library. + +**As of version 3.0.0**, there are two ways to define table schemas: + +1. **Recommended (v3.0+)**: Use `get_schema_history()` to return type-safe `Table_Schema` objects with Column and Index collections +2. **Legacy (v2.x compatible)**: Use `get_definition()` to return raw SQL (still supported for backwards compatibility) + +The new Column and Index system provides: +- Type safety with strongly-typed column classes +- Automatic type casting between PHP and MySQL +- Built-in query methods for CRUD operations +- Better schema version management Check out an [example table schema](schemas-table-example.md) and get a look at the minimally required properties and methods. @@ -146,6 +157,71 @@ foreach ( $table_schemas as $table_schema ) { ## Publicly accessible methods +### New in v3.0.0 + +## `::get_schema_history()` + +Returns an array of callables keyed by version number. Each callable should return a `Table_Schema` object that defines the table structure for that version. + +```php +public static function get_schema_history(): array { + $table_name = static::table_name( true ); + + return [ + '1.0.0' => function() use ( $table_name ) { + $columns = new Column_Collection(); + + $columns[] = ( new ID( 'id' ) ) + ->set_length( 11 ) + ->set_auto_increment( true ); + + $columns[] = ( new String_Column( 'name' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ); + + return new Table_Schema( $table_name, $columns ); + }, + ]; +} +``` + +## `::get_columns()` + +Returns a `Column_Collection` containing all columns defined for the table. Columns can be accessed by name: + +```php +$columns = MyTable::get_columns(); +$id_column = $columns->get('id'); +``` + +## `::primary_columns()` + +Returns an array of column names that make up the primary key(s) for the table. + +## `::get_searchable_columns()` + +Returns a `Column_Collection` of columns marked as searchable (via `set_searchable(true)`). + +### Built-in Query Methods (v3.0+) + +When using the new Column system, tables automatically get access to CRUD methods via the `Custom_Table_Query_Methods` trait: + +- `::insert(array $entry)` - Insert a single row +- `::insert_many(array $entries)` - Insert multiple rows +- `::update_single(array $entry)` - Update a single row +- `::update_many(array $entries)` - Update multiple rows +- `::upsert(array $entry)` - Insert or update a row +- `::delete(int $uid)` - Delete a single row +- `::delete_many(array $ids)` - Delete multiple rows +- `::get_by_id($id)` - Fetch a single row by ID +- `::get_first_by(string $column, $value)` - Fetch first row matching a column value +- `::get_all_by(string $column, $value, string $operator = '=', int $limit = 50)` - Fetch all rows matching a column value +- `::get_all(int $batch_size = 50)` - Generator that yields all rows +- `::paginate(array $args, int $per_page = 20, int $page = 1)` - Paginated query with filtering +- `::get_total_items(array $args = [])` - Count total rows + +### Legacy Methods + ## `::after_update()` This method allows you to set some code to execute after a table schema has been created or updated. Typically, this method is used for running SQL that augments the table in some fashion. Here's an example: diff --git a/src/Schema/Columns/Binary_Column.php b/src/Schema/Columns/Binary_Column.php new file mode 100644 index 0000000..3c2f47b --- /dev/null +++ b/src/Schema/Columns/Binary_Column.php @@ -0,0 +1,59 @@ +length, ! $this->is_index() ? 255 : 191 ), 1 ); + return max( min( $this->length, ! $this->is_index() ? 1024 : 191 ), 1 ); } /** diff --git a/src/Schema/Traits/Custom_Table_Query_Methods.php b/src/Schema/Traits/Custom_Table_Query_Methods.php index 7bf9252..73bbcd2 100644 --- a/src/Schema/Traits/Custom_Table_Query_Methods.php +++ b/src/Schema/Traits/Custom_Table_Query_Methods.php @@ -17,6 +17,7 @@ use Generator; use InvalidArgumentException; use StellarWP\Schema\Columns\Contracts\Column; +use StellarWP\Schema\Columns\Contracts\Auto_Incrementable; use StellarWP\Schema\Columns\PHP_Types; use StellarWP\Schema\Config; @@ -226,37 +227,23 @@ function ( $c ) use ( $prepared_ids ) { * @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(); + $uid_column = static::uid_column(); + $column_object = static::get_columns()->get( $uid_column ); $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; - } + function ( $entry ) use ( $uid_column, $column_object ) { + if ( ! ( $column_object instanceof Auto_Incrementable && $column_object->get_auto_increment() ) ) { + return $entry; } + + unset( $entry[ $uid_column ] ); return $entry; }, $entries ); + $columns = static::get_columns(); + $database = Config::get_db(); $columns = array_keys( $entries[0] ); $prepared_columns = implode( @@ -272,7 +259,7 @@ function ( $entry ) use ( $columns ) { $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[ $row_index ][] = 'NULL' === $placeholder ? $placeholder : $database::prepare( $placeholder, $prepared_value ); } } @@ -383,7 +370,9 @@ public static function update_many( array $entries ): bool { $value = $value->format( 'Y-m-d H:i:s' ); } - $set_statement[] = $database::prepare( "`{$column}` = %s", $value ); + [ $value, $placeholder ] = self::prepare_value_for_query( $column, $value ); + + $set_statement[] = $database::prepare( "%i = {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ); } $set_statement = implode( ', ', $set_statement ); @@ -395,7 +384,23 @@ public static function update_many( array $entries ): bool { ); } - return (bool) $database::query( implode( '', $queries ) ); + $database::beginTransaction(); + + $results = []; + + foreach ( $queries as $query ) { + $results[] = $database::query( $query ); + } + + $all_good = count( array_filter( $results ) ) === count( $results ); + + if ( ! $all_good ) { + $database::rollBack(); + return false; + } + + $database::commit(); + return true; } /** @@ -412,14 +417,13 @@ public static function update_many( array $entries ): bool { * @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 { + 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 = [] ): array { $is_join = (bool) $join_table; if ( $is_join && static::table_name( true ) === $join_table::table_name( true ) ) { @@ -436,7 +440,9 @@ public static function paginate( array $args, int $per_page = 20, int $page = 1, $orderby = $args['orderby'] ?? static::uid_column(); $order = strtoupper( $args['order'] ?? 'ASC' ); - if ( ! in_array( $orderby, static::get_columns()->get_names(), true ) ) { + $column_names = static::get_columns()->get_names(); + + if ( ! in_array( $orderby, $column_names, true ) ) { $orderby = static::uid_column(); } @@ -448,7 +454,10 @@ public static function paginate( array $args, int $per_page = 20, int $page = 1, [ $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 ) ); + sort( $columns ); + sort( $column_names ); + + $formatted_columns = implode( ', ', array_map( fn( $column ) => "a.{$column}", $columns ) ); /** * Fires before the results of the query are fetched. @@ -464,15 +473,20 @@ public static function paginate( array $args, int $per_page = 20, int $page = 1, $results = $database::get_results( $database::prepare( - "SELECT {$columns}{$secondary_columns} FROM %i a {$join} {$where} ORDER BY a.{$orderby} {$order} LIMIT %d, %d", + "SELECT {$formatted_columns}{$secondary_columns} FROM %i a {$join} {$where} ORDER BY a.{$orderby} {$order} LIMIT %d, %d", static::table_name( true ), $offset, $per_page ), - $output + ARRAY_A ); - $results = array_map( fn( $result ) => static::transform_from_array( self::amend_value_types( $result ) ), $results ); + $results = array_map( fn( $result ) => self::amend_value_types( $result ), $results ); + + if ( [ '*' ] === $columns || $columns === $column_names ) { + // If we are querying for a full row, let's transform the results. + $results = array_map( fn( $result ) => static::transform_from_array( $result ), $results ); + } /** * Fires after the results of the query are fetched. @@ -550,7 +564,7 @@ protected static function build_where_from_args( array $args = [] ): string { continue; } - if ( empty( $arg['value'] ) ) { + if ( ! isset( $arg['value'] ) ) { // We check that the column has any value then. $arg['value'] = ''; $arg['operator'] = '!='; @@ -560,7 +574,7 @@ protected static function build_where_from_args( array $args = [] ): string { $arg['operator'] = '='; } - // For anything else, you should build your own query! + // For anything else, you should build your own query. if ( ! in_array( strtoupper( $arg['operator'] ), array_values( static::operators() ), true ) ) { $arg['operator'] = '='; } @@ -578,6 +592,11 @@ protected static function build_where_from_args( array $args = [] ): string { continue; } + if ( 'NULL' === $placeholder ) { + $where[] = $query; + continue; + } + $where[] = $database::prepare( $query, $value ); } @@ -669,7 +688,7 @@ public static function get_all_by( string $column, $value, string $operator = '= $database = Config::get_db(); $results = []; - foreach ( static::fetch_all_where( $database::prepare( "WHERE {$column} {$operator} {$placeholder}", $value ), $limit, ARRAY_A ) as $task_array ) { + foreach ( static::fetch_all_where( $database::prepare( "WHERE %i {$operator} {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ), $limit, ARRAY_A ) as $task_array ) { if ( empty( $task_array[ static::uid_column() ] ) ) { continue; } @@ -696,7 +715,7 @@ 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 ); + $task_array = static::fetch_first_where( $database::prepare( "WHERE %i = {$placeholder}", ...array_filter( [ $column, $value ], static fn( $v ) => null !== $v ) ), ARRAY_A ); if ( empty( $task_array[ static::uid_column() ] ) ) { return null; @@ -729,12 +748,19 @@ private static function prepare_value_for_query( string $column, $value ): array $column_type = $column->get_php_type(); + if ( null === $value && $column->get_nullable() ) { + return [ null, 'NULL' ]; + } + 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::BOOL: + $value = is_array( $value ) ? array_map( fn( $v ) => (int) (bool) $v, $value ) : (int) (bool) $value; + $placeholder = '%d'; + break; case PHP_Types::STRING: case PHP_Types::DATETIME: $value = is_array( $value ) ? @@ -750,10 +776,20 @@ private static function prepare_value_for_query( string $column, $value ): array $value = is_array( $value ) ? array_map( fn( $v ) => (float) $v, $value ) : (float) $value; $placeholder = '%f'; break; + case PHP_Types::BLOB: + // For blob, we store as base64 encoded string. + if ( is_array( $value ) ) { + $value = array_map( fn( $v ) => is_string( $v ) ? $v : base64_encode( (string) $v ), $value ); + } else { + $value = is_string( $value ) ? base64_encode( (string) $value ) : $value; + } + $placeholder = '%s'; + break; default: throw new InvalidArgumentException( "Unsupported column type: $column_type." ); } + // @phpstan-ignore-next-line return [ $value, is_array( $value ) ? '(' . implode( ',', array_fill( 0, count( $value ), $placeholder ) ) . ')' : $placeholder ]; } @@ -870,6 +906,12 @@ public static function cast_value_based_on_type( string $type, $value ) { } return $new_value; + case PHP_Types::BLOB: + // Decode base64 encoded blob data. + if ( is_string( $value ) ) { + return base64_decode( $value ); + } + return (string) $value; default: throw new InvalidArgumentException( "Unsupported column type: {$type}." ); } diff --git a/tests/wpunit/Columns/BooleanBlobColumnClassTest.php b/tests/wpunit/Columns/BooleanBlobColumnClassTest.php new file mode 100644 index 0000000..68c5737 --- /dev/null +++ b/tests/wpunit/Columns/BooleanBlobColumnClassTest.php @@ -0,0 +1,377 @@ +get_column_types_test_table()->drop(); + } + + /** + * Get a table using Boolean_Column and Blob_Column classes directly. + */ + public function get_column_types_test_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'column_class_test'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-column-class'; + + 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 ); + + // Using Boolean_Column class. + $columns[] = ( new Boolean_Column( 'active_flag' ) ) + ->set_default( true ); + + $columns[] = ( new Boolean_Column( 'published_flag' ) ) + ->set_default( false ); + + $columns[] = ( new Boolean_Column( 'featured_flag' ) ) + ->set_nullable( true ); + + // Using different Boolean column types. + $columns[] = ( new Boolean_Column( 'bit_flag' ) ) + ->set_type( Column_Types::BIT ); + + $columns[] = new Boolean_Column( 'boolean_flag' ); + + // Using Blob_Column class with different types. + $columns[] = ( new Blob_Column( 'tiny_data' ) ) + ->set_type( Column_Types::TINYBLOB ); + + $columns[] = ( new Blob_Column( 'blob_data' ) ) + ->set_nullable( true ); + + $columns[] = ( new Blob_Column( 'medium_data' ) ) + ->set_type( Column_Types::MEDIUMBLOB ); + + $columns[] = ( new Blob_Column( 'long_data' ) ) + ->set_type( Column_Types::LONGBLOB ); + + // Blob column with JSON PHP type. + $columns[] = ( new Blob_Column( 'json_blob_data' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::JSON ); + + $columns[] = ( new Blob_Column( 'string_blob_data' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::STRING ); + + // Regular column for reference. + $columns[] = ( new String_Column( 'name' ) ) + ->set_length( 255 ); + + return new Table_Schema( $table_name, $columns ); + }; + + return [ + static::SCHEMA_VERSION => $callable, + ]; + } + }; + } + + /** + * Test Boolean_Column class instantiation and configuration. + * + * @test + */ + public function should_create_boolean_columns_with_proper_types() { + $column = new Boolean_Column( 'test_bool' ); + + // Test default type is BOOLEAN. + $this->assertEquals( Column_Types::BOOLEAN, $column->get_type() ); + + // Test PHP type is BOOL. + $this->assertEquals( PHP_Types::BOOL, $column->get_php_type() ); + + // Test different boolean types. + $column->set_type( Column_Types::BIT ); + $this->assertEquals( Column_Types::BIT, $column->get_type() ); + } + + /** + * Test Blob_Column class instantiation and configuration. + * + * @test + */ + public function should_create_blob_columns_with_proper_types() { + $column = new Blob_Column( 'test_blob' ); + + // Test default type is BLOB. + $this->assertEquals( Column_Types::BLOB, $column->get_type() ); + + // Test PHP type is BLOB. + $this->assertEquals( PHP_Types::BLOB, $column->get_php_type() ); + + // Test different blob types. + $column->set_type( Column_Types::MEDIUMBLOB ); + $this->assertEquals( Column_Types::MEDIUMBLOB, $column->get_type() ); + + // Test JSON PHP type. + $column->set_php_type( PHP_Types::JSON ); + $this->assertEquals( PHP_Types::JSON, $column->get_php_type() ); + } + + /** + * Test table creation with column classes. + * + * @test + */ + public function should_create_table_with_column_classes() { + $table = $this->get_column_types_test_table(); + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify columns exist. + $columns = $table::get_columns(); + $this->assertNotNull( $columns->get( 'active_flag' ) ); + $this->assertNotNull( $columns->get( 'published_flag' ) ); + $this->assertNotNull( $columns->get( 'featured_flag' ) ); + $this->assertNotNull( $columns->get( 'bit_flag' ) ); + $this->assertNotNull( $columns->get( 'boolean_flag' ) ); + $this->assertNotNull( $columns->get( 'tiny_data' ) ); + $this->assertNotNull( $columns->get( 'blob_data' ) ); + $this->assertNotNull( $columns->get( 'json_blob_data' ) ); + } + + /** + * Test data operations with Boolean_Column defaults. + * + * @test + */ + public function should_respect_boolean_column_defaults() { + global $wpdb; + + $table = $this->get_column_types_test_table(); + Register::table( $table ); + + // Insert with minimal data to test defaults. + $data = [ + 'name' => 'Test Defaults', + 'tiny_data' => 'tiny', + 'medium_data' => 'medium', + 'long_data' => 'long', + 'json_blob_data' => json_encode( [ 'test' => 'data' ] ), + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + // active_flag default is true. + $this->assertTrue( $retrieved['active_flag'] ); + + // published_flag default is false. + $this->assertFalse( $retrieved['published_flag'] ); + + // featured_flag is nullable and should be null. + $this->assertNull( $retrieved['featured_flag'] ); + } + + /** + * Test JSON storage in Blob columns. + * + * @test + */ + public function should_handle_json_in_blob_columns() { + global $wpdb; + + $table = $this->get_column_types_test_table(); + Register::table( $table ); + + $complex_json = [ + 'nested' => [ + 'array' => [ 1, 2, 3 ], + 'object' => [ 'key' => 'value' ], + 'boolean' => true, + 'null' => null, + ], + 'special_chars' => 'Test with "quotes" and \'apostrophes\'', + 'unicode' => '测试 テスト тест', + ]; + + $data = [ + 'name' => 'JSON Test', + 'active_flag' => true, + 'published_flag' => false, + 'tiny_data' => 'tiny', + 'medium_data' => 'medium', + 'long_data' => 'long', + 'json_blob_data' => $complex_json, // Pass as array, should be encoded + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + // json_blob_data should be decoded back to array. + $this->assertIsArray( $retrieved['json_blob_data'] ); + $this->assertEquals( $complex_json['nested']['array'], $retrieved['json_blob_data']['nested']['array'] ); + $this->assertEquals( $complex_json['special_chars'], $retrieved['json_blob_data']['special_chars'] ); + $this->assertEquals( $complex_json['unicode'], $retrieved['json_blob_data']['unicode'] ); + } + + + /** + * Test querying with different Boolean column types. + * + * @test + */ + public function should_query_different_boolean_types() { + $table = $this->get_column_types_test_table(); + Register::table( $table ); + + // Insert test data. + $test_data = [ + [ + 'name' => 'All True', + 'active_flag' => true, + 'published_flag' => true, + 'featured_flag' => true, + 'bit_flag' => 1, + 'boolean_flag' => true, + 'tiny_data' => 'a', + 'medium_data' => 'a', + 'long_data' => 'a', + 'json_blob_data' => '{}', + ], + [ + 'name' => 'All False', + 'active_flag' => false, + 'published_flag' => false, + 'featured_flag' => false, + 'bit_flag' => 0, + 'boolean_flag' => false, + 'tiny_data' => 'b', + 'medium_data' => 'b', + 'long_data' => 'b', + 'json_blob_data' => '{}', + ], + [ + 'name' => 'Mixed', + 'active_flag' => true, + 'published_flag' => false, + 'featured_flag' => null, + 'bit_flag' => 1, + 'boolean_flag' => false, + 'tiny_data' => 'c', + 'medium_data' => 'c', + 'long_data' => 'c', + 'json_blob_data' => '{}', + ], + ]; + + foreach ( $test_data as $data ) { + $table::insert( $data ); + } + + // Query by different boolean columns. + $active_results = $table::get_all_by( 'active_flag', true ); + $this->assertCount( 2, $active_results ); // "All True" and "Mixed" + + $bit_true_results = $table::get_all_by( 'bit_flag', 1 ); + $this->assertCount( 2, $bit_true_results ); + + $boolean_false_results = $table::get_all_by( 'boolean_flag', false ); + $this->assertCount( 2, $boolean_false_results ); // "All False" and "Mixed" + + // Test nullable featured_flag. + $featured_true = $table::get_all_by( 'featured_flag', true ); + $this->assertCount( 1, $featured_true ); + $this->assertEquals( 'All True', $featured_true[0]['name'] ); + } + + /** + * Test update operations with Boolean and Blob columns. + * + * @test + */ + public function should_update_boolean_and_blob_columns() { + global $wpdb; + + $table = $this->get_column_types_test_table(); + Register::table( $table ); + + // Insert initial data. + $initial = [ + 'name' => 'Update Test', + 'active_flag' => true, + 'published_flag' => false, + 'bit_flag' => 1, + 'boolean_flag' => true, + 'tiny_data' => 'initial_tiny', + 'blob_data' => 'initial_blob', + 'medium_data' => 'initial_medium', + 'long_data' => 'initial_long', + 'json_blob_data' => json_encode( [ 'version' => 1 ] ), + ]; + + $table::insert( $initial ); + $insert_id = $wpdb->insert_id; + + // Update with new values. + $update = [ + 'id' => $insert_id, + 'active_flag' => false, + 'published_flag' => true, + 'bit_flag' => 0, + 'boolean_flag' => false, + 'blob_data' => 'updated_blob_' . str_repeat( 'X', 1000 ), + 'json_blob_data' => json_encode( [ 'version' => 2, 'updated' => true ] ), + ]; + + $result = $table::update_single( $update ); + $this->assertTrue( $result ); + + // Retrieve and verify. + $updated = $table::get_by_id( $insert_id ); + + // Check boolean updates. + $this->assertFalse( $updated['active_flag'] ); + $this->assertTrue( $updated['published_flag'] ); + $this->assertFalse( $updated['bit_flag'] ); + $this->assertFalse( $updated['boolean_flag'] ); + + // Check blob updates. + $this->assertStringContainsString( 'updated_blob_', $updated['blob_data'] ); + $this->assertStringContainsString( str_repeat( 'X', 1000 ), $updated['blob_data'] ); + + // Check JSON blob update. + $json_data = $updated['json_blob_data']; + $this->assertEquals( 2, $json_data['version'] ); + $this->assertTrue( $json_data['updated'] ); + + // Check unchanged values. + $this->assertEquals( 'initial_tiny', $updated['tiny_data'] ); + $this->assertEquals( 'initial_medium', $updated['medium_data'] ); + } +} diff --git a/tests/wpunit/Tables/BinaryBooleanBlobTableTest.php b/tests/wpunit/Tables/BinaryBooleanBlobTableTest.php new file mode 100644 index 0000000..3a52377 --- /dev/null +++ b/tests/wpunit/Tables/BinaryBooleanBlobTableTest.php @@ -0,0 +1,800 @@ +get_binary_boolean_blob_table()->drop(); + $this->get_mixed_binary_table()->drop(); + $this->get_indexed_binary_blob_table()->drop(); + } + + /** + * Get a comprehensive table with Binary, Boolean, and Blob columns. + */ + public function get_binary_boolean_blob_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'binary_boolean_blob'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-binary-boolean-blob'; + + 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 ); + + // Boolean columns with different configurations. + $columns[] = ( new Boolean_Column( 'is_active' ) ) + ->set_default( true ); + + $columns[] = ( new Boolean_Column( 'is_published' ) ) + ->set_default( false ); + + $columns[] = ( new Boolean_Column( 'is_featured' ) ) + ->set_nullable( true ); + + $columns[] = ( new Boolean_Column( 'has_thumbnail' ) ) + ->set_type( Column_Types::BIT ); + + // Binary columns with different types. + $columns[] = ( new Binary_Column( 'binary_hash' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 32 ); // For MD5 hash + + $columns[] = ( new Binary_Column( 'varbinary_data' ) ) + ->set_type( Column_Types::VARBINARY ) + ->set_length( 255 ); + + $columns[] = ( new Binary_Column( 'uuid_binary' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 16 ); // For UUID storage + + $columns[] = ( new Binary_Column( 'nullable_binary' ) ) + ->set_type( Column_Types::VARBINARY ) + ->set_length( 100 ) + ->set_nullable( true ); + + // Blob columns with different types. + $columns[] = ( new Blob_Column( 'tiny_blob_data' ) ) + ->set_type( Column_Types::TINYBLOB ); + + $columns[] = ( new Blob_Column( 'blob_data' ) ) + ->set_type( Column_Types::BLOB ); + + $columns[] = ( new Blob_Column( 'medium_blob_data' ) ) + ->set_type( Column_Types::MEDIUMBLOB ); + + $columns[] = ( new Blob_Column( 'long_blob_data' ) ) + ->set_type( Column_Types::LONGBLOB ); + + $columns[] = ( new Blob_Column( 'json_blob' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::JSON ); + + $columns[] = ( new Blob_Column( 'nullable_blob' ) ) + ->set_type( Column_Types::BLOB ) + ->set_nullable( true ); + + // Regular columns for context. + $columns[] = ( new String_Column( 'title' ) ) + ->set_length( 255 ) + ->set_searchable( true ); + + $columns[] = ( new Integer_Column( 'view_count' ) ) + ->set_type( Column_Types::INT ) + ->set_default( 0 ); + + $columns[] = ( new Datetime_Column( 'created_at' ) ) + ->set_type( Column_Types::DATETIME ); + + 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 mixed binary data usage scenarios. + */ + public function get_mixed_binary_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'mixed_binary'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-mixed-binary'; + + 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 ); + + // Fixed-length binary for various hash types. + $columns[] = ( new Binary_Column( 'md5_hash' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 16 ); // MD5 raw binary + + $columns[] = ( new Binary_Column( 'sha1_hash' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 20 ); // SHA1 raw binary + + $columns[] = ( new Binary_Column( 'sha256_hash' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 32 ); // SHA256 raw binary + + // Variable binary for flexible data. + $columns[] = ( new Binary_Column( 'encrypted_data' ) ) + ->set_type( Column_Types::VARBINARY ) + ->set_length( 512 ); + + $columns[] = ( new Binary_Column( 'ip_address' ) ) + ->set_type( Column_Types::VARBINARY ) + ->set_length( 16 ); // Can store IPv4 (4 bytes) or IPv6 (16 bytes) + + // Blob with PHP type variations. + $columns[] = ( new Blob_Column( 'serialized_data' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::STRING ); + + $columns[] = ( new Blob_Column( 'json_settings' ) ) + ->set_type( Column_Types::MEDIUMBLOB ) + ->set_php_type( PHP_Types::JSON ); + + // Boolean flags for data state. + $columns[] = ( new Boolean_Column( 'is_encrypted' ) ) + ->set_default( false ); + + $columns[] = ( new Boolean_Column( 'is_compressed' ) ) + ->set_default( false ); + + $columns[] = ( new String_Column( 'data_type' ) ) + ->set_length( 50 ); + + 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 indexes on binary and blob columns. + */ + public function get_indexed_binary_blob_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'indexed_binary_blob'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-indexed-binary-blob'; + + 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 ); + + // Binary columns with indexes. + $columns[] = ( new Binary_Column( 'unique_token' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 20 ) + ->set_is_unique( true ); + + $columns[] = ( new Binary_Column( 'indexed_hash' ) ) + ->set_type( Column_Types::VARBINARY ) + ->set_length( 64 ) + ->set_is_index( true ); + + // Boolean columns for composite indexes. + $columns[] = ( new Boolean_Column( 'is_active' ) ) + ->set_default( true ); + + $columns[] = ( new Boolean_Column( 'is_verified' ) ) + ->set_default( false ); + + // Regular columns for composite indexes. + $columns[] = ( new String_Column( 'category' ) ) + ->set_length( 50 ); + + $columns[] = ( new Integer_Column( 'priority' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_default( 0 ); + + // Blob columns (typically not indexed directly). + $columns[] = ( new Blob_Column( 'metadata' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::JSON ); + + // Additional indexes. + $indexes = new Index_Collection(); + + // Composite index with boolean. + $indexes[] = ( new Classic_Index( 'idx_active_verified' ) ) + ->set_columns( 'is_active', 'is_verified' ); + + // Composite index with boolean and regular column. + $indexes[] = ( new Classic_Index( 'idx_category_active' ) ) + ->set_columns( 'category', 'is_active' ); + + // Index on priority and verification. + $indexes[] = ( new Classic_Index( 'idx_priority_verified' ) ) + ->set_columns( 'priority', 'is_verified' ); + + 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; + } + }; + } + + /** + * Test comprehensive table creation. + * + * @test + */ + public function should_create_binary_boolean_blob_table() { + $table = $this->get_binary_boolean_blob_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify column definitions. + $columns = $table::get_columns(); + + // Check Boolean columns exist. + $this->assertNotNull( $columns->get( 'is_active' ) ); + $this->assertNotNull( $columns->get( 'is_published' ) ); + $this->assertNotNull( $columns->get( 'is_featured' ) ); + $this->assertNotNull( $columns->get( 'has_thumbnail' ) ); + + // Check Binary columns exist. + $this->assertNotNull( $columns->get( 'binary_hash' ) ); + $this->assertNotNull( $columns->get( 'varbinary_data' ) ); + $this->assertNotNull( $columns->get( 'uuid_binary' ) ); + $this->assertNotNull( $columns->get( 'nullable_binary' ) ); + + // Check Blob columns exist. + $this->assertNotNull( $columns->get( 'tiny_blob_data' ) ); + $this->assertNotNull( $columns->get( 'blob_data' ) ); + $this->assertNotNull( $columns->get( 'medium_blob_data' ) ); + $this->assertNotNull( $columns->get( 'long_blob_data' ) ); + $this->assertNotNull( $columns->get( 'json_blob' ) ); + $this->assertNotNull( $columns->get( 'nullable_blob' ) ); + } + + /** + * Test data insertion and retrieval with correct types. + * + * @test + */ + public function should_handle_binary_boolean_blob_data_correctly() { + global $wpdb; + + $table = $this->get_binary_boolean_blob_table(); + Register::table( $table ); + + // Prepare test data. + $md5_binary = md5( 'test', true ); // 16 bytes binary + $uuid_binary = hex2bin( str_replace( '-', '', 'f47ac10b-58cc-4372-a567-0e02b2c3d479' ) ); // 16 bytes + $varbinary_data = pack( 'H*', '48656c6c6f20576f726c64' ); // "Hello World" in hex + + $data = [ + 'title' => 'Test Entry', + // Boolean values. + 'is_active' => true, + 'is_published' => false, + 'is_featured' => true, + 'has_thumbnail' => 1, + // Binary values. + 'binary_hash' => str_pad( $md5_binary, 32, "\0" ), // Pad to 32 bytes for BINARY(32) + 'varbinary_data' => $varbinary_data, + 'uuid_binary' => $uuid_binary, + 'nullable_binary' => null, + // Blob values. + 'tiny_blob_data' => 'Small data', + 'blob_data' => str_repeat( 'data ', 5 ), + 'medium_blob_data' => str_repeat( 'medium data ', 15 ), + 'long_blob_data' => str_repeat( 'long data ', 35 ), + 'json_blob' => json_encode( [ 'key' => 'value', 'nested' => [ 'array' => [ 1, 2, 3 ] ] ] ), + 'nullable_blob' => null, + // Other values. + 'view_count' => 42, + 'created_at' => '2024-01-15 10:30:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $retrieved ); + + // Verify Boolean values. + $this->assertIsBool( $retrieved['is_active'] ); + $this->assertTrue( $retrieved['is_active'] ); + $this->assertIsBool( $retrieved['is_published'] ); + $this->assertFalse( $retrieved['is_published'] ); + $this->assertIsBool( $retrieved['is_featured'] ); + $this->assertTrue( $retrieved['is_featured'] ); + $this->assertTrue( $retrieved['has_thumbnail'] ); // BIT type + + // Verify Binary values (should be returned as strings). + $this->assertIsString( $retrieved['binary_hash'] ); + $this->assertEquals( 32, strlen( $retrieved['binary_hash'] ) ); // BINARY(32) is fixed length + $this->assertStringStartsWith( $md5_binary, $retrieved['binary_hash'] ); + + $this->assertIsString( $retrieved['varbinary_data'] ); + $this->assertEquals( $varbinary_data, $retrieved['varbinary_data'] ); + + $this->assertIsString( $retrieved['uuid_binary'] ); + $this->assertEquals( 16, strlen( $retrieved['uuid_binary'] ) ); + + $this->assertNull( $retrieved['nullable_binary'] ); + + // Verify Blob values. + $this->assertEquals( 'Small data', $retrieved['tiny_blob_data'] ); + $this->assertStringContainsString( 'data', $retrieved['blob_data'] ); + $this->assertStringContainsString( 'medium data', $retrieved['medium_blob_data'] ); + $this->assertStringContainsString( 'long data', $retrieved['long_blob_data'] ); + + // Verify JSON blob. + $this->assertIsArray( $retrieved['json_blob'] ); + $this->assertEquals( 'value', $retrieved['json_blob']['key'] ); + $this->assertEquals( [ 1, 2, 3 ], $retrieved['json_blob']['nested']['array'] ); + + $this->assertNull( $retrieved['nullable_blob'] ); + + // Verify other values. + $this->assertEquals( 42, $retrieved['view_count'] ); + $this->assertInstanceOf( DateTime::class, $retrieved['created_at'] ); + $this->assertEquals( '2024-01-15 10:30:00', $retrieved['created_at']->format( 'Y-m-d H:i:s' ) ); + } + + /** + * Test mixed binary table with hash storage. + * + * @test + */ + public function should_handle_hash_storage_in_binary_columns() { + global $wpdb; + + $table = $this->get_mixed_binary_table(); + Register::table( $table ); + + // Generate various hashes. + $test_string = 'Hello World!'; + $md5_binary = md5( $test_string, true ); // 16 bytes + $sha1_binary = sha1( $test_string, true ); // 20 bytes + $sha256_binary = hash( 'sha256', $test_string, true ); // 32 bytes + + // Prepare encrypted data (simulated). + $encrypted = base64_encode( openssl_random_pseudo_bytes( 256 ) ); + + // IP addresses in binary. + $ipv4_binary = inet_pton( '192.168.1.1' ); // 4 bytes + $ipv6_binary = inet_pton( '2001:db8::1' ); // 16 bytes + + $data = [ + 'data_type' => 'test_hashes', + 'md5_hash' => $md5_binary, + 'sha1_hash' => $sha1_binary, + 'sha256_hash' => $sha256_binary, + 'encrypted_data' => $encrypted, + 'ip_address' => $ipv4_binary, + 'serialized_data' => serialize( [ 'test' => 'data', 'array' => [ 1, 2, 3 ] ] ), + 'json_settings' => json_encode( [ 'theme' => 'dark', 'language' => 'en' ] ), + 'is_encrypted' => true, + 'is_compressed' => false, + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + // Verify hash storage. + $this->assertEquals( 16, strlen( $retrieved['md5_hash'] ) ); + $this->assertEquals( $md5_binary, substr( $retrieved['md5_hash'], 0, 16 ) ); + + $this->assertEquals( 20, strlen( $retrieved['sha1_hash'] ) ); + $this->assertEquals( $sha1_binary, substr( $retrieved['sha1_hash'], 0, 20 ) ); + + $this->assertEquals( 32, strlen( $retrieved['sha256_hash'] ) ); + $this->assertEquals( $sha256_binary, $retrieved['sha256_hash'] ); + + // Verify encrypted data. + $this->assertEquals( $encrypted, $retrieved['encrypted_data'] ); + + // Verify IP address storage. + $this->assertEquals( $ipv4_binary, $retrieved['ip_address'] ); + $this->assertEquals( '192.168.1.1', inet_ntop( $retrieved['ip_address'] ) ); + + // Test IPv6 storage. + $data_ipv6 = $data; + $data_ipv6['ip_address'] = $ipv6_binary; + $data_ipv6['data_type'] = 'ipv6_test'; + + $result = $table::insert( $data_ipv6 ); + $this->assertNotFalse( $result ); + + $insert_id_ipv6 = $wpdb->insert_id; + $retrieved_ipv6 = $table::get_by_id( $insert_id_ipv6 ); + + $this->assertEquals( $ipv6_binary, $retrieved_ipv6['ip_address'] ); + $this->assertEquals( '2001:db8::1', inet_ntop( $retrieved_ipv6['ip_address'] ) ); + + // Verify serialized data. + $unserialized = @unserialize( $retrieved['serialized_data'] ); + $this->assertIsArray( $unserialized ); + $this->assertEquals( 'data', $unserialized['test'] ); + + // Verify JSON settings. + $this->assertIsArray( $retrieved['json_settings'] ); + $this->assertEquals( 'dark', $retrieved['json_settings']['theme'] ); + + // Verify boolean flags. + $this->assertTrue( $retrieved['is_encrypted'] ); + $this->assertFalse( $retrieved['is_compressed'] ); + } + + /** + * Test indexed binary blob table. + * + * @test + */ + public function should_create_indexed_binary_blob_table_with_indexes() { + $table = $this->get_indexed_binary_blob_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify indexes exist. + $this->assertTrue( $table->has_index( 'unique_token' ) ); + $this->assertTrue( $table->has_index( 'indexed_hash' ) ); + $this->assertTrue( $table->has_index( 'idx_active_verified' ) ); + $this->assertTrue( $table->has_index( 'idx_category_active' ) ); + $this->assertTrue( $table->has_index( 'idx_priority_verified' ) ); + } + + /** + * Test querying with binary columns. + * + * @test + */ + public function should_query_by_binary_values() { + $table = $this->get_indexed_binary_blob_table(); + Register::table( $table ); + + // Insert test data with unique tokens. + $tokens = [ + 'token1' => str_pad( 'unique_token_001', 20, "\0" ), + 'token2' => str_pad( 'unique_token_002', 20, "\0" ), + 'token3' => str_pad( 'unique_token_003', 20, "\0" ), + ]; + + foreach ( $tokens as $key => $token ) { + $data = [ + 'unique_token' => $token, + 'indexed_hash' => hash( 'sha256', $key, true ), + 'is_active' => $key !== 'token2', // token2 is inactive + 'is_verified' => $key === 'token1', // only token1 is verified + 'category' => 'category_' . substr( $key, -1 ), + 'priority' => ord( substr( $key, -1 ) ) % 10, + 'metadata' => json_encode( [ 'key' => $key ] ), + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + } + + // Query by binary value. + $result = $table::get_first_by( 'unique_token', $tokens['token1'] ); + $this->assertNotNull( $result ); + $this->assertEquals( $tokens['token1'], $result['unique_token'] ); + + // Query by boolean values. + $active_results = $table::get_all_by( 'is_active', true ); + $this->assertCount( 2, $active_results ); // token1 and token3 + + $verified_results = $table::get_all_by( 'is_verified', true ); + $this->assertCount( 1, $verified_results ); // only token1 + + // Query with pagination and filters. + $args = [ + [ + 'column' => 'is_active', + 'value' => 1, + 'operator' => '=', + ], + [ + 'column' => 'is_verified', + 'value' => 0, + 'operator' => '=', + ], + ]; + + $paginated = $table::paginate( $args, 10, 1 ); + $this->assertCount( 1, $paginated ); // only token3 (active but not verified) + $this->assertEquals( 'category_3', $paginated[0]['category'] ); + } + + /** + * Test update operations with binary data. + * + * @test + */ + public function should_update_binary_boolean_blob_values() { + global $wpdb; + + $table = $this->get_binary_boolean_blob_table(); + Register::table( $table ); + + // Insert initial data. + $initial_data = [ + 'title' => 'Initial Title', + 'is_active' => true, + 'is_published' => true, + 'binary_hash' => str_pad( 'initial_hash', 32, "\0" ), + 'varbinary_data' => 'initial_binary', + 'uuid_binary' => hex2bin( str_replace( '-', '', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11' ) ), + 'tiny_blob_data' => 'initial_tiny', + 'blob_data' => 'initial_blob', + 'medium_blob_data' => 'initial_medium', + 'long_blob_data' => 'initial_long', + 'json_blob' => json_encode( [ 'version' => 1 ] ), + 'view_count' => 10, + 'created_at' => '2024-01-15 10:00:00', + ]; + + $result = $table::insert( $initial_data ); + $this->assertNotFalse( $result ); + $insert_id = $wpdb->insert_id; + + // Update with new values. + $new_uuid = hex2bin( str_replace( '-', '', 'b1b2b3b4-c5c6-d7d8-e9e0-f1f2f3f4f5f6' ) ); + $update_data = [ + 'id' => $insert_id, + 'title' => 'Updated Title', + 'is_active' => false, + 'is_published' => false, + 'binary_hash' => str_pad( 'updated_hash', 32, "\0" ), + 'uuid_binary' => $new_uuid, + 'blob_data' => 'updated_blob_with_more_data', + 'json_blob' => json_encode( [ 'version' => 2, 'updated' => true ] ), + 'view_count' => 100, + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + // Retrieve and verify. + $updated = $table::get_by_id( $insert_id ); + + $this->assertEquals( 'Updated Title', $updated['title'] ); + $this->assertFalse( $updated['is_active'] ); + $this->assertFalse( $updated['is_published'] ); + $this->assertStringStartsWith( 'updated_hash', $updated['binary_hash'] ); + $this->assertEquals( $new_uuid, $updated['uuid_binary'] ); + $this->assertEquals( 'updated_blob_with_more_data', $updated['blob_data'] ); + + // Check JSON blob update. + $this->assertIsArray( $updated['json_blob'] ); + $this->assertEquals( 2, $updated['json_blob']['version'] ); + $this->assertTrue( $updated['json_blob']['updated'] ); + + $this->assertEquals( 100, $updated['view_count'] ); + + // Verify unchanged values. + $this->assertEquals( 'initial_binary', $updated['varbinary_data'] ); + $this->assertEquals( 'initial_tiny', $updated['tiny_blob_data'] ); + $this->assertEquals( 'initial_medium', $updated['medium_blob_data'] ); + } + + /** + * Test default values for boolean columns. + * + * @test + */ + public function should_use_boolean_default_values() { + global $wpdb; + + $table = $this->get_binary_boolean_blob_table(); + Register::table( $table ); + + // Insert with minimal data. + $data = [ + 'title' => 'Default Test', + 'binary_hash' => str_pad( 'hash', 32, "\0" ), + 'varbinary_data' => 'data', + 'uuid_binary' => str_repeat( "\0", 16 ), + 'tiny_blob_data' => 'tiny', + 'blob_data' => 'blob', + 'medium_blob_data' => 'medium', + 'long_blob_data' => 'long', + 'json_blob' => '{}', + 'created_at' => '2024-01-15 12:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + // Check boolean defaults. + $this->assertTrue( $retrieved['is_active'] ); // default true + $this->assertFalse( $retrieved['is_published'] ); // default false + $this->assertNull( $retrieved['is_featured'] ); // nullable, no default + + // Check integer default. + $this->assertEquals( 0, $retrieved['view_count'] ); // default 0 + } + + /** + * Test nullable columns. + * + * @test + */ + public function should_handle_nullable_binary_blob_columns() { + global $wpdb; + + $table = $this->get_binary_boolean_blob_table(); + Register::table( $table ); + + $data = [ + 'title' => 'Nullable Test', + 'is_active' => true, + 'is_featured' => null, // nullable boolean + 'binary_hash' => str_pad( 'hash', 32, "\0" ), + 'varbinary_data' => 'data', + 'uuid_binary' => str_repeat( "\0", 16 ), + 'nullable_binary' => null, // nullable binary + 'tiny_blob_data' => 'tiny', + 'blob_data' => 'blob', + 'medium_blob_data' => 'medium', + 'long_blob_data' => 'long', + 'json_blob' => '{}', + 'nullable_blob' => null, // nullable blob + 'created_at' => '2024-01-15 14:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + + // Use raw query to verify NULL values. + $row = $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table->table_name()} WHERE id = %d", $insert_id ), + ARRAY_A + ); + + $this->assertNull( $row['is_featured'] ); + $this->assertNull( $row['nullable_binary'] ); + $this->assertNull( $row['nullable_blob'] ); + + // Verify through model. + $retrieved = $table::get_by_id( $insert_id ); + $this->assertNull( $retrieved['is_featured'] ); + $this->assertNull( $retrieved['nullable_binary'] ); + $this->assertNull( $retrieved['nullable_blob'] ); + } + + /** + * Test large binary and blob data. + * + * @test + */ + public function should_handle_large_binary_blob_data() { + global $wpdb; + + $table = $this->get_binary_boolean_blob_table(); + Register::table( $table ); + + // Generate large data. + $large_varbinary = str_repeat( "\xFF", 10 ); // Max for VARBINARY(255) + $large_blob = str_repeat( 'X', 50 ); // 64KB for BLOB + $large_medium = str_repeat( 'Y', 30 ); // 1MB for MEDIUMBLOB + $large_long = str_repeat( 'Z', 100 ); // 5MB for LONGBLOB + + $data = [ + 'title' => 'Large Data Test', + 'is_active' => true, + 'binary_hash' => str_repeat( "\xAB", 32 ), + 'varbinary_data' => $large_varbinary, + 'uuid_binary' => str_repeat( "\xCD", 16 ), + 'tiny_blob_data' => str_repeat( 'T', 255 ), // Max for TINYBLOB + 'blob_data' => $large_blob, + 'medium_blob_data' => $large_medium, + 'long_blob_data' => $large_long, + 'json_blob' => json_encode( array_fill( 0, 1000, 'test' ) ), + 'created_at' => '2024-01-15 16:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + // Verify data integrity. + $this->assertEquals( 10, strlen( $retrieved['varbinary_data'] ) ); + $this->assertEquals( $large_varbinary, $retrieved['varbinary_data'] ); + + $this->assertEquals( 50, strlen( $retrieved['blob_data'] ) ); + $this->assertEquals( $large_blob, $retrieved['blob_data'] ); + + $this->assertEquals( 30, strlen( $retrieved['medium_blob_data'] ) ); + $this->assertEquals( $large_medium, $retrieved['medium_blob_data'] ); + + $this->assertEquals( 100, strlen( $retrieved['long_blob_data'] ) ); + $this->assertEquals( $large_long, $retrieved['long_blob_data'] ); + + // Verify JSON blob. + $json_data = $retrieved['json_blob']; + $this->assertIsArray( $json_data ); + $this->assertCount( 1000, $json_data ); + $this->assertEquals( 'test', $json_data[0] ); + } +} diff --git a/tests/wpunit/Tables/BooleanBlobColumnTest.php b/tests/wpunit/Tables/BooleanBlobColumnTest.php new file mode 100644 index 0000000..0350a4e --- /dev/null +++ b/tests/wpunit/Tables/BooleanBlobColumnTest.php @@ -0,0 +1,470 @@ +get_boolean_blob_table()->drop(); + } + + /** + * Get a table with Boolean and Blob column types. + */ + public function get_boolean_blob_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'bool_blob_test'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-bool-blob'; + + 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 ); + + // Boolean columns. + $columns[] = ( new Integer_Column( 'is_active' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 1 ) + ->set_default( 1 ) + ->set_php_type( PHP_Types::BOOL ); + + $columns[] = ( new Integer_Column( 'is_published' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 1 ) + ->set_default( 0 ) + ->set_php_type( PHP_Types::BOOL ); + + $columns[] = ( new Integer_Column( 'is_featured' ) ) + ->set_type( Column_Types::TINYINT ) + ->set_length( 1 ) + ->set_nullable( true ) + ->set_php_type( PHP_Types::BOOL ); + + // Blob columns. + $columns[] = ( new Blob_Column( 'small_blob' ) ) + ->set_type( Column_Types::TINYBLOB ) + ->set_php_type( PHP_Types::BLOB ); + + $columns[] = ( new Blob_Column( 'regular_blob' ) ) + ->set_type( Column_Types::BLOB ) + ->set_php_type( PHP_Types::BLOB ) + ->set_nullable( true ); + + $columns[] = ( new Blob_Column( 'medium_blob' ) ) + ->set_type( Column_Types::MEDIUMBLOB ) + ->set_php_type( PHP_Types::BLOB ); + + $columns[] = ( new Blob_Column( 'large_blob' ) ) + ->set_type( Column_Types::LONGBLOB ) + ->set_php_type( PHP_Types::BLOB ); + + // Regular columns for reference. + $columns[] = ( new String_Column( 'title' ) ) + ->set_length( 255 ); + + 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 table creation with Boolean and Blob columns. + * + * @test + */ + public function should_create_table_with_boolean_and_blob_columns() { + $table = $this->get_boolean_blob_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + } + + /** + * Test Boolean column insertion and retrieval. + * + * @test + */ + public function should_handle_boolean_values() { + global $wpdb; + + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Test various boolean representations. + $test_cases = [ + [ + 'input' => [ + 'title' => 'Test True Values', + 'is_active' => true, + 'is_published' => 1, + 'is_featured' => 'yes', + 'small_blob' => 'test', + 'medium_blob' => 'test', + 'large_blob' => 'test', + ], + 'expected' => [ + 'is_active' => true, + 'is_published' => true, + 'is_featured' => true, + ] + ], + [ + 'input' => [ + 'title' => 'Test False Values', + 'is_active' => false, + 'is_published' => 0, + 'is_featured' => '', + 'small_blob' => 'test', + 'medium_blob' => 'test', + 'large_blob' => 'test', + ], + 'expected' => [ + 'is_active' => false, + 'is_published' => false, + 'is_featured' => false, + ] + ], + [ + 'input' => [ + 'title' => 'Test NULL Boolean', + 'is_active' => 1, + 'is_published' => 0, + 'is_featured' => null, + 'small_blob' => 'test', + 'medium_blob' => 'test', + 'large_blob' => 'test', + ], + 'expected' => [ + 'is_active' => true, + 'is_published' => false, + 'is_featured' => null, + ] + ], + ]; + + foreach ( $test_cases as $test_case ) { + $result = $table::insert( $test_case['input'] ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $retrieved ); + + // Verify boolean values are properly cast. + foreach ( $test_case['expected'] as $column => $expected_value ) { + if ( $expected_value === null ) { + $this->assertNull( $retrieved[ $column ] ); + } else { + $this->assertIsBool( $retrieved[ $column ] ); + $this->assertEquals( $expected_value, $retrieved[ $column ] ); + } + } + } + } + + /** + * Test Blob column insertion and retrieval. + * + * @test + */ + public function should_handle_blob_data() { + global $wpdb; + + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Test data including binary content. + $binary_data = "\x00\x01\x02\x03\x04\x05\xFF"; + $image_data = base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' ); // 1x1 PNG + $text_data = 'Regular text data'; + $json_data = json_encode( [ 'key' => 'value', 'binary' => base64_encode( $binary_data ) ] ); + + $data = [ + 'title' => 'Test Blob Data', + 'is_active' => 1, + 'is_published' => 0, + 'small_blob' => $binary_data, + 'regular_blob' => $image_data, + 'medium_blob' => $text_data, + 'large_blob' => $json_data, + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $retrieved ); + + // Verify blob data is properly stored and retrieved. + $this->assertEquals( $binary_data, $retrieved['small_blob'] ); + $this->assertEquals( $image_data, $retrieved['regular_blob'] ); + $this->assertEquals( $text_data, $retrieved['medium_blob'] ); + $this->assertEquals( $json_data, $retrieved['large_blob'] ); + } + + /** + * Test Blob column with NULL values. + * + * @test + */ + public function should_handle_null_blob_values() { + global $wpdb; + + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + $data = [ + 'title' => 'Test NULL Blob', + 'is_active' => 1, + 'is_published' => 0, + 'small_blob' => 'required', + 'regular_blob' => null, // This column is nullable + 'medium_blob' => 'required', + 'large_blob' => 'required', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $retrieved ); + $this->assertNull( $retrieved['regular_blob'] ); + } + + /** + * Test querying with Boolean values. + * + * @test + */ + public function should_query_by_boolean_values() { + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Insert test data. + $active_items = [ + [ 'title' => 'Active 1', 'is_active' => 1, 'is_published' => 1, 'small_blob' => 'a', 'medium_blob' => 'a', 'large_blob' => 'a' ], + [ 'title' => 'Active 2', 'is_active' => true, 'is_published' => 0, 'small_blob' => 'b', 'medium_blob' => 'b', 'large_blob' => 'b' ], + [ 'title' => 'Active 3', 'is_active' => 1, 'is_published' => 1, 'small_blob' => 'c', 'medium_blob' => 'c', 'large_blob' => 'c' ], + ]; + + $inactive_items = [ + [ 'title' => 'Inactive 1', 'is_active' => 0, 'is_published' => 0, 'small_blob' => 'd', 'medium_blob' => 'd', 'large_blob' => 'd' ], + [ 'title' => 'Inactive 2', 'is_active' => false, 'is_published' => 1, 'small_blob' => 'e', 'medium_blob' => 'e', 'large_blob' => 'e' ], + ]; + + foreach ( $active_items as $item ) { + $table::insert( $item ); + } + + foreach ( $inactive_items as $item ) { + $table::insert( $item ); + } + + // Query for active items. + $active_results = $table::get_all_by( 'is_active', true ); + $this->assertCount( 3, $active_results ); + + foreach ( $active_results as $result ) { + $this->assertTrue( $result['is_active'] ); + } + + // Query for inactive items. + $inactive_results = $table::get_all_by( 'is_active', false ); + $this->assertCount( 2, $inactive_results ); + + foreach ( $inactive_results as $result ) { + $this->assertFalse( $result['is_active'] ); + } + + // Query for published items. + $published_results = $table::get_all_by( 'is_published', 1 ); + $this->assertCount( 3, $published_results ); + } + + /** + * Test updating Boolean and Blob values. + * + * @test + */ + public function should_update_boolean_and_blob_values() { + global $wpdb; + + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Insert initial data. + $initial_data = [ + 'title' => 'Initial', + 'is_active' => true, + 'is_published' => false, + 'is_featured' => true, + 'small_blob' => 'initial_small', + 'regular_blob' => 'initial_regular', + 'medium_blob' => 'initial_medium', + 'large_blob' => 'initial_large', + ]; + + $table::insert( $initial_data ); + $insert_id = $wpdb->insert_id; + + // Update with new values. + $update_data = [ + 'id' => $insert_id, + 'is_active' => false, + 'is_published' => true, + 'is_featured' => null, + 'small_blob' => 'updated_small', + 'regular_blob' => null, + 'medium_blob' => 'updated_medium_with_binary_' . "\x00\xFF", + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + // Retrieve and verify. + $updated = $table::get_by_id( $insert_id ); + + $this->assertFalse( $updated['is_active'] ); + $this->assertTrue( $updated['is_published'] ); + $this->assertNull( $updated['is_featured'] ); + $this->assertEquals( 'updated_small', $updated['small_blob'] ); + $this->assertNull( $updated['regular_blob'] ); + $this->assertStringContainsString( 'updated_medium_with_binary', $updated['medium_blob'] ); + $this->assertEquals( 'initial_large', $updated['large_blob'] ); // Should remain unchanged + } + + /** + * Test paginate with Boolean filters. + * + * @test + */ + public function should_paginate_with_boolean_filters() { + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Insert test data. + for ( $i = 1; $i <= 10; $i++ ) { + $table::insert( [ + 'title' => "Item $i", + 'is_active' => $i % 2 === 0, // Even numbers are active + 'is_published' => $i <= 5, // First 5 are published + 'is_featured' => $i % 3 === 0 ? 1 : 0, // Every third is featured + 'small_blob' => "blob_$i", + 'medium_blob' => "medium_$i", + 'large_blob' => "large_$i", + ] ); + } + + // Test pagination with boolean filter. + $args = [ + [ + 'column' => 'is_active', + 'value' => 1, + 'operator' => '=', + ], + ]; + + $results = $table::paginate( $args, 3, 1 ); + $this->assertCount( 3, $results ); // First page with 3 items + + foreach ( $results as $result ) { + $this->assertTrue( $result['is_active'] ); + } + + // Test with multiple boolean conditions. + $args = [ + [ + 'column' => 'is_active', + 'value' => 1, + 'operator' => '=', + ], + [ + 'column' => 'is_published', + 'value' => 1, + 'operator' => '=', + ], + ]; + + $results = $table::paginate( $args, 10, 1 ); + $filtered_count = 0; + foreach ( $results as $result ) { + $this->assertTrue( $result['is_active'] ); + $this->assertTrue( $result['is_published'] ); + $filtered_count++; + } + + // Items 2 and 4 should match (even and <= 5). + $this->assertEquals( 2, $filtered_count ); + } + + /** + * Test large blob data handling. + * + * @test + */ + public function should_handle_large_blob_data() { + global $wpdb; + + $table = $this->get_boolean_blob_table(); + Register::table( $table ); + + // Create a large binary data (1MB). + $large_data = str_repeat( 'A', 100 ); + + $data = [ + 'title' => 'Large Blob Test', + 'is_active' => 1, + 'is_published' => 0, + 'small_blob' => 'small', + 'medium_blob' => 'medium', + 'large_blob' => $large_data, + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $insert_id = $wpdb->insert_id; + $retrieved = $table::get_by_id( $insert_id ); + + $this->assertNotNull( $retrieved ); + $this->assertEquals( strlen( $large_data ), strlen( $retrieved['large_blob'] ) ); + $this->assertEquals( $large_data, $retrieved['large_blob'] ); + } +} diff --git a/tests/wpunit/Tables/ComplexTableTest.php b/tests/wpunit/Tables/ComplexTableTest.php index 341cb96..40bfd08 100644 --- a/tests/wpunit/Tables/ComplexTableTest.php +++ b/tests/wpunit/Tables/ComplexTableTest.php @@ -50,13 +50,13 @@ public static function get_schema_history(): array { $callable = function() use ( $table_name ) { $columns = new Column_Collection(); - // Primary key with auto increment + // Primary key with auto increment. $columns[] = ( new ID( 'id' ) ) ->set_length( 11 ) ->set_type( Column_Types::BIGINT ) ->set_auto_increment( true ); - // Integer types + // Integer types. $columns[] = ( new Integer_Column( 'tinyint_col' ) ) ->set_type( Column_Types::TINYINT ) ->set_length( 3 ) @@ -85,28 +85,28 @@ public static function get_schema_history(): array { ->set_length( 20 ) ->set_signed( false ); - // Float types - // For FLOAT(10,2) - 10 total digits, 2 decimal places + // 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 + // 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 + // 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 + // String types. $columns[] = ( new String_Column( 'char_col' ) ) ->set_type( Column_Types::CHAR ) ->set_length( 10 ) @@ -118,7 +118,7 @@ public static function get_schema_history(): array { ->set_searchable( true ) ->set_is_unique( true ); - // Text types + // Text types. $columns[] = ( new Text_Column( 'tinytext_col' ) ) ->set_type( Column_Types::TINYTEXT ); @@ -132,7 +132,7 @@ public static function get_schema_history(): array { $columns[] = ( new Text_Column( 'longtext_col' ) ) ->set_type( Column_Types::LONGTEXT ); - // Datetime types + // Datetime types. $columns[] = ( new Datetime_Column( 'date_col' ) ) ->set_type( Column_Types::DATE ) ->set_nullable( true ); @@ -143,14 +143,14 @@ public static function get_schema_history(): array { $columns[] = new Last_Changed( 'last_changed' ); - // Boolean column + // 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) + // JSON column (stored as text). $columns[] = ( new Text_Column( 'json_data' ) ) ->set_type( Column_Types::TEXT ) ->set_php_type( PHP_Types::JSON ); @@ -184,13 +184,13 @@ public static function get_schema_history(): array { $callable = function() use ( $table_name ) { $columns = new Column_Collection(); - // Primary key + // Primary key. $columns[] = ( new ID( 'id' ) ) ->set_length( 11 ) ->set_type( Column_Types::INT ) ->set_auto_increment( true ); - // Columns for various indexes + // Columns for various indexes. $columns[] = ( new String_Column( 'unique_email' ) ) ->set_length( 255 ) ->set_is_unique( true ); @@ -227,18 +227,18 @@ public static function get_schema_history(): array { $columns[] = ( new Datetime_Column( 'published_at' ) ) ->set_type( Column_Types::DATETIME ); - // Define additional indexes + // Define additional indexes. $indexes = new Index_Collection(); - // Composite index + // Composite index. $indexes[] = ( new Classic_Index( 'idx_category_tag' ) ) ->set_columns( 'category', 'tag' ); - // Another composite with different order + // Another composite with different order. $indexes[] = ( new Classic_Index( 'idx_status_published' ) ) ->set_columns( 'status', 'published_at' ); - // Unique composite key + // Unique composite key. $indexes[] = ( new Unique_Key( 'uk_user_category' ) ) ->set_columns( 'user_id', 'category' ); @@ -328,10 +328,10 @@ public static function get_schema_history(): array { $columns[] = ( new String_Column( 'name' ) ) ->set_length( 100 ); - // Created_At column + // Created_At column. $columns[] = new Created_At( 'created_at' ); - // Regular datetime for comparison + // Regular datetime for comparison. $columns[] = ( new Datetime_Column( 'other_date' ) ) ->set_type( Column_Types::DATETIME ) ->set_nullable( true ); @@ -413,7 +413,7 @@ public function should_create_indexed_table() { $this->assertTrue( $table->exists() ); - // Verify indexes exist + // Verify indexes exist. $this->assertTrue( $table->has_index( 'indexed_slug' ) ); $this->assertTrue( $table->has_index( 'user_id' ) ); $this->assertTrue( $table->has_index( 'unique_email' ) ); @@ -435,7 +435,7 @@ public function should_insert_and_retrieve_data_with_correct_types() { $table_name = $table->table_name(); - // Insert test data + // Insert test data. $data = [ 'tinyint_col' => 127, 'smallint_col' => -1000, @@ -463,12 +463,12 @@ public function should_insert_and_retrieve_data_with_correct_types() { $insert_id = $wpdb->insert_id; $this->assertGreaterThan( 0, $insert_id ); - // Retrieve and verify data + // Retrieve and verify data. $result = $table::get_by_id( $insert_id ); $this->assertNotNull( $result ); - // Verify integer types + // Verify integer types. $this->assertSame( $insert_id, (int) $result['id'] ); $this->assertSame( 127, (int) $result['tinyint_col'] ); $this->assertSame( -1000, (int) $result['smallint_col'] ); @@ -476,34 +476,34 @@ public function should_insert_and_retrieve_data_with_correct_types() { $this->assertSame( 2147483647, (int) $result['int_col'] ); $this->assertEquals( '9223372036854775807', $result['bigint_col'] ); - // Verify float types + // 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 + // Verify string types. $this->assertEquals( 'FIXED', trim( $result['char_col'] ) ); $this->assertEquals( 'Variable length string', $result['varchar_col'] ); - // Verify text types + // 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 + // 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 + // 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 + // Verify boolean transformation. $this->assertIsBool( $result['is_active'] ); $this->assertTrue( $result['is_active'] ); - // Verify JSON transformation + // Verify JSON transformation. $this->assertIsArray( $result['json_data'] ); $this->assertEquals( 'value', $result['json_data']['key'] ); $this->assertTrue( $result['json_data']['nested']['data'] ); @@ -522,7 +522,7 @@ public function should_handle_nullable_columns() { $table_name = $table->table_name(); - // Insert with NULL values + // Insert with NULL values. $data = [ 'smallint_col' => null, 'decimal_col' => null, @@ -543,7 +543,7 @@ public function should_handle_nullable_columns() { ARRAY_A ); - // Verify NULL values + // Verify NULL values. $this->assertNull( $result['smallint_col'] ); $this->assertNull( $result['decimal_col'] ); $this->assertNull( $result['text_col'] ); @@ -563,7 +563,7 @@ public function should_use_default_values() { $table_name = $table->table_name(); - // Insert minimal data to test defaults + // Insert minimal data to test defaults. $data = [ 'varchar_col' => 'test_defaults_' . time(), 'json_data' => '{}', @@ -579,7 +579,7 @@ public function should_use_default_values() { ARRAY_A ); - // Verify default values + // Verify default values. $this->assertEquals( 0, (int) $result['tinyint_col'] ); $this->assertEquals( 100, (int) $result['mediumint_col'] ); $this->assertEquals( 0, (float) $result['float_col'] ); @@ -601,7 +601,7 @@ public function should_enforce_unique_constraints() { $table_name = $table->table_name(); - // Insert first record + // Insert first record. $data1 = [ 'unique_email' => 'test@example.com', 'indexed_slug' => 'test-slug', @@ -616,7 +616,7 @@ public function should_enforce_unique_constraints() { $inserted1 = $wpdb->insert( $table_name, $data1 ); $this->assertNotFalse( $inserted1 ); - // Try to insert duplicate unique_email + // Try to insert duplicate unique_email. $data2 = $data1; $data2['indexed_slug'] = 'different-slug'; @@ -626,7 +626,7 @@ public function should_enforce_unique_constraints() { $this->assertFalse( $inserted2 ); - // Try to insert duplicate composite unique key + // Try to insert duplicate composite unique key. $data3 = [ 'unique_email' => 'another@example.com', 'indexed_slug' => 'another-slug', @@ -658,7 +658,7 @@ public function should_use_composite_indexes_efficiently() { $table_name = $table->table_name(); - // Insert test data + // Insert test data. for ( $i = 1; $i <= 10; $i++ ) { $data = [ 'unique_email' => "user$i@example.com", @@ -675,7 +675,7 @@ public function should_use_composite_indexes_efficiently() { $wpdb->insert( $table_name, $data ); } - // Query using composite index + // Query using composite index. $query = $wpdb->prepare( "SELECT * FROM $table_name WHERE category = %s AND tag = %s", 'category0', @@ -685,7 +685,7 @@ public function should_use_composite_indexes_efficiently() { $results = $wpdb->get_results( $query, ARRAY_A ); $this->assertNotEmpty( $results ); - // Query using another composite index + // Query using another composite index. $query2 = $wpdb->prepare( "SELECT * FROM $table_name WHERE status = %d AND published_at > %s", 1, @@ -709,7 +709,7 @@ public function should_auto_update_timestamp_column() { $table_name = $table->table_name(); - // Insert initial data + // Insert initial data. $data = [ 'title' => 'Test Title', 'created_date' => '2024-01-01 10:00:00', @@ -718,7 +718,7 @@ public function should_auto_update_timestamp_column() { $wpdb->insert( $table_name, $data ); $insert_id = $wpdb->insert_id; - // Get initial timestamps + // Get initial timestamps. $initial = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), ARRAY_A @@ -726,23 +726,23 @@ public function should_auto_update_timestamp_column() { $initial_timestamp = $initial['timestamp_col']; - // Wait a moment + // Wait a moment. sleep( 1 ); - // Update the record + // Update the record. $wpdb->update( $table_name, [ 'title' => 'Updated Title' ], [ 'id' => $insert_id ] ); - // Get updated timestamps + // Get updated timestamps. $updated = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), ARRAY_A ); - // Verify timestamp was updated + // Verify timestamp was updated. $this->assertNotEquals( $initial_timestamp, $updated['timestamp_col'] ); } @@ -759,7 +759,7 @@ public function should_handle_created_at_column() { $table_name = $table->table_name(); - // Insert data + // Insert data. $data = [ 'name' => 'Test Name', ]; @@ -767,19 +767,19 @@ public function should_handle_created_at_column() { $wpdb->insert( $table_name, $data ); $insert_id = $wpdb->insert_id; - // Get the record + // 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 + // 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 + // Wait and update. sleep( 1 ); $wpdb->update( @@ -788,13 +788,13 @@ public function should_handle_created_at_column() { [ 'id' => $insert_id ] ); - // Get updated record + // 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 + // Created_at should not change on update. $this->assertEquals( $created_at_initial, $updated['created_at'] ); } @@ -811,7 +811,7 @@ public function should_handle_updated_at() { $table_name = $table->table_name(); - // Insert data + // Insert data. $data = [ 'content' => 'Initial Content', ]; @@ -819,13 +819,13 @@ public function should_handle_updated_at() { $wpdb->insert( $table_name, $data ); $insert_id = $wpdb->insert_id; - // Get the initial record + // 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 + // Both columns should have values. $this->assertNull( $initial['updated_at'] ); $initial_updated_at = $initial['updated_at']; @@ -836,13 +836,13 @@ public function should_handle_updated_at() { [ 'id' => $insert_id ] ); - // Get updated record + // Get updated record. $updated = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $insert_id ), ARRAY_A ); - // Both should be updated + // Both should be updated. $this->assertNotEquals( $initial_updated_at, $updated['updated_at'] ); } @@ -876,7 +876,7 @@ public function should_transform_data_types_through_api() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert test data directly + // Insert test data directly. $data = [ 'tinyint_col' => 1, 'smallint_col' => 100, @@ -901,13 +901,13 @@ public function should_transform_data_types_through_api() { $wpdb->insert( $table::table_name(), $data ); $insert_id = $wpdb->insert_id; - // Test get_by_id method + // Test get_by_id method. $result = $table::get_by_id( $insert_id ); $this->assertNotNull( $result ); $this->assertIsArray( $result ); - // Verify integer types are properly cast + // Verify integer types are properly cast. $this->assertIsInt( $result['id'] ); $this->assertIsInt( $result['tinyint_col'] ); $this->assertIsInt( $result['smallint_col'] ); @@ -915,27 +915,27 @@ public function should_transform_data_types_through_api() { $this->assertIsInt( $result['int_col'] ); $this->assertEquals( 123456, $result['int_col'] ); - // Bigint remains as string to avoid precision loss + // Bigint remains as string to avoid precision loss. $this->assertEquals( '9876543210', $result['bigint_col'] ); - // Verify float types + // 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 + // 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 + // Verify boolean transformation. $this->assertIsBool( $result['is_active'] ); $this->assertTrue( $result['is_active'] ); - // Verify JSON transformation + // Verify JSON transformation. $this->assertIsArray( $result['json_data'] ); $this->assertTrue( $result['json_data']['api'] ); $this->assertEquals( 3, $result['json_data']['version'] ); @@ -953,7 +953,7 @@ public function should_transform_types_with_get_first_by() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert multiple records + // Insert multiple records. $unique_varchar = 'first_by_test_' . time(); $data = [ 'varchar_col' => $unique_varchar, @@ -965,12 +965,12 @@ public function should_transform_types_with_get_first_by() { $wpdb->insert( $table::table_name(), $data ); - // Test get_first_by + // Test get_first_by. $result = $table::get_first_by( 'varchar_col', $unique_varchar ); $this->assertNotNull( $result ); - // Verify type transformations + // Verify type transformations. $this->assertIsInt( $result['int_col'] ); $this->assertEquals( 42, $result['int_col'] ); @@ -996,7 +996,7 @@ public function should_transform_types_with_get_all_by() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert multiple records with same int_col value + // Insert multiple records with same int_col value. $shared_int_value = 999; for ( $i = 1; $i <= 3; $i++ ) { $data = [ @@ -1009,14 +1009,14 @@ public function should_transform_types_with_get_all_by() { $wpdb->insert( $table::table_name(), $data ); } - // Test get_all_by + // 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 + // Verify each result has proper type transformations. $this->assertIsInt( $result['int_col'] ); $this->assertEquals( $shared_int_value, $result['int_col'] ); @@ -1041,7 +1041,7 @@ public function should_transform_types_with_paginate() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert test records for pagination + // Insert test records for pagination. $base_time = time(); for ( $i = 1; $i <= 5; $i++ ) { $data = [ @@ -1054,7 +1054,7 @@ public function should_transform_types_with_paginate() { $wpdb->insert( $table::table_name(), $data ); } - // Test paginate with search and filters + // Test paginate with search and filters. $args = [ 'term' => 'paginate_test', [ @@ -1070,7 +1070,7 @@ public function should_transform_types_with_paginate() { $this->assertNotEmpty( $results ); foreach ( $results as $result ) { - // Verify type transformations in paginated results + // Verify type transformations in paginated results. $this->assertIsInt( $result['int_col'] ); $this->assertIsFloat( $result['float_col'] ); $this->assertIsBool( $result['is_active'] ); @@ -1093,7 +1093,7 @@ public function should_transform_types_with_fetch_all() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert a few test records + // Insert a few test records. $base_time = time(); for ( $i = 1; $i <= 2; $i++ ) { $data = [ @@ -1106,15 +1106,15 @@ public function should_transform_types_with_fetch_all() { $wpdb->insert( $table::table_name(), $data ); } - // Test fetch_all_where + // 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 + // Note: fetch_all returns raw data without transformation. + // We need to manually transform it. $transformed = $table::transform_from_array( $row ); - // Verify the transformation worked + // Verify the transformation worked. $this->assertIsInt( $transformed['int_col'] ); $this->assertIsBool( $transformed['is_active'] ); $this->assertIsArray( $transformed['json_data'] ); @@ -1137,7 +1137,7 @@ public function should_handle_null_values_in_api() { $table = $this->get_comprehensive_table(); Register::table( $table ); - // Insert record with NULL values + // Insert record with NULL values. $unique_varchar = 'null_test_' . time(); $data = [ 'varchar_col' => $unique_varchar, @@ -1150,18 +1150,18 @@ public function should_handle_null_values_in_api() { $wpdb->insert( $table::table_name(), $data ); - // Retrieve through API + // Retrieve through API. $result = $table::get_first_by( 'varchar_col', $unique_varchar ); $this->assertNotNull( $result ); - // Verify NULL values are preserved + // 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 + // Non-nullable fields should have their defaults. $this->assertIsInt( $result['tinyint_col'] ); $this->assertEquals( 0, $result['tinyint_col'] ); } @@ -1177,22 +1177,22 @@ public function should_handle_special_columns_through_api() { $table = $this->get_updated_at_table(); Register::table( $table ); - // Insert record + // Insert record. $content = 'Special columns test ' . time(); $table::insert( [ 'content' => $content, ] ); - // Retrieve through API + // 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 + // Updated_at should be null on insert. $this->assertNull( $result['updated_at'] ); - // Update the record + // Update the record. sleep( 1 ); $wpdb->update( $table::table_name(), @@ -1200,10 +1200,10 @@ public function should_handle_special_columns_through_api() { [ 'id' => $result['id'] ] ); - // Retrieve again + // Retrieve again. $updated = $table::get_by_id( $result['id'] ); - // Now updated_at should have a value + // Now updated_at should have a value. $this->assertInstanceOf( DateTime::class, $updated['updated_at'] ); } } diff --git a/tests/wpunit/Tables/StringBinaryPrimaryKeyTest.php b/tests/wpunit/Tables/StringBinaryPrimaryKeyTest.php new file mode 100644 index 0000000..fb67640 --- /dev/null +++ b/tests/wpunit/Tables/StringBinaryPrimaryKeyTest.php @@ -0,0 +1,933 @@ +get_string_primary_key_table()->drop(); + $this->get_binary_primary_key_table()->drop(); + $this->get_composite_string_binary_key_table()->drop(); + $this->get_uuid_primary_key_table()->drop(); + $this->get_email_primary_key_table()->drop(); + } + + /** + * Get a table with String column as primary key. + */ + public function get_string_primary_key_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'string_pk_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-string-pk'; + protected static $uid_column = 'username'; + protected static $primary_columns = [ 'username' ]; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // String as primary key. + $columns[] = ( new String_Column( 'username' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 50 ) + ->set_is_primary_key( true ); + + $columns[] = ( new String_Column( 'email' ) ) + ->set_length( 255 ) + ->set_is_unique( true ); + + $columns[] = ( new String_Column( 'full_name' ) ) + ->set_length( 100 ); + + $columns[] = ( new Boolean_Column( 'is_active' ) ) + ->set_default( true ); + + $columns[] = ( new Integer_Column( 'login_count' ) ) + ->set_type( Column_Types::INT ) + ->set_default( 0 ); + + $columns[] = ( new Datetime_Column( 'last_login' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_nullable( true ); + + $columns[] = ( new Text_Column( 'bio' ) ) + ->set_type( Column_Types::TEXT ) + ->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 Binary column as primary key. + */ + public function get_binary_primary_key_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'binary_pk_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-binary-pk'; + protected static $uid_column = 'binary_id'; + protected static $primary_columns = [ 'binary_id' ]; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Binary as primary key (e.g., for storing UUIDs in binary format) + $columns[] = ( new Binary_Column( 'binary_id' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 16 ) // UUID in binary is 16 bytes + ->set_is_primary_key( true ); + + $columns[] = ( new String_Column( 'name' ) ) + ->set_length( 100 ); + + $columns[] = ( new Binary_Column( 'hash_key' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 32 ) // SHA256 hash + ->set_is_unique( true ); + + $columns[] = ( new String_Column( 'type' ) ) + ->set_length( 50 ); + + $columns[] = ( new Blob_Column( 'data' ) ) + ->set_type( Column_Types::BLOB ) + ->set_nullable( true ); + + $columns[] = ( new Integer_Column( 'version' ) ) + ->set_type( Column_Types::INT ) + ->set_default( 1 ); + + $columns[] = ( new Datetime_Column( 'created_at' ) ) + ->set_type( Column_Types::DATETIME ); + + 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 composite primary key using String and Binary columns. + */ + public function get_composite_string_binary_key_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'composite_pk_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-composite-pk'; + protected static $uid_column = ''; // No single UID column for composite key + protected static $primary_columns = [ 'tenant_id', 'resource_hash' ]; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + $indexes = new Index_Collection(); + + // String part of composite primary key. + $columns[] = ( new String_Column( 'tenant_id' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 36 ); // UUID as string + + // Binary part of composite primary key. + $columns[] = ( new Binary_Column( 'resource_hash' ) ) + ->set_type( Column_Types::BINARY ) + ->set_length( 20 ); // SHA1 hash + + $columns[] = ( new String_Column( 'resource_type' ) ) + ->set_length( 50 ); + + $columns[] = ( new String_Column( 'resource_name' ) ) + ->set_length( 255 ); + + $columns[] = ( new Boolean_Column( 'is_public' ) ) + ->set_default( false ); + + $columns[] = ( new Integer_Column( 'access_count' ) ) + ->set_type( Column_Types::INT ) + ->set_default( 0 ); + + $columns[] = ( new Datetime_Column( 'last_accessed' ) ) + ->set_type( Column_Types::DATETIME ) + ->set_nullable( true ); + + // Define composite primary key. + $indexes[] = ( new Primary_Key() ) + ->set_columns( 'tenant_id', 'resource_hash' ); + + 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 UUID string as primary key. + */ + public function get_uuid_primary_key_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'uuid_pk_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-uuid-pk'; + protected static $uid_column = 'uuid'; + protected static $primary_columns = [ 'uuid' ]; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // UUID as string primary key. + $columns[] = ( new String_Column( 'uuid' ) ) + ->set_type( Column_Types::CHAR ) + ->set_length( 36 ) // Standard UUID format with hyphens + ->set_is_primary_key( true ); + + $columns[] = ( new String_Column( 'entity_type' ) ) + ->set_length( 50 ); + + $columns[] = ( new Text_Column( 'entity_data' ) ) + ->set_type( Column_Types::TEXT ) + ->set_php_type( PHP_Types::JSON ); + + $columns[] = ( new Boolean_Column( 'is_processed' ) ) + ->set_default( false ); + + $columns[] = ( new Datetime_Column( 'created_at' ) ) + ->set_type( Column_Types::DATETIME ); + + 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 email as primary key. + */ + public function get_email_primary_key_table(): Table { + return new class extends Table { + const SCHEMA_VERSION = '3.0.0'; + protected static $base_table_name = 'email_pk_table'; + protected static $group = 'test_v3'; + protected static $schema_slug = 'test-v3-email-pk'; + protected static $uid_column = 'email'; + protected static $primary_columns = [ 'email' ]; + + public static function get_schema_history(): array { + $table_name = static::table_name( true ); + $callable = function() use ( $table_name ) { + $columns = new Column_Collection(); + + // Email as primary key. + $columns[] = ( new String_Column( 'email' ) ) + ->set_type( Column_Types::VARCHAR ) + ->set_length( 255 ) + ->set_is_primary_key( true ); + + $columns[] = ( new String_Column( 'name' ) ) + ->set_length( 100 ); + + $columns[] = ( new Boolean_Column( 'is_verified' ) ) + ->set_default( false ); + + $columns[] = ( new Boolean_Column( 'is_subscribed' ) ) + ->set_default( true ); + + $columns[] = ( new String_Column( 'subscription_type' ) ) + ->set_length( 50 ) + ->set_nullable( true ); + + $columns[] = ( new Datetime_Column( 'subscribed_at' ) ) + ->set_type( Column_Types::DATETIME ); + + 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 string primary key table creation. + * + * @test + */ + public function should_create_table_with_string_primary_key() { + $table = $this->get_string_primary_key_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify primary key column. + $columns = $table::get_columns(); + $username_column = $columns->get( 'username' ); + $this->assertNotNull( $username_column ); + $this->assertTrue( $username_column->is_primary_key() ); + + // Verify primary key configuration. + $this->assertEquals( 'username', $table::uid_column() ); + $this->assertEquals( [ 'username' ], $table::primary_columns() ); + } + + /** + * Test binary primary key table creation. + * + * @test + */ + public function should_create_table_with_binary_primary_key() { + $table = $this->get_binary_primary_key_table(); + + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Verify primary key column. + $columns = $table::get_columns(); + $binary_id_column = $columns->get( 'binary_id' ); + $this->assertNotNull( $binary_id_column ); + $this->assertTrue( $binary_id_column->is_primary_key() ); + + // Verify primary key configuration. + $this->assertEquals( 'binary_id', $table::uid_column() ); + $this->assertEquals( [ 'binary_id' ], $table::primary_columns() ); + } + + /** + * Test CRUD operations with string primary key. + * + * @test + */ + public function should_perform_crud_with_string_primary_key() { + $table = $this->get_string_primary_key_table(); + Register::table( $table ); + + // Insert. + $data = [ + 'username' => 'john_doe', + 'email' => 'john@example.com', + 'full_name' => 'John Doe', + 'is_active' => true, + 'login_count' => 5, + 'last_login' => '2024-01-15 10:30:00', + 'bio' => 'Software developer', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + // Read by primary key. + $retrieved = $table::get_by_id( 'john_doe' ); + $this->assertNotNull( $retrieved ); + $this->assertEquals( 'john_doe', $retrieved['username'] ); + $this->assertEquals( 'john@example.com', $retrieved['email'] ); + $this->assertEquals( 'John Doe', $retrieved['full_name'] ); + $this->assertTrue( $retrieved['is_active'] ); + $this->assertEquals( 5, $retrieved['login_count'] ); + + // Update. + $update_data = [ + 'username' => 'john_doe', // Primary key + 'login_count' => 6, + 'last_login' => '2024-01-16 11:00:00', + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + $updated = $table::get_by_id( 'john_doe' ); + $this->assertEquals( 6, $updated['login_count'] ); + $this->assertEquals( '2024-01-16 11:00:00', $updated['last_login']->format( 'Y-m-d H:i:s' ) ); + + // Delete. + $result = $table::delete_many( [ 'john_doe' ], 'username' ); + $this->assertNotFalse( $result ); + + $deleted = $table::get_by_id( 'john_doe' ); + $this->assertNull( $deleted ); + } + + /** + * Test CRUD operations with binary primary key. + * + * @test + */ + public function should_perform_crud_with_binary_primary_key() { + $table = $this->get_binary_primary_key_table(); + Register::table( $table ); + + // Generate UUID and convert to binary. + $uuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + $binary_uuid = hex2bin( str_replace( '-', '', $uuid ) ); + $hash_key = hash( 'sha256', 'test_data', true ); + + // Insert. + $data = [ + 'binary_id' => $binary_uuid, + 'name' => 'Test Resource', + 'hash_key' => $hash_key, + 'type' => 'document', + 'data' => 'Some binary data content', + 'version' => 1, + 'created_at' => '2024-01-15 12:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + // Read by binary primary key. + $retrieved = $table::get_by_id( $binary_uuid ); + $this->assertNotNull( $retrieved ); + $this->assertEquals( $binary_uuid, $retrieved['binary_id'] ); + $this->assertEquals( 'Test Resource', $retrieved['name'] ); + $this->assertEquals( $hash_key, $retrieved['hash_key'] ); + $this->assertEquals( 'document', $retrieved['type'] ); + + // Update using binary primary key. + $update_data = [ + 'binary_id' => $binary_uuid, // Primary key + 'version' => 2, + 'name' => 'Updated Resource', + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + $updated = $table::get_by_id( $binary_uuid ); + $this->assertEquals( 2, $updated['version'] ); + $this->assertEquals( 'Updated Resource', $updated['name'] ); + + // Query by binary column. + $results = $table::get_all_by( 'hash_key', $hash_key ); + $this->assertCount( 1, $results ); + $this->assertEquals( $binary_uuid, $results[0]['binary_id'] ); + + // Delete using binary primary key. + $result = $table::delete_many( [ $binary_uuid ], 'binary_id' ); + $this->assertNotFalse( $result ); + + $deleted = $table::get_by_id( $binary_uuid ); + $this->assertNull( $deleted ); + } + + /** + * Test composite primary key operations. + * + * @test + */ + public function should_handle_composite_string_binary_primary_key() { + $table = $this->get_composite_string_binary_key_table(); + Register::table( $table ); + + $this->assertTrue( $table->exists() ); + + // Prepare composite key values. + $tenant_id = '550e8400-e29b-41d4-a716-446655440000'; // UUID + $resource_hash = sha1( 'resource_content', true ); // Binary SHA1 + + // Insert. + $data = [ + 'tenant_id' => $tenant_id, + 'resource_hash' => $resource_hash, + 'resource_type' => 'image', + 'resource_name' => 'profile_picture.jpg', + 'is_public' => true, + 'access_count' => 10, + 'last_accessed' => '2024-01-15 14:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + // Since this table has composite primary key, we need to query differently. + // We'll query by one of the key columns. + $results = $table::get_all_by( 'tenant_id', $tenant_id ); + $this->assertCount( 1, $results ); + $this->assertEquals( $tenant_id, $results[0]['tenant_id'] ); + $this->assertEquals( $resource_hash, $results[0]['resource_hash'] ); + $this->assertEquals( 'image', $results[0]['resource_type'] ); + + // Insert another resource for same tenant. + $resource_hash2 = sha1( 'another_resource', true ); + $data2 = [ + 'tenant_id' => $tenant_id, + 'resource_hash' => $resource_hash2, + 'resource_type' => 'document', + 'resource_name' => 'report.pdf', + 'is_public' => false, + 'access_count' => 0, + ]; + + $result = $table::insert( $data2 ); + $this->assertNotFalse( $result ); + + // Query all resources for tenant. + $results = $table::get_all_by( 'tenant_id', $tenant_id ); + $this->assertCount( 2, $results ); + + // Test uniqueness of composite key - inserting duplicate should fail. + $duplicate_data = [ + 'tenant_id' => $tenant_id, + 'resource_hash' => $resource_hash, // Same composite key + 'resource_type' => 'video', + 'resource_name' => 'duplicate.mp4', + ]; + + $this->expectException( DatabaseQueryException::class ); + $table::insert( $duplicate_data ); + } + + /** + * Test UUID string as primary key. + * + * @test + */ + public function should_handle_uuid_string_primary_key() { + $table = $this->get_uuid_primary_key_table(); + Register::table( $table ); + + // Generate UUIDs. + $uuid1 = '123e4567-e89b-12d3-a456-426614174000'; + $uuid2 = '987fcdeb-51a2-43f1-b321-123456789abc'; + + // Insert first entity. + $data1 = [ + 'uuid' => $uuid1, + 'entity_type' => 'user', + 'entity_data' => json_encode( [ 'name' => 'Alice', 'role' => 'admin' ] ), + 'is_processed' => false, + 'created_at' => '2024-01-15 09:00:00', + ]; + + $result = $table::insert( $data1 ); + $this->assertNotFalse( $result ); + + // Insert second entity. + $data2 = [ + 'uuid' => $uuid2, + 'entity_type' => 'order', + 'entity_data' => json_encode( [ 'total' => 99.99, 'items' => 3 ] ), + 'is_processed' => true, + 'created_at' => '2024-01-15 10:00:00', + ]; + + $result = $table::insert( $data2 ); + $this->assertNotFalse( $result ); + + // Read by UUID primary key. + $entity1 = $table::get_by_id( $uuid1 ); + $this->assertNotNull( $entity1 ); + $this->assertEquals( $uuid1, $entity1['uuid'] ); + $this->assertEquals( 'user', $entity1['entity_type'] ); + $this->assertIsArray( $entity1['entity_data'] ); + $this->assertEquals( 'Alice', $entity1['entity_data']['name'] ); + + $entity2 = $table::get_by_id( $uuid2 ); + $this->assertNotNull( $entity2 ); + $this->assertEquals( $uuid2, $entity2['uuid'] ); + $this->assertEquals( 'order', $entity2['entity_type'] ); + $this->assertEquals( 99.99, $entity2['entity_data']['total'] ); + + // Update by UUID. + $update_data = [ + 'uuid' => $uuid1, + 'is_processed' => true, + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + $updated = $table::get_by_id( $uuid1 ); + $this->assertTrue( $updated['is_processed'] ); + + // Delete by UUID. + $result = $table::delete_many( [ $uuid1 ], 'uuid' ); + $this->assertNotFalse( $result ); + + $deleted = $table::get_by_id( $uuid1 ); + $this->assertNull( $deleted ); + + // Verify second entity still exists. + $entity2_check = $table::get_by_id( $uuid2 ); + $this->assertNotNull( $entity2_check ); + } + + /** + * Test email as primary key. + * + * @test + */ + public function should_handle_email_primary_key() { + $table = $this->get_email_primary_key_table(); + Register::table( $table ); + + // Test emails. + $emails = [ + 'alice@example.com', + 'bob@test.org', + 'charlie+tag@domain.co.uk', + ]; + + // Insert subscribers. + foreach ( $emails as $index => $email ) { + $data = [ + 'email' => $email, + 'name' => 'User ' . ( $index + 1 ), + 'is_verified' => $index === 0, // Only first is verified + 'is_subscribed' => true, + 'subscription_type' => $index === 0 ? 'premium' : 'basic', + 'subscribed_at' => '2024-01-' . sprintf( '%02d', 10 + $index ) . ' 12:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + } + + // Read by email primary key. + $subscriber = $table::get_by_id( 'alice@example.com' ); + $this->assertNotNull( $subscriber ); + $this->assertEquals( 'alice@example.com', $subscriber['email'] ); + $this->assertEquals( 'User 1', $subscriber['name'] ); + $this->assertTrue( $subscriber['is_verified'] ); + $this->assertEquals( 'premium', $subscriber['subscription_type'] ); + + $lowercase = $table::get_by_id( 'alice@example.com' ); + $this->assertNotNull( $lowercase ); + + // Update subscription status. + $update_data = [ + 'email' => 'bob@test.org', + 'is_subscribed' => false, + 'subscription_type' => null, + ]; + + $result = $table::update_single( $update_data ); + $this->assertTrue( $result ); + + $updated = $table::get_by_id( 'bob@test.org' ); + $this->assertFalse( $updated['is_subscribed'] ); + $this->assertNull( $updated['subscription_type'] ); + + // Query by verification status. + $verified = $table::get_all_by( 'is_verified', true ); + $this->assertCount( 1, $verified ); + $this->assertEquals( 'alice@example.com', $verified[0]['email'] ); + + // Delete by email. + $result = $table::delete_many( [ 'charlie+tag@domain.co.uk' ] ); + $this->assertNotFalse( $result ); + + $deleted = $table::get_by_id( 'charlie+tag@domain.co.uk' ); + $this->assertNull( $deleted ); + + // Test case sensitivity - MySQL is case-insensitive by default. + $uppercase_email = 'ALICE@EXAMPLE.COM'; + $data_uppercase = [ + 'email' => $uppercase_email, + 'name' => 'Alice Uppercase', + 'is_verified' => false, + 'is_subscribed' => true, + 'subscribed_at' => '2024-01-15 15:00:00', + ]; + + $this->expectException( DatabaseQueryException::class ); + $table::insert( $data_uppercase ); + } + + /** + * Test querying and pagination with string primary keys. + * + * @test + */ + public function should_paginate_with_string_primary_key() { + $table = $this->get_string_primary_key_table(); + Register::table( $table ); + + // Insert test users. + $users = [ + [ 'username' => 'alice', 'email' => 'alice@test.com', 'full_name' => 'Alice Smith', 'is_active' => true, 'login_count' => 10 ], + [ 'username' => 'bob', 'email' => 'bob@test.com', 'full_name' => 'Bob Jones', 'is_active' => true, 'login_count' => 5 ], + [ 'username' => 'charlie', 'email' => 'charlie@test.com', 'full_name' => 'Charlie Brown', 'is_active' => false, 'login_count' => 0 ], + [ 'username' => 'david', 'email' => 'david@test.com', 'full_name' => 'David Wilson', 'is_active' => true, 'login_count' => 15 ], + [ 'username' => 'eve', 'email' => 'eve@test.com', 'full_name' => 'Eve Davis', 'is_active' => true, 'login_count' => 20 ], + ]; + + foreach ( $users as $user ) { + $table::insert( $user ); + } + + // Paginate with ordering by string primary key. + $page1 = $table::paginate( [], 2, 1, [ '*' ], '', '', [], ARRAY_A ); + $this->assertCount( 2, $page1 ); + $this->assertEquals( 'alice', $page1[0]['username'] ); // Alphabetically first + + $page2 = $table::paginate( [], 2, 2, [ '*' ], '', '', [], ARRAY_A ); + $this->assertCount( 2, $page2 ); + $this->assertEquals( 'charlie', $page2[0]['username'] ); + + // Filter active users. + $args = [ + [ + 'column' => 'is_active', + 'value' => 1, + 'operator' => '=', + ], + ]; + + $active_users = $table::paginate( $args, 10, 1, [ '*' ], '', '', [], ARRAY_A ); + $this->assertCount( 4, $active_users ); // alice, bob, david, eve + + // Order by login_count. + $args = [ + 'orderby' => 'login_count', + 'order' => 'DESC', + ]; + + $ordered = $table::paginate( $args, 10, 1, [ '*' ], '', '', [], ARRAY_A ); + $this->assertEquals( 'eve', $ordered[0]['username'] ); // Highest login count + $this->assertEquals( 'david', $ordered[1]['username'] ); + } + + /** + * Test bulk operations with string and binary primary keys. + * + * @test + */ + public function should_handle_bulk_operations_with_non_integer_keys() { + $table = $this->get_string_primary_key_table(); + Register::table( $table ); + + // Bulk insert. + $users = [ + [ 'username' => 'user1', 'email' => 'user1@test.com', 'full_name' => 'User One', 'is_active' => true ], + [ 'username' => 'user2', 'email' => 'user2@test.com', 'full_name' => 'User Two', 'is_active' => true ], + [ 'username' => 'user3', 'email' => 'user3@test.com', 'full_name' => 'User Three', 'is_active' => false ], + ]; + + $result = $table::insert_many( $users ); + $this->assertNotFalse( $result ); + + // Verify all inserted. + $all_users = $table::get_all(); + $count = 0; + foreach ( $all_users as $user ) { + $count++; + } + $this->assertEquals( 3, $count ); + + // Bulk update. + $updates = [ + [ 'username' => 'user1', 'is_active' => false, 'login_count' => 1 ], + [ 'username' => 'user2', 'is_active' => false, 'login_count' => 2 ], + ]; + + $result = $table::update_many( $updates ); + $this->assertTrue( $result ); + + // Verify updates. + $user1 = $table::get_by_id( 'user1' ); + $this->assertFalse( $user1['is_active'] ); + $this->assertEquals( 1, $user1['login_count'] ); + + $user2 = $table::get_by_id( 'user2' ); + $this->assertFalse( $user2['is_active'] ); + $this->assertEquals( 2, $user2['login_count'] ); + + // Bulk delete. + $result = $table::delete_many( [ 'user1', 'user3' ], 'username' ); + $this->assertEquals( 2, $result ); // 2 rows deleted + + // Verify only user2 remains. + $remaining = $table::get_all(); + $count = 0; + $last_user = null; + foreach ( $remaining as $user ) { + $count++; + $last_user = $user; + } + $this->assertEquals( 1, $count ); + $this->assertEquals( 'user2', $last_user['username'] ); + } + + /** + * Test upsert operations with string primary key. + * + * @test + */ + public function should_handle_upsert_with_string_primary_key() { + $table = $this->get_string_primary_key_table(); + Register::table( $table ); + + $data = [ + 'username' => 'test_user', + 'email' => 'test@example.com', + 'full_name' => 'Test User', + 'is_active' => true, + 'login_count' => 0, + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $user = $table::get_by_id( 'test_user' ); + $this->assertNotNull( $user ); + $this->assertEquals( 'test@example.com', $user['email'] ); + $this->assertEquals( 0, $user['login_count'] ); + + // Second upsert with same key (should update). + $data['login_count'] = 5; + $data['email'] = 'newemail@example.com'; + + $result = $table::upsert( $data ); + $this->assertTrue( $result ); + + $user = $table::get_by_id( 'test_user' ); + $this->assertNotNull( $user ); + $this->assertEquals( 'newemail@example.com', $user['email'] ); + $this->assertEquals( 5, $user['login_count'] ); + + // Verify still only one row. + $all = $table::get_all(); + $count = 0; + foreach ( $all as $row ) { + $count++; + } + $this->assertEquals( 1, $count ); + } + + /** + * Test edge cases with binary primary keys. + * + * @test + */ + public function should_handle_binary_key_edge_cases() { + $table = $this->get_binary_primary_key_table(); + Register::table( $table ); + + // Test with null bytes in binary key. + $binary_with_nulls = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F"; + $hash_key = hash( 'sha256', 'test', true ); + + $data = [ + 'binary_id' => $binary_with_nulls, + 'name' => 'Binary with null bytes', + 'hash_key' => $hash_key, + 'type' => 'special', + 'version' => 1, + 'created_at' => '2024-01-15 10:00:00', + ]; + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + // Retrieve and verify. + $retrieved = $table::get_by_id( $binary_with_nulls ); + $this->assertNotNull( $retrieved ); + $this->assertEquals( $binary_with_nulls, $retrieved['binary_id'] ); + $this->assertEquals( 16, strlen( $retrieved['binary_id'] ) ); + + // Test with all zeros binary key. + $all_zeros = str_repeat( "\x00", 16 ); + $data['binary_id'] = $all_zeros; + $data['name'] = 'All zeros binary'; + $data['hash_key'] = hash( 'sha256', 'zeros', true ); + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $retrieved = $table::get_by_id( $all_zeros ); + $this->assertNotNull( $retrieved ); + $this->assertEquals( $all_zeros, $retrieved['binary_id'] ); + + // Test with all ones (0xFF) binary key. + $all_ones = str_repeat( "\xFF", 16 ); + $data['binary_id'] = $all_ones; + $data['name'] = 'All ones binary'; + $data['hash_key'] = hash( 'sha256', 'ones', true ); + + $result = $table::insert( $data ); + $this->assertNotFalse( $result ); + + $retrieved = $table::get_by_id( $all_ones ); + $this->assertNotNull( $retrieved ); + $this->assertEquals( $all_ones, $retrieved['binary_id'] ); + + // Verify all three exist. + $all = $table::get_all(); + $count = 0; + foreach ( $all as $row ) { + $count++; + } + $this->assertEquals( 3, $count ); + } +}