diff --git a/.gitattributes b/.gitattributes index 2cbb4fa..952b5e7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,4 +7,5 @@ /.gitattributes export-ignore /phpunit.xml export-ignore /phpcs.xml export-ignore +/phpunit-multisite.xml export-ignore /README.md export-ignore \ No newline at end of file diff --git a/README.md b/README.md index 34d90a1..132f0ea 100644 --- a/README.md +++ b/README.md @@ -10,37 +10,6 @@ --- -## Table of Contents - -- [Installation](#installation) -- [Schema Builder](#schema-builder) - - [Creating Tables](#creating-tables) - - [Altering Tables](#altering-tables) - - [Dropping or Renaming Tables](#dropping-or-renaming-tables) - - [Returning SQL Queries](#returning-sql-queries) - - [Foreign Key Safety](#foreign-key-safety) - - [Supported Blueprint Methods](#supported-blueprint-methods) -- [Eloquent Models](#eloquent-models) - - [Creating Models](#creating-models) - - [Inserting Data](#inserting-data) - - [Updating Data](#updating-data) - - [Deleting Data](#deleting-data) - - [Reading Data](#reading-data) - - [Aggregates](#aggregates) - - [Retrieving Models](#retrieving-models) - - [Select Statements](#select-statements) - - [Joins](#joins) - - [Where Clauses](#where-clauses) - - [Ordering, Grouping, Limit & Offset](#ordering-grouping-limit--offset) -- [Relationships](#relationships) - - [One-to-One](#one-to-one) - - [One-to-Many](#one-to-many) - - [One-to-Many (Inverse) / Belongs To](#one-to-many-inverse--belongs-to) - - [Constraining Query Loads](#constraining-query-loads) -- [License](#license) - ---- - ## Installation Install WpMVC Database using Composer: @@ -51,416 +20,34 @@ composer require wpmvc/database --- -## Schema Builder - -The Schema Builder provides a fluent interface for creating and modifying database tables. - -### Creating Tables - -Create a table with the `Schema::create` method: - -```php -use WpMVC\Database\Schema; - -Schema::create('products', function (Blueprint $table) { - $table->big_increments('id'); - $table->unsigned_big_integer('category_id'); - $table->string('title'); - $table->long_text('description')->nullable(); - $table->enum('visibility', ['publish', 'draft'])->default('publish'); - $table->timestamps(); - - $table->foreign('category_id') - ->references('id') - ->on('categories') - ->on_delete('cascade'); -}); -``` - -### Altering Tables - -Modify an existing table with `Schema::alter`: - -```php -Schema::alter('products', function (Blueprint $table) { - $table->string('short_description')->after('title')->nullable(); - $table->drop_column('legacy_column'); - $table->drop_index('index_abc123'); -}); -``` - -### Dropping or Renaming Tables - -Drop or rename tables as needed: - -```php -Schema::drop_if_exists('products'); -Schema::rename('old_products', 'products'); -``` +## Modular Documentation -### Returning SQL Queries +For detailed information on how to use WpMVC Database, please refer to the following guides: -Generate SQL without executing it by passing `true` as the third argument: - -```php -$sql = Schema::create('products', function (Blueprint $table) { - $table->string('title'); -}, true); - -echo $sql; -``` - -### Foreign Key Safety - -Foreign keys are automatically checked to avoid duplicates. The naming convention is: - -``` -fk_{prefix}{table}_{column} -``` - -### Example Schema - -A complete example for a `products` table: - -```php -Schema::create('products', function (Blueprint $table) { - $table->big_increments('id'); - $table->unsigned_big_integer('category_id'); - $table->string('title'); - $table->string('sku')->nullable(); - $table->long_text('description')->nullable(); - $table->decimal('price', 10, 2)->default(0.00); - $table->boolean('is_active')->default(true); - $table->enum('status', ['publish', 'draft'])->default('publish'); - $table->timestamps(); - - $table->index(['status']); - $table->foreign('category_id') - ->references('id') - ->on('categories') - ->on_delete('cascade'); -}); -``` - -### Supported Blueprint Methods - -#### Column Types -- `big_increments(name)`: Auto-incrementing big integer (primary key). -- `unsigned_big_integer(name)`: Unsigned big integer. -- `integer(name)`: Signed integer. -- `unsigned_integer(name)`: Unsigned integer. -- `decimal(name, precision, scale)`: DECIMAL column with optional precision and scale (default: `10, 2`). -- `string(name, length)`: VARCHAR column with optional length. -- `text(name)`: TEXT column. -- `long_text(name)`: LONGTEXT column. -- `json(name)`: JSON column. -- `enum(name, values)`: ENUM column with specified values. -- `tiny_integer(name)`: TINYINT column. -- `timestamp(name)`: TIMESTAMP column. -- `timestamps()`: Adds `created_at` and `updated_at` TIMESTAMP columns. -- `boolean(name)`: BOOLEAN column. - -#### Column Modifiers -- `nullable()`: Allows NULL values. -- `default(value)`: Sets a default value. -- `comment(text)`: Adds a column comment. -- `use_current()`: Sets the default to the current timestamp. -- `use_current_on_update()`: Updates timestamp on record update. -- `after(column)`: Places the column after another (only for `ALTER`). - -#### Indexes & Constraints -- `primary(column|[columns])`: Sets primary key. -- `unique(column|[columns])`: Sets unique index. -- `index(column|[columns])`: Creates an index. -- `drop_column(name)`: Drops a column. -- `drop_index(name)`: Drops an index. -- `foreign(column)->references()->on()->on_delete()->on_update()`: Defines a foreign key constraint. +1. [**Schema Builder**](docs/schema-builder.md) — Creating, altering, and dropping tables. +2. [**Eloquent Models**](docs/eloquent-models.md) — Creating models, configuration, mass assignment, and serialization. +3. [**Mutators & Casting**](docs/mutators-and-casting.md) — Accessors, mutators, and attribute casting. +4. [**Query Builder**](docs/query-builder.md) — Advanced retrieval, filtering, joins, unions, and transactions. +5. [**Eloquent Collections**](docs/collections.md) — Working with multi-result sets. +6. [**Relationships**](docs/relationships.md) — One-to-one, one-to-many, many-to-many, and polymorphic relationships. +7. [**Factories & Seeding**](docs/factories-and-seeding.md) — Generating dummy data for testing and seeding. --- -## Eloquent Models +## Resolver Utility -### Creating Models - -Define an Eloquent model by extending the `Model` class: +Use the `Resolver` to manually resolve database table names with WordPress prefixes: ```php -namespace WpMVC\App\Models; - -use WpMVC\Database\Eloquent\Model; use WpMVC\Database\Resolver; -class Post extends Model { - public static function get_table_name(): string { - return 'posts'; - } - - public function resolver(): Resolver { - return new Resolver; - } -} -``` - -### Inserting Data - -Insert a single record: - -```php -Post::query()->insert([ - 'post_author' => wp_get_current_user()->ID, - 'post_title' => 'Test Post', -]); -``` - -Insert multiple records: - -```php -Post::query()->insert([ - [ - 'post_author' => wp_get_current_user()->ID, - 'post_title' => 'Test Post 1', - ], - [ - 'post_author' => wp_get_current_user()->ID, - 'post_title' => 'Test Post 2', - ], -]); -``` - -Insert and retrieve the ID: - -```php -$post_id = Post::query()->insert_get_id([ - 'post_author' => wp_get_current_user()->ID, - 'post_title' => 'Test Post', -]); -``` - -### Updating Data - -Update a record based on a condition: - -```php -Post::query()->where('post_id', 100)->update([ - 'post_title' => 'Updated Post', -]); -``` - -### Deleting Data - -Delete a record based on a condition: - -```php -Post::query()->where('post_id', 100)->delete(); -``` - -### Reading Data - -#### Aggregates - -Retrieve aggregate values like `count`, `max`, `min`, `avg`, or `sum`: - -```php -$count = Post::query()->count(); -``` - -#### Retrieving Models - -Fetch all records: - -```php -$posts = Post::query()->get(); -``` - -Fetch a single record: - -```php -$post = Post::query()->where('id', 100)->first(); -``` - -#### Select Statements - -Select specific columns: - -```php -$posts = Post::query()->select('post_title', 'post_date')->get(); -``` - -Use `distinct` for unique results: - -```php -$posts = Post::query()->distinct()->select('post_title')->get(); -``` - -#### Joins - -Perform an inner join: - -```php -$users = User::query() - ->join('contacts', 'users.id', '=', 'contacts.user_id') - ->select('users.*', 'contacts.phone', 'contacts.email') - ->get(); -``` - -Perform left or right joins: - -```php -$users = User::query() - ->left_join('posts', 'users.id', '=', 'posts.user_id') - ->get(); -``` - -Advanced join with a closure: - -```php -use WpMVC\Database\Query\JoinClause; - -$posts = Post::query()->join('postmeta', function (JoinClause $join) { - $join->on('postmeta.post_id', '=', 'posts.ID') - ->where('postmeta.meta_value', '>', 500); -})->get(); -``` - -#### Where Clauses - -Basic where clause: - -```php -$posts = Post::query()->where('post_status', 'publish')->get(); -``` - -Or where clause: - -```php -$posts = Post::query() - ->where('post_status', 'publish') - ->orWhere('post_title', 'Test Post') - ->get(); -``` - -Where exists clause: - -```php -$posts = Post::query()->where_exists(function (Builder $query) { - $query->select(1) - ->from('postmeta') - ->where_column('postmeta.post_id', 'posts.id') - ->limit(1); -})->get(); -``` - -Where between: - -```php -$posts = Post::query()->where_between('ID', [1, 100])->get(); -``` - -Where in: - -```php -$posts = Post::query()->where_in('ID', [100, 105])->get(); -``` - -#### Ordering, Grouping, Limit & Offset - -Order results: +$resolver = new Resolver(); -```php -$posts = Post::query()->order_by('post_title', 'asc')->get(); -``` - -Group results: - -```php -$posts = Post::query() - ->group_by('post_author') - ->having('post_author', '>', 100) - ->get(); -``` - -Limit and offset: - -```php -$posts = Post::query()->offset(10)->limit(5)->get(); -``` - ---- - -## Relationships - -WpMVC Database supports common Eloquent relationships for managing related data. - -### One-to-One - -Define a one-to-one relationship (e.g., a `User` has one `Phone`): - -```php -namespace WpMVC\App\Models; - -use WpMVC\Database\Eloquent\Model; -use WpMVC\Database\Eloquent\Relations\HasOne; - -class User extends Model { - public function phone(): HasOne { - return $this->has_one(Phone::class, 'ID', 'user_id'); - } -} -``` - -Retrieve users with their phones: - -```php -$users = User::query()->with('phone')->get(); -``` - -### One-to-Many - -Define a one-to-many relationship (e.g., a `Post` has many `PostMeta`): - -```php -namespace WpMVC\App\Models; - -use WpMVC\Database\Eloquent\Model; -use WpMVC\Database\Eloquent\Relations\HasMany; - -class Post extends Model { - public function meta(): HasMany { - return $this->has_many(PostMeta::class, 'ID', 'post_id'); - } -} -``` - -### One-to-Many (Inverse) / Belongs To - -Define the inverse relationship (e.g., a `PostMeta` belongs to a `Post`): - -```php -namespace WpMVC\App\Models; - -use WpMVC\Database\Eloquent\Model; -use WpMVC\Database\Eloquent\Relations\BelongsToOne; - -class PostMeta extends Model { - public function post(): BelongsToOne { - return $this->belongs_to_one(Post::class, 'post_id', 'ID'); - } -} -``` - -### Constraining Query Loads - -Add conditions to relationship queries: - -```php -use WpMVC\Database\Query\Builder; +// Get a single table name +$table = $resolver->table('my_custom_table'); // wp_my_custom_table -$posts = Post::query()->with([ - 'meta' => function (Builder $query) { - $query->where('meta_id', 672); - }, - 'user', -])->get(); +// Get multiple table names +[$posts, $users] = $resolver->table('posts', 'users'); ``` --- diff --git a/docs/collections.md b/docs/collections.md new file mode 100644 index 0000000..f3a5470 --- /dev/null +++ b/docs/collections.md @@ -0,0 +1,121 @@ +# Eloquent: Collections + +- [Introduction](#introduction) +- [Available Methods](#available-methods) + + +## Introduction + +All Eloquent methods that return more than one model result will return instances of the `WpMVC\Database\Eloquent\Collection` class, including results retrieved via the `get` method or accessed via a [relationship](relationships.md). + +All Eloquent collections extend the base WpMVC collection object; therefore, they naturally inherit dozens of methods used to fluently work with the underlying array of Eloquent models. Be sure to review the WpMVC collection documentation to learn all about these helpful methods! + +All collections also serve as iterators, allowing you to loop over them as if they were simple PHP arrays: + +```php +use MyPluginNamespace\App\Models\User; + +$users = User::query()->where('active', 1)->get(); + +foreach ($users as $user) { + echo $user->display_name; +} +``` + +Since collections are much more powerful than arrays, they expose an intuitive, fluent interface of method chaining. For example, you may remove all inactive models and gather the first name for each remaining user: + +```php +$names = $users->filter(function ($user) { + return $user->user_status === 0; + }) + ->map(function ($user) { + return $user->display_name; + }); +``` + + +## Available Methods + +The Eloquent collection class provides an enormous variety of methods for iterating and manipulating the items in the collection. For brevity, we'll cover a few of the most commonly used methods below. + +#### all() +The `all` method returns the underlying array represented by the collection: +```php +$collection->all(); +``` + +#### chunk() +The `chunk` method breaks the collection into multiple, smaller collections of a given size: +```php +$chunks = $collection->chunk(4); +``` + +#### contains() +The `contains` method determines whether the collection contains a given item: +```php +if ($collection->contains('Apple')) { + // ... +} +``` + +#### count() +The `count` method returns the total number of items in the collection: +```php +$count = $collection->count(); +``` + +#### each() +The `each` method iterates over the items in the collection and passes each item to a closure: +```php +$collection->each(function ($item, $key) { + // ... +}); +``` + +#### filter() +The `filter` method filters the collection using a given closure, keeping only those items that pass a given truth test: +```php +$filtered = $collection->filter(function ($item, $key) { + return $item > 2; +}); +``` + +#### first() +The `first` method returns the first element in the collection that passes a given truth test: +```php +$first = $collection->first(function ($item, $key) { + return $item > 2; +}); +``` + +#### map() +The `map` method iterates through the collection and passes each value to the given closure. The closure is free to modify the item and return it, thus forming a new collection of modified items: +```php +$multiplied = $collection->map(function ($item, $key) { + return $item * 2; +}); +``` + +#### pluck() +The `pluck` method retrieves all of the values for a given key: +```php +$plucked = $collection->pluck('display_name'); +``` + +#### push() +The `push` method appends an item to the end of the collection: +```php +$collection->push('Tea'); +``` + +#### sort_by() +The `sort_by` method sorts the collection by the given key: +```php +$sorted = $collection->sort_by('comment_count'); +``` + +#### unique() +The `unique` method returns all of the unique items in the collection: +```php +$unique = $collection->unique(); +``` \ No newline at end of file diff --git a/docs/eloquent-models.md b/docs/eloquent-models.md new file mode 100644 index 0000000..34dbb88 --- /dev/null +++ b/docs/eloquent-models.md @@ -0,0 +1,409 @@ +# Eloquent: Models + +- [Introduction](#introduction) +- [Defining Models](#defining-models) + - [Eloquent Model Conventions](#eloquent-model-conventions) +- [Default Attribute Values](#default-attribute-values) +- [Retrieving Models](#retrieving-models) + - [Query Scopes](#query-scopes) +- [Inserting & Updating Models](#inserting-and-updating-models) + - [Mass Assignment](#mass-assignment) +- [Deleting Models](#deleting-models) +- [Serialization](#serialization) +- [Events & Observers](#events-and-observers) + + +## Introduction + +Eloquent is a powerful ORM that makes it easy to interact with your database. Each database table has a corresponding "Model" that is used to interact with that table. Models allow you to query for data in your tables, as well as insert new records into the table. + +To get started, let's create an Eloquent model. Models typically live in the `MyPluginNamespace\App\Models` namespace and extend the `WpMVC\Database\Eloquent\Model` class. + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Resolver; + +class Post extends Model +{ + /** + * Get the table associated with the model. + * + * @return string + */ + public static function get_table_name(): string + { + return 'posts'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } +} +``` + + +### Eloquent Model Conventions + +Eloquent will also assume that each table has a primary key column named `id`. If you wish to override this convention (for example, to match the WordPress `ID` column), you may define a protected `$primary_key` property on your model: + +```php +class Post extends Model +{ + /** + * Indicates if the model should handle timestamps. + * + * @var bool + */ + public bool $timestamps = false; + + /** + * The primary key associated with the table. + * + * @var string + */ + protected string $primary_key = 'ID'; +} +``` + +In addition, Eloquent assumes that the primary key is an incrementing integer value. If you wish to use a non-incrementing primary key, you should set the public `$incrementing` property on your model to `false`: + +```php +class Post extends Model +{ + /** + * Indicates if the model's ID is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; +} +``` + +#### Timestamps + +By default, Eloquent expects `created_at` and `updated_at` columns to exist on your model's corresponding database table. Eloquent automatically sets these columns' values when models are created or updated. If you do not want these columns to be automatically managed by Eloquent, set the `$timestamps` property on your model to `false`: + +```php +class Post extends Model +{ + /** + * Indicates if the model should be timestamped. + * + * @var bool + */ + public $timestamps = false; +} +``` + + +## Default Attribute Values + +By default, a newly instantiated model instance will not contain any attribute values. If you would like to define default values for some of your model's attributes, you may define an `$attributes` property on your model: + +```php +class Post extends Model +{ + /** + * The model's default values for attributes. + * + * @var array + */ + protected array $attributes = [ + 'post_status' => 'publish', + ]; +} +``` + + +## Retrieving Models + +Once you have created a model and its associated database table, you are ready to start retrieving data from your database. You may think of each Eloquent model as a powerful [query builder](query-builder.md) allowing you to fluently query the database table associated with the model. For example: + +```php +use MyPluginNamespace\App\Models\Post; + +$posts = Post::query()->where('post_status', 'publish')->get(); + +foreach ($posts as $post) { + echo $post->post_title; +} +``` + + +### Query Scopes + +Scopes allow you to define common sets of constraints that you may easily re-use throughout your application. For example, you may need to frequently retrieve all users that are considered "popular". To define a scope, prefix an Eloquent model method with `scope_`. + +Scopes should always return a query builder instance: + +```php +class User extends Model +{ + /** + * Scope a query to only include popular users. + * + * @param \WpMVC\Database\Query\Builder $query + * @return \WpMVC\Database\Query\Builder + */ + public function scope_popular($query) + { + return $query->where('votes', '>', 100); + } +} +``` + +#### Utilizing A Query Scope + +Once the scope has been defined, you may call the scope method when querying the model. However, you should not include the `scope_` prefix when calling the method. You can even chain calls to various scopes: + +```php +$users = User::query()->popular()->order_by('created_at')->get(); +``` + +> [!TIP] +> WpMVC supports both `scope_popular` (snake_case) and `scopePopular` (camelCase) naming conventions for defining scopes. + + + +## Inserting & Updating Models + +To create a new record in the database, instantiate a new model instance and set attributes on the model. Then, call the `save` method on the model instance: + +```php +use MyPluginNamespace\App\Models\Post; + +$post = new Post; +$post->post_title = 'Developing with WpMVC'; +$post->save(); +``` + +You may also use the `save` method to update models that already exist in the database: + +```php +$post = Post::query()->find(1); +$post->post_title = 'Advanced Eloquent'; +$post->save(); +``` + + +### Mass Assignment + +You may also use the `create` method to "save" a new model using a single PHP statement. The inserted model instance will be returned to you by the method: + +```php +use MyPluginNamespace\App\Models\Post; + +$post = Post::create([ + 'post_title' => 'London to New York', +]); +``` + +However, before using the `create` method, you will need to specify either a `fillable` or `guarded` property on your model class. These properties are required because all Eloquent models are protected against mass assignment vulnerabilities by default. + +```php +class Post extends Model +{ + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected array $fillable = ['post_title', 'post_content', 'post_author']; +} +``` + +#### Global Unguarding + +If you are importing data and want to temporarily disable mass assignment protection entirely, use `Model::unguard()`: + +```php +use WpMVC\Database\Eloquent\Model; + +Model::unguard(); + +$post = Post::create(['post_title' => 'Imported Post', 'import_id' => 99]); + +Model::reguard(); +``` + + +## Deleting Models + +To delete a model, you may call the `delete` method on a model instance: + +```php +$post = Post::query()->find(1); +$post->delete(); +``` + + + + +## Serialization + +When building APIs, you will often need to convert your models and relationships to arrays or JSON. Eloquent includes convenient methods for making these conversions and controlling which attributes are included in the serialized representation of your models. + +#### Converting To Arrays + +To convert a model and its loaded [relationships](relationships.md) to an array, you should use the `to_array` method: + +```php +$post = Post::query()->with('meta')->first(); + +return $post->to_array(); +``` + +#### Hiding Attributes From JSON + +Sometimes you may wish to limit the attributes, such as passwords, that are included in your model's array or JSON representation. To do so, add a `$hidden` property to your model: + +```php +class User extends Model +{ + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected array $hidden = ['user_pass']; +} +``` + +Alternatively, you may use the `visible` property to define an "allow list" of attributes that should be included in your model's array and JSON representation: + +```php +class User extends Model +{ + /** + * The attributes that should be visible in serialization. + * + * @var array + */ + protected array $visible = ['first_name', 'last_name']; +} +``` + +#### Appending Values To JSON + +Occasionally, when converting models to an array or JSON, you may wish to add attributes that do not have a corresponding column in your database. To do so, first define an [accessor](mutators-and-casting.md) for the value: + +```php +public function get_is_admin_attribute(): bool +{ + return $this->attributes['admin'] === 'yes'; +} +``` + +After creating the accessor, add the attribute name to the `appends` property on the model: + +```php +class User extends Model +{ + /** + * The accessors to append to the model's array form. + * + * @var array + */ + protected array $appends = ['is_admin']; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected array $casts = [ + 'user_status' => 'int', + 'user_registered' => 'datetime', + ]; +} +``` + +Once the attribute has been added to the `appends` list, it will be included in both the model's array and JSON representations. + + +## Events & Observers + +### Events + +Eloquent models fire several events, allowing you to hook into the following moments in a model's lifecycle: `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`. + +#### WordPress Integration + +Every Eloquent event is also broadcast as a WordPress hook. This allows you to listen for model changes from anywhere in your WordPress plugin. + +- **Hook Prefix**: The default prefix is `wpmvc`, but this can be changed in your `app.php` config. +- **Global Hooks**: `{prefix}_model_{event}` (e.g., `wpmvc_model_saved`) +- **Table Specific Hooks**: `{prefix}_model_{event}_{table_name}` (e.g., `wpmvc_model_saving_users`) + +##### Halting Operations + +When an model event is broadcast with an "ing" suffix (e.g., `saving`, `creating`), you may return `false` from the filter to cancel the database operation. These events use `apply_filters`. + +```php +// Prevent posts with empty titles from being saved +add_filter('wpmvc_model_saving_posts', function ($halt, $post) { + if (empty($post->post_title)) { + return false; // Cancels the save operation + } + return $halt; +}, 10, 2); +``` + +> [!NOTE] +> The `retrieved` event is fired when an existing model is retrieved from the database. +``` + +Completed events (e.g., `saved`, `created`, `retrieved`) use `do_action` and cannot be halted. + +```php +add_action('wpmvc_model_created_posts', function ($post) { + // Perform post-creation tasks... +}); +``` + +### Observers + +If you are listening for many events on a given model, you may use observers to group all of your listeners into a single class. Observers classes have method names which reflect the Eloquent events you wish to listen for: + +```php +class UserObserver +{ + /** + * Handle the User "created" event. + * + * @param \MyPluginNamespace\App\Models\User $user + * @return void + */ + public function created(User $user) + { + // ... + } + + /** + * Handle the User "updated" event. + * + * @param \MyPluginNamespace\App\Models\User $user + * @return void + */ + public function updated(User $user) + { + // ... + } +} +``` + +To register an observer, use the `observe` method on the model you wish to observe: + +```php +use MyPluginNamespace\App\Models\User; +use MyPluginNamespace\App\Observers\UserObserver; + +User::observe(UserObserver::class); +``` diff --git a/docs/factories-and-seeding.md b/docs/factories-and-seeding.md new file mode 100644 index 0000000..db712f5 --- /dev/null +++ b/docs/factories-and-seeding.md @@ -0,0 +1,322 @@ +# Database: Factories & Seeding + +- [Introduction](#introduction) +- [Database Factories](#database-factories) + - [Defining Factories](#defining-factories) + - [Factory States](#factory-states) + - [Factory Hooks](#factory-hooks) +- [Creating Models Using Factories](#creating-models-using-factories) + - [Instantiating Models](#instantiating-models) + - [Persisting Models](#persisting-models) + - [Sequences](#sequences) +- [Factory Relationships](#factory-relationships) + - [Has Many Relationships](#has-many-relationships) + - [Belongs To Relationships](#belongs-to-relationships) + - [Polymorphic Relationships](#polymorphic-relationships) + - [Recycling Models](#recycling-models) +- [Database Seeding](#database-seeding) + - [Writing Seeders](#writing-seeders) + - [Running Seeders](#running-seeders) + + +## Introduction + +When testing your application or seeding your database, you may need to insert a few records into your database. Instead of manually specifying the value of each column, WpMVC allows you to define a set of default attributes for each of your Eloquent models using database factories. + +To learn how to write seeders and use factories to hydrate your database with records, let's take a look at the following documentation. + + +## Database Factories + + +To define a factory, create a class that extends `WpMVC\Database\Eloquent\Factory` and define a `definition` method. The `definition` method should return the default set of attribute values that should be applied when creating a model using the factory. + +Through the `faker()` method, factories have access to the `FakeData` generator, which allows you to conveniently generate various kinds of random data for testing and seeding: + +```php +namespace MyPluginNamespace\Database\Factories; + +use WpMVC\Database\Eloquent\Factory; + +class CustomerFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'first_name' => $this->faker()->first_name(), + 'email' => $this->faker()->unique()->email(), + 'status' => 'active', + ]; + } +} +``` + + +State manipulation methods allow you to define discrete modifications that can be applied to your database factories in any combination. For example, your `Customer` model might have a `suspended` state that modifies one of its default attribute values. + +You may define your state transformations using the `state` method. The `state` method accepts a closure which will receive the array of raw attributes defined for the factory: + +```php +/** + * Indicate that the customer is suspended. + * + * @return \WpMVC\Database\Eloquent\Factory + */ +public function suspended(): Factory +{ + return $this->state(function (array $attributes) { + return [ + 'status' => 'suspended', + ]; + }); +} +``` + +> [!TIP] +> Eloquent factories also support "magic states." If you call a method on a factory that is not explicitly defined, it will automatically apply a state where the attribute matching the method name is set to `true`. For example, `$customer->active()` is equivalent to `$customer->state(['active' => true])`. + + +### Factory Hooks + +Factory hooks allow you to perform additional tasks after making or creating a model. You should register these hooks by overriding the `configure` method within your factory class and calling the `after_making` or `after_creating` methods: + +```php +namespace MyPluginNamespace\Database\Factories; + +use MyPluginNamespace\App\Models\Customer; +use WpMVC\Database\Eloquent\Factory; + +class CustomerFactory extends Factory +{ + /** + * Configure the model factory. + * + * @return $this + */ + public function configure() + { + return $this->after_making(function (Customer $customer) { + // ... + })->after_creating(function (Customer $customer) { + // ... + }); + } +} +``` + + +## Creating Models Using Factories + + +Once you have defined your factories, you may use the static `factory` method provided to your models by the `WpMVC\Database\Eloquent\Concerns\HasFactory` trait to instantiate a factory instance for that model. Let's look at a few examples of creating models. First, we'll use the `make` method to create models without persisting them to the database: + +```php +use MyPluginNamespace\App\Models\Customer; + +$customer = Customer::factory()->make(); +``` + +You may create a collection of many models using the `count` method: + +```php +$customers = Customer::factory()->count(3)->make(); +``` + +#### Overriding Attributes + +If you would like to override some of the default values of your models, you may pass an array of values to the `make` method. Only the specified attributes will be replaced while the rest of the attributes remain set to their default values as defined by the factory: + +```php +$customer = Customer::factory()->make([ + 'first_name' => 'Abigail', +]); +``` + + +The `create` method instantiates model instances and persists them to the database using Eloquent's `save` method: + +```php +use MyPluginNamespace\App\Models\Customer; + +// Create a single Customer instance... +$customer = Customer::factory()->create(); + +// Create three Customer instances... +$customers = Customer::factory()->count(3)->create(); +``` + +You may override attributes on the `create` method by passing an array to the method: + +```php +$customer = Customer::factory()->create([ + 'first_name' => 'Abigail', +]); +``` + + +Sometimes you may wish to alternate the value of a given model attribute for each created model. You may accomplish this by defining a state transformation as a sequence. For example, you may wish to alternate the value of a `status` column between `active` and `pending` for each created customer: + +```php +use MyPluginNamespace\App\Models\Customer; +use WpMVC\Database\Eloquent\Sequence; + +$customers = Customer::factory() + ->count(10) + ->state(new Sequence( + ['status' => 'active'], + ['status' => 'pending'], + )) + ->create(); +``` + + +## Factory Relationships + + +Next, let's explore building Eloquent relationship associations using factories. First, let's imagine our `Customer` model has a `products` relationship. To create a customer with three products, we can use the `has` method. The `has` method accepts a factory instance: + +```php +use MyPluginNamespace\App\Models\Product; +use MyPluginNamespace\App\Models\Customer; + +$customer = Customer::factory() + ->has(Product::factory()->count(3)) + ->create(); +``` + +For convenience, you may also use magic methods to define factory relationships. For example, because our `Customer` model defines a `products` relationship, we can do the following: + +```php +$customer = Customer::factory() + ->has_products(3) + ->create(); +``` + + +### Belongs To Relationships + +Now that we have explored how to build "has many" relationships using factories, let's explore the inverse of the relationship. The `for` method may be used to define the parent model that factory created models belong to. For example, we can create three review model instances that belong to a single product: + +```php +use MyPluginNamespace\App\Models\Product; +use MyPluginNamespace\App\Models\Review; + +$reviews = Review::factory() + ->count(3) + ->for(Product::factory()->create()) + ->create(); +``` + +Like the `has` method, you may use magic methods to define "belongs to" relationships: + +```php +$reviews = Review::factory() + ->count(3) + ->for_product(['name' => 'Jessica']) + ->create(); +``` + + +### Polymorphic Relationships + +Polymorphic relationships may also be created using factories. For example, if a `Review` model has a polymorphic `reviewable` relationship, you may use the `for` method to specify the parent model: + +```php +use MyPluginNamespace\App\Models\Product; +use MyPluginNamespace\App\Models\Review; + +$product = Product::factory()->create(); + +$review = Review::factory() + ->for($product, 'reviewable') + ->create(); +``` + + +### Recycling Models + +When creating models that share a common relationship, you may use the `recycle` method to ensure that all generated models use the same instance of a related model. This prevents the factory from creating a new related model for every instance: + +```php +use MyPluginNamespace\App\Models\Customer; +use MyPluginNamespace\App\Models\Product; + +$customer = Customer::factory()->create(); + +$products = Product::factory() + ->count(10) + ->recycle($customer) + ->create(); +``` + + +## Database Seeding + +WpMVC includes the ability to seed your database with test data using seed classes. All seed classes are stored in the `MyPluginNamespace\App\Seeders` namespace. By default, a `DatabaseSeeder` class is defined for you. From this class, you may use the `call` method to run other seed classes, allowing you to control the seeding order. + + +### Writing Seeders + +To write a seeder, define a `run` method on your seeder class. Within the `run` method, you may insert data into your database however you wish. You may use [Eloquent model factories](#database-factories) or you may use the [query builder](query-builder.md) to manually insert data. + +As an example, let's modify the default `DatabaseSeeder` class and add a database insertion statement to the `run` method using the `Customer` model: + +```php +namespace MyPluginNamespace\Database\Seeders; + +use WpMVC\Database\Seeder; +use MyPluginNamespace\App\Models\Customer; + +class DatabaseSeeder extends Seeder +{ + /** + * Seed the application's database. + * + * @return void + */ + public function run() + { + Customer::query()->insert([ + 'first_name' => 'Admin', + 'email' => 'admin@example.com', + ]); + } +} +``` + +Within the `run` method, you may use the `call` method to execute additional seed classes. Using the `call` method allows you to break up your database seeding into multiple files so that no single seeder class becomes too large. The `call` method accepts an array of seeder classes that should be executed: + +```php +/** + * Seed the application's database. + * + * @return void + */ +public function run() +{ + $this->call([ + CustomerSeeder::class, + ProductSeeder::class, + ReviewSeeder::class, + ]); +} +``` + + +### Running Seeders + +You may execute your seeders using the static `run_seeder` method on the `WpMVC\Database\Seeder` class: + +```php +use WpMVC\Database\Seeder; +use MyPluginNamespace\Database\Seeders\DatabaseSeeder; + +Seeder::run_seeder(DatabaseSeeder::class); +``` + +By default, the `run_seeder` method will execute the `run` method on the given seeder class. diff --git a/docs/mutators-and-casting.md b/docs/mutators-and-casting.md new file mode 100644 index 0000000..9301f13 --- /dev/null +++ b/docs/mutators-and-casting.md @@ -0,0 +1,199 @@ +# Eloquent: Mutators & Casting + +- [Introduction](#introduction) +- [Accessors & Mutators](#accessors-and-mutators) + - [Defining An Accessor](#defining-an-accessor) + - [Defining A Mutator](#defining-a-mutator) +- [Attribute Casting](#attribute-casting) + - [Array & JSON Casting](#array-and-json-casting) + - [Date Casting](#date-casting) + + +## Introduction + +Accessors and mutators allow you to format Eloquent attribute values when you retrieve or set them on model instances. For example, you may want to format a date attribute to a specific format or capitalize a string when it is retrieved. + +In addition to custom accessors and mutators, Eloquent can also automatically cast date fields to `DateTime` instances or even cast JSON fields to arrays. + + +## Accessors & Mutators + + +### Defining An Accessor + +To define an accessor, create a `get_{attribute}_attribute` method on your model where `{attribute}` is the "snake case" name of the column you wish to access. In this example, we'll define an accessor for the `first_name` attribute. The accessor will automatically be called by Eloquent when attempting to retrieve the value of the `first_name` attribute: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; + +class Customer extends Model +{ + /** + * Get the customer's first name. + * + * @param string $value + * @return string + */ + public function get_first_name_attribute($value) + { + return ucfirst($value); + } +} +``` + +As you can see, the original value of the column is passed to the accessor, allowing you to manipulate and return the value. To access the value of the accessor, you may simply access the `first_name` attribute on a model instance: + +```php +use MyPluginNamespace\App\Models\Customer; + +$customer = Customer::query()->find(1); + +$first_name = $customer->first_name; +``` + + +### Defining A Mutator + +To define a mutator, define a `set_{attribute}_attribute` method on your model where `{attribute}` is the "snake case" name of the column you wish to access. This mutator will be automatically called when we attempt to set the value of the `first_name` attribute on the model: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; + +class Customer extends Model +{ + /** + * Set the customer's first name. + * + * @param string $value + * @return void + */ + public function set_first_name_attribute($value) + { + $this->attributes['first_name'] = strtolower($value); + } +} +``` + +The mutator will receive the value that is being set on the attribute, allowing you to manipulate the value and set the manipulated value on the Eloquent model's internal `$attributes` property. To use our mutator, we only need to set the `first_name` attribute on an Eloquent model: + +```php +use MyPluginNamespace\App\Models\Customer; + +$customer = Customer::query()->find(1); + +$customer->first_name = 'Sally'; +``` + +In this example, the `set_first_name_attribute` function will be called with the value `Sally`. The mutator will then apply the `strtolower` function to the name and set its resulting value in the internal `$attributes` array. + + +## Attribute Casting + +Attribute casting provides functionality similar to accessors and mutators without requiring you to define any additional methods on your model. Instead, your model's `$casts` property should be an array where the key is the name of the attribute being cast and the value is the type you wish to cast the column to. + +The supported cast types are: +- `int`, `integer` +- `real`, `float`, `double` +- `decimal:` (e.g., `decimal:2`) +- `string` +- `bool`, `boolean` +- `object` (json_decode to stdClass) +- `array`, `json` (json_decode to array) +- `date`, `datetime` +- `date:`, `datetime:` (e.g., `datetime:Y-m-d`) + +For example, let's cast the `is_admin` attribute, which is stored in our database as an integer (`0` or `1`) to a boolean value: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; + +class Customer extends Model +{ + /** + * The attributes that should be cast. + * + * @var array + */ + protected array $casts = [ + 'status' => 'int', + 'is_admin' => 'boolean', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; +} +``` + +Now the `status` attribute will always be cast to an integer when you access it: + +```php +$customer = Customer::query()->find(1); + +if ($customer->status === 0) { + // ... +} +``` + + +### Array & JSON Casting + +The `array` cast is particularly useful when working with columns that are stored as serialized JSON. For example, if your database has a `JSON` or `TEXT` field type that contains serialized JSON, adding the `array` or `json` cast to that attribute will automatically deserialize the attribute to a PHP array when you access it on your Eloquent model: + +```php +class Order extends Model +{ + /** + * The attributes that should be cast. + * + * @var array + */ + protected array $casts = [ + 'meta' => 'json', + ]; +} +``` + +Once the cast is defined, you may access the `meta` attribute and it will automatically be deserialized from JSON into a PHP array. When you set the value of the `meta` attribute, the given array will automatically be serialized back into JSON for storage: + +```php +$order = Order::query()->find(1); + +$meta = $order->meta; + +$meta['source'] = 'web'; + +$order->meta = $meta; + +$order->save(); +``` + + +### Date Casting + +By default, Eloquent will cast the `created_at` and `updated_at` columns to `DateTime` instances. You may customize this behavior by defining additional date casts in your model's `$casts` array: + +```php +protected array $casts = [ + 'registered_at' => 'datetime', + 'trial_ends_at' => 'date:Y-m-d', +]; +``` + +#### Serialization Format + +When casting dates with a specific format, that format will be used when the model is converted to an array or JSON: + +```php +protected array $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', +]; +``` + +If no format is specified, the date will be serialized to `Y-m-d H:i:s` (ISO 8601-ish) by default. +``` + + diff --git a/docs/query-builder.md b/docs/query-builder.md new file mode 100644 index 0000000..2c1cf74 --- /dev/null +++ b/docs/query-builder.md @@ -0,0 +1,573 @@ +# Database: Query Builder + +- [Introduction](#introduction) +- [Retrieving Results](#retrieving-results) + - [Aggregates](#aggregates) + - [Pagination](#pagination) +- [Selects](#selects) +- [Joins](#joins) +- [Where Clauses](#where-clauses) + - [Logical Grouping](#logical-grouping) + - [Advanced Where Clauses](#advanced-where-clauses) +- [Ordering, Grouping, Limit, & Offset](#ordering-grouping-limit-and-offset) +- [Inserts](#inserts) +- [Updates](#updates) +- [Deletes](#deletes) +- [Transactions](#transactions) +- [Unions](#unions) +- [Debugging](#debugging) + + +## Introduction + +WpMVC's database query builder provides a convenient, fluent interface to creating and running database queries. It can be used to perform most database operations in your application and works on all supported database systems. + +The WpMVC query builder uses PDO parameter binding to protect your application against SQL injection attacks. There is no need to clean strings passed to the query builder as bindings. + +> [!TIP] +> While you may use the `Builder::table()` method to interact with database tables directly, it is generally recommended to use [Eloquent Models](eloquent-models.md) for a more expressive and powerful development experience. + + +## Retrieving Results + +#### Retrieving All Rows From A Table + +To start a query, you may use the `query` method on an Eloquent model. This method returns a fluent query builder instance for the model's table, allowing you to chain more constraints onto the query and then finally retrieve the results using the `get` method: + +```php +use MyPluginNamespace\App\Models\User; + +$users = User::query()->get(); + +foreach ($users as $user) { + echo $user->display_name; +} +``` + +The `get` method returns an `WpMVC\Database\Eloquent\Collection` instance containing the results of the query. You may access each column's value by accessing the column as a property of the model: + +```php +foreach ($users as $user) { + echo $user->display_name; +} +``` + +#### Retrieving A Single Row / Column From A Table + +If you just need to retrieve a single row from a database table, you may use the `first` method. This method will return a single model instance: + +```php +$user = User::query()->where('display_name', 'John')->first(); + +echo $user->display_name; +``` + +#### Retrieving A Single Row By ID + +If you have a row's `id` column value, you may retrieve the row using the `find` method: + +```php +$user = User::query()->find(3); +``` + + +### Aggregates + +The query builder also provides a variety of methods for retrieving aggregate values like `count`, `max`, `min`, `avg`, and `sum`. You may call any of these methods after constructing your query: + +```php +$count = User::query()->count(); + +$max_comments = Post::query()->max('comment_count'); +``` + +Of course, you may combine these methods with other clauses: + +```php +$average = Post::query() + ->where('post_status', 'publish') + ->avg('comment_count'); +``` + +#### Determining If Records Exist + +Instead of using the `count` method to determine if any records exist that match your query's constraints, you may use the `exists` and `doesnt_exist` methods: + +```php +if (Post::query()->where('post_status', 'publish')->exists()) { + // ... +} + +if (Post::query()->where('post_status', 'publish')->doesnt_exist()) { + // ... +} +``` + + +### Pagination + +WpMVC provides a powerful `pagination` method to handle result segmenting. The `pagination` method takes the current page and number of results per page: + +```php +$users = User::query()->pagination(1, 15); +``` + +The `pagination` method returns a `WpMVC\Database\Pagination\LengthAwarePaginator` instance, which provides methods for the total count, total pages, and is convertible to an array or JSON. + +```php +$users = User::query()->pagination(1, 15); + +$total = $users->total(); // Total count of matching records +$current_page = $users->current_page(); +$last_page = $users->last_page(); +``` + +> [!IMPORTANT] +> Ensure you use the method name `pagination()`. The standard Laravel name `paginate()` is not available in the current version of WpMVC. + + +## Selects + +Of course, you may not always want to select all columns from a database table. Using the `select` method, you can specify a custom `select` clause for the query: + +```php +$users = User::query()->select('display_name', 'user_email')->get(); +``` + +The `distinct` method allows you to force the query to return distinct results: + +```php +$users = User::query()->distinct()->get(); +``` + +If you already have a query builder instance and you wish to add a column to its existing select clause, you may use the `add_select` method: + +```php +$query = User::query()->select('display_name'); + +$users = $query->add_select('age')->get(); +``` + + +## Joins + +#### Inner Join Clause + +The query builder may also be used to add join clauses to your queries. To perform a basic "inner join", you may use the `join` method on a query builder instance. The first argument passed to the `join` method is the name of the table you need to join to, while the remaining arguments specify the column constraints for the join: + +```php +$posts = Post::query() + ->join('postmeta', 'posts.ID', '=', 'postmeta.post_id') + ->select('posts.*', 'postmeta.meta_key', 'postmeta.meta_value') + ->get(); +``` + +#### Left Join / Right Join Clause + +If you would like to perform a "left join" or "right join" instead of an "inner join", use the `left_join` or `right_join` methods: + +```php +$users = User::query() + ->left_join('posts', 'users.ID', '=', 'posts.post_author') + ->get(); +``` + + +## Where Clauses + +You may use the `where` method on a query builder instance to add `where` clauses to the query. The most basic call to the `where` method requires three arguments. The first argument is the name of the column. The second argument is an operator, which can be any of the database's supported operators. Finally, the third argument is the value to evaluate against the column. + +For example, here is a query that verifies the value of the "user_status" column is equal to 0: + +```php +$users = User::query()->where('user_status', '=', 0)->get(); +``` + +For convenience, if you want to verify that a column is equal to a given value, you may pass the value as the second argument to the `where` method: + +```php +$users = User::query()->where('user_status', 0)->get(); +``` + +As noted earlier, you may use any operator that is supported by your database system: + +```php +$users = User::query() + ->where('user_status', '>=', 0) + ->get(); + +$users = User::query() + ->where('user_status', '<>', 1) + ->get(); + +$users = User::query() + ->where('display_name', 'like', 'T%') + ->get(); +``` + +#### where_key + +The `where_key` method may be used to filter a query by the model's primary key: + +```php +$user = User::query()->where_key(1)->first(); +``` + +#### where_raw / or_where_raw + +The `where_raw` and `or_where_raw` methods allow you to inject raw SQL into your query. These methods accept a raw SQL string as their first argument and an optional array of bindings as their second argument: + +```php +$users = User::query() + ->where_raw('user_status > ?', [0]) + ->get(); + +$users = User::query() + ->or_where_raw('user_status > ?', [1]) + ->get(); +``` + +#### Or Where Clauses + +You may chain where constraints together as well as add `or` clauses to the query. The `or_where` method accepts the same arguments as the `where` method: + +```php +$users = User::query() + ->where('user_status', '>', 0) + ->or_where('display_name', 'John') + ->get(); +``` + + +### Logical Grouping + +Sometimes you may need to group several "where" clauses within parentheses in order to achieve your query's desired logical grouping. To achieve this, you may pass a closure to the `where` method: + +```php +$users = User::query() + ->where('display_name', '=', 'John') + ->where(function ($query) { + $query->where('user_status', '>', 0) + ->or_where('user_nicename', '=', 'admin'); + }) + ->get(); +``` + +As you can see, passing a closure to the `where` method instructs the query builder to begin a constraint group. The closure will receive a query builder instance which you can use to set the constraints that should be contained within the parenthesis group. The example above will produce the following SQL: + +```sql +select * from users where display_name = 'John' and (user_status > 0 or user_nicename = 'admin') +``` + + +### Advanced Where Clauses + +#### where_between / or_where_between + +The `where_between` method verifies that a column's value is between two values: + +```php +$users = User::query() + ->where_between('user_status', [0, 1]) + ->get(); +``` + +#### where_in / where_not_in / or_where_in / or_where_not_in + +The `where_in` method verifies that a given column's value is contained within the given array: + +```php +$users = User::query() + ->where_in('ID', [1, 2, 3]) + ->get(); +``` + +#### where_null / where_not_null / or_where_null / or_where_not_null + +The `where_null` method verifies that the value of the given column is `NULL`: + +```php +$users = User::query() + ->where_null('updated_at') + ->get(); +``` + + +#### where_column / or_where_column + +The `where_column` method may be used to verify that two columns are equal: + +```php +$users = User::query() + ->where_column('user_login', 'user_nicename') + ->get(); +``` + +You may also pass a comparison operator to the method: + +```php +$users = User::query() + ->where_column('updated_at', '>', 'created_at') + ->get(); +``` + +#### where_exists / where_not_exists / or_where_exists / or_where_not_exists + +The `where_exists` method allows you to write `WHERE EXISTS` SQL clauses. The `where_exists` method accepts a `Closure` argument, which will receive a query builder instance allowing you to define the query that should be placed inside of the "exists" clause: + +```php +$users = User::query() + ->where_exists(function ($query) { + $query->select('ID') + ->from('posts') + ->where_column('posts.post_author', 'users.ID'); + }) + ->get(); +``` + +The query above will produce the following SQL: + +```sql +select * from users +where exists ( + select ID from posts where posts.post_author = users.ID +) +``` + + +### Conditional Clauses + +Sometimes you may want clauses to apply to a query only when something else is true. For instance, you may only want to apply a `where` statement if a given input value is present on the incoming HTTP request. You may accomplish this using the `when` method: + +```php +$role = 'admin'; + +$users = User::query() + ->when($role, function ($query, $role) { + return $query->where('user_role', $role); + }) + ->get(); +``` + +The `when` method only executes the given Closure when the first parameter is `true`. If the first parameter is `false`, the Closure will not be executed. + +You may pass another Closure as the third parameter to the `when` method. This Closure will execute if the first parameter evaluates as `false`: + +```php +$role = null; + +$users = User::query() + ->when($role, function ($query, $role) { + return $query->where('user_role', $role); + }, function ($query) { + return $query->where('user_role', 'subscriber'); + }) + ->get(); +``` + +The `unless` method is the inverse of `when`. The given Closure will only be executed if the first parameter is `false`: + +```php +$verified = false; + +$users = User::query() + ->unless($verified, function ($query) { + return $query->where('user_status', 0); + }) + ->get(); +``` + +#### tap + +The `tap` method allows you to execute a callback with the query builder instance, which is useful for performing side effects or conditionally applying builder logic without breaking the method chain: + +```php +$users = User::query() + ->where('user_status', 1) + ->tap(function ($query) { + // Perform some logging or side effect + }) + ->get(); +``` + + +## Ordering, Grouping, Limit, & Offset + +#### Ordering + +The `order_by` method allows you to sort the result of the query by a given column. The first argument accepted by the `order_by` method should be the column you wish to sort by, while the second argument determines the direction of the sort and may be either `asc` or `desc`: + +```php +$users = User::query() + ->order_by('display_name', 'desc') + ->get(); +``` + +#### latest / oldest + +The `latest` and `oldest` methods allow you to easily order results by date. By default, the result will be ordered by the table's `created_at` column: + +```php +$user = User::query()->latest()->first(); +``` + +#### Grouping + +The `group_by` and `having` methods may be used to group the query results. The `having` method's signature is similar to that of the `where` method: + +```php +$posts = Post::query() + ->group_by('post_author') + ->having('comment_count', '>', 100) + ->get(); +``` + +#### Limit & Offset + +The `offset` and `limit` methods may be used to limit the number of results returned by the query or to skip a given number of results in the query: + +```php +$users = User::query()->offset(10)->limit(5)->get(); +``` + +#### Chunking Results + +If you need to work with thousands of database records, consider using the `chunk` method. This method retrieves a small chunk of results at a time and feeds each chunk into a `Closure` for processing: + +```php +User::query()->chunk(200, function ($users) { + foreach ($users as $user) { + // Process user... + } +}); +``` + +You may stop further chunks from being processed by returning `false` from the `Closure`: + +```php +User::query()->chunk(200, function ($users) { + // Process the records... + + return false; +}); +``` + +#### Streaming Results Lazily + +The `cursor` method allows you to iterate through your database records using a cursor, which will only execute a single query. When processing large amounts of data, the `cursor` method may be used to greatly reduce your memory usage: + +```php +foreach (User::query()->where('user_status', 1)->cursor() as $user) { + // Process user... +} +``` + +The `cursor` returns a PHP `Generator` instance. + + +## Inserts + +#### Inserts + +The query builder also provides an `insert` method for inserting records into the database table. The `insert` method accepts an array of column names and values: + +```php +User::query()->insert([ + 'user_login' => 'john_doe', + 'user_email' => 'john@example.com' +]); +``` + +#### Auto-Incrementing IDs + +If the table has an auto-incrementing id, use the `insert_get_id` method to insert a record and then retrieve the ID: + +```php +$id = User::query()->insert_get_id([ + 'user_login' => 'john_doe', + 'user_email' => 'john@example.com' +]); +``` + +You may even insert several records at once by passing an array of arrays. Each array represents a row that should be inserted into the table: + +```php +User::query()->insert([ + ['user_email' => 'taylor@example.com', 'display_name' => 'Taylor'], + ['user_email' => 'dayle@example.com', 'display_name' => 'Dayle'], +]); +``` + + +## Updates + +In addition to inserting records into the database, the query builder can also update existing records using the `update` method. The `update` method, like the `insert` method, accepts an array of column and value pairs indicating the columns to be updated. You may constrain the `update` query using `where` clauses: + +```php +$affected = User::query() + ->where('ID', 1) + ->update(['user_status' => 1]); +``` + + + +## Deletes + +The query builder's `delete` method may be used to delete records from the table. You may constrain `delete` statements by adding `where` clauses before calling the `delete` method: + +```php +User::query()->delete(); + +User::query()->where('user_status', '>', 0)->delete(); +``` + + + +## Transactions + +The query builder also includes a static `transaction` method to help you run a set of operations within a database transaction: + +```php +use WpMVC\Database\Query\Builder; + +Builder::transaction(function () { + User::query()->where('ID', 1)->update(['user_status' => 1]); + Post::query()->where('post_author', 1)->delete(); +}); +``` + +If an exception is thrown within the transaction closure, the transaction will automatically be rolled back. If the closure executes successfully, the transaction will automatically be committed. + +#### Unions + +The query builder also provides a convenient way to "union" two queries together. For example, you may create an initial query and then use the `union` method to union it with a second query: + +```php +$first = User::query()->where('user_login', 'admin'); + +$users = User::query() + ->where('user_login', 'editor') + ->union($first) + ->get(); +``` + +The `union_all` method is also available and has the same method signature as the `union` method. + + +## Debugging + +You may use the `to_sql` and `get_raw_sql` methods while building a query to see the SQL output. + +The `get_raw_sql` method returns the SQL with placeholders: + +```php +$sql = User::query()->where('ID', 1)->get_raw_sql(); +// select * from wp_users where ID = ? +``` + +The `to_sql` method returns the SQL with the values already bound and escaped: + +```php +$sql = User::query()->where('ID', 1)->to_sql(); +// select * from wp_users where ID = 1 +``` diff --git a/docs/relationships.md b/docs/relationships.md new file mode 100644 index 0000000..afbeaa4 --- /dev/null +++ b/docs/relationships.md @@ -0,0 +1,846 @@ +# Eloquent: Relationships + +- [Introduction](#introduction) +- [Defining Relationships](#defining-relationships) + - [One To One](#one-to-one) + - [One To Many](#one-to-many) + - [Many To Many](#many-to-many) + - [Pivot Attributes](#pivot-attributes) + - [Has One Through](#has-one-through) + - [Has Many Through](#has-many-through) + - [Default Relationship Constraints](#default-relationship-constraints) +- [Polymorphic Relationships](#polymorphic-relationships) + - [One To One (Polymorphic)](#one-to-one-polymorphic) + - [One To Many (Polymorphic)](#one-to-many-polymorphic) + - [Many To Many (Polymorphic)](#many-to-many-polymorphic) +- [Querying Relations](#querying-relations) + - [Relationship Methods Vs. Dynamic Properties](#relationship-methods-vs-dynamic-properties) + - [Querying Relationship Existence](#querying-relationship-existence) + - [Querying Relationship Absence](#querying-relationship-absence) +- [Aggregating Related Models](#aggregating-related-models) + - [Counting Related Models](#counting-related-models) +- [Eager Loading](#eager-loading) + - [Constraining Eager Loads](#constraining-eager-loads) + - [Nested Eager Loading](#nested-eager-loading) +- [Manual Relationship Management](#manual-relationship-management) + + +## Introduction + +Database tables are often related to one another. For example, a store may have many products, or a product could be related to the customer who ordered it. Eloquent makes managing and working with these relationships easy, and supports several common relationships. + + +## Defining Relationships + +Eloquent relationships are defined as methods on your Eloquent model classes. Since relationships also serve as powerful [query builders](query-builder.md), defining relationships as methods provides powerful method chaining and querying capabilities. For example, we may chain additional query constraints on this `orders` relationship: + +```php +$customers = Customer::query()->where('status', 'active')->get(); +``` + + +### One To One + +A one-to-one relationship is a very basic type of database relationship. For example, a `Customer` model might be associated with one `Profile` model. To define this relationship, we will place a `profile` method on the `Customer` model. The `profile` method should call the `has_one` method and return its result: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\HasOne; + +class Customer extends Model +{ + /** + * Get the customer's profile. + */ + public function profile(): HasOne + { + return $this->has_one(Profile::class, 'customer_id', 'id'); + } +} +``` + +The first argument passed to the `has_one` method is the name of the related model class. Once the relationship is defined, we may retrieve the related record using Eloquent's dynamic properties: + +```php +$profile = Customer::query()->find(1)->profile; +``` + +Eloquent determines the foreign key of the relationship based on the parent model name. In this case, the `Profile` model is automatically assumed to have a `customer_id` foreign key. If you wish to override this convention, you may pass the second and third arguments to the `has_one` method: + +```php +return $this->has_one(Profile::class, 'customer_id', 'id'); +``` + +#### Defining The Inverse Of The Relationship + +Now that we can access the `Profile` model from our `Customer` model, let's define a relationship on the `Profile` model that will let us access the customer that owns the profile. We can define the inverse of a `has_one` relationship using the `belongs_to` method: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\BelongsTo; + +class Profile extends Model +{ + /** + * Get the customer that owns the profile. + */ + public function customer(): BelongsTo + { + return $this->belongs_to(Customer::class, 'customer_id', 'id'); + } +} +``` + + +### One To Many + +A one-to-many relationship is used to define relationships where a single model is the parent to one or more child models. For example, a store may have many products. Like all other Eloquent relationships, one-to-many relationships are defined by placing a function on your Eloquent model: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\HasMany; + +class Store extends Model +{ + /** + * Get the products for the store. + */ + public function products(): HasMany + { + return $this->has_many(Product::class, 'store_id', 'id'); + } + + /** + * Get the owner of the store. + */ + public function owner(): BelongsTo + { + return $this->belongs_to(Customer::class, 'customer_id', 'id'); + } +} +``` + +Remember, Eloquent will automatically determine the proper foreign key column for the `Product` model. By convention, Eloquent will take the "snake case" name of the parent model and suffix it with `_id`. So, in this example, Eloquent will assume the foreign key column on the `Product` model is `store_id`. + +Once the relationship method has been defined, we can access the collection of products by accessing the `products` property: + +```php +use MyPluginNamespace\App\Models\Store; + +$products = Store::query()->find(1)->products; + +foreach ($products as $product) { + // ... +} +``` + +Of course, since all relationships also serve as query builders, you may add further constraints to the relationship query: + +```php +$product = Store::query()->find(1)->products() + ->where('status', 'in_stock') + ->first(); +``` + + +#### Has One Of Many + +Sometimes a relationship may have many related models, but you want to easily retrieve the "latest" or "oldest" related model of the relationship. For example, a `Customer` model may be related to many `Order` models, but you may want to define a one-to-one relationship representing the customer's latest order: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\HasOne; +use WpMVC\Database\Eloquent\Relations\HasOneOfMany; + +class Customer extends Model +{ + /** + * Get the customer's latest order using has_one_of_many. + */ + public function latest_order(): HasOneOfMany + { + return $this->has_one_of_many(Order::class, 'customer_id', 'id', 'id', 'desc'); + } + + /** + * Alternatively, you can chain latest() on a normal has_one. + */ + public function latest_order_alt(): HasOne + { + return $this->has_one(Order::class, 'customer_id', 'id')->latest('id'); + } +} +``` + + +### Many To Many + +Many-to-many relations are slightly more complicated than `has_one` and `has_many` relationships. An example of such a relationship is a product with many categories, where the categories are also shared by other products in the system. For example, a product may be assigned the category of "Home" and "Garden"; however, those categories may also be assigned to other products. + + +#### Pivot Attributes + +When working with many-to-many relationships, you often need to access the data stored in the intermediate (pivot) table. Eloquent automatically prefixes the foreign and local pivot keys with `pivot_` (e.g., `pivot_product_id`). + +However, other columns in your pivot table must be explicitly selected in your relationship definition if you wish to access them with a `pivot_` prefix. For example, if your `category_product` table has a `position` column: + +```php +public function categories(): BelongsToMany +{ + return $this->belongs_to_many(Category::class, 'category_product') + ->add_select('category_product.position as pivot_position'); +} +``` + +Now you can access it like so: + +```php +$product = Product::query()->find(1); + +foreach ($product->categories as $category) { + echo $category->pivot_position; +} +``` + +In this example, the `Product` model is related to `Category` via the `category_product` table. + +To define this relationship, three database tables are needed: `products`, `categories`, and `category_product`. The `category_product` table contains `product_id` and `category_id` columns. + +Many-to-many relationships are defined by returning the result of the `belongs_to_many` method: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\BelongsToMany; + +class Product extends Model +{ + /** + * Get the categories for the product. + */ + public function categories(): BelongsToMany + { + return $this->belongs_to_many(Category::class, 'category_product', 'product_id', 'category_id', 'id', 'id'); + } +} +``` + +Once the relationship is defined, you may access the product's categories using the `categories` dynamic property: + +```php +use MyPluginNamespace\App\Models\Product; + +$product = Product::query()->find(1); + +foreach ($product->categories as $category) { + // ... +} +``` + + +### Has One Through + +The "has-one-through" relationship defines a one-to-one relationship with another model. However, this relationship indicates that the declaring model can be matched with one instance of another model by proceeding *through* a third model. + +For example, you might want to access a supplier's latest review through their products. While the supplier and the review have no direct relationship within the database, the supplier can access the review *through* the product. Let's look at the tables necessary to define this relationship: + +```php +suppliers + id - integer + name - string + +products + id - integer + name - string + supplier_id - integer + +reviews + id - integer + content - text + product_id - integer +``` + +Now that we have examined the table structure for the relationship, let's define the relationship on the `Supplier` model: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\HasOneThrough; + +/** + * We'll use the supplier's latest review as an example of Has One Through. + */ +class Supplier extends Model +{ + /** + * Get the supplier's latest review through their products. + */ + public function latest_review(): HasOneThrough + { + return $this->has_one_through(Review::class, Product::class, 'supplier_id', 'product_id'); + } +} +``` + + +### Has Many Through + +The "has-many-through" relationship provides a convenient way to access distant relations via an intermediate relation. For example, a `Supplier` model might access many `Review` models through an intermediate `Product` model. This is useful for retrieving all reviews made on any of a supplier's products: + +```php +suppliers + id - integer + name - string + +products + id - integer + supplier_id - integer + name - string + +reviews + id - integer + product_id - integer + content - string +``` + +Though the `reviews` table does not contain a `supplier_id` column, the `has_many_through` relation provides access to a supplier's reviews via `$supplier->reviews_through_products`. To define this relationship, we will pass the "final" model we wish to access as the first argument and the "intermediate" model as the second argument: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\HasManyThrough; + +class Supplier extends Model +{ + /** + * Get all of the reviews for the supplier's products. + */ + public function reviews_through_products(): HasManyThrough + { + return $this->has_many_through(Review::class, Product::class, 'supplier_id', 'product_id'); + } +} +``` + +#### Polymorphic Through Relationships + +If the "distant" (final) table in your through relationship is polymorphic, you may use the `where_morph_type` method to constrain the results by a specific morph type. For example, if your `Review` model can belong to both `Product` and `Service`, and you want all product reviews for a supplier: + +```php +public function product_reviews(): HasManyThrough +{ + // In this example, the 'reviews' table uses 'reviewable_id' and 'reviewable_type' + return $this->has_many_through(Review::class, Product::class, 'supplier_id', 'reviewable_id') + ->where_morph_type('reviewable_type', 'product'); +} +``` + + +### Default Relationship Constraints + +Since relationship methods also serve as query builders, you may add additional constraints to the relationship definition itself. For example, if you frequently want to retrieve only the "featured" products for a store, you can define a relationship that includes this constraint: + +```php +public function featured_products(): HasMany +{ + return $this->has_many(Product::class, 'store_id', 'id') + ->where('is_featured', true) + ->order_by('created_at', 'desc'); +} +``` + +Now, when you access the `featured_products` property, the constraint will be applied automatically: + +```php +$store = Store::query()->find(1); + +foreach ($store->featured_products as $product) { + // These products are already filtered and ordered... +} +``` + +You can even use these constrained relationships with eager loading: + +```php +$stores = Store::with('featured_products')->get(); +``` + + +## Polymorphic Relationships + +A polymorphic relationship allows the child model to belong to more than one type of model using a single association. For example, imagine you are building an application where both products and stores can have multiple images. + + +### One To One (Polymorphic) + +A one-to-one polymorphic relation is similar to a simple one-to-one relation; however, the child model can belong to more than one type of model using a single association. For example, a `Product` and a `Store` may share a polymorphic relation to an `Image` model. + +```php +products + id - integer + name - string + +stores + id - integer + name - string + +images + id - integer + url - string + imageable_id - integer + imageable_type - string +``` + +Take note of the `imageable_id` and `imageable_type` columns on the `images` table. The `imageable_id` column will contain the ID value of the product or store, while the `imageable_type` column will contain the class name of the parent model. + +> [!IMPORTANT] +> When creating polymorphic models via a relationship (e.g., using `create()` or `make()`), you must manually set the morph type column, as the current implementation does not automatically populate it for one-to-one or one-to-many polymorphic relations. + +```php +$product = Product::query()->find(1); + +$product->image()->create([ + 'url' => 'https://example.com/image.jpg', + 'imageable_type' => $product->get_morph_class(), +]); +``` + +#### Model Structure + +Next, let's examine the model definitions needed to build this relationship: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\MorphTo; + +class Image extends Model +{ + /** + * Get the parent imageable model (product or store). + */ + public function imageable(): MorphTo + { + return $this->morph_to('imageable', null, null, 'id'); + } +} + +class Product extends Model +{ + /** + * Get the product's image. + */ + public function image(): MorphOne + { + return $this->morph_one(Image::class, 'imageable', null, null, 'id'); + } +} + +class Store extends Model +{ + /** + * Get the store's image. + */ + public function image(): MorphOne + { + return $this->morph_one(Image::class, 'imageable', null, null, 'id'); + } +} +``` + + +### One To Many (Polymorphic) + +A one-to-many polymorphic relation is similar to a simple one-to-many relation; however, the child model can belong to more than one type of model using a single association. For example, imagine users of your application can leave "reviews" on both products and stores: + +```php +products + id - integer + name - string + +stores + id - integer + name - string + +reviews + id - integer + content - text + reviewable_id - integer + reviewable_type - string +``` + +#### Model Structure + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\MorphMany; + +class Product extends Model +{ + /** + * Get all of the product's reviews. + */ + public function reviews(): MorphMany + { + return $this->morph_many(Review::class, 'reviewable', null, null, 'id'); + } +} + +class Store extends Model +{ + /** + * Get all of the store's reviews. + */ + public function reviews(): MorphMany + { + return $this->morph_many(Review::class, 'reviewable', null, null, 'id'); + } +} +``` + + +### Many To Many (Polymorphic) + +Many-to-many polymorphic relations are slightly more complicated than `morph_one` and `morph_many` relationships. For example, a `Product` and `Store` model could share a polymorphic relation to a `Tag` model. Using a many-to-many polymorphic relation allows you to have a single list of unique tags that are shared across products and stores. + +```php +products + id - integer + name - string + +stores + id - integer + name - string + +tags + id - integer + name - string + +taggables + tag_id - integer + taggable_id - integer + taggable_type - string +``` + +#### Model Structure + +Next, we're ready to define the relationships on the models. The `Product` and `Store` models will both have a `tags` method that calls the `morph_to_many` method: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\MorphToMany; + +class Product extends Model +{ + /** + * Get all of the tags for the product. + */ + public function tags(): MorphToMany + { + return $this->morph_to_many(Tag::class, 'taggable', 'taggables', null, null, 'id', 'id'); + } +} +``` + +On the `Tag` model, you should define a method for each of its related models. In this example, we will define a `products` method and a `stores` method. These methods should both return the result of the `morphed_by_many` method: + +```php +namespace MyPluginNamespace\App\Models; + +use WpMVC\Database\Eloquent\Model; +use WpMVC\Database\Eloquent\Relations\MorphToMany; + +class Tag extends Model +{ + /** + * Get all of the products that are assigned this tag. + */ + public function products(): MorphToMany + { + return $this->morphed_by_many(Product::class, 'taggable', 'taggables', null, null, 'id', 'id'); + } + + /** + * Get all of the stores that are assigned this tag. + */ + public function stores(): MorphToMany + { + return $this->morphed_by_many(Store::class, 'taggable', 'taggables', null, null, 'id', 'id'); + } +} +``` + + +#### Custom Polymorphic Types + +By default, Eloquent will use the fully qualified class name to store the "type" of the related model. For example, given the one-to-many example above where a `Review` may belong to a `Product` or a `Store`, the `reviewable_type` would be either `MyPluginNamespace\App\Models\Product` or `MyPluginNamespace\App\Models\Store`, respectively. However, you may wish to decouple your database from your application's internal structure. + +You may define a "morph map" to instruct Eloquent to use a custom name for each model instead of the class name: + +```php +use WpMVC\Database\Eloquent\Model; + +Model::morph_map([ + 'product' => 'MyPluginNamespace\App\Models\Product', + 'store' => 'MyPluginNamespace\App\Models\Store', +]); +``` + +You may register the `morph_map` in your plugin's boot method or a service provider. + + +## Querying Relations + + +### Relationship Methods Vs. Dynamic Properties + +If you do not need to add additional constraints to an Eloquent relationship query, you may access the relationship as if it were a property. For example, continuing to use our `Customer` and `Order` models: + +```php +$customer = Customer::query()->find(1); + +foreach ($customer->orders as $order) { + // ... +} +``` + +Dynamic properties are "lazy loading", meaning they will only load their relationship data when you actually access them. Because of this, developers often use [eager loading](#eager-loading) to pre-load relationships they know will be accessed after loading the model. Eager loading provides a significant reduction in SQL queries that must be executed to load a model's relations. + + +### Querying Relationship Existence + +When retrieving model records, you may wish to limit your results based on the existence of a relationship. For example, imagine you want to retrieve all stores that have at least one product. To do so, you may pass the name of the relationship to the `has` method: + +```php +use MyPluginNamespace\App\Models\Store; + +$stores = Store::has('products')->get(); +``` + +You may also specify an operator and count to further customize the query: + +```php +$stores = Store::query()->where_has('products', function ($query) { + $query->where('price', '>', 100); +})->get(); +``` + +You may also use "dot" notation to query relationship existence for nested relationships. For example, you may retrieve all stores that have at least one product that has at least one review: + +```php +$stores = Store::has('products.reviews')->get(); +``` + +You can also use dot notation with the `where_has` method to add constraints to those nested relations: + +```php +$stores = Store::query()->where_has('products.reviews', function ($query) { + $query->where('rating', 5); +})->get(); +``` + + +### Querying Relationship Absence + +When retrieving model records, you may wish to limit your results based on the absence of a relationship. For example, you may retrieve all stores that do **not** have any products using the `doesnt_have` method: + +```php +$stores = Store::doesnt_have('products')->get(); +``` + +If you need even more power, you may use the `where_doesnt_have` method to put "where" constraints on your `doesnt_have` queries: + +```php +$stores = Store::where_doesnt_have('products', function ($query) { + $query->where('price', '>', 100); +})->get(); +``` + +You may also use "or" variants such as `or_has`, `or_where_has`, `or_doesnt_have`, and `or_where_doesnt_have` to combine relationship checks: + +```php +// Retrieve stores that have products OR have at least one review +$stores = Store::has('products')->or_has('reviews')->get(); +``` + + +## Aggregating Related Models + + +### Counting Related Models + +Sometimes you may want to count the number of related models for a given relationship without actually loading the models. To accomplish this, you may use the `with_count` method, which will place a `{relation}_count` column on your resulting models: + +```php +use MyPluginNamespace\App\Models\Store; + +$stores = Store::with_count('products')->get(); + +foreach ($stores as $store) { + echo $store->products_count; +} +``` + +You may also "count" into multiple relationships as well as add constraints to the queries: + +```php +$stores = Store::with_count(['products'])->get(); + +echo $stores[0]->products_count; +``` + +#### Other Aggregates + +In addition to `with_count`, Eloquent provides `with_sum`, `with_avg`, `with_min`, and `with_max` aggregate methods. These methods will place a `{relation}_{function}_{column}` attribute on your resulting models: + +```php +$customers = Customer::with_sum('orders', 'total_price')->get(); + +foreach ($customers as $customer) { + echo $customer->orders_sum_total_price; +} +``` + +#### Aggregate Aliasing + +If you wish to specify a custom name for the aggregate result, you may use the `as` keyword: + +```php +$stores = Store::with_count('products as total_products')->get(); + +echo $stores[0]->total_products; +``` + + +## Eager Loading + +When accessing Eloquent relationships as properties, the relationship data is "lazy loaded". This means the relationship data is not actually loaded until you first access the property. However, Eloquent can "eager load" relationships at the time you query the parent model. Eager loading alleviates the N + 1 query problem. + +To eager load a relationship, use the `with` method: + +```php +use MyPluginNamespace\App\Models\Store; + +$stores = Store::query()->with('owner')->get(); + +foreach ($stores as $store) { + echo $store->owner->name; +} +``` + +You may eager load multiple relationships in a single operation: + +```php +$stores = Store::query()->with('owner', 'products')->get(); +``` + + +### Nested Eager Loading + +To eager load nested relationships, you may use "dot" syntax. For example, let's eager load all of the store's owner and all of the owner's profile in one Eloquent statement: + +```php +$stores = Store::query()->with('owner.profile')->get(); +``` + + +### Constraining Eager Loads + +Sometimes you may wish to eager load a relationship but also specify additional query constraints for the eager loading query: + +```php +$customers = Customer::query()->with(['orders' => function ($query) { + $query->where('status', 'completed'); +}])->get(); +``` +In this example, Eloquent will only eager load orders that have a status of `completed`. Of course, you may call other [query builder](query-builder.md) methods to further customize the eager loading operation: + +```php +$customers = Customer::query()->with(['orders' => function ($query) { + $query->order_by('created_at', 'desc'); +}])->get(); +``` + + +## Inserting & Updating Related Models + +### The `make` and `create` Methods + +Eloquent provides methods for creating new related models. For example, you may use the `make` method to create a new instance of a related model with the foreign key already set, but without persisting it to the database: + +```php +$store = Store::query()->find(1); + +$product = $store->products()->make([ + 'name' => 'My new product', +]); +``` + +The `create` method works similarly but will actually persist the model to the database: + +```php +$store = Store::query()->find(1); + +$product = $store->products()->create([ + 'name' => 'My new product', +]); +``` + +### Many To Many Relationships + +#### Attaching / Detaching + +The many-to-many relationship also provides an `attach` method to make associating models easy. For example, let's imagine a product can belong to multiple categories and a category can contain multiple products. To attach a category to a product by inserting a record in the relationship's intermediate table: + +```php +$product = Product::query()->find(1); + +$product->categories()->attach($category_id); +``` + +When attaching a relationship to a model, you may also pass an array of additional data to be inserted into the intermediate table: + +```php +$product->categories()->attach($category_id, ['position' => 1]); +``` + + +## Manual Relationship Management + +Sometimes you may want to manually set or check a relationship on a model instance without querying the database. + +#### Setting & Getting Relations + +You may use the `set_relation` and `get_relation` methods to manually manage the loaded relationships for a model: + +```php +$customer->set_relation('orders', $orders_collection); + +$loaded_orders = $customer->get_relation('orders'); +``` + +#### Checking & Unsetting Relations + +The `relation_loaded` and `unset_relation` methods are useful for checking if a relationship has been loaded or discarding a loaded relationship: + +```php +if ($customer->relation_loaded('orders')) { + $customer->unset_relation('orders'); +} +``` diff --git a/docs/schema-builder.md b/docs/schema-builder.md new file mode 100644 index 0000000..bbc7ef0 --- /dev/null +++ b/docs/schema-builder.md @@ -0,0 +1,329 @@ +# Schema Builder + +- [Introduction](#introduction) +- [Creating Tables](#creating-tables) +- [Updating Tables](#updating-tables) +- [Dropping Columns](#dropping-columns) +- [Indexes & Constraints](#indexes-and-constraints) +- [Available Column Types](#available-column-types) + + +## Introduction + +The `Schema` class provides a database agnostic way of manipulating tables. It works gracefully with all supported databases and has an expressive API for creating and modifying database tables. + + +## Creating Tables + +To create a new database table, use the `create` method on the `Schema` class. The `create` method accepts two arguments: the first is the name of the table, while the second is a closure which receives a `Blueprint` object that may be used to define the new table: + +```php +use WpMVC\Database\Schema; +use WpMVC\Database\Schema\Blueprint; + +Schema::create('customers', function (Blueprint $table) { + $table->big_increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamps(); +}); +``` + +When creating the table, you may use any of the schema builder's [column methods](#available-column-types) to define the table's columns. + +#### Checking For Table Existence + +You may easily check for the existence of a table or column using the `has_table` and `has_column` methods: + +```php +if (Schema::has_table('customers')) { + // The "customers" table exists... +} + +if (Schema::has_column('customers', 'email')) { + // The "customers" table exists and has an "email" column... +} +``` + + +## Updating Tables + +The `alter` method on the `Schema` class may be used to update existing tables. Like the `create` method, the `alter` method accepts the name of the table and a closure that receives a `Blueprint` instance: + +```php +use WpMVC\Database\Schema; +use WpMVC\Database\Schema\Blueprint; + +Schema::alter('customers', function (Blueprint $table) { + $table->string('phone')->nullable(); +}); +``` + +#### SQL Preview + +Most `Schema` methods (`create`, `alter`, `rename`, `drop`, `drop_if_exists`) accept an optional boolean as the final argument. If set to `true`, the method will return the generated SQL string instead of executing it: + +```php +$sql = Schema::create('customers', function (Blueprint $table) { + $table->big_increments('id'); +}, true); +``` + +#### Renaming & Dropping Tables + +To rename an existing database table, use the `rename` method: + +```php +Schema::rename($from, $to); +``` + +To drop an existing table, you may use the `drop` or `drop_if_exists` methods: + +```php +Schema::drop('customers'); + +Schema::drop_if_exists('customers'); +``` + + +## Indexes & Constraints + +The schema builder supports several types of indexes. The following example creates a new `email` column and specifies that its values should be unique. + +```php +$table->string('email')->unique(); +``` + +Alternatively, you may create the index after defining the column: + +```php +$table->string('email'); + +$table->unique('email'); +``` + +You may even pass an array of columns to an index method to create a compound index: + +```php +$table->index(['account_id', 'created_at']); +``` + +#### Primary Keys + +You may also define a primary key for the table: + +```php +$table->primary('id'); + +// Or for composite keys: +$table->primary(['account_id', 'id']); +``` + +#### Foreign Key Constraints + +WpMVC also provides support for creating foreign key constraints, which are used to force referential integrity at the database level. For example, let's define a `customer_id` column on the `products` table that references the `id` column on a `customers` table: + +```php +use WpMVC\Database\Schema; +use WpMVC\Database\Schema\Blueprint; + +Schema::create('products', function (Blueprint $table) { + $table->big_increments('id'); + $table->unsigned_big_integer('customer_id'); + + $table->foreign('customer_id') + ->references('id') + ->on('customers') + ->on_delete('cascade'); +}); +``` + +> [!NOTE] +> Foreign keys are automatically checked to avoid duplicates. The naming convention used is `fk_{prefix}{table}_{column}`. + +#### Dropping Indexes + +To drop an index, you must specify the index's name. By default, WpMVC assigns a reasonable name to the indexes. You can simply pass the index name to the `drop_index` method: + +```php +$table->drop_index('geo_state_index'); +``` + + +## Available Column Types + +The schema builder blueprint offers a variety of methods that correspond to the different types of columns you can add to your database tables. + +### Big Integer +#### big_increments() +The `big_increments` method creates an auto-incrementing `BIGINT UNSIGNED` (primary key) equivalent column: +```php +$table->big_increments('id'); +``` + +#### unsigned_big_integer() +The `unsigned_big_integer` method creates an `UNSIGNED BIGINT` equivalent column: +```php +$table->unsigned_big_integer('customer_id'); +``` + +#### big_integer() +The `big_integer` method creates a `BIGINT` equivalent column: +```php +$table->big_integer('votes'); +``` + +### Strings & Text +#### string() +The `string` method creates a `VARCHAR` equivalent column of a given length: +```php +$table->string('name', 100); +``` + +#### text() +The `text` method creates a `TEXT` equivalent column: +```php +$table->text('description'); +``` + +#### long_text() +The `long_text` method creates a `LONGTEXT` equivalent column. This column is nullable by default: +```php +$table->long_text('content'); +``` + +### Numbers +#### integer() +The `integer` method creates an `INT` equivalent column: +```php +$table->integer('votes'); +``` + +#### unsigned_integer() +The `unsigned_integer` method creates an `UNSIGNED INT` equivalent column: +```php +$table->unsigned_integer('votes'); +``` + +#### tiny_integer() +The `tiny_integer` method creates a `TINYINT` equivalent column: +```php +$table->tiny_integer('status'); +``` + +#### decimal() +The `decimal` method creates a `DECIMAL` equivalent column with the given precision (total digits) and scale (decimal digits): +```php +$table->decimal('amount', 8, 2); +``` + +#### float() +The `float` method creates a `FLOAT` equivalent column: +```php +$table->float('price'); +``` + +#### boolean() +The `boolean` method creates a `BOOLEAN` equivalent column: +```php +$table->boolean('confirmed'); +``` + +### Date & Time +#### timestamps() +The `timestamps` method adds `created_at` and `updated_at` `TIMESTAMP` equivalent columns: +```php +$table->timestamps(); +``` + +#### timestamp() +The `timestamp` method creates a `TIMESTAMP` equivalent column. This column is nullable by default: +```php +$table->timestamp('added_on'); +``` + +#### date() +The `date` method creates a `DATE` equivalent column: +```php +$table->date('birthday'); +``` + +#### datetime() +The `datetime` method creates a `DATETIME` equivalent column: +```php +$table->datetime('published_at'); +``` + +### Miscellaneous +#### enum() +The `enum` method creates an `ENUM` equivalent column with the given allowed values: +```php +$table->enum('difficulty', ['easy', 'hard']); +``` + +#### json() +The `json` method creates a `JSON` equivalent column: +```php +$table->json('options'); +``` + +### Column Modifiers + +In addition to the column types listed above, there are several column "modifiers" you may use while adding a column to a database table. For example, to make the column "nullable", you may use the `nullable` method: + +```php +Schema::alter('users', function (Blueprint $table) { + $table->string('email')->nullable(); +}); +``` + +#### nullable() +Allows the column to allow `NULL` values. + +#### default() +Specify a "default" value for the column: +```php +$table->string('status')->default('draft'); +``` + +#### comment() +Add a comment to the column: +```php +$table->string('role')->comment('User role in the system'); +``` + +#### use_current() +Sets the default value for a timestamp column to the current timestamp: +```php +$table->timestamp('created_at')->use_current(); +``` + +#### use_current_on_update() +Sets the column to automatically update to the current timestamp when the record is updated: +```php +$table->timestamp('updated_at')->use_current_on_update(); +``` + +#### after() +When using MySQL, the `after` method may be used to specify the order of columns: +```php +$table->string('address')->after('email'); +``` + + +## Dropping Columns + +To drop columns from an existing table, you may use the `drop_column` method on the `Blueprint` instance within a `Schema::alter` callback: + +```php +use WpMVC\Database\Schema; +use WpMVC\Database\Schema\Blueprint; + +Schema::alter('customers', function (Blueprint $table) { + $table->drop_column('phone'); + + // You may also drop multiple columns + $table->drop_column('address'); + $table->drop_column('social_security_number'); +}); +``` diff --git a/src/Clauses/Clause.php b/src/Clauses/Clause.php index bd467a5..6f693a3 100644 --- a/src/Clauses/Clause.php +++ b/src/Clauses/Clause.php @@ -1,4 +1,11 @@ clauses; } @@ -237,7 +256,7 @@ protected function or_clause_not_exists( string $clause_type, $callback, ?string * @param bool $not Whether to negate the in clause. * @return static */ - protected function clause_in( string $clause_type, string $column, array $values, ?string $name = null, $boolean = 'and', $not = false ) { + protected function clause_in( string $clause_type, string $column, $values, ?string $name = null, $boolean = 'and', $not = false ) { $type = 'in'; // Define the type of the clause $data = compact( 'type', 'boolean', 'column', 'values', 'not' ); diff --git a/src/Clauses/HavingClause.php b/src/Clauses/HavingClause.php index c55b61a..ef36b69 100644 --- a/src/Clauses/HavingClause.php +++ b/src/Clauses/HavingClause.php @@ -1,9 +1,25 @@ clause_in( "havings", $column, $values, $name ); } @@ -168,7 +184,7 @@ public function having_in( string $column, array $values, ?string $name = null ) * @param ?string $name Optional name for the having. * @return static */ - public function or_having_in( string $column, array $values, ?string $name = null ) { + public function or_having_in( string $column, $values, ?string $name = null ) { return $this->or_clause_in( "havings", $column, $values, $name ); } @@ -180,7 +196,7 @@ public function or_having_in( string $column, array $values, ?string $name = nul * @param ?string $name Optional name for the having. * @return static */ - public function having_not_in( string $column, array $values, ?string $name = null ) { + public function having_not_in( string $column, $values, ?string $name = null ) { return $this->clause_not_in( "havings", $column, $values, $name ); } @@ -192,7 +208,7 @@ public function having_not_in( string $column, array $values, ?string $name = nu * @param ?string $name Optional name for the having. * @return static */ - public function or_having_not_in( string $column, array $values, ?string $name = null ) { + public function or_having_not_in( string $column, $values, ?string $name = null ) { return $this->or_clause_not_in( "havings", $column, $values, $name ); } diff --git a/src/Clauses/OnClause.php b/src/Clauses/OnClause.php index da7762f..019a662 100644 --- a/src/Clauses/OnClause.php +++ b/src/Clauses/OnClause.php @@ -1,9 +1,25 @@ clause( "ons", $column, $operator, $value, $name ); + public function on( $first, $operator = null, $second = null, $boolean = 'and' ) { + if ( $first instanceof Closure ) { + return $this->clause( "ons", $first, $operator, $second, null, $boolean ); + } + + return $this->on_column( $first, $operator, $second, $boolean ); } /** - * Add an "or on" to the query. + * Add an "or on" clause to the join. * - * @param (Closure(static): mixed)|static|string $column The column to compare. - * @param mixed $operator The operator for comparison. - * @param mixed $value The value to compare. - * @param ?string $name Optional name for the on. + * @param string|Closure $first + * @param string|null $operator + * @param string|null $second * @return static */ - public function or_on( string $column, $operator = null, $value = null, ?string $name = null ) { - return $this->or_clause( "ons", $column, $operator, $value, $name ); + public function or_on( $first, $operator = null, $second = null ) { + return $this->on( $first, $operator, $second, 'or' ); } /** @@ -87,8 +109,8 @@ public function or_on_not( $column, $operator = null, $value = null, ?string $na * @param ?string $name Optional name for the on. * @return static */ - public function on_column( string $first_column, $operator = null, $second_column = null, ?string $name = null ) { - return $this->clause_column( "ons", $first_column, $operator, $second_column, $name ); + public function on_column( string $first_column, $operator = null, $second_column = null, $boolean = 'and' ) { + return $this->clause_column( "ons", $first_column, $operator, $second_column, null, $boolean ); } /** @@ -100,8 +122,8 @@ public function on_column( string $first_column, $operator = null, $second_colum * @param ?string $name Optional name for the on. * @return static */ - public function or_on_column( string $first_column, $operator = null, $second_column = null, ?string $name = null ) { - return $this->or_clause_column( "ons", $first_column, $operator, $second_column, $name ); + public function or_on_column( string $first_column, $operator = null, $second_column = null ) { + return $this->on_column( $first_column, $operator, $second_column, 'or' ); } /** diff --git a/src/Clauses/WhereClause.php b/src/Clauses/WhereClause.php index 589a3ac..9aa783f 100644 --- a/src/Clauses/WhereClause.php +++ b/src/Clauses/WhereClause.php @@ -1,9 +1,25 @@ clause_in( "wheres", $column, $values, $name ); } @@ -168,7 +184,7 @@ public function where_in( string $column, array $values, ?string $name = null ) * @param ?string $name Optional name for the where. * @return static */ - public function or_where_in( string $column, array $values, ?string $name = null ) { + public function or_where_in( string $column, $values, ?string $name = null ) { return $this->or_clause_in( "wheres", $column, $values, $name ); } @@ -180,7 +196,7 @@ public function or_where_in( string $column, array $values, ?string $name = null * @param ?string $name Optional name for the where. * @return static */ - public function where_not_in( string $column, array $values, ?string $name = null ) { + public function where_not_in( string $column, $values, ?string $name = null ) { return $this->clause_not_in( "wheres", $column, $values, $name ); } @@ -192,7 +208,7 @@ public function where_not_in( string $column, array $values, ?string $name = nul * @param ?string $name Optional name for the where. * @return static */ - public function or_where_not_in( string $column, array $values, ?string $name = null ) { + public function or_where_not_in( string $column, $values, ?string $name = null ) { return $this->or_clause_not_in( "wheres", $column, $values, $name ); } diff --git a/src/Eloquent/Collection.php b/src/Eloquent/Collection.php new file mode 100644 index 0000000..21ef1f0 --- /dev/null +++ b/src/Eloquent/Collection.php @@ -0,0 +1,489 @@ +items = $this->get_arrayable_items( $items ); + } + + /** + * Results array of items from Collection or Arrayable. + * + * @param mixed $items + * @return array + */ + protected function get_arrayable_items( $items ) { + if ( is_array( $items ) ) { + return $items; + } elseif ( $items instanceof self ) { + return $items->all(); + } + + return (array) $items; + } + + /** + * Get all of the items in the collection. + * + * @return array + */ + public function all() { + return $this->items; + } + + /** + * Get the first item from the collection. + * + * @param callable|null $callback + * @param mixed $default + * @return mixed + */ + public function first( ?callable $callback = null, $default = null ) { + if ( is_null( $callback ) ) { + if ( empty( $this->items ) ) { + return $default; + } + + foreach ( $this->items as $item ) { + return $item; + } + } + + foreach ( $this->items as $key => $value ) { + if ( call_user_func( $callback, $value, $key ) ) { + return $value; + } + } + + return $default; + } + + /** + * Get the last item from the collection. + * + * @param callable|null $callback + * @param mixed $default + * @return mixed + */ + public function last( ?callable $callback = null, $default = null ) { + if ( is_null( $callback ) ) { + return empty( $this->items ) ? $default : end( $this->items ); + } + + return ( new static( array_reverse( $this->items, true ) ) )->first( $callback, $default ); + } + + /** + * Run a map over each of the items. + * + * @param callable $callback + * @return static + */ + public function map( callable $callback ) { + $keys = array_keys( $this->items ); + $items = array_map( $callback, $this->items, $keys ); + + return new static( array_combine( $keys, $items ) ); + } + + /** + * Execute a callback over each item. + * + * @param callable $callback + * @return $this + */ + public function each( callable $callback ) { + foreach ( $this->items as $key => $item ) { + if ( $callback( $item, $key ) === false ) { + break; + } + } + + return $this; + } + + /** + * Run a filter over each of the items. + * + * @param callable|null $callback + * @return static + */ + public function filter( ?callable $callback = null ) { + if ( $callback ) { + return new static( array_filter( $this->items, $callback, ARRAY_FILTER_USE_BOTH ) ); + } + + return new static( array_filter( $this->items ) ); + } + + /** + * Pluck an array of values from a given key. + * + * @param string|array $value + * @param string|array|null $key + * @return static + */ + public function pluck( $value, $key = null ) { + $results = []; + + foreach ( $this->items as $item ) { + $item_value = is_object( $item ) ? ( $item->{$value} ?? null ) : ( $item[$value] ?? null ); + + if ( is_null( $key ) ) { + $results[] = $item_value; + } else { + $item_key = is_object( $item ) ? ( $item->{$key} ?? null ) : ( $item[$key] ?? null ); + $results[$item_key] = $item_value; + } + } + + return new static( $results ); + } + + /** + * Filter items by the given key value pair. + * + * @param string $key + * @param mixed $operator + * @param mixed $value + * @return static + */ + public function where( $key, $operator = null, $value = null ) { + if ( func_num_args() === 2 ) { + $value = $operator; + $operator = '='; + } + + return $this->filter( + function ( $item ) use ( $key, $operator, $value ) { + $retrieved = is_object( $item ) ? ( $item->{$key} ?? null ) : ( $item[$key] ?? null ); + + switch ( $operator ) { + case '==': + case '=': + return $retrieved == $value; + case '===': + return $retrieved === $value; + case '!=': + case '<>': + return $retrieved != $value; + case '!==': + return $retrieved !== $value; + case '<': + return $retrieved < $value; + case '>': + return $retrieved > $value; + case '<=': + return $retrieved <= $value; + case '>=': + return $retrieved >= $value; + } + + return false; + } + ); + } + + /** + * Return only unique items from the collection array. + * + * @param string|callable|null $key + * @return static + */ + public function unique( $key = null ) { + if ( is_null( $key ) ) { + return new static( array_unique( $this->items, SORT_REGULAR ) ); + } + + $exists = []; + + return $this->filter( + function ( $item ) use ( $key, &$exists ) { + $id = is_callable( $key ) ? $key( $item ) : ( is_object( $item ) ? $item->{$key} : $item[$key] ); + + if ( in_array( $id, $exists, true ) ) { + return false; + } + + $exists[] = $id; + + return true; + } + ); + } + + /** + * Group an associative array by a field or using a callback. + * + * @param array|callable|string $group_by + * @return static + */ + public function group_by( $group_by ) { + $results = []; + + foreach ( $this->items as $key => $value ) { + $group_keys = is_callable( $group_by ) ? $group_by( $value, $key ) : ( is_object( $value ) ? $value->{$group_by} : $value[$group_by] ); + + if ( ! is_array( $group_keys ) ) { + $group_keys = [$group_keys]; + } + + foreach ( $group_keys as $group_key ) { + $results[$group_key][] = $value; + } + } + + return new static( + array_map( + function( $items ) { + return new static( $items ); + }, $results + ) + ); + } + + /** + * Sort the collection using the given callback or key. + * + * @param callable|string $callback + * @param int $options + * @param bool $descending + * @return static + */ + public function sort_by( $callback, $options = SORT_REGULAR, $descending = false ) { + $results = []; + + $callback = is_callable( $callback ) ? $callback : function ( $item ) use ( $callback ) { + return is_object( $item ) ? $item->{$callback} : $item[$callback]; + }; + + foreach ( $this->items as $key => $value ) { + $results[$key] = call_user_func( $callback, $value, $key ); + } + + $descending ? arsort( $results, $options ) : asort( $results, $options ); + + foreach ( array_keys( $results ) as $key ) { + $results[$key] = $this->items[$key]; + } + + return new static( $results ); + } + + /** + * Get and remove the last item from the collection. + * + * @return mixed + */ + public function pop() { + return array_pop( $this->items ); + } + + /** + * Push an item onto the end of the collection. + * + * @param mixed $item + * @return $this + */ + public function push( $item ) { + $this->items[] = $item; + + return $this; + } + + /** + * Reduce the collection to a single value. + * + * @param callable $callback + * @param mixed $initial + * @return mixed + */ + public function reduce( callable $callback, $initial = null ) { + return array_reduce( $this->items, $callback, $initial ); + } + + /** + * Chunk the collection into chunks of a given size. + * + * @param int $size + * @return static + */ + public function chunk( int $size ) { + if ( $size <= 0 ) { + return new static( [] ); + } + + $chunks = []; + foreach ( array_chunk( $this->items, $size ) as $chunk ) { + $chunks[] = new static( $chunk ); + } + + return new static( $chunks ); + } + + /** + * Determine if an item exists in the collection. + * + * @param mixed $item + * @return bool + */ + public function contains( $item ): bool { + if ( is_callable( $item ) ) { + foreach ( $this->items as $key => $value ) { + if ( $item( $value, $key ) ) { + return true; + } + } + return false; + } + + return in_array( $item, $this->items, true ); + } + + /** + * Determine if an item exists at an offset. + * + * @param mixed $key + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists( $key ) { + return array_key_exists( $key, $this->items ); + } + + /** + * Get an item at a given offset. + * + * @param mixed $key + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet( $key ) { + return $this->items[$key]; + } + + /** + * Set the item at a given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet( $key, $value ) { + if ( is_null( $key ) ) { + $this->items[] = $value; + } else { + $this->items[$key] = $value; + } + } + + /** + * Unset the item at a given offset. + * + * @param string $key + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset( $key ) { + unset( $this->items[$key] ); + } + + /** + * Count the number of items in the collection. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function count() { + return count( $this->items ); + } + + /** + * Get an iterator for the items. + * + * @return \ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() { + return new ArrayIterator( $this->items ); + } + + /** + * Cache to prevent circular recursion during serialization. + * + * @var array + */ + protected static array $serializing = []; + + /** + * Get the collection of items as a plain array. + * + * @return array + */ + public function to_array() { + $hash = spl_object_hash( $this ); + + if ( isset( static::$serializing[$hash] ) ) { + return []; + } + + static::$serializing[$hash] = true; + + $results = array_map( + function ( $value ) { + return $value instanceof Model || $value instanceof self ? $value->to_array() : $value; + }, $this->items + ); + + unset( static::$serializing[$hash] ); + + return $results; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->to_array(); + } +} diff --git a/src/Eloquent/Concerns/GuardsAttributes.php b/src/Eloquent/Concerns/GuardsAttributes.php new file mode 100644 index 0000000..b85c85f --- /dev/null +++ b/src/Eloquent/Concerns/GuardsAttributes.php @@ -0,0 +1,171 @@ +totally_guarded(); + + foreach ( $attributes as $key => $value ) { + if ( $this->is_fillable( $key ) ) { + $this->set_attribute( $key, $value ); + } elseif ( $totally_guarded || static::$unguarded ) { + // If it's totally guarded and not fillable, we just ignore it for security + // UNLESS it's globally unguarded + if ( static::$unguarded ) { + $this->set_attribute( $key, $value ); + } + } + } + + return $this; + } + + /** + * Determine if the given attribute may be mass assigned. + * + * @param string $key + * @return bool + */ + public function is_fillable( $key ) { + if ( static::$unguarded ) { + return true; + } + + if ( in_array( $key, $this->fillable ) ) { + return true; + } + + if ( count( $this->fillable ) > 0 ) { + return false; + } + + if ( $this->totally_guarded() ) { + return false; + } + + return ! in_array( $key, $this->guarded ) && strpos( $key, '_' ) !== 0; + } + + /** + * Determine if the model is "totally guarded". + * + * @return bool + */ + public function totally_guarded() { + return count( $this->fillable ) === 0 && $this->guarded === ['*']; + } + + /** + * Get the fillable attributes for the model. + * + * @return array + */ + public function get_fillable() { + return $this->fillable; + } + + /** + * Set the fillable attributes for the model. + * + * @param array $fillable + * @return $this + */ + public function fillable( array $fillable ) { + $this->fillable = $fillable; + + return $this; + } + + /** + * Get the guarded attributes for the model. + * + * @return array + */ + public function get_guarded() { + return $this->guarded; + } + + /** + * Set the guarded attributes for the model. + * + * @param array $guarded + * @return $this + */ + public function guarded( array $guarded ) { + $this->guarded = $guarded; + + return $this; + } +} diff --git a/src/Eloquent/Concerns/HasAttributes.php b/src/Eloquent/Concerns/HasAttributes.php new file mode 100644 index 0000000..54cb52a --- /dev/null +++ b/src/Eloquent/Concerns/HasAttributes.php @@ -0,0 +1,551 @@ +attributes as $key => $value ) { + if ( ! $this->is_visible_attribute( $key ) ) { + continue; + } + $result[$key] = $this->has_cast( $key ) ? $this->cast_attribute( $key, $value ) : $value; + } + + // 2. Add/Override with appended attributes (which triggers accessors) + foreach ( $this->appends as $append ) { + if ( ! $this->is_visible_attribute( $append ) ) { + continue; + } + $result[$append] = $this->get_attribute( $append ); + } + + // 3. Process loaded relationships + if ( method_exists( $this, 'get_relations' ) ) { + foreach ( $this->get_relations() as $key => $value ) { + $result[$key] = $value; + } + } + + // 4. Serialize values for array representation + foreach ( $result as $key => $value ) { + if ( $value instanceof Model || $value instanceof Collection ) { + $result[$key] = $value->to_array(); + } elseif ( is_array( $value ) ) { + $result[$key] = array_map( + function( $item ) { + return $item instanceof Model || $item instanceof Collection ? $item->to_array() : $item; + }, $value + ); + } elseif ( $value instanceof DateTimeInterface ) { + $result[$key] = $this->serialize_date( $key, $value ); + } + } + + return $result; + } finally { + unset( static::$serializing[$hash] ); + } + } + + /** + * Serialize a date attribute. + * + * @param string $key + * @param DateTimeInterface $date + * @return string + */ + protected function serialize_date( string $key, DateTimeInterface $date ) { + if ( ! $this->has_cast( $key ) ) { + return $date->format( DateTime::ATOM ); + } + + $cast_type = $this->casts[$key]; + + if ( strpos( $cast_type, 'date:' ) === 0 || strpos( $cast_type, 'datetime:' ) === 0 ) { + list( , $format ) = explode( ':', $cast_type, 2 ); + return $date->format( $format ); + } + + if ( in_array( strtolower( trim( $cast_type ) ), ['date', 'datetime'] ) ) { + return $date->format( 'Y-m-d H:i:s' ); + } + + return $date->format( DateTime::ATOM ); + } + + /** + * Get an attribute from the model. + * + * @param string $key + * @return mixed + */ + public function get_attribute( $key ) { + if ( ! $key ) { + return; + } + + // 1. If the attribute exists in the attributes array or has an accessor, get its value. + if ( array_key_exists( $key, $this->attributes ) || $this->has_accessor( $key ) || $this->has_cast( $key ) ) { + return $this->get_attribute_value( $key ); + } + + // 2. If the attribute exists as a loaded relationship, return it. + if ( method_exists( $this, 'relation_loaded' ) && $this->relation_loaded( $key ) ) { + return $this->get_relation( $key ); + } + + // 3. Fallback to lazy-loading relationships if the method exists. + if ( method_exists( $this, $key ) ) { + $relation = $this->$key(); + + if ( $relation instanceof Relation ) { + $results = $relation->get_results(); + + // Cache it so we don't repeatedly query the DB + if ( method_exists( $this, 'set_relation' ) ) { + $this->set_relation( $key, $results ); + } else { + $this->attributes[$key] = $results; + } + + return $results; + } + } + + return null; + } + + /** + * Get the value of an attribute after applying accessors and casts. + * + * @param string $key + * @return mixed + */ + protected function get_attribute_value( string $key ) { + $value = $this->attributes[$key] ?? null; + + // If the attribute has an accessor, call it. + if ( $this->has_accessor( $key ) ) { + return $this->{$this->get_accessor_method( $key )}( $value ); + } + + // If the attribute has a cast, apply it. + if ( $this->has_cast( $key ) ) { + return $this->cast_attribute( $key, $value ); + } + + return $value; + } + + /** + * Set an attribute's value, applying mutators if they exist. + * + * @param string $key + * @param mixed $value + * @return $this + */ + public function set_attribute( string $key, $value ) { + if ( $this->has_mutator( $key ) ) { + $this->{$this->get_mutator_method( $key )}( $value ); + return $this; + } + + if ( is_null( $value ) ) { + $this->attributes[$key] = null; + return $this; + } + + if ( $this->has_cast( $key ) ) { + $cast_type = $this->casts[$key]; + if ( strpos( $cast_type, ':' ) !== false ) { + list( $type, ) = explode( ':', $cast_type, 2 ); + $type = strtolower( trim( $type ) ); + } else { + $type = strtolower( trim( $cast_type ) ); + } + + if ( in_array( $type, ['array', 'json', 'object'] ) ) { + if ( ! is_string( $value ) ) { + $value = json_encode( $value ); + } + } elseif ( in_array( $type, ['date', 'datetime'] ) && $value instanceof DateTimeInterface ) { + $value = $value->format( 'Y-m-d H:i:s' ); + } + } + if ( method_exists( $this, 'relation_loaded' ) && ( $this->relation_loaded( $key ) || ( method_exists( $this, $key ) && ! Model::is_reserved_method( $key ) ) ) ) { + return $this->set_relation( $key, $value ); + } + + $this->attributes[$key] = $value; + + // Clear the cache for this attribute if it exists + if ( isset( $this->class_cast_cache[$key] ) ) { + unset( $this->class_cast_cache[$key] ); + } + + return $this; + } + + /** + * Cast an attribute to a native type. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function cast_attribute( string $key, $value ) { + if ( is_null( $value ) ) { + return $value; + } + + $cast_type = $this->casts[$key]; + + if ( strpos( $cast_type, ':' ) !== false ) { + list( $type, $param ) = explode( ':', $cast_type, 2 ); + $type = strtolower( trim( $type ) ); + } else { + $type = strtolower( trim( $cast_type ) ); + $param = null; + } + + switch ( $type ) { + case 'int': + case 'integer': + return (int) $value; + case 'real': + case 'float': + case 'double': + return (float) $value; + case 'decimal': + return number_format( (float) $value, (int) $param, '.', '' ); + case 'string': + return (string) $value; + case 'bool': + case 'boolean': + return (bool) $value; + case 'date': + case 'datetime': + if ( isset( $this->class_cast_cache[$key] ) ) { + return $this->class_cast_cache[$key]; + } + return $this->class_cast_cache[$key] = $value instanceof DateTimeInterface ? $value : new DateTime( $value ); + case 'object': + if ( isset( $this->class_cast_cache[$key] ) ) { + return $this->class_cast_cache[$key]; + } + return $this->class_cast_cache[$key] = is_string( $value ) ? json_decode( $value ) : $value; + case 'array': + case 'json': + if ( isset( $this->class_cast_cache[$key] ) ) { + return $this->class_cast_cache[$key]; + } + return $this->class_cast_cache[$key] = is_string( $value ) ? json_decode( $value, true ) : $value; + default: + return $value; + } + } + + /** + * Determine if a cast exists for an attribute. + * + * @param string $key + * @return bool + */ + protected function has_cast( string $key ): bool { + return array_key_exists( $key, $this->casts ); + } + + /** + * Determine if an accessor exists for an attribute. + * + * @param string $key + * @return bool + */ + protected function has_accessor( string $key ): bool { + return method_exists( $this, $this->get_accessor_method( $key ) ); + } + + /** + * Get the method name for an attribute's accessor. + * + * @param string $key + * @return string + */ + protected function get_accessor_method( string $key ): string { + return 'get_' . $key . '_attribute'; + } + + /** + * Determine if a mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + protected function has_mutator( string $key ): bool { + return method_exists( $this, $this->get_mutator_method( $key ) ); + } + + /** + * Get the method name for an attribute's mutator. + * + * @param string $key + * @return string + */ + protected function get_mutator_method( string $key ): string { + return 'set_' . $key . '_attribute'; + } + + /** + * Get all attributes from the model. + * + * @return array + */ + public function get_attributes() { + return $this->attributes; + } + + /** + * Sync the original attributes with the current. + * + * @return $this + */ + public function sync_original() { + $this->original = $this->attributes; + $this->class_cast_cache = []; + + return $this; + } + + /** + * Get the attributes that have been changed since last sync. + * + * @return array + */ + public function get_dirty() { + $dirty = []; + + foreach ( $this->attributes as $key => $value ) { + if ( ! array_key_exists( $key, $this->original ) ) { + $dirty[$key] = $value; + continue; + } + + $original = $this->original[$key]; + + if ( $value === $original ) { + continue; + } + + if ( $value instanceof DateTimeInterface && $original instanceof DateTimeInterface ) { + if ( $value->getTimestamp() === $original->getTimestamp() ) { + continue; + } + } + + if ( $this->has_cast( $key ) ) { + $cast_type = strtolower( trim( $this->casts[$key] ) ); + if ( in_array( $cast_type, ['json', 'array', 'object'] ) ) { + $decoded_value = is_string( $value ) ? json_decode( $value, true ) : $value; + $decoded_original = is_string( $original ) ? json_decode( $original, true ) : $original; + if ( $decoded_value === $decoded_original ) { + continue; + } + } + } + + $dirty[$key] = $value; + } + + return $dirty; + } + + /** + * Determine if an attribute is visible for serialization. + * + * @param string $key + * @return bool + */ + protected function is_visible_attribute( string $key ): bool { + if ( count( $this->visible ) > 0 ) { + return in_array( $key, $this->visible ); + } + + return ! in_array( $key, $this->hidden ); + } + + /** + * Set the hidden attributes for the model. + * + * @param array $hidden + * @return $this + */ + public function make_hidden( array $hidden ) { + $this->hidden = array_merge( $this->hidden, $hidden ); + return $this; + } + + /** + * Set the visible attributes for the model. + * + * @param array $visible + * @return $this + */ + public function make_visible( array $visible ) { + $this->visible = array_merge( $this->visible, $visible ); + return $this; + } + + /** + * Determine if the model or given attribute(s) have been modified. + * + * @param array|string|null $attributes + * @return bool + */ + public function is_dirty( $attributes = null ) { + $all_args = func_get_args(); + $dirty = $this->get_dirty(); + + if ( is_null( $attributes ) ) { + return count( $dirty ) > 0; + } + + $args = is_array( $attributes ) ? $attributes : $all_args; + + foreach ( $args as $attribute ) { + if ( array_key_exists( $attribute, $dirty ) ) { + return true; + } + } + + return false; + } + + /** + * Get the primary key for the model. + * + * @return string + */ + public function get_key_name() { + return $this->primary_key; + } + + /** + * Get the value of the model's primary key. + * + * @return mixed + */ + public function get_key() { + return $this->get_attribute( $this->get_key_name() ); + } + + /** + * Get the fully qualified primary key for the model. + * + * @return string + */ + public function get_qualified_key_name() { + return static::get_table_name() . '.' . $this->get_key_name(); + } +} diff --git a/src/Eloquent/Concerns/HasEvents.php b/src/Eloquent/Concerns/HasEvents.php new file mode 100644 index 0000000..3a9fbda --- /dev/null +++ b/src/Eloquent/Concerns/HasEvents.php @@ -0,0 +1,116 @@ +attributes = (array) $attributes; + + $model->exists = true; + + $model->sync_original(); + + $model->fire_model_event( 'retrieved', false ); + + return $model; + } + + /** + * Register an observer with the model. + * + * @param object|string $class + * @return void + */ + public static function observe( $class ) { + $instance = is_string( $class ) ? new $class : $class; + + static::$observers[static::class][] = $instance; + } + + /** + * Clear all registered observers. + * + * @return void + */ + public static function flush_observers() { + if ( isset( static::$observers[static::class] ) ) { + unset( static::$observers[static::class] ); + } + } + + /** + * Fire a custom model event. + * + * @param string $event + * @param bool $halt + * @return mixed + */ + protected function fire_model_event( $event, $halt = true ) { + // 1. Fire Observer Methods + foreach ( static::$observers[static::class] ?? [] as $observer ) { + if ( method_exists( $observer, $event ) ) { + $result = $observer->$event( $this ); + + if ( $halt && $result === false ) { + return false; + } + } + } + + // 2. Fire WordPress Hooks + $prefix = App::get_config()->get( 'app.hook_prefix' ) ?: 'wpmvc'; + $table_name = static::get_table_name(); + + $hook_name = "{$prefix}_model_{$event}"; + $class_hook_name = "{$hook_name}_{$table_name}"; + + if ( $halt ) { + // "ing" events (saving, creating, etc.) use filters to allow halting + if ( false === apply_filters( $class_hook_name, true, $this ) ) { + return false; + } + if ( false === apply_filters( $hook_name, true, $this ) ) { + return false; + } + } else { + // Completed events use actions + do_action( $class_hook_name, $this ); + do_action( $hook_name, $this ); + } + + return true; + } +} diff --git a/src/Eloquent/Concerns/HasFactory.php b/src/Eloquent/Concerns/HasFactory.php new file mode 100644 index 0000000..f5c15f9 --- /dev/null +++ b/src/Eloquent/Concerns/HasFactory.php @@ -0,0 +1,42 @@ +count( $count ); + } + + /** + * Create a new factory instance for the model. + * + * @return Factory|null + */ + protected static function new_factory() { + return null; + } +} diff --git a/src/Eloquent/Concerns/HasRelationships.php b/src/Eloquent/Concerns/HasRelationships.php new file mode 100644 index 0000000..436edc4 --- /dev/null +++ b/src/Eloquent/Concerns/HasRelationships.php @@ -0,0 +1,404 @@ +relations[$relation] = $value; + + return $this; + } + + /** + * Get a specific relationship from the model. + * + * @param string $relation + * @return mixed + */ + public function get_relation( string $relation ) { + return $this->relations[$relation] ?? null; + } + + /** + * Determine if the given relationship is loaded. + * + * @param string $key + * @return bool + */ + public function relation_loaded( string $key ) { + return array_key_exists( $key, $this->relations ); + } + + /** + * Unset a loaded relationship. + * + * @param string $relation + * @return $this + */ + public function unset_relation( string $relation ) { + unset( $this->relations[$relation] ); + + return $this; + } + + /** + * Get all of the loaded relationships for the model. + * + * @return array + */ + public function get_relations() { + return $this->relations; + } + + /** + * Get the actual class name for a given morph alias. + * + * @param string $alias + * @return string + */ + public static function get_actual_class_for_morph( $alias ) { + $morph_map = static::morph_map(); + + return isset( $morph_map[$alias] ) ? $morph_map[$alias] : $alias; + } + + /** + * Get the morph name for a given class. + * + * @param string $class + * @return string + */ + public static function get_morph_class_for( $class ) { + $morph_map = static::morph_map(); + + if ( ! empty( $morph_map ) && $alias = array_search( $class, $morph_map ) ) { + return $alias; + } + + return $class; + } + + /** + * Get or set the morph map for polymorphic relationships. + * + * @param array|null $map + * @param bool $merge + * @return array + */ + public static function morph_map( ?array $map = null, $merge = true ) { + if ( is_array( $map ) ) { + static::$morph_map = $merge && ! empty( static::$morph_map ) + ? $map + static::$morph_map + : $map; + } + + return static::$morph_map; + } + + /** + * Get the class name for polymorphic relations. + * + * @return string + */ + public function get_morph_class() { + return static::get_morph_class_for( static::class ); + } + + /** + * Define a one-to-many relationship. + * + * @param string $related + * @param string $foreign_key + * @param string $local_key + * @return HasMany + */ + public function has_many( string $related, ?string $foreign_key = null, ?string $local_key = null ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_key = $foreign_key ?: $this->get_foreign_key(); + $local_key = $local_key ?: $this->get_key_name(); + + return new HasMany( $instance->new_query(), $this, $foreign_key, $local_key ); + } + + /** + * Define a one-to-one relationship. + * + * @param string $related + * @param string $foreign_key + * @param string $local_key + * @return HasOne + */ + public function has_one( $related, ?string $foreign_key = null, ?string $local_key = null ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_key = $foreign_key ?: $this->get_foreign_key(); + $local_key = $local_key ?: $this->get_key_name(); + + return new HasOne( $instance->new_query(), $this, $foreign_key, $local_key ); + } + + /** + * Define an inverse one-to-one or many relationship. + * + * @param string $related + * @param string|null $foreign_key (on the child model) + * @param string|null $owner_key (on the parent model) + * @return BelongsTo + */ + public function belongs_to( $related, ?string $foreign_key = null, ?string $owner_key = null ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_key = $foreign_key ?: $instance->get_foreign_key(); + $owner_key = $owner_key ?: $instance->get_key_name(); + + return new BelongsTo( $instance->new_query(), $this, $foreign_key, $owner_key ); + } + + /** + * Define an inverse many-to-many relationship. + * + * @param string $related + * @param string|Model $pivot + * @param string $foreign_pivot_key + * @param string $local_pivot_key + * @param string $foreign_key + * @param string $local_key + * @return BelongsToMany + */ + public function belongs_to_many( $related, $pivot, $foreign_pivot_key = null, $local_pivot_key = null, $foreign_key = null, $local_key = null ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_pivot_key = $foreign_pivot_key ?: $this->get_foreign_key(); + $local_pivot_key = $local_pivot_key ?: $instance->get_foreign_key(); + $foreign_key = $foreign_key ?: $instance->get_key_name(); + $local_key = $local_key ?: $this->get_key_name(); + + return new BelongsToMany( $instance->new_query(), $this, $pivot, $foreign_pivot_key, $local_pivot_key, $foreign_key, $local_key ); + } + + /** + * Define a one-to-one of many relationship. + * + * @param string $related + * @param string $foreign_key + * @param string $local_key + * @param string $sort_column + * @param string $sort_direction + * @return HasOneOfMany + */ + public function has_one_of_many( $related, ?string $foreign_key = null, ?string $local_key = null, string $sort_column = 'id', string $sort_direction = 'desc' ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_key = $foreign_key ?: $this->get_foreign_key(); + $local_key = $local_key ?: $this->get_key_name(); + + return new HasOneOfMany( $instance->new_query(), $this, $foreign_key, $local_key, $sort_column, $sort_direction ); + } + + /** + * Define a has-many-through relationship. + * + * @param string $related + * @param string $through + * @param string $first_key + * @param string $second_key + * @param string $local_key + * @param string $second_local_key + * @return HasManyThrough + */ + public function has_many_through( $related, $through, $first_key = null, $second_key = null, $local_key = null, $second_local_key = null ) { + /** @var Model $instance */ + $instance = new $related; + /** @var Model $through_instance */ + $through_instance = new $through; + + $first_key = $first_key ?: $this->get_foreign_key(); + $second_key = $second_key ?: $through_instance->get_foreign_key(); + $local_key = $local_key ?: $this->get_key_name(); + $second_local_key = $second_local_key ?: $through_instance->get_key_name(); + + return new HasManyThrough( $instance->new_query(), $this, $through_instance, $first_key, $second_key, $local_key, $second_local_key ); + } + + /** + * Define a has-one-through relationship. + * + * @param string $related + * @param string $through + * @param string $first_key + * @param string $second_key + * @param string $local_key + * @param string $second_local_key + * @return HasOneThrough + */ + public function has_one_through( $related, $through, $first_key = null, $second_key = null, $local_key = null, $second_local_key = null ) { + /** @var Model $instance */ + $instance = new $related; + /** @var Model $through_instance */ + $through_instance = new $through; + + $first_key = $first_key ?: $this->get_foreign_key(); + $second_key = $second_key ?: $through_instance->get_foreign_key(); + $local_key = $local_key ?: $this->get_key_name(); + $second_local_key = $second_local_key ?: $through_instance->get_key_name(); + + return new HasOneThrough( $instance->new_query(), $this, $through_instance, $first_key, $second_key, $local_key, $second_local_key ); + } + + /** + * Define a polymorphic, inverse one-to-one or many relationship. + * + * @param string $morph_name + * @param string|null $type_column + * @param string|null $id_column + * @param string $local_key + * @return MorphTo + */ + public function morph_to( $morph_name, ?string $type_column = null, ?string $id_column = null, string $local_key = 'id' ) { + $type_column = $type_column ?: $morph_name . '_type'; + $id_column = $id_column ?: $morph_name . '_id'; + + return new MorphTo( $this->new_query(), $this, $morph_name, $type_column, $id_column, $local_key ); + } + + /** + * Define a polymorphic one-to-one relationship. + * + * @param string $related + * @param string $morph_name + * @param string $type_column + * @param string $id_column + * @param string $local_key + * @return MorphOne + */ + public function morph_one( $related, $morph_name, ?string $type_column = null, ?string $id_column = null, string $local_key = 'id' ) { + /** @var Model $instance */ + $instance = new $related; + $type_column = $type_column ?: $morph_name . '_type'; + $id_column = $id_column ?: $morph_name . '_id'; + + return new MorphOne( $instance->new_query(), $this, $morph_name, $type_column, $id_column, $local_key ); + } + + /** + * Define a polymorphic one-to-many relationship. + * + * @param string $related + * @param string $morph_name + * @param string $type_column + * @param string $id_column + * @param string $local_key + * @return MorphMany + */ + public function morph_many( $related, $morph_name, ?string $type_column = null, ?string $id_column = null, string $local_key = 'id' ) { + /** @var Model $instance */ + $instance = new $related; + $type_column = $type_column ?: $morph_name . '_type'; + $id_column = $id_column ?: $morph_name . '_id'; + + return new MorphMany( $instance->new_query(), $this, $morph_name, $type_column, $id_column, $local_key ); + } + + /** + * Define a polymorphic many-to-many relationship. + * + * @param string $related + * @param string $morph_name + * @param string $pivot_table + * @param string $foreign_pivot_key + * @param string $related_pivot_key + * @param string $parent_key + * @param string $related_key + * @return MorphToMany + */ + public function morph_to_many( $related, $morph_name, $pivot_table, ?string $foreign_pivot_key = null, ?string $related_pivot_key = null, string $parent_key = 'id', string $related_key = 'id' ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_pivot_key = $foreign_pivot_key ?: $morph_name . '_id'; + $related_pivot_key = $related_pivot_key ?: static::get_foreign_key_from_class( $related ); + + return new MorphToMany( $instance->new_query(), $this, $morph_name, $this->get_morph_class(), $pivot_table, $foreign_pivot_key, $related_pivot_key, $parent_key, $related_key ); + } + + /** + * Define a polymorphic, inverse many-to-many relationship. + * + * @param string $related + * @param string $morph_name + * @param string $pivot_table + * @param string $related_pivot_key + * @param string $foreign_pivot_key + * @param string $parent_key + * @param string $related_key + * @return MorphToMany + */ + public function morphed_by_many( $related, $morph_name, $pivot_table, ?string $foreign_pivot_key = null, ?string $related_pivot_key = null, string $parent_key = 'id', string $related_key = 'id' ) { + /** @var Model $instance */ + $instance = new $related; + $foreign_pivot_key = $foreign_pivot_key ?: static::get_foreign_key_from_class( static::class ); + $related_pivot_key = $related_pivot_key ?: $morph_name . '_id'; + + return new MorphToMany( $instance->new_query(), $this, $morph_name, static::get_morph_class_for( $related ), $pivot_table, $foreign_pivot_key, $related_pivot_key, $parent_key, $related_key, true ); + } + + /** + * Derive a foreign key name from a fully qualified class name. + * e.g., 'App\Models\Tag' => 'tag_id', 'App\Models\PostCategory' => 'post_category_id' + * + * @param string $class + * @return string + */ + public static function get_foreign_key_from_class( string $class ): string { + $parts = explode( '\\', $class ); + $base_name = end( $parts ); + + // Convert PascalCase to snake_case + $snake = strtolower( preg_replace( '/(?exists && ! $this->is_dirty( 'created_at' ) ) { + $this->set_attribute( 'created_at', $time ); + } + + if ( ! $this->is_dirty( 'updated_at' ) ) { + $this->set_attribute( 'updated_at', $time ); + } + } +} diff --git a/src/Eloquent/Factory.php b/src/Eloquent/Factory.php new file mode 100644 index 0000000..195f83d --- /dev/null +++ b/src/Eloquent/Factory.php @@ -0,0 +1,619 @@ +configure(); + } + } + + // Guess factory name: App\Models\User -> Database\Factories\UserFactory + // Handle scoping: MyPluginNamespace\App\Models\User -> MyPluginNamespace\Database\Factories\UserFactory + + $factory_name = str_replace( ['\\Models\\', 'Models\\'], ['\\Factories\\', 'Factories\\'], $model_class ) . 'Factory'; + $factory_name = str_replace( 'App\\', 'Database\\', $factory_name ); + + if ( class_exists( $factory_name ) ) { + return ( new $factory_name() )->configure(); + } + + throw new InvalidArgumentException( "Factory not found for model: {$model_class} (Guessed: {$factory_name})" ); + } + + /** + * Specify the callback to use to guess factory names. + * + * @param callable $callback + * @return void + */ + public static function guess_factory_names_using( callable $callback ) { + static::$factory_name_resolver = $callback; + } + + /** + * Create a new factory instance. + * + * @param int|null $count + * @return static + */ + public static function new( ?int $count = null ) { + $factory = new static(); + + if ( $count ) { + $factory->count = $count; + } + + return $factory->configure(); + } + + /** + * Set the number of models that should be generated. + * + * @param int|null $count + * @return $this + */ + public function count( ?int $count = null ) { + $this->count = $count; + + return $this; + } + + /** + * Add a new state transformation to the model definition. + * + * @param array|callable $state + * @return $this + */ + public function state( $state ) { + $this->states[] = $state; + + return $this; + } + + /** + * Recycle a specific model instance. + * + * @param Model|Collection|array $models + * @return $this + */ + public function recycle( $models ) { + if ( ! is_array( $models ) && ! $models instanceof Collection ) { + $models = [$models]; + } + + foreach ( $models as $model ) { + $this->recycled[get_class( $model )][] = $model; + } + + return $this; + } + + /** + * Disable model events for the model. + * + * @return $this + */ + public function without_events() { + $this->without_events = true; + + return $this; + } + + /** + * Create a collection of models and persist them to the database. + * + * @param array $attributes + * @return Model|Collection + */ + public function create( array $attributes = [] ) { + $results = $this->make( $attributes ); + + if ( $results instanceof Model ) { + $this->store( [$results] ); + } else { + $this->store( $results->all() ); + } + + return $results; + } + + /** + * Create a collection of models and return them. + * + * @param array $attributes + * @return Model|Collection + */ + public function make( array $attributes = [] ) { + if ( $this->count === null ) { + $model = $this->make_instance( $attributes ); + $this->call_after_making( [$model] ); + return $model; + } + + if ( $this->count < 1 ) { + return new Collection(); + } + + $instances = []; + for ( $i = 0; $i < $this->count; $i++ ) { + $instances[] = $this->make_instance( $attributes ); + } + + $collection = new Collection( $instances ); + $this->call_after_making( $collection->all() ); + + return $collection; + } + + /** + * Get the raw attributes for the models. + * + * @param array $attributes + * @return array + */ + public function raw( array $attributes = [] ) { + if ( $this->count === null ) { + return $this->get_raw_attributes( $attributes ); + } + + if ( $this->count < 1 ) { + return []; + } + + $results = []; + for ( $i = 0; $i < $this->count; $i++ ) { + $results[] = $this->get_raw_attributes( $attributes ); + } + + return $results; + } + + /** + * Create a single instance of the model. + * + * @param array $attributes + * @return Model + */ + protected function make_instance( array $attributes ) { + $model_class = $this->model_name(); + $raw_attributes = $this->get_raw_attributes( $attributes ); + + $model_class::unguard(); + $model = new $model_class( $raw_attributes ); + $model_class::reguard(); + + return $model; + } + + /** + * Get the raw attributes for a single model instance. + * + * @param array $attributes + * @return array + */ + protected function get_raw_attributes( array $attributes ) { + $definition = $this->definition(); + + foreach ( $this->states as $state ) { + if ( is_callable( $state ) ) { + $definition = array_merge( $definition, $state( $definition ) ); + } elseif ( $state instanceof Sequence ) { + $definition = array_merge( $definition, $state->next( $this->count ?? 1 ) ); + } else { + $definition = array_merge( $definition, $state ); + } + } + + $definition = array_merge( $definition, $attributes ); + + // Resolve attributes that are factories or closures + return $this->expand_attributes( $definition ); + } + + /** + * Expand any attributes that are factories or closures. + * + * @param array $attributes + * @return array + */ + protected function expand_attributes( array $attributes ) { + /** + * @var Model $model + */ + foreach ( $attributes as $key => &$value ) { + if ( $value instanceof Factory ) { + $model_class = $value->model_name(); + if ( isset( $this->recycled[$model_class] ) ) { + $model = $this->recycled[$model_class][array_rand( $this->recycled[$model_class] )]; + $value = $model->get_key(); + } else { + // Propagate recycled models to nested factories + foreach ( $this->recycled as $recycled_models ) { + $value->recycle( $recycled_models ); + } + + $model = $value->create(); + $value = $model->get_key(); + if ( is_null( $value ) ) { + error_log( "Factory Debug: Created model " . get_class( $model ) . " but get_key() returned NULL. Attributes: " . json_encode( $model->to_array() ) ); + } + } + } elseif ( $value instanceof Closure ) { + $value = $value( $attributes ); + } elseif ( $value instanceof Sequence ) { + $value = $value->next( $this->count ?? 1 ); + } + } + + return $attributes; + } + + /** + * Store the given models. + * + * @param array $models + * @return void + */ + protected function store( array $models ) { + foreach ( $models as $model ) { + if ( $this->without_events ) { + // This would require a proper way to disable events in the Model itself + // but for now we'll just fire the saving events manually or skip if possible. + // In WpMVC Model, we can probably use a flag if we modify it, + // but let's assume we skip fire_model_event if we had a flag. + } + $model->save(); + } + + $this->call_after_creating( $models ); + } + + /** + * Get the model class name. + * + * @return string + */ + public function model_name() { + if ( $this->model ) { + return $this->model; + } + + $factory_name = get_class( $this ); + + // Handle scoping: MyPluginNamespace\Database\Factories\UserFactory -> MyPluginNamespace\App\Models\User + $model_class = str_replace( ['\\Factories\\', 'Factories\\'], ['\\Models\\', 'Models\\'], $factory_name ); + $model_class = str_replace( 'Database\\', 'App\\', $model_class ); + $model_class = str_replace( 'Factory', '', $model_class ); + + return $model_class; + } + + /** + * Configure the factory. + * + * @return $this + */ + public function configure() { + return $this; + } + + /** + * Get the Faker instance. + * + * @return FakeData + */ + protected function faker() { + return FakeData::instance(); + } + + /** + * Proxy calls to state for magic states. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call( $method, $parameters ) { + if ( method_exists( $this, $method ) ) { + return $this->{$method}( ...$parameters ); + } + + // Handle magic has{Relation} and for{Relation} + if ( strpos( $method, 'has' ) === 0 ) { + $relationship = lcfirst( substr( $method, 3 ) ); + $factory = $parameters[0] ?? null; + $count = $parameters[1] ?? null; + + if ( ! $factory instanceof Factory ) { + $model_class = $this->model_name(); + $model = new $model_class(); + + if ( method_exists( $model, $relationship ) ) { + $relation_obj = $model->$relationship(); + // Interrogate relation for related model + try { + $ref = new \ReflectionProperty( $relation_obj, 'related' ); + if ( \PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + $related_model = $ref->getValue( $relation_obj ); + $factory = static::new_factory_for_model( get_class( $related_model ) ); + $count = $parameters[0] ?? null; + } catch ( \Exception $e ) { + // Fallback to old guessing + } + } + + if ( ! $factory instanceof Factory ) { + // Fallback guessing logic + $guessed_model = 'App\\Models\\' . ucfirst( rtrim( $relationship, 's' ) ); + if ( strpos( get_class( $this ), 'MyPluginNamespace' ) === 0 ) { + $guessed_model = 'MyPluginNamespace\\' . $guessed_model; + } + $factory = static::new_factory_for_model( $guessed_model ); + $count = $parameters[0] ?? null; + } + } + + return $this->has( $factory, $count, $relationship ); + } + + if ( strpos( $method, 'for' ) === 0 ) { + $relationship = lcfirst( substr( $method, 3 ) ); + return $this->for( $parameters[0], $relationship ); + } + + // Try to call magic state method + // e.g., published() -> state(['status' => 'published']) + return $this->state( [$method => true] ); + } + + /** + * Register an after making callback. + * + * @param callable $callback + * @return $this + */ + public function after_making( callable $callback ) { + $this->after_making[] = $callback; + + return $this; + } + + /** + * Register an after creating callback. + * + * @param callable $callback + * @return $this + */ + public function after_creating( callable $callback ) { + $this->after_creating[] = $callback; + + return $this; + } + + /** + * Call the after making callbacks. + * + * @param array $models + * @return void + */ + protected function call_after_making( array $models ) { + foreach ( $models as $model ) { + foreach ( $this->after_making as $callback ) { + $callback( $model ); + } + } + } + + /** + * Call the after creating callbacks. + * + * @param array $models + * @return void + */ + protected function call_after_creating( array $models ) { + foreach ( $models as $model ) { + foreach ( $this->after_creating as $callback ) { + $callback( $model ); + } + } + } + + /** + * Define a related model factory. + * + * @param Factory $factory + * @param int|string|null $count + * @param string|null $relationship + * @return $this + */ + public function has( Factory $factory, $count = null, ?string $relationship = null ) { + return $this->after_creating( + function( Model $model ) use ( $factory, $count, $relationship ) { + $factory = clone $factory; + + if ( is_integer( $count ) ) { + $factory->count( $count ); + } elseif ( is_string( $count ) ) { + $relationship = $count; + } + + $relationship = $relationship ?: strtolower( ( new \ReflectionClass( $factory->model_name() ) )->getShortName() ) . 's'; + + $foreign_key = $model->get_foreign_key(); + + // Interrogate the relationship if it exists on the model + if ( method_exists( $model, $relationship ) ) { + $relation_obj = $model->$relationship(); + // We use reflection because the property is protected in WpMVC base Relations + try { + $ref = new \ReflectionProperty( $relation_obj, 'foreign_key' ); + if ( \PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + $foreign_key = $ref->getValue( $relation_obj ); + } catch ( \Exception $e ) { + // Fallback to default + } + } + + // Pass the model being created to the related factory for recycling + $factory->recycle( $model )->create( [$foreign_key => $model->get_key()] ); + } + ); + } + + /** + * Set the parent model for the factory. + * + * @param Model|Factory $parent + * @param string|null $relationship + * @return $this + */ + public function for( $parent, ?string $relationship = null ) { + return $this->state( + function() use ( $parent, $relationship ) { + $instance = $parent instanceof Factory ? $parent->create() : $parent; + + // Try to determine if it's polymorphic or simple + $foreign_key = null; + $class_parts = explode( '\\', get_class( $instance ) ); + $base_name = strtolower( end( $class_parts ) ); + + if ( $relationship ) { + // If the model has a method with this name, interrogate it + $model_class = $this->model_name(); + $model = new $model_class(); + if ( method_exists( $model, $relationship ) ) { + $relation_obj = $model->$relationship(); + // We check if it's a MorphTo relationship + $is_morph_to = false; + $parts = explode( '\\', get_class( $relation_obj ) ); + if ( end( $parts ) === 'MorphTo' ) { + $is_morph_to = true; + } + + if ( $is_morph_to ) { + return [ + $relationship . '_id' => $instance->get_key(), + $relationship . '_type' => get_class( $instance ), + ]; + } + + // Simple belongsTo - try to find the foreign key + try { + $ref = new \ReflectionProperty( $relation_obj, 'foreign_key' ); + if ( \PHP_VERSION_ID < 80100 ) { + $ref->setAccessible( true ); + } + $foreign_key = $ref->getValue( $relation_obj ); + } catch ( \Exception $e ) { + // Fallback + } + } + + if ( ! $foreign_key ) { + $foreign_key = $relationship . '_id'; + } + } else { + // Guess based on convention + $foreign_key = $base_name . '_id'; + } + + return [$foreign_key => $instance->get_key()]; + } + ); + } +} diff --git a/src/Eloquent/FakeData.php b/src/Eloquent/FakeData.php new file mode 100644 index 0000000..204ba6a --- /dev/null +++ b/src/Eloquent/FakeData.php @@ -0,0 +1,346 @@ +unique ) { + $this->unique = new UniqueProxy( $this ); + } + + return $this->unique; + } + + /** + * Generate a full name. + */ + public function name(): string { + return $this->first_name() . ' ' . $this->last_name(); + } + + /** + * Generate a first name. + */ + public function first_name(): string { + return $this->random_element( + [ + 'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Charles', + 'Mary', 'Patricia', 'Jennifer', 'Linda', 'Elizabeth', 'Barbara', 'Susan', 'Jessica', 'Sarah', 'Karen', + 'Md', 'Al', 'Amin', 'Karim', 'Rahim', 'Fatima', 'Ayesha', 'Zainab', 'Omar', 'Hassan' + ] + ); + } + + /** + * Generate a last name. + */ + public function last_name(): string { + return $this->random_element( + [ + 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', + 'Khan', 'Ahmed', 'Ali', 'Hossain', 'Islam', 'Uddin', 'Chowdhury', 'Begum', 'Akter', 'Rahman' + ] + ); + } + + /** + * Generate a title. + */ + public function title(): string { + return $this->random_element( ['Mr.', 'Mrs.', 'Ms.', 'Dr.', 'Prof.'] ); + } + + /** + * Generate a word. + */ + public function word(): string { + return $this->random_element( + [ + 'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit', 'sed', 'do', + 'eiusmod', 'tempor', 'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua' + ] + ); + } + + /** + * Generate a sentence. + */ + public function sentence( int $nb_words = 6 ): string { + $words = []; + for ( $i = 0; $i < $nb_words; $i++ ) { + $words[] = $this->word(); + } + + return ucfirst( implode( ' ', $words ) ) . '.'; + } + + /** + * Generate a paragraph. + */ + public function paragraph( int $nb_sentences = 3 ): string { + $sentences = []; + for ( $i = 0; $i < $nb_sentences; $i++ ) { + $sentences[] = $this->sentence( mt_rand( 4, 10 ) ); + } + + return implode( ' ', $sentences ); + } + + /** + * Generate text. + */ + public function text( int $max_nb_chars = 200 ): string { + $text = $this->paragraph( 5 ); + if ( strlen( $text ) > $max_nb_chars ) { + $text = substr( $text, 0, $max_nb_chars ); + } + return $text; + } + + /** + * Generate a slug. + */ + public function slug( int $nb_words = 3 ): string { + $words = []; + for ( $i = 0; $i < $nb_words; $i++ ) { + $words[] = $this->word(); + } + return implode( '-', $words ); + } + + /** + * Generate an email. + */ + public function email(): string { + return $this->user_name() . '@' . $this->domain_name(); + } + + /** + * Generate a safe email. + */ + public function safe_email(): string { + return $this->user_name() . '@example.' . $this->random_element( ['com', 'net', 'org'] ); + } + + /** + * Generate a username. + */ + public function user_name(): string { + return strtolower( $this->first_name() . mt_rand( 1, 99 ) ); + } + + /** + * Generate a domain name. + */ + public function domain_name(): string { + return $this->word() . '.' . $this->random_element( ['com', 'net', 'org', 'io', 'dev'] ); + } + + /** + * Generate a URL. + */ + public function url(): string { + return 'https://' . $this->domain_name() . '/' . $this->slug(); + } + + /** + * Generate an IPv4 address. + */ + public function ipv4(): string { + return mt_rand( 0, 255 ) . '.' . mt_rand( 0, 255 ) . '.' . mt_rand( 0, 255 ) . '.' . mt_rand( 0, 255 ); + } + + /** + * Generate an IPv6 address. + */ + public function ipv6(): string { + $parts = []; + for ( $i = 0; $i < 8; $i++ ) { + $parts[] = dechex( mt_rand( 0, 65535 ) ); + } + return implode( ':', $parts ); + } + + /** + * Generate a date (Y-m-d). + */ + public function date( string $format = 'Y-m-d' ): string { + return date( $format, mt_rand( 0, time() ) ); + } + + /** + * Generate a time (H:i:s). + */ + public function time( string $format = 'H:i:s' ): string { + return date( $format, mt_rand( 0, time() ) ); + } + + /** + * Generate a datetime (Y-m-d H:i:s). + */ + public function date_time( string $format = 'Y-m-d H:i:s' ): string { + return date( $format, mt_rand( 0, time() ) ); + } + + /** + * Generate an ISO8601 date. + */ + public function iso8601(): string { + return date( 'c', mt_rand( 0, time() ) ); + } + + /** + * Generate a timestamp. + */ + public function timestamp(): int { + return mt_rand( 0, time() ); + } + + /** + * Generate an address. + */ + public function address(): string { + return $this->number_between( 10, 9999 ) . ' ' . $this->street_name() . ', ' . $this->city() . ', ' . $this->country(); + } + + /** + * Generate a city. + */ + public function city(): string { + return $this->random_element( ['New York', 'London', 'Paris', 'Tokyo', 'Dhaka', 'Berlin', 'Madrid', 'Rome', 'Sydney', 'Toronto'] ); + } + + /** + * Generate a street name. + */ + public function street_name(): string { + return $this->word() . ' ' . $this->random_element( ['St', 'Ave', 'Rd', 'Blvd', 'Lane'] ); + } + + /** + * Generate a postcode. + */ + public function postcode(): string { + return (string) mt_rand( 10000, 99999 ); + } + + /** + * Generate a country. + */ + public function country(): string { + return $this->random_element( ['USA', 'UK', 'France', 'Japan', 'Bangladesh', 'Germany', 'Spain', 'Italy', 'Australia', 'Canada'] ); + } + + /** + * Generate latitude. + */ + public function latitude(): float { + return ( mt_rand( -90000000, 90000000 ) / 1000000 ); + } + + /** + * Generate longitude. + */ + public function longitude(): float { + return ( mt_rand( -180000000, 180000000 ) / 1000000 ); + } + + /** + * Generate a number between min and max. + */ + public function number_between( int $min = 0, int $max = PHP_INT_MAX ): int { + return mt_rand( $min, $max ); + } + + /** + * Generate a random digit. + */ + public function random_digit(): int { + return mt_rand( 0, 9 ); + } + + /** + * Generate a random float. + */ + public function random_float( int $nb_decimals = 2, float $min = 0, float $max = 100 ): float { + $factor = pow( 10, $nb_decimals ); + return mt_rand( $min * $factor, $max * $factor ) / $factor; + } + + /** + * Generate a UUID. + */ + public function uuid(): string { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), + mt_rand( 0, 0xffff ), + mt_rand( 0, 0x0fff ) | 0x4000, + mt_rand( 0, 0x3fff ) | 0x8000, + mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ) + ); + } + + /** + * Generate a boolean. + */ + public function boolean( int $chance_of_getting_true = 50 ): bool { + return mt_rand( 1, 100 ) <= $chance_of_getting_true; + } + + /** + * Get a random element from an array. + */ + public function random_element( array $array ) { + if ( empty( $array ) ) { + return null; + } + return $array[array_rand( $array )]; + } +} diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 656bfa8..c9704ad 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -1,82 +1,403 @@ fill( $attributes ); + } + + /** + * Save the model to the database. + * + * @return bool + */ + public function save() { + if ( $this->fire_model_event( 'saving' ) === false ) { + return false; + } + + if ( $this->timestamps ) { + $this->update_timestamps(); + } + + $query = $this->new_query(); + + if ( $this->exists ) { + if ( $this->fire_model_event( 'updating' ) === false ) { + return false; + } + + $dirty = $this->get_dirty(); + + if ( empty( $dirty ) ) { + return true; + } + + $query->where( $this->get_key_name(), $this->get_attribute( $this->get_key_name() ) ) + ->update( $dirty ); + + $this->fire_model_event( 'updated', false ); + } else { + if ( $this->fire_model_event( 'creating' ) === false ) { + return false; + } + + if ( $this->incrementing ) { + $id = $query->insert_get_id( $this->attributes ); + if ( ! $id ) { + return false; + } + $this->set_attribute( $this->get_key_name(), $id ); + } else { + if ( ! $query->insert( $this->attributes ) ) { + return false; + } + } + + $this->exists = true; + + $this->fire_model_event( 'created', false ); + } + + $this->sync_original(); + + $this->fire_model_event( 'saved', false ); + + return true; + } + + /** + * Update the model in the database. + * + * @param array $attributes + * @return bool + */ + public function update( array $attributes = [] ) { + if ( ! $this->exists ) { + return false; + } + + return $this->fill( $attributes )->save(); + } + + /** + * Delete the model from the database. + * + * @return bool + */ + public function delete() { + if ( ! $this->exists ) { + return false; + } + + if ( $this->fire_model_event( 'deleting' ) === false ) { + return false; + } + + $this->new_query() + ->where( $this->get_key_name(), $this->get_attribute( $this->get_key_name() ) ) + ->delete(); + + $this->exists = false; + unset( $this->attributes[$this->get_key_name()] ); + + $this->fire_model_event( 'deleted', false ); + + return true; + } + + /** + * Get a new query builder for the model's table. + * + * @param string|null $as * @return Builder */ public static function query( $as = null ) { - $model = new static; - $builder = new Builder( $model ); + return ( new static )->new_query( $as ); + } + /** + * Save a new model and return the instance. + * + * @param array $attributes + * @return static + */ + public static function create( array $attributes = [] ) { + $model = new static( $attributes ); + + $model->save(); + + return $model; + } + + /** + * Get a new query builder for the model's table. + * + * @param string|null $as + * @return Builder + */ + public function new_query( $as = null ) { + $builder = new Builder( $this ); + $builder->from( static::get_table_name(), $as ); return $builder; } /** - * Define a one-to-many relationship. + * Get the table name associated with the model. + * + * @return string + */ + abstract static function get_table_name(): string; + + /** + * Get the fully qualified table name (with prefix). + * + * @return string + */ + public function get_table_full_name() { + return $this->resolver()->table( static::get_table_name() ); + } + + /** + * Get the resolver instance. * - * @param string $related - * @param string $foreign_key - * @param HasMany + * @return Resolver */ - public function has_many( string $related, string $foreign_key, string $local_key ) { - return new HasMany( $related, $foreign_key, $local_key ); + public function resolver(): Resolver { + if ( empty( $this->_resolver ) ) { + $this->_resolver = new Resolver(); + } + + return $this->_resolver; + } + + /** + * Get the default foreign key name for the model. + * + * @return string + */ + public function get_foreign_key() { + return static::get_foreign_key_from_class( static::class ); + } + + /** + * Create a new Eloquent Collection instance. + * + * @param array $models + * @return Collection + */ + public function new_collection( array $models = [] ) { + return new Collection( $models ); + } + + /** + * Determine if a method name is reserved by the model. + * + * @param string $method + * @return bool + */ + public static function is_reserved_method( string $method ) { + static $reserved = [ + // Core Model methods + 'save', 'update', 'delete', 'fill', 'query', 'new_query', 'all', 'create', 'find', 'find_or_fail', + 'get_table_name', 'resolver', 'new_collection', 'get_foreign_key', 'json_serialize', + + // HasAttributes methods + 'to_array', 'get_attribute', 'set_attribute', 'sync_original', 'get_dirty', 'is_dirty', + 'get_key_name', 'get_qualified_key_name', 'get_attributes', 'serialize_date', + + // HasRelationships methods + 'set_relation', 'get_relation', 'relation_loaded', 'unset_relation', 'get_relations', + 'morph_map', 'get_morph_class', 'has_many', 'has_one', 'belongs_to', 'belongs_to_many', + 'has_one_of_many', 'has_many_through', 'has_one_through', 'morph_to', 'morph_one', + 'morph_many', 'morph_to_many', 'morphed_by_many', 'get_foreign_key_from_class', + + 'fire_model_event', 'observe', 'register_observer', 'update_timestamps', + 'is_fillable', 'totally_guarded', 'get_fillable', 'fillable', 'get_guarded', 'guarded', + 'make_hidden', 'make_visible' + ]; + + return in_array( strtolower( $method ), $reserved ); + } + + /** + * Dynamically retrieve attributes on the model. + * + * @param string $key + * @return mixed + */ + public function __get( $key ) { + return $this->get_attribute( $key ); + } + + /** + * Dynamically set attributes on the model. + * + * @param string $key + * @param mixed $value + * @return void + */ + public function __set( $key, $value ) { + if ( $this->relation_loaded( $key ) || ( method_exists( $this, $key ) && ! static::is_reserved_method( $key ) ) ) { + $this->set_relation( $key, $value ); + return; + } + + $this->set_attribute( $key, $value ); + } + + /** + * Determine if an attribute exists on the model. + * + * @param string $key + * @return bool + */ + public function __isset( $key ) { + return isset( $this->attributes[$key] ) || $this->relation_loaded( $key ); + } + + /** + * Dynamically unset attributes on the model. + * + * @param string $key + * @return void + */ + public function __unset( $key ) { + unset( $this->attributes[$key] ); + + if ( method_exists( $this, 'unset_relation' ) ) { + $this->unset_relation( $key ); + } } /** - * Define a one-to-many relationship. + * Handle dynamic method calls into the model. * - * @param string $related - * @param string $foreign_key - * @param string $local_key - * @return HasOne + * @param string $method + * @param array $parameters + * @return mixed */ - public function has_one( $related, $foreign_key, $local_key ) { - return new HasOne( $related, $foreign_key, $local_key ); + public function __call( $method, $parameters ) { + return $this->new_query()->$method( ...$parameters ); } /** - * Define an inverse one-to-one relationship. + * Handle dynamic static method calls into the model. * - * @param string $related - * @param string $foreign_key - * @param string $local_key - * @return BelongsToOne + * @param string $method + * @param array $parameters + * @return mixed */ - public function belongs_to_one( $related, $foreign_key, $local_key ) { - return new BelongsToOne( $related, $foreign_key, $local_key ); + public static function __callStatic( $method, $parameters ) { + return ( new static )->$method( ...$parameters ); } /** - * Define an inverse many-to-many relationship. + * Convert the object into something JSON serializable. * - * @param string $related - * @param string $pivot - * @param string $foreign_pivot_key - * @param string $local_pivot_key - * @param string $foreign_key - * @param string $local_key - * @return BelongsToMany + * @return array */ - public function belongs_to_many( $related, $pivot, $foreign_pivot_key, $local_pivot_key, $foreign_key, $local_key ) { - return new BelongsToMany( $related, $pivot, $foreign_pivot_key, $local_pivot_key, $foreign_key, $local_key ); + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->to_array(); } } \ No newline at end of file diff --git a/src/Eloquent/QueriesRelationships.php b/src/Eloquent/QueriesRelationships.php new file mode 100644 index 0000000..a2b084d --- /dev/null +++ b/src/Eloquent/QueriesRelationships.php @@ -0,0 +1,467 @@ + $callback]; + } + + foreach ( $relations as $relation => $callback ) { + if ( is_int( $relation ) ) { + $relation = $callback; + } + + $current = &$this->relations; + + // Traverse the items string and create nested arrays + $items = explode( '.', $relation ); + + /** @var Model $model */ + $model = $this->model; + + foreach ( $items as $key ) { + if ( ! $model ) { + break; + } + + if ( ! method_exists( $model, $key ) ) { + throw new \RuntimeException( sprintf( 'Call to undefined relationship [%s] on model [%s].', $key, get_class( $model ) ) ); + } + + /** @var Relation $relationship */ + $relationship = $model->{$key}(); + + if ( ! $relationship instanceof Relation ) { + throw new \RuntimeException( sprintf( 'Relationship [%s] on model [%s] must return a Relation instance.', $key, get_class( $model ) ) ); + } + + $model = $relationship->get_related(); + + if ( ! isset( $current[$key] ) ) { + $query = clone $relationship; + $current[$key] = [ + 'query' => $query, + 'children' => [] + ]; + } else { + $query = $current[$key]['query']; + } + $current = &$current[$key]['children']; + } + + // Apply the callback to the last item + if ( is_callable( $callback ) ) { + call_user_func( $callback, $query ); + } + } + + return $this; + } + + /** + * Add subselect queries for counts of relationships. + * + * @param mixed $relations + * @param callable|null $callback + * @return $this + */ + public function with_count( $relations, $callback = null ) { + return $this->with_aggregate( $relations, 'count', '*', $callback ); + } + + /** + * Add subselect queries for sums of relationship columns. + * + * @param mixed $relation + * @param string $column + * @param callable|null $callback + * @return $this + */ + public function with_sum( $relation, $column, $callback = null ) { + return $this->with_aggregate( $relation, 'sum', $column, $callback ); + } + + /** + * Add subselect queries for averages of relationship columns. + * + * @param mixed $relation + * @param string $column + * @param callable|null $callback + * @return $this + */ + public function with_avg( $relation, $column, $callback = null ) { + return $this->with_aggregate( $relation, 'avg', $column, $callback ); + } + + /** + * Add subselect queries for minimums of relationship columns. + * + * @param mixed $relation + * @param string $column + * @param callable|null $callback + * @return $this + */ + public function with_min( $relation, $column, $callback = null ) { + return $this->with_aggregate( $relation, 'min', $column, $callback ); + } + + /** + * Add subselect queries for maximums of relationship columns. + * + * @param mixed $relation + * @param string $column + * @param callable|null $callback + * @return $this + */ + public function with_max( $relation, $column, $callback = null ) { + return $this->with_aggregate( $relation, 'max', $column, $callback ); + } + + /** + * Add subselect queries for relationship aggregates. + * + * @param mixed $relations + * @param string $function + * @param string $column + * @param callable|null $callback + * @return $this + */ + public function with_aggregate( $relations, $function, $column = '*', $callback = null ) { + if ( is_array( $relations ) ) { + foreach ( $relations as $key => $value ) { + if ( is_numeric( $key ) ) { + $this->with_aggregate( $value, $function, $column ); + } else { + $this->with_aggregate( $key, $function, $column, $value ); + } + } + return $this; + } + + if ( empty( $this->columns ) ) { + $this->select( ["{$this->as}.*"] ); + } + + $relation_keys = explode( ' as ', $relations ); + $name = $relation_keys[0]; + + if ( ! method_exists( $this->model, $name ) ) { + throw new \RuntimeException( sprintf( 'Call to undefined relationship [%s] on model [%s].', $name, get_class( $this->model ) ) ); + } + + /** @var Relation $relationship */ + $relationship = $this->model->{$name}(); + + if ( ! $relationship instanceof Relation ) { + throw new RuntimeException( sprintf( 'Relationship [%s] on model [%s] must return a Relation instance.', $name, get_class( $this->model ) ) ); + } + + $wrapped_column = $column === '*' ? '*' : $this->get_grammar()->wrap( $relationship->get_related()::get_table_name() . '.' . $column ); + $query = $relationship->get_relation_existence_query( $this, ["{$function}({$wrapped_column})"] ); + + if ( is_callable( $callback ) ) { + call_user_func( $callback, $query ); + } + + $total_key = isset( $relation_keys[1] ) ? $relation_keys[1] : $name . '_' . $function . ( $column === '*' ? '' : '_' . $column ); + + $this->columns[$total_key] = $query; + + return $this; + } + + protected function process_relationships( $parent_items, array $relations, Model $model ) { + if ( empty( $relations ) || empty( $parent_items ) || ! is_array( $parent_items ) ) { + return $parent_items; + } + + foreach ( $relations as $key => $relation_data ) { + // 1. Get the Relationship instance + /** @var Relation $relationship */ + $relationship = isset( $relation_data['query'] ) ? $relation_data['query'] : $model->$key(); + + // 2. Initialize the relationship on all parent items (sets default null/empty array) + $parent_items = $relationship->init_relation( $parent_items, $key ); + + // 3. Handle MorphTo separately as it involves multiple tables + if ( $relationship instanceof MorphTo ) { + $results = $this->process_morph_to( $parent_items, $relationship, $relation_data ); + } else { + // 4. Set eager constraints on the relationship's query + $relationship->add_eager_constraints( $parent_items ); + + // 5. Execute the relationship query + $results = $relationship->get_eager(); + + // 6. Handle nested eager loading recursively + $all_sub_relations = array_merge( $relationship->get_relations(), $relation_data['children'] ); + if ( ! empty( $all_sub_relations ) ) { + $results_array = $results instanceof \WpMVC\Database\Eloquent\Collection ? $results->all() : (array) $results; + $this->process_relationships( $results_array, $all_sub_relations, $relationship->get_related() ); + } + } + + // 8. Match the results back to their parent models + $results_array = $results instanceof \WpMVC\Database\Eloquent\Collection ? $results->all() : (array) $results; + $parent_items = $relationship->match( $parent_items, $results_array, $key ); + } + + return $parent_items; + } + + /** + * Add a relationship existence condition to the query. + * + * @param string $relation + * @param string $operator + * @param int $count + * @param string $boolean + * @param Closure|null $callback + * @return $this + */ + public function has( $relation, $operator = '>=', $count = 1, $boolean = 'and', $callback = null ) { + if ( is_string( $relation ) ) { + if ( strpos( $relation, '.' ) !== false ) { + return $this->has_nested( $relation, $operator, $count, $boolean, $callback ); + } + + $relationship = $this->model->$relation(); + } else { + $relationship = $relation; + } + + $is_existence = $operator === '>=' && $count === 1; + $query = $relationship->get_relation_existence_query( $this, $is_existence ? ['*'] : ['count(*)'] ); + + if ( $callback ) { + call_user_func( $callback, $query ); + } + + return $this->add_has_where( + $query, $operator, $count, $boolean + ); + } + + /** + * Add a relationship existence condition to the query with a callback. + * + * @param string $relation + * @param Closure|null $callback + * @param string $operator + * @param int $count + * @return $this + */ + public function where_has( $relation, $callback = null, $operator = '>=', $count = 1 ) { + return $this->has( $relation, $operator, $count, 'and', $callback ); + } + + /** + * Add a relationship existence condition to the query with an "or". + * + * @param string $relation + * @param string $operator + * @param int $count + * @return $this + */ + public function or_has( $relation, $operator = '>=', $count = 1 ) { + return $this->has( $relation, $operator, $count, 'or' ); + } + + /** + * Add a relationship existence condition to the query with a callback and "or". + * + * @param string $relation + * @param Closure|null $callback + * @param string $operator + * @param int $count + * @return $this + */ + public function or_where_has( $relation, $callback = null, $operator = '>=', $count = 1 ) { + return $this->has( $relation, $operator, $count, 'or', $callback ); + } + + /** + * Add a relationship absence condition to the query. + * + * @param string $relation + * @param string $boolean + * @param Closure|null $callback + * @return $this + */ + public function doesnt_have( $relation, $boolean = 'and', $callback = null ) { + $relationship = $this->model->$relation(); + + $query = $relationship->get_relation_existence_query( $this, ['*'] ); + + if ( $callback ) { + call_user_func( $callback, $query ); + } + + return $boolean === 'and' + ? $this->where_not_exists( $query ) + : $this->or_where_not_exists( $query ); + } + + /** + * Add a relationship absence condition to the query with a callback. + * + * @param string $relation + * @param Closure|null $callback + * @return $this + */ + public function where_doesnt_have( $relation, $callback = null ) { + return $this->doesnt_have( $relation, 'and', $callback ); + } + + /** + * Add a relationship absence condition to the query with an "or". + * + * @param string $relation + * @return $this + */ + public function or_doesnt_have( $relation ) { + return $this->doesnt_have( $relation, 'or' ); + } + + /** + * Add a relationship absence condition to the query with a callback and "or". + * + * @param string $relation + * @param Closure|null $callback + * @return $this + */ + public function or_where_doesnt_have( $relation, $callback = null ) { + return $this->doesnt_have( $relation, 'or', $callback ); + } + + /** + * Add nested relationship existence conditions to the query. + * + * @param string $relations + * @param string $operator + * @param int $count + * @param string $boolean + * @param \Closure|null $callback + * @return $this + */ + protected function has_nested( $relations, $operator = '>=', $count = 1, $boolean = 'and', $callback = null ) { + $relations = explode( '.', $relations ); + + $closure = function( $q ) use ( &$closure, &$relations, $operator, $count, $callback ) { + if ( count( $relations ) > 1 ) { + $q->where_has( array_shift( $relations ), $closure ); + } else { + $q->has( array_shift( $relations ), $operator, $count, 'and', $callback ); + } + }; + + return $this->has( array_shift( $relations ), '>=', 1, $boolean, $closure ); + } + + /** + * Add the "has" condition where clause to the query. + * + * @param Builder $has_query + * @param string $operator + * @param int $count + * @param string $boolean + * @return $this + */ + protected function add_has_where( Builder $has_query, $operator, $count, $boolean ) { + if ( $operator === '>=' && $count === 1 ) { + return $boolean === 'and' + ? $this->where_exists( $has_query ) + : $this->or_where_exists( $has_query ); + } + + return $boolean === 'and' + ? $this->where( $has_query, $operator, $count ) + : $this->or_where( $has_query, $operator, $count ); + } + + /** + * Processes a polymorphic inverse relationship (MorphTo). + * Collects all unique types from the parent items and fetches related models. + * + * @param array $parent_items + * @param MorphTo $relationship + * @param array $relation_data + * @return array + */ + protected function process_morph_to( array $parent_items, MorphTo $relationship, array $relation_data ) { + $items_by_type = []; + foreach ( $parent_items as $item ) { + $type = $item->{$relationship->type_column}; + if ( empty( $type ) ) { + continue; + } + + // Resolve morph alias to actual class BEFORE checking class_exists + $actual_class = Model::get_actual_class_for_morph( $type ); + if ( ! class_exists( $actual_class ) ) { + continue; + } + + $items_by_type[$type][] = $item->{$relationship->id_column}; + } + + $all_results = []; + + foreach ( $items_by_type as $type => $ids ) { + $actual_class = Model::get_actual_class_for_morph( $type ); + + /** @var Model $model */ + $model = new $actual_class; + $query = clone $relation_data['query']; + $query->set_model( $model )->from( $model::get_table_name() )->where_in( $relationship->local_key, array_unique( $ids ) ); + + global $wpdb; + //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $results = $wpdb->get_results( $query->to_sql() ); + $items = $query->hydrate( is_array( $results ) ? $results : [] ); + + $all_sub_relations = array_merge( $query->get_relations(), $relation_data['children'] ); + $processed = $this->process_relationships( $items, $all_sub_relations, $model ); + + // Tag results with their type for unambiguous mapping + foreach ( $processed as $result ) { + $result->_morph_type = $type; + } + + $all_results = array_merge( $all_results, $processed ); + } + + return $all_results; + } +} \ No newline at end of file diff --git a/src/Eloquent/Relations/BelongsTo.php b/src/Eloquent/Relations/BelongsTo.php new file mode 100644 index 0000000..3e3dfc8 --- /dev/null +++ b/src/Eloquent/Relations/BelongsTo.php @@ -0,0 +1,111 @@ +where( $this->local_key, '=', $this->parent->{$this->foreign_key} ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + $this->where_in( + $this->local_key, $this->get_keys( $models, $this->foreign_key ) + ); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + foreach ( $models as $model ) { + $model->set_relation( $relation, null ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = []; + + foreach ( $results as $result ) { + $dictionary[$result->{$this->local_key}] = $result; + } + + foreach ( $models as $model ) { + if ( isset( $dictionary[$model->{$this->foreign_key}] ) ) { + $model->set_relation( $relation, $dictionary[$model->{$this->foreign_key}] ); + } else { + $model->set_relation( $relation, null ); + } + } + + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $this->add_constraints(); + return $this->first(); + } + + /** + * Get the key for comparing relationship existence. + * + * @return string + */ + public function get_relation_existence_compare_key() { + return $this->related::get_table_name() . '.' . $this->local_key; + } + + /** + * Get the name of the parent key. + * + * @return string + */ + public function get_parent_key_name() { + return $this->foreign_key; + } +} diff --git a/src/Eloquent/Relations/BelongsToMany.php b/src/Eloquent/Relations/BelongsToMany.php index 105f5cc..af9ebdb 100644 --- a/src/Eloquent/Relations/BelongsToMany.php +++ b/src/Eloquent/Relations/BelongsToMany.php @@ -1,31 +1,263 @@ pivot = new $pivot; + protected $performed_join = false; + + /** + * Create a new belongs to many relation instance. + * + * @param Builder $query + * @param Model $parent + * @param string $pivot + * @param string $foreign_pivot_key + * @param string $local_pivot_key + * @param string $foreign_key + * @param string $local_key + */ + public function __construct( Builder $query, Model $parent, $pivot, $foreign_pivot_key, $local_pivot_key, $foreign_key, $local_key ) { + if ( is_string( $pivot ) && class_exists( $pivot ) ) { + $this->pivot = new $pivot; + } else { + $this->pivot = $pivot; + } $this->foreign_pivot_key = $foreign_pivot_key; $this->local_pivot_key = $local_pivot_key; - parent::__construct( $related, $foreign_key, $local_key ); + + parent::__construct( $query, $parent, $foreign_key, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + $this->perform_join(); + + $this->where( $this->get_qualified_foreign_pivot_key(), '=', $this->parent->{$this->local_key} ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + $this->perform_join(); + + $this->where_in( + $this->get_qualified_foreign_pivot_key(), $this->get_keys( $models, $this->local_key ) + ); + } + + /** + * Perform the join for the relationship. + * + * @param Builder|null $query + * @return void + */ + protected function perform_join( $query = null ) { + if ( $this->performed_join ) { + return; + } + + $query = $query ?: $this; + $pivot_table = $this->get_pivot_table(); + + $query->join( $pivot_table, $this->get_qualified_local_pivot_key(), '=', $this->related->get_qualified_key_name() ); + + if ( empty( $query->columns ) ) { + $query->select( [$this->related::get_table_name() . '.*'] ); + } + + // Pivot Aliasing Guardrail + $query->add_select( + [ + "{$pivot_table}.{$this->foreign_pivot_key} as pivot_{$this->foreign_pivot_key}", + "{$pivot_table}.{$this->local_pivot_key} as pivot_{$this->local_pivot_key}" + ] + ); + + $this->performed_join = true; + } + + /** + * Get the name of the pivot table. + * + * @return string + */ + public function get_pivot_table() { + return $this->pivot instanceof Model ? $this->pivot::get_table_name() : $this->pivot; + } + + /** + * Get the fully qualified foreign pivot key name. + * + * @return string + */ + public function get_qualified_foreign_pivot_key() { + return $this->get_pivot_table() . '.' . $this->foreign_pivot_key; + } + + /** + * Get the fully qualified local pivot key name. + * + * @return string + */ + public function get_qualified_local_pivot_key() { + return $this->get_pivot_table() . '.' . $this->local_pivot_key; + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + foreach ( $models as $model ) { + $model->set_relation( $relation, $this->related->new_collection() ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = $this->build_dictionary( $results ); + + foreach ( $models as $model ) { + $key = $model->{$this->local_key}; + + if ( isset( $dictionary[$key] ) ) { + $model->set_relation( $relation, $this->get_results_as_collection( $dictionary[$key] ) ); + } else { + $model->set_relation( $relation, $this->related->new_collection() ); + } + } + + return $models; + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param array $results + * @return array + */ + protected function build_dictionary( array $results ) { + $dictionary = []; + $pivot_key = "pivot_{$this->foreign_pivot_key}"; + + foreach ( $results as $result ) { + $dictionary[$result->{$pivot_key}][] = $result; + } + + return $dictionary; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $this->add_constraints(); + return $this->get(); + } + + /** + * Attach a model instance to the parent model. + * + * @param mixed $id + * @param array $attributes + * @return void + */ + public function attach( $id, array $attributes = [] ) { + $pivot_table = $this->get_pivot_table(); + $parent_id = $this->parent->{$this->local_key}; + + $exists = $this->parent->new_query() + ->from( $pivot_table ) + ->where( $this->foreign_pivot_key, $parent_id ) + ->where( $this->local_pivot_key, $id ) + ->exists(); + + if ( $exists ) { + return; + } + + $this->parent->query()->from( $pivot_table )->insert( + array_merge( + [ + $this->foreign_pivot_key => $parent_id, + $this->local_pivot_key => $id, + ], $attributes + ) + ); + } + + public function get_relation_existence_compare_key() { + return $this->get_qualified_foreign_pivot_key(); + } + + public function get_parent_key_name() { + return $this->local_key; } } \ No newline at end of file diff --git a/src/Eloquent/Relations/BelongsToOne.php b/src/Eloquent/Relations/BelongsToOne.php deleted file mode 100644 index 3feaf03..0000000 --- a/src/Eloquent/Relations/BelongsToOne.php +++ /dev/null @@ -1,36 +0,0 @@ -prepare_value_and_operator( $value, $operator, func_num_args() === 2 ); - - $this->wheres[] = [ - 'column' => $parent_column, - 'value' => $value, - 'operator' => $operator, - 'boolean' => $boolean, - ]; - - return $this; - } - - public function relation_or_where( string $parent_column, $operator = null, $value = null, $boolean = 'and' ) { - return $this->relation_where( $parent_column, $operator, $value, 'or' ); - } - - protected function prepare_value_and_operator( $value, $operator, $use_default = false ) { - if ( $use_default ) { - return [$operator, '=']; - } - // elseif ($this->invalid_operatorAndValue($operator, $value)) { - // throw new InvalidArgumentException('Illegal operator and value combination.'); - // } - return [$value, $operator]; - } -} \ No newline at end of file diff --git a/src/Eloquent/Relations/HasMany.php b/src/Eloquent/Relations/HasMany.php index ca87cc7..bf4a20a 100644 --- a/src/Eloquent/Relations/HasMany.php +++ b/src/Eloquent/Relations/HasMany.php @@ -1,7 +1,32 @@ related->new_collection(); + } +} \ No newline at end of file diff --git a/src/Eloquent/Relations/HasManyThrough.php b/src/Eloquent/Relations/HasManyThrough.php new file mode 100644 index 0000000..21027eb --- /dev/null +++ b/src/Eloquent/Relations/HasManyThrough.php @@ -0,0 +1,274 @@ +through = $through; + $this->first_key = $first_key; + $this->second_key = $second_key; + $this->second_local_key = $second_local_key; + + parent::__construct( $query, $parent, $second_key, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + $this->perform_join(); + + $this->where( $this->get_qualified_first_key(), '=', $this->parent->{$this->local_key} ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + $this->perform_join(); + + $this->where_in( + $this->get_qualified_first_key(), $this->get_keys( $models, $this->local_key ) + ); + } + + /** + * Perform the join for the relationship. + * + * @param Builder|null $query + * @return void + */ + protected function perform_join( $query = null ) { + if ( $this->performed_join ) { + return; + } + + $query = $query ?: $this; + $through_table = $this->through::get_table_name(); + + $query->join( $through_table, $this->get_qualified_second_key_on_through(), '=', $this->get_qualified_second_local_key() ); + + if ( $this->second_key_type_column && $this->second_key_type_value ) { + $query->where( $this->related->get_table_name() . '.' . $this->second_key_type_column, '=', $this->second_key_type_value ); + } + + if ( empty( $query->columns ) ) { + $query->select( [$this->related::get_table_name() . '.*'] ); + } + + // Select through key for matching + $query->add_select( ["{$through_table}.{$this->first_key} as through_{$this->first_key}"] ); + + $this->performed_join = true; + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + foreach ( $models as $model ) { + $model->set_relation( $relation, $this->related->new_collection() ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = $this->build_dictionary( $results ); + + foreach ( $models as $model ) { + $key = $model->{$this->local_key}; + + if ( isset( $dictionary[$key] ) ) { + $model->set_relation( $relation, $this->get_results_as_collection( $dictionary[$key] ) ); + } else { + $model->set_relation( $relation, $this->related->new_collection() ); + } + } + + return $models; + } + + /** + * Build model dictionary keyed by the through model's first foreign key. + * + * @param array $results + * @return array + */ + protected function build_dictionary( array $results ) { + $dictionary = []; + $through_key = "through_{$this->first_key}"; + + foreach ( $results as $result ) { + $dictionary[$result->{$through_key}][] = $result; + } + + return $dictionary; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $this->add_constraints(); + return $this->get(); + } + + /** + * Add a morph type constraint for the distant (final) table. + * Use this when the second_key references a polymorphic relationship. + * + * @param string $type_column The type column on the distant table (e.g., 'commentable_type') + * @param string $type_value The expected morph type value (e.g., 'post') + * @return $this + */ + public function where_morph_type( string $type_column, string $type_value ) { + $this->second_key_type_column = $type_column; + $this->second_key_type_value = $type_value; + return $this; + } + + /** + * Get the fully qualified first foreign key. + * + * @return string + */ + protected function get_qualified_first_key() { + return $this->through::get_table_name() . '.' . $this->first_key; + } + + /** + * Get the fully qualified second foreign key on the through model. + * + * @return string + */ + protected function get_qualified_second_key_on_through() { + return $this->through::get_table_name() . '.' . $this->second_local_key; + } + + /** + * Get the fully qualified second local key. + * + * @return string + */ + protected function get_qualified_second_local_key() { + return $this->related::get_table_name() . '.' . $this->foreign_key; + } + + /** + * Get the key for comparing relationship existence. + * + * @return string + */ + public function get_relation_existence_compare_key() { + return $this->get_qualified_first_key(); + } + + /** + * Get the name of the parent key. + * + * @return string + */ + public function get_parent_key_name() { + return $this->local_key; + } +} diff --git a/src/Eloquent/Relations/HasOne.php b/src/Eloquent/Relations/HasOne.php index cc1bef5..9c50a68 100644 --- a/src/Eloquent/Relations/HasOne.php +++ b/src/Eloquent/Relations/HasOne.php @@ -1,7 +1,64 @@ build_dictionary( $results ); + + foreach ( $models as $model ) { + $key = $model->{$this->local_key}; + + if ( isset( $dictionary[$key] ) ) { + $model->set_relation( $relation, reset( $dictionary[$key] ) ); + } else { + $model->set_relation( $relation, null ); + } + } + + return $models; + } + + /** + * Get the default value for the relationship. + * + * @return null + */ + protected function get_default_value() { + return null; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $this->add_constraints(); + return $this->first(); + } +} diff --git a/src/Eloquent/Relations/HasOneOfMany.php b/src/Eloquent/Relations/HasOneOfMany.php new file mode 100644 index 0000000..abc0ed9 --- /dev/null +++ b/src/Eloquent/Relations/HasOneOfMany.php @@ -0,0 +1,78 @@ +sort_column = $sort_column; + $this->sort_direction = $sort_direction; + + parent::__construct( $query, $parent, $foreign_key, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + parent::add_constraints(); + + $this->order_by( $this->sort_column, $this->sort_direction ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + parent::add_eager_constraints( $models ); + + $this->order_by( $this->sort_column, $this->sort_direction ); + } +} diff --git a/src/Eloquent/Relations/HasOneOrMany.php b/src/Eloquent/Relations/HasOneOrMany.php new file mode 100644 index 0000000..83681b1 --- /dev/null +++ b/src/Eloquent/Relations/HasOneOrMany.php @@ -0,0 +1,159 @@ +where( $this->foreign_key, '=', $this->parent->{$this->local_key} ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + $this->where_in( + $this->foreign_key, $this->get_keys( $models, $this->local_key ) + ); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + foreach ( $models as $model ) { + $model->set_relation( $relation, $this->get_default_value() ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = $this->build_dictionary( $results ); + + foreach ( $models as $model ) { + $key = $model->{$this->local_key}; + + if ( isset( $dictionary[$key] ) ) { + $model->set_relation( $relation, $this->get_results_as_collection( $dictionary[$key] ) ); + } else { + $model->set_relation( $relation, $this->get_default_value() ); + } + } + + return $models; + } + + /** + * Get the default value for the relationship. + * + * @return mixed + */ + abstract protected function get_default_value(); + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $this->add_constraints(); + return $this->get(); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param array $results + * @return array + */ + protected function build_dictionary( array $results ) { + $dictionary = []; + + foreach ( $results as $result ) { + $dictionary[$result->{$this->foreign_key}][] = $result; + } + + return $dictionary; + } + + /** + * Build a new instance of the related model with the FK set, without saving. + * + * @param array $attributes + * @return Model + */ + public function make( array $attributes = [] ) { + $class = get_class( $this->related ); + $instance = new $class( $attributes ); + + $instance->set_attribute( $this->foreign_key, $this->parent->{$this->local_key} ); + + return $instance; + } + + /** + * Create and persist a new instance of the related model. + * + * @param array $attributes + * @return Model|false Returns the saved model, or false if save was cancelled by an event. + * @throws RuntimeException If the parent model has not been saved yet. + */ + public function create( array $attributes = [] ) { + if ( ! $this->parent->exists ) { + throw new RuntimeException( + sprintf( + 'Cannot create a related [%s]: the parent [%s] has not been saved yet.', + get_class( $this->related ), + get_class( $this->parent ) + ) + ); + } + + $instance = $this->make( $attributes ); + + if ( ! $instance->save() ) { + return false; + } + + return $instance; + } +} diff --git a/src/Eloquent/Relations/HasOneThrough.php b/src/Eloquent/Relations/HasOneThrough.php new file mode 100644 index 0000000..90154b5 --- /dev/null +++ b/src/Eloquent/Relations/HasOneThrough.php @@ -0,0 +1,69 @@ +set_relation( $relation, null ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = $this->build_dictionary( $results ); + + foreach ( $models as $model ) { + $key = $model->{$this->local_key}; + + if ( isset( $dictionary[$key] ) ) { + $model->set_relation( $relation, reset( $dictionary[$key] ) ); + } else { + $model->set_relation( $relation, null ); + } + } + + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + return $this->first(); + } +} diff --git a/src/Eloquent/Relations/MorphMany.php b/src/Eloquent/Relations/MorphMany.php new file mode 100644 index 0000000..3618fd6 --- /dev/null +++ b/src/Eloquent/Relations/MorphMany.php @@ -0,0 +1,90 @@ +morph_name = $morph_name; + $this->type_column = $type_column; + $this->id_column = $id_column; + + parent::__construct( $query, $parent, $id_column, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + parent::add_constraints(); + + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + parent::add_eager_constraints( $models ); + + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } + + protected function add_existence_constraints() { + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } +} diff --git a/src/Eloquent/Relations/MorphOne.php b/src/Eloquent/Relations/MorphOne.php new file mode 100644 index 0000000..e1ecf08 --- /dev/null +++ b/src/Eloquent/Relations/MorphOne.php @@ -0,0 +1,90 @@ +morph_name = $morph_name; + $this->type_column = $type_column; + $this->id_column = $id_column; + + parent::__construct( $query, $parent, $id_column, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + parent::add_constraints(); + + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + parent::add_eager_constraints( $models ); + + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } + + protected function add_existence_constraints() { + $this->where( $this->type_column, '=', $this->parent->get_morph_class() ); + } +} diff --git a/src/Eloquent/Relations/MorphTo.php b/src/Eloquent/Relations/MorphTo.php new file mode 100644 index 0000000..d0b8dc5 --- /dev/null +++ b/src/Eloquent/Relations/MorphTo.php @@ -0,0 +1,156 @@ +morph_name = $morph_name; + $this->type_column = $type_column; + $this->id_column = $id_column; + + parent::__construct( $query, $parent, $id_column, $local_key ); + } + + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() { + // MorphTo constraints are dynamic and depend on the parent's current type. + if ( $this->parent->{$this->type_column} ) { + $this->where( $this->local_key, '=', $this->parent->{$this->id_column} ); + } + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) { + // Eager loading MorphTo is handled via specialized logic in the orchestrator + // because it requires querying multiple different tables. + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + foreach ( $models as $model ) { + $model->set_relation( $relation, null ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + $dictionary = []; + + foreach ( $results as $result ) { + $type = $result->_morph_type ?? null; + if ( $type ) { + $dictionary[$type][$result->{$this->local_key}] = $result; + } + } + + foreach ( $models as $model ) { + $type = $model->{$this->type_column}; + $id = $model->{$this->id_column}; + + if ( isset( $dictionary[$type][$id] ) ) { + $model->set_relation( $relation, $dictionary[$type][$id] ); + } else { + $model->set_relation( $relation, null ); + } + } + + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function get_results() { + $type = $this->parent->{$this->type_column}; + + if ( ! $type ) { + return null; + } + + $actual_class = Model::get_actual_class_for_morph( $type ); + + if ( ! class_exists( $actual_class ) ) { + return null; + } + + $model = new $actual_class; + + return $model->new_query()->where( $this->local_key, '=', $this->parent->{$this->id_column} )->first(); + } +} diff --git a/src/Eloquent/Relations/MorphToMany.php b/src/Eloquent/Relations/MorphToMany.php new file mode 100644 index 0000000..1478001 --- /dev/null +++ b/src/Eloquent/Relations/MorphToMany.php @@ -0,0 +1,114 @@ +morph_name = $morph_name; + $this->morph_type = $morph_type; + $this->inverse = $inverse; + $this->type_column = $morph_name . '_type'; + + parent::__construct( $query, $parent, $pivot_table, $foreign_pivot_key, $local_pivot_key, $foreign_key, $local_key ); + } + + /** + * Perform the join for the relationship. + * + * @param Builder|null $query + * @return void + */ + protected function perform_join( $query = null ) { + parent::perform_join( $query ); + + $query = $query ?: $this; + + $pivot_table = $this->get_pivot_table(); + + $query->where( "{$pivot_table}.{$this->type_column}", '=', $this->morph_type ); + } + + /** + * Get the name of the pivot table. + * + * @return string + */ + public function get_pivot_table() { + // Since MorphToMany often uses a table name string instead of a model class for the pivot + return $this->pivot instanceof Model ? $this->pivot::get_table_name() : $this->pivot; + } + + /** + * Attach a model instance to the parent model. + * + * @param mixed $id + * @param array $attributes + * @return void + */ + public function attach( $id, array $attributes = [] ) { + $attributes[$this->type_column] = $this->morph_type; + + parent::attach( $id, $attributes ); + } +} diff --git a/src/Eloquent/Relations/Relation.php b/src/Eloquent/Relations/Relation.php index e8209c1..cb2023d 100644 --- a/src/Eloquent/Relations/Relation.php +++ b/src/Eloquent/Relations/Relation.php @@ -1,25 +1,283 @@ related = new $related; + /** + * Create a new relation instance. + * + * @param Builder $query + * @param Model $parent + * @param string $foreign_key + * @param string $local_key + */ + public function __construct( Builder $query, Model $parent, $foreign_key, $local_key ) { + parent::__construct( $query->model ); + + // Inherit state from the existing query + $this->from = $query->from; + $this->as = $query->as; + $this->columns = $query->columns; + $this->joins = $query->joins; + $this->orders = $query->orders; + $this->limit = $query->limit; + $this->offset = $query->offset; + $this->distinct = $query->distinct; + $this->set_bindings( $query->get_bindings() ); + + // Copy clauses from the query traits + $this->clauses = $query->get_clauses(); + + $this->parent = $parent; + $this->related = $query->model; $this->foreign_key = $foreign_key; $this->local_key = $local_key; } - public function get_related() { + /** + * Set the constraints for an individual relationship query. + * + * @return void + */ + protected function add_constraints() {} + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) {} + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + abstract public function get_results(); + + /** + * Get the results of the relationship for eager loading. + * + * @return Collection + */ + public function get_eager() { + return $this->get(); + } + + /** + * Wrap the results in a collection if necessary. + * + * @param mixed $results + * @return array|Collection + */ + protected function get_results_as_collection( $results ) { + if ( ! $this->is_many() ) { + return $results; + } + + if ( $results instanceof Collection ) { + return $results; + } + + return $this->related->new_collection( (array) $results ); + } + + /** + * Determine if the relationship is a "many" relationship. + * + * @return bool + */ + protected function is_many() { + return $this instanceof HasMany || + $this instanceof BelongsToMany || + $this instanceof MorphMany || + $this instanceof MorphToMany || + $this instanceof HasManyThrough; + } + + /** + * Get the underlying query for the relation. + * + * @return Builder + */ + protected function get_query() { + return $this; + } + + /** + * Get the related model of the relation. + * + * @return Model + */ + protected function get_related() { return $this->related; } + + /** + * Get the parent model of the relation. + * + * @return Model + */ + protected function get_parent() { + return $this->parent; + } + + /** + * Get the fully qualified foreign key name. + * + * @return string + */ + public function get_qualified_foreign_key() { + return $this->related::get_table_name() . '.' . $this->foreign_key; + } + + /** + * Get the fully qualified parent key name. + * + * @return string + */ + public function get_qualified_parent_key_name() { + return $this->parent::get_table_name() . '.' . $this->local_key; + } + + /** + * Get the key for comparing relationship existence. + * + * @return string + */ + public function get_relation_existence_compare_key() { + return $this->get_qualified_foreign_key(); + } + + /** + * Get the name of the parent key. + * + * @return string + */ + public function get_parent_key_name() { + return $this->local_key; + } + + /** + * Get the existence query for a with_count aggregate. + * + * @param Builder $parent_query + * @return Builder + */ + public function get_relation_existence_query( Builder $parent_query, $columns = ['count(*)'] ) { + $query = clone $this; + + // Perform join for complex relations if not already done + if ( method_exists( $query, 'perform_join' ) ) { + $query->perform_join(); + } + + // Add basic existence constraints (polymorphic types, etc.) + $query->add_existence_constraints(); + + return $query->select( $columns ) + ->where_column( + $this->get_relation_existence_compare_key(), + '=', + "{$parent_query->as}.{$this->get_parent_key_name()}" + ); + } + + /** + * Add the constraints for a relationship existence query. + * + * @return void + */ + protected function add_existence_constraints() {} + + /** + * Get all of the primary keys for an array of models. + * + * @param array $models + * @param string $key + * @return array + */ + protected function get_keys( array $models, $key ) { + return array_unique( + array_values( + array_map( + function ( $model ) use ( $key ) { + return is_object( $model ) ? $model->{$key} : $model[$key]; + }, $models + ) + ) + ); + } } diff --git a/src/Eloquent/Relationship.php b/src/Eloquent/Relationship.php deleted file mode 100644 index 0aac988..0000000 --- a/src/Eloquent/Relationship.php +++ /dev/null @@ -1,186 +0,0 @@ - $relation ) { - /** - * @var Relation $relationship - */ - $relationship = $model->$key(); - - /** - * @var Model $related - */ - $related = $relationship->get_related(); - - /** - * @var Builder $query - */ - $query = $relation['query']; - - $table_name = $related::get_table_name(); - - $query->from( $table_name ); - - $local_key = $relationship->local_key; - $foreign_key = $relationship->foreign_key; - - if ( $relationship instanceof BelongsToOne ) { - if ( empty( $relationship->wheres ) ) { - $relations[$key]['relation_status'] = false; - $local_ids = array_unique( array_column( $parent_items, $local_key ) ); - } else { - $relation_ids = $this->get_where_in_ids( $parent_items, $local_key, $relationship->wheres ); - $local_ids = $relation_ids['column_ids']; - $relations[$key]['relation_status'] = true; - $relations[$key]['relation_ids'] = $relation_ids['ids']; - } - } else { - $local_ids = array_unique( array_column( $parent_items, $local_key ) ); - } - - if ( empty( $local_ids ) ) { - continue; - } - - if ( $relationship instanceof BelongsToMany ) { - $pivot_table_name = $relationship->pivot::get_table_name(); - $foreign_pivot_key = $relationship->foreign_pivot_key; - $local_pivot_key = $relationship->local_pivot_key; - - $query->select( "{$table_name}.*", "{$pivot_table_name}.{$local_pivot_key} as pivot_{$local_pivot_key}" ) - ->join( $pivot_table_name, "{$pivot_table_name}.{$foreign_pivot_key}", "{$table_name}.{$foreign_key}" ) - ->where_in( "{$pivot_table_name}.{$local_pivot_key}", $local_ids ); - } else { - $query->where_in( $query->as . '.' . $foreign_key, $local_ids ); - } - - global $wpdb; - - /** - * @var wpdb $wpdb - */ - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - $results = $wpdb->get_results( $query->to_sql() ); - - $relations[$key]['relationship'] = $relationship; - $relations[$key]['items'] = $this->process_relationships( $results, $relation['children'], $related ); - } - - return $this->push_related_items( $parent_items, $relations ); - } - - protected function push_related_items( array $parent_items, array $relations ) { - foreach ( $parent_items as $parent_key => $item ) { - - foreach ( $relations as $key => $relation ) { - - if ( empty( $relation['relationship'] ) ) { - continue; - } - - /** - * @var Relation $relationship - */ - $relationship = $relation['relationship']; - - if ( $relationship instanceof BelongsToOne && true === $relation['relation_status'] && ! in_array( $item->id, $relation['relation_ids'] ) ) { - continue; - } - - $local_value = $item->{$relationship->local_key}; - - if ( $relationship instanceof BelongsToMany ) { - $foreign_key = "pivot_{$relationship->local_pivot_key}"; - } else { - $foreign_key = $relationship->foreign_key; - } - - $children_items = array_values( - array_filter( - $relation['items'], function( $single_item ) use ( $local_value, $foreign_key ) { - return $single_item->{$foreign_key} == $local_value; - } - ) - ); - - if ( $relationship instanceof HasOne || $relationship instanceof BelongsToOne ) { - $children_items = isset( $children_items[0] ) ? $children_items[0] : null; - } - - $parent_items[$parent_key]->$key = $children_items; - } - } - return $parent_items; - } - - public function get_where_in_ids( array $items, string $column_key, array $wheres ) { - $ids = []; - $column_ids = []; - - foreach ( $items as $item ) { - $matches = true; - foreach ( $wheres as $index => $condition ) { - $column = $condition['column']; - $value = $condition['value']; - $operator = $condition['operator']; - $boolean = $condition['boolean']; - - if ( $index === 0 ) { - $matches = $this->evaluate_condition( $item->$column, $operator, $value ); - } else { - if ( $boolean === 'and' && ! $matches ) { - $matches = false; - break; - } elseif ( $boolean === 'or' && $matches ) { - $matches = true; - break; - } - - $current_matches = $this->evaluate_condition( $item->$column, $operator, $value ); - - if ( $boolean === 'and' ) { - $matches = $matches && $current_matches; - } elseif ( $boolean === 'or' ) { - $matches = $matches || $current_matches; - } - } - } - - if ( $matches ) { - $ids[] = $item->id; - $column_ids[] = $item->$column_key; - } - } - - return compact( 'ids', 'column_ids' ); - } - - protected function evaluate_condition( $left_operand, $operator, $right_operand ) { - switch ( $operator ) { - case '=': - return $left_operand == $right_operand; - case '!=': - return $left_operand != $right_operand; - // Add more cases for other operators as needed - default: - // Unsupported operator, handle error or continue as desired - return false; - } - } -} \ No newline at end of file diff --git a/src/Eloquent/Sequence.php b/src/Eloquent/Sequence.php new file mode 100644 index 0000000..1032cd8 --- /dev/null +++ b/src/Eloquent/Sequence.php @@ -0,0 +1,73 @@ +sequence = ( count( $sequence ) === 1 && is_array( $sequence[0] ) ) + ? $sequence[0] + : $sequence; + } + + /** + * Get the next value in the sequence. + * + * @param int $count + * @return mixed + */ + public function next( int $count ) { + $value = $this->sequence[$this->index % count( $this->sequence )]; + + $this->index++; + + if ( is_callable( $value ) ) { + return $value( $count ); + } + + return $value; + } + + /** + * Get the number of items in the sequence. + * + * @return int + */ + public function count(): int { + return count( $this->sequence ); + } +} diff --git a/src/Eloquent/UniqueProxy.php b/src/Eloquent/UniqueProxy.php new file mode 100644 index 0000000..ae67fae --- /dev/null +++ b/src/Eloquent/UniqueProxy.php @@ -0,0 +1,82 @@ +faker = $faker; + } + + /** + * Reset the generated values. + * + * @return void + */ + public function reset() { + $this->values = []; + } + + /** + * Proxy magic method calls to FakeData. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws RuntimeException + */ + public function __call( $method, $parameters ) { + for ( $i = 0; $i < $this->max_attempts; $i++ ) { + $value = $this->faker->{$method}( ...$parameters ); + + if ( ! isset( $this->values[$method] ) || ! in_array( $value, $this->values[$method], true ) ) { + $this->values[$method][] = $value; + return $value; + } + } + + throw new RuntimeException( "Maximum attempts ({$this->max_attempts}) reached to generate a unique value for [{$method}]." ); + } +} diff --git a/src/Pagination/LengthAwarePaginator.php b/src/Pagination/LengthAwarePaginator.php new file mode 100644 index 0000000..cbb69e7 --- /dev/null +++ b/src/Pagination/LengthAwarePaginator.php @@ -0,0 +1,206 @@ +items = $items instanceof Collection ? $items : new Collection( $items ); + $this->total = (int) $total; + $this->per_page = (int) $per_page; + $this->current_page = $current_page ?: 1; + } + + /** + * Get the total number of items before pagination. + * + * @return int + */ + public function total() { + return $this->total; + } + + /** + * Get the number of items shown per page. + * + * @return int + */ + public function per_page() { + return $this->per_page; + } + + /** + * Get the current page. + * + * @return int + */ + public function current_page() { + return $this->current_page; + } + + /** + * Get the last page number. + * + * @return int + */ + public function last_page() { + return max( (int) ceil( $this->total / $this->per_page ), 1 ); + } + + /** + * Get the items for the current page. + * + * @return Collection + */ + public function items() { + return $this->items; + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function to_array() { + return [ + 'current_page' => $this->current_page(), + 'data' => $this->items->to_array(), + 'last_page' => $this->last_page(), + 'per_page' => $this->per_page(), + 'total' => $this->total(), + ]; + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { + return $this->to_array(); + } + + /** + * Count the number of items. + * + * @return int + */ + #[\ReturnTypeWillChange] + public function count() { + return $this->items->count(); + } + + /** + * Get an iterator for the items. + * + * @return ArrayIterator + */ + #[\ReturnTypeWillChange] + public function getIterator() { + return $this->items->getIterator(); + } + + /** + * Determine if the given item exists. + * + * @param mixed $key + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists( $key ) { + return $this->items->offsetExists( $key ); + } + + /** + * Get the item at the given offset. + * + * @param mixed $key + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet( $key ) { + return $this->items->offsetGet( $key ); + } + + /** + * Set the item at the given offset. + * + * @param mixed $key + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet( $key, $value ) { + $this->items->offsetSet( $key, $value ); + } + + /** + * Unset the item at the given key. + * + * @param mixed $key + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset( $key ) { + $this->items->offsetUnset( $key ); + } +} diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b900130..2b322cc 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1,4 +1,11 @@ [], + 'from' => [], + 'join' => [], + 'where' => [], + 'groupBy' => [], + 'having' => [], + 'order' => [], + 'union' => [], + 'limit' => [], + 'offset' => [], + ]; /** * The model being queried. * - * @param \WpMVC\Database\Eloquent\Model + * @var \WpMVC\Database\Eloquent\Model|null */ public $model; /** + * The table which the query is targeting. * * @var string */ public $from; /** + * The table alias for the query. * * @var string */ @@ -56,7 +105,7 @@ class Builder extends Relationship { /** * An aggregate function and column to be run. * - * @var array + * @var array|null */ public $aggregate; @@ -68,9 +117,21 @@ class Builder extends Relationship { public $columns = []; /** - * Indicates if the query returns distinct results. + * The query unions. * - * Occasionally contains the columns that should be distinct. + * @var array + */ + public $unions = []; + + /** + * Indicates if the query unions are "all". + * + * @var bool + */ + public $union_all = false; + + /** + * Indicates if the query returns distinct results. * * @var bool|array */ @@ -100,7 +161,7 @@ class Builder extends Relationship { /** * The number of records to skip. * - * @var int + * @var int|null */ public $offset; @@ -111,6 +172,11 @@ class Builder extends Relationship { */ protected $relations = []; + /** + * The relationships that should be counted. + * + * @var array + */ public $count_relations = []; /** @@ -127,20 +193,232 @@ class Builder extends Relationship { 'not similar to', 'not ilike', '~~*', '!~~*', ]; - public function __construct( Model $model ) { + public function __construct( ?Model $model = null ) { + $this->model = $model; + } + + /** + * Get a new instance of the query builder. + * + * @return $this + */ + public function new_query() { + return new static( $this->model ); + } + + /** + * Clone the query builder instance. + * + * @return void + */ + public function __clone() { + if ( is_array( $this->joins ) ) { + $this->joins = array_map( + function ( $join ) { + return clone $join; + }, $this->joins + ); + } + + if ( is_array( $this->relations ) ) { + $this->relations = $this->clone_relations( $this->relations ); + } + + if ( is_array( $this->bindings ) ) { + foreach ( $this->bindings as $key => $values ) { + $this->bindings[$key] = array_map( + function ( $binding ) { + return is_object( $binding ) ? clone $binding : $binding; + }, $values + ); + } + } + + if ( is_array( $this->clauses ) ) { + $this->clauses = $this->clone_clauses( $this->clauses ); + } + } + + /** + * Deep clone the clauses array. + * + * @param array $clauses + * @return array + */ + protected function clone_clauses( array $clauses ) { + $cloned = []; + foreach ( $clauses as $key => $value ) { + if ( is_array( $value ) ) { + $cloned[$key] = $this->clone_clauses( $value ); + } elseif ( $key === 'query' && is_object( $value ) ) { + $cloned[$key] = clone $value; + } else { + $cloned[$key] = $value; + } + } + return $cloned; + } + + /** + * Deep clone the relations array. + * + * @param array $relations + * @return array + */ + protected function clone_relations( array $relations ) { + foreach ( $relations as $key => $data ) { + $relations[$key]['query'] = clone $data['query']; + + if ( ! empty( $data['children'] ) ) { + $relations[$key]['children'] = $this->clone_relations( $data['children'] ); + } + } + + return $relations; + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + protected function add_eager_constraints( array $models ) {} + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + protected function init_relation( array $models, string $relation ) { + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + protected function match( array $models, array $results, string $relation ) { + return $models; + } + + /** + * @param Model $model + * @return $this + */ + public function set_model( Model $model ) { $this->model = $model; + return $this; + } + + /** + * Set the current query value bindings. + * + * @param array $bindings + * @param string $type + * @return $this + * + * @throws InvalidArgumentException If the binding type is invalid. + */ + public function set_bindings( array $bindings, $type = 'where' ) { + if ( ! array_key_exists( $type, $this->bindings ) ) { + throw new \InvalidArgumentException( "Invalid binding type: {$type}." ); + } + + $this->bindings[$type] = $bindings; + + return $this; + } + + /** + * Add a binding to the query. + * + * @param mixed $value + * @param string $type + * @return $this + * + * @throws InvalidArgumentException If the binding type is invalid. + */ + public function add_binding( $value, $type = 'where' ) { + if ( ! array_key_exists( $type, $this->bindings ) ) { + throw new \InvalidArgumentException( "Invalid binding type: {$type}." ); + } + + if ( is_array( $value ) ) { + $this->bindings[$type] = array_values( array_merge( $this->bindings[$type], $value ) ); + } else { + $this->bindings[$type][] = $value; + } + + return $this; + } + + /** + * Merge the bindings from another builder. + * + * @param self $query + * @return $this + */ + public function merge_bindings( self $query, ?string $target_category = null ) { + if ( $target_category ) { + foreach ( $query->get_bindings() as $binding ) { + $this->add_binding( $binding, $target_category ); + } + } else { + $this->bindings = array_merge( $this->bindings, $query->get_raw_bindings() ); + } + + return $this; + } + + /** + * @return array + */ + public function get_relations() { + return $this->relations; + } + + /** + * Get the array of "on" clauses for join. + * + * @return array + */ + public function get_ons(): array { + return []; + } + + /** + * Set the aggregate information and get the SQL query. + * + * @param string $function + * @param array $columns + * @return string + */ + public function aggregate_to_sql( $function, $columns = ['*'] ) { + $this->aggregate = compact( 'function', 'columns' ); + return $this->to_sql(); } /** * Set the table which the query is targeting. * - * @param Builder $query + * @param string $table * @param string|null $as * @return $this */ public function from( string $table, $as = null ) { - $this->from = $this->model->resolver()->table( $table ); - $this->as = is_null( $as ) ? $table : $as; + if ( $this->model ) { + $this->from = $this->model->resolver()->table( $table ); + } else { + $this->from = $table; + } + $this->as = is_null( $as ) ? $table : $as; return $this; } @@ -155,6 +433,26 @@ public function select( $columns = ['*'] ) { return $this; } + /** + * Add a new select column to the query. + * + * @param array|mixed $columns + * @return $this + */ + public function add_select( $columns ) { + $columns = is_array( $columns ) ? $columns : func_get_args(); + + foreach ( $columns as $as => $column ) { + if ( is_string( $as ) ) { + $this->columns[$as] = $column; + } else { + $this->columns[] = $column; + } + } + + return $this; + } + /** * Force the query to only return distinct results. * @@ -167,103 +465,68 @@ public function distinct() { } /** - * Set the relationships that should be eager loaded. + * Apply the callback if the given "value" is (or resolves to) true. * - * @param string|array $relations - * @param string|(Closure(static): mixed)|array|null $callback - * @return $this + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed */ - public function with( $relations, $callback = null ) { - if ( ! is_array( $relations ) ) { - $relations = [$relations => $callback]; + public function when( $value, $callback, $default = null ) { + if ( $value ) { + return $callback( $this, $value ) ?: $this; + } elseif ( $default ) { + return $default( $this, $value ) ?: $this; } - foreach ( $relations as $relation => $callback ) { - if ( is_int( $relation ) ) { - $relation = $callback; - } - - $current = &$this->relations; - - // Traverse the items string and create nested arrays - $items = explode( '.', $relation ); - - foreach ( $items as $key ) { - if ( ! isset( $current[$key] ) ) { - $query = new static( $this->model ); - $current[$key] = [ - 'query' => $query, - 'children' => [] - ]; - } else { - $query = $current[$key]['query']; - } - $current = &$current[$key]['children']; - } + return $this; + } - // Apply the callback to the last item - if ( is_callable( $callback ) ) { - call_user_func( $callback, $query ); - } + /** + * Apply the callback if the given "value" is (or resolves to) false. + * + * @param mixed $value + * @param callable $callback + * @param callable|null $default + * @return $this|mixed + */ + public function unless( $value, $callback, $default = null ) { + if ( ! $value ) { + return $callback( $this, $value ) ?: $this; + } elseif ( $default ) { + return $default( $this, $value ) ?: $this; } return $this; } - /** - * @param string $relations - * @param (Closure(static): mixed)|null $callback + /** + * Pass the query to a given callback. + * + * @param callable $callback * @return $this */ - public function with_count( $relations, $callback = null ) { - $relation_keys = explode( ' as ', $relations ); - /** - * @var Relation $relationship - */ - $relationship = $this->model->{$relation_keys[0]}(); - - if ( ! $relationship instanceof HasMany ) { - return $this; - } - - $related = $relationship->get_related(); - $table_name = $related::get_table_name(); - - $total_key = $relation_keys[1]; - $join_alias = $total_key . '_count'; - - $columns = $this->columns; - $columns[] = "COALESCE({$join_alias}.{$total_key}, 0) as {$total_key}"; - $this->select( $columns ); - - return $this->left_join( - "$table_name as $join_alias", function( JoinClause $join ) use( $relationship, $total_key, $callback ) { - $join->on_column( "{$join->as}.{$relationship->foreign_key}", '=', "{$this->as}.{$relationship->local_key}" ) - ->select( "{$join->as}.{$relationship->foreign_key}", "COUNT(*) AS {$total_key}" ) - ->group_by( "{$join->as}.{$relationship->foreign_key}" ); - if ( is_callable( $callback ) ) { - call_user_func( $callback, $join ); - } - } - ); + public function tap( $callback ) { + $callback( $this ); + return $this; } - /** + /** * Add a join clause to the query. * - * @param string $table - * @param (Closure(JoinClause): mixed)|array|string $first + * @param string $table + * @param Closure(JoinClause):mixed|array|string $first * @param string|null $operator - * @param string|null $second - * @param string $type - * @param bool $where + * @param string|null $second + * @param string $type + * @param bool $where * @return $this */ public function join( $table, $first, $operator = null, $second = null, $type = 'inner', $where = false ) { $join = new JoinClause( $table, $type, $this->model ); - if ( is_callable( $first ) ) { + if ( $first instanceof \Closure ) { call_user_func( $first, $join ); } else { if ( $where ) { @@ -281,10 +544,10 @@ public function join( $table, $first, $operator = null, $second = null, $type = * Add a left join to the query. * * @param string $table - * @param (Closure(JoinClause): mixed)|array|string $first - * @param string|null $operator - * @param string|null $second - * @param bool $where + * @param Closure(JoinClause):mixed|array|string $first + * @param string|null $operator + * @param string|null $second + * @param bool $where * @return $this */ public function left_join( $table, $first, $operator = null, $second = null, $where = false ) { @@ -294,11 +557,11 @@ public function left_join( $table, $first, $operator = null, $second = null, $wh /** * Add a right join to the query. * - * @param string $table - * @param (Closure(JoinClause): mixed)|array|string $first - * @param string|null $operator - * @param string|null $second - * @param bool $where + * @param string $table + * @param Closure(JoinClause):mixed|array|string $first + * @param string|null $operator + * @param string|null $second + * @param bool $where * @return $this */ public function right_join( $table, $first, $operator = null, $second = null, $where = false ) { @@ -339,12 +602,34 @@ public function order_by( $column, $direction = 'asc' ) { /** * Add a descending "order by" clause to the query. + * + * @param string $column * @return $this */ public function order_by_desc( $column ) { return $this->order_by( $column, 'desc' ); } + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param string $column + * @return $this + */ + public function latest( string $column = 'created_at' ) { + return $this->order_by( $column, 'desc' ); + } + + /** + * Add an "order by" clause for a timestamp to the query. + * + * @param string $column + * @return $this + */ + public function oldest( string $column = 'created_at' ) { + return $this->order_by( $column, 'asc' ); + } + /** * Add an "order by raw" clause to the query. * @@ -362,8 +647,12 @@ public function order_by_raw( string $sql ) { * @param int $value * @return $this */ - public function offset( int $value ) { - $this->offset = max( 0, $value ); + public function offset( ?int $value ) { + if ( is_null( $value ) || $value < 0 ) { + $this->offset = null; + } else { + $this->offset = $value; + } return $this; } @@ -373,34 +662,66 @@ public function offset( int $value ) { * @param int $value * @return $this */ - public function limit( int $value ) { - $this->limit = max( 1, $value ); + public function limit( ?int $value ) { + if ( is_null( $value ) || $value < 0 ) { + $this->limit = null; + } else { + $this->limit = $value; + } return $this; } /** - * Get the SQL representation of the query. + * Compile the query SQL without resetting bindings. + * + * Used internally by union/subquery compilation so bindings + * remain available to be merged into the parent query. * * @return string */ - public function to_sql() { + public function compile_sql(): string { if ( empty( $this->columns ) ) { $this->columns = ['*']; } + return $this->get_grammar()->compile_select( $this ); + } - $compiler = new Compiler; - return $this->bind_values( $compiler->compile_select( $this ) ); + /** + * Get the raw SQL representation of the query with placeholders. + * + * @return string + */ + public function get_raw_sql() { + // Reset bindings before compilation to avoid duplication on repeated calls. + $this->reset_bindings(); + return $this->compile_sql(); + } + + /** + * Get the SQL representation of the query with bound values. + * + * @return string + */ + public function to_sql() { + return $this->bind_values( $this->get_raw_sql() ); + } + + /** + * Get the grammar instance. + * + * @return Grammar + */ + public function get_grammar() { + return new Grammar; } /** - * Insert new records into the database. * * @param array $values * @return string */ public function to_sql_insert( array $values ) { - $compiler = new Compiler; - return $this->bind_values( $compiler->compile_insert( $this, $values ) ); + return $this->bind_values( $this->get_grammar()->compile_insert( $this, $values ) ); } /** @@ -409,8 +730,7 @@ public function to_sql_insert( array $values ) { * @return string */ public function to_sql_update( array $values ) { - $compiler = new Compiler; - return $this->bind_values( $compiler->compile_update( $this, $values ) ); + return $this->bind_values( $this->get_grammar()->compile_update( $this, $values ) ); } /** @@ -419,19 +739,180 @@ public function to_sql_update( array $values ) { * @return string */ public function to_sql_delete() { - $compiler = new Compiler; - return $this->bind_values( $compiler->compile_delete( $this ) ); + return $this->bind_values( $this->get_grammar()->compile_delete( $this ) ); } + /** + * Map the raw database results into a collection of models. + * + * @param array $items + * @return Model[] + */ + public function hydrate( array $items ) { + $model = $this->model; + + return array_map( + function ( $item ) use ( $model ) { + return $model->new_from_builder( (array) $item ); + }, $items + ); + } + + /** + * Execute the query as a "select" statement. + * + * @return Collection + */ public function get() { global $wpdb; /** * @var wpdb $wpdb */ //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - return $this->process_relationships( $wpdb->get_results( $this->to_sql() ), $this->relations, $this->model ); + $results = $wpdb->get_results( $this->to_sql() ); + + $items = $this->hydrate( is_array( $results ) ? $results : [] ); + + if ( ! $this->aggregate ) { + $items = $this->process_relationships( $items, $this->relations, $this->model ); + } + + if ( $this->model ) { + return $this->model->new_collection( $items ); + } + + return new \WpMVC\Database\Eloquent\Collection( $items ); + } + + /** + * Get all of the models from the database. + * + * @return Collection + */ + public function all() { + return $this->get(); + } + + /** + * Process results in fixed-size batches. + * + * @param int $count Rows per batch. + * @param callable $callback Called with (Collection $batch, int $page). + * @return bool Returns false early if the callback returns false. + */ + public function chunk( int $count, callable $callback ): bool { + $page = 1; + do { + $results = ( clone $this )->limit( $count )->offset( ( $page - 1 ) * $count )->get(); + + if ( $results->count() === 0 ) { + break; + } + + if ( $callback( $results, $page ) === false ) { + return false; + } + + $page++; + } while ( $results->count() === $count ); + + return true; + } + + /** + * Return a Generator that yields one hydrated row at a time. + * + * @return Generator + */ + public function cursor(): \Generator { + global $wpdb; + + $results = $wpdb->get_results( $this->to_sql() ); //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + foreach ( $results as $row ) { + $hydrated = $this->hydrate( [ $row ] ); + yield $hydrated[0] ?? $row; + } + } + + /** + * Execute a callback inside a database transaction. + * Commits on success, rolls back and re-throws on exception. + * + * @param callable $callback + * @return mixed Return value of the callback. + * @throws Throwable + */ + public static function transaction( callable $callback ) { + global $wpdb; + + $wpdb->query( 'START TRANSACTION' ); + + try { + $result = $callback(); + $wpdb->query( 'COMMIT' ); + return $result; + } catch ( \Throwable $e ) { + $wpdb->query( 'ROLLBACK' ); + throw $e; + } + } + + /** + * Add a WHERE EXISTS subquery clause. + * + * @param Closure(Builder):void|Builder $callback Closure or an existing Builder subquery. + * @param string $boolean 'and' or 'or'. + * @param bool $not If true, compiles as WHERE NOT EXISTS. + * @return $this + */ + public function where_exists( $callback, string $boolean = 'and', bool $not = false ) { + if ( $callback instanceof \Closure ) { + $query = $this->new_query(); + $callback( $query ); + } else { + $query = $callback; // Already a Builder instance (e.g. from doesnt_have / where_has) + } + return $this->clause_exists( 'wheres', $query, null, $boolean, $not ); + } + + /** + * Add a WHERE NOT EXISTS subquery clause. + * + * @param Closure(Builder):void|Builder $callback + * @return $this + */ + public function where_not_exists( $callback ) { + return $this->where_exists( $callback, 'and', true ); } + /** + * Add an OR WHERE EXISTS subquery clause. + * + * @param Closure(Builder):void|Builder $callback + * @return $this + */ + public function or_where_exists( $callback ) { + return $this->where_exists( $callback, 'or' ); + } + + /** + * Add an OR WHERE NOT EXISTS subquery clause. + * + * @param Closure(Builder):void|Builder $callback + * @return $this + */ + public function or_where_not_exists( $callback ) { + return $this->where_exists( $callback, 'or', true ); + } + + /** + * @param int $current_page + * @param int $per_page + * @param int $min_per_page + * @param int $max_per_page + * @return LengthAwarePaginator + */ public function pagination( int $current_page, int $per_page = 10, int $min_per_page = 10, int $max_per_page = 100 ) { if ( $per_page > $max_per_page || $per_page < $min_per_page ) { $per_page = $max_per_page; @@ -443,22 +924,92 @@ public function pagination( int $current_page, int $per_page = 10, int $min_per_ $offset = ( $current_page - 1 ) * $per_page; - return $this->limit( $per_page )->offset( $offset )->get(); + // Calculate total count without limit, offset, or active aggregate. + $clone = clone $this; + $total = $clone->limit( null )->offset( null )->count(); + + // Get the specific page rows + $items = $this->limit( $per_page )->offset( $offset )->get(); + + return new LengthAwarePaginator( $items, $total, $per_page, $current_page ); } /** - * @return stdClass|null + * Find a model by its primary key. + * + * @param mixed $id + * @return Model|stdClass|null|Collection + */ + public function find( $id ) { + if ( is_array( $id ) ) { + return $this->where_in( $this->model->get_key_name(), $id )->get(); + } + + return $this->where( $this->model->get_key_name(), '=', $id )->first(); + } + + /** + * Find a model by its primary key or throw an exception. + * + * @param mixed $id + * @return Model|stdClass + * @throws Exception + */ + public function find_or_fail( $id ) { + $result = $this->find( $id ); + + if ( is_null( $result ) ) { + throw new \Exception( "Model not found." ); + } + + return $result; + } + + /** + * Execute the query and get the first result. + * + * @return Model|stdClass|null */ public function first() { $data = $this->limit( 1 )->get(); return isset( $data[0] ) ? $data[0] : null; } + /** + * Execute the query and get the first result or throw an exception. + * + * @return stdClass|Model + * @throws Exception + */ + public function first_or_fail() { + if ( ! is_null( $result = $this->first() ) ) { + return $result; + } + + throw new \Exception( "Model not found." ); + } + + /** + * Filter the query by primary key. + * + * @param mixed $id + * @return $this + */ + public function where_key( $id ) { + if ( is_array( $id ) ) { + $this->where_in( $this->model->get_key_name(), $id ); + + return $this; + } + + return $this->where( $this->model->get_key_name(), '=', $id ); + } + /** * Insert new records into the database. * * @param array $values - * @return bool|integer + * @return bool */ public function insert( array $values ) { $sql = $this->to_sql_insert( $values ); @@ -467,13 +1018,14 @@ public function insert( array $values ) { * @var wpdb $wpdb */ //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared - return $wpdb->query( $sql ); + return (bool) $wpdb->query( $sql ); } /** - * Insert new single record into the database and get id. + * Insert a new record into the database and get the ID. * * @param array $values + * @return int */ public function insert_get_id( array $values ) { $this->insert( $values ); @@ -516,13 +1068,16 @@ public function delete() { } /** - * Prepare Query Values + * Prepare Query Values and return the final SQL. * * @param string $sql + * @param bool $clear_bindings * @return string */ - public function bind_values( string $sql ) { - if ( empty( $this->bindings ) ) { + public function bind_values( string $sql, $clear_bindings = true ) { + $bindings = $this->get_bindings(); + + if ( empty( $bindings ) ) { return $sql; } @@ -534,57 +1089,96 @@ public function bind_values( string $sql ) { $sql, ...array_map( //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared function( $value ) { return $value instanceof DateTime ? $value->format( 'Y-m-d H:i:s' ) : $value; - }, $this->bindings + }, $bindings ) ); - $this->bindings = []; + if ( $clear_bindings ) { + $this->reset_bindings(); + } return $sql_query; } /** - * Set query values for the using wpdb::prepare + * Reset the query bindings. * - * @param mixed $value - * @return string + * @return $this */ - public function set_binding( $value ) { + public function reset_bindings() { + foreach ( $this->bindings as $type => $values ) { + $this->bindings[$type] = []; + } + + return $this; + } + /** + * Set query values for the using wpdb::prepare and return placeholder. + * + * @param mixed $value + * @param string $type + * @return string + */ + public function set_binding( $value, $type = 'where' ) { if ( is_null( $value ) ) { return "null"; } - $this->bindings[] = $value; + $this->add_binding( $value, $type ); - $type = gettype( $value ); + $type_val = gettype( $value ); - if ( 'integer' === $type || 'boolean' === $type ) { + if ( 'integer' === $type_val || 'boolean' === $type_val ) { return '%d'; } - if ( 'double' === $type ) { + if ( 'double' === $type_val ) { return '%f'; } return '%s'; } + /** + * Get the flattened list of bindings in order of SQL execution. + * + * @return array + */ public function get_bindings() { - return $this->bindings; + $flat = []; + + foreach ( $this->bindings as $category => $values ) { + foreach ( $values as $value ) { + $flat[] = $value; + } + } + + return $flat; } - public function set_bindings( array $bindings ) { - return $this->bindings = array_merge( $this->bindings, $bindings ); + /** + * Get the raw associative bindings array. + * + * @return array + */ + public function get_raw_bindings() { + return $this->bindings; } + /** + * Execute an aggregate function on the database. + * + * @param string $function + * @param array $columns + * @return mixed + */ public function aggregate( $function, $columns = ['*'] ) { $results = $this->set_aggregate( $function, $columns )->get(); - return (int) $results[0]->aggregate; - } - - public function aggregate_to_sql( $function, $columns = ['*'] ) { - return $this->set_aggregate( $function, $columns )->to_sql(); + if ( ! isset( $results[0] ) ) { + return 0; + } + return $results[0]->aggregate; } /** @@ -611,7 +1205,37 @@ protected function set_aggregate( $function, $columns ) { * @return int */ public function count( string $column = '*' ) { - return $this->aggregate( __FUNCTION__, [$column] ); + if ( ! empty( $this->groups ) ) { + // Optimization: Use COUNT(DISTINCT ...) if no HAVING clause is present + if ( method_exists( $this, 'get_havings' ) && empty( $this->get_havings() ) ) { + $query = clone $this; + $query->groups = null; + return (int) $query->distinct()->aggregate( __FUNCTION__, $this->groups ); + } + + $query = clone $this; + return count( $query->set_aggregate( __FUNCTION__, [$column] )->get() ); + } + + return (int) $this->aggregate( __FUNCTION__, [$column] ); + } + + /** + * Determine if any rows exist for the current query. + * + * @return bool + */ + public function exists() { + return $this->count() > 0; + } + + /** + * Determine if no rows exist for the current query. + * + * @return bool + */ + public function doesnt_exist() { + return ! $this->exists(); } /** @@ -669,7 +1293,7 @@ public function avg( $column ) { protected function prepare_value_and_operator( $value, $operator, $use_default = false ) { if ( $use_default ) { return [$operator, '=']; - } elseif ( $this->invalid_operatorAndValue( $operator, $value ) ) { + } elseif ( $this->invalid_operator_and_value( $operator, $value ) ) { throw new InvalidArgumentException( 'Illegal operator and value combination.' ); } @@ -685,11 +1309,69 @@ protected function prepare_value_and_operator( $value, $operator, $use_default = * @param mixed $value * @return bool */ - protected function invalid_operatorAndValue( $operator, $value ) { + protected function invalid_operator_and_value( $operator, $value ) { return is_null( $value ) && in_array( $operator, $this->operators ) && ! in_array( $operator, ['=', '<>', '!='] ); } + /** + * Add a union statement to the query. + * + * @param Builder|Closure(Builder):void $query + * @param bool $all + * @return $this + */ + public function union( $query, $all = false ) { + if ( $query instanceof \Closure ) { + $callback = $query; + $sub = $this->new_query(); + // Propagate table context so where_has / from() work inside the closure + $sub->from = $this->from; + $sub->as = $this->as; + $callback( $sub ); + $query = $sub; + } + + $this->unions[] = compact( 'query', 'all' ); + + return $this; + } + + /** + * Add a union all statement to the query. + * + * @param Builder|Closure(Builder):void $query + * @return $this + */ + public function union_all( $query ) { + return $this->union( $query, true ); + } + + /** + * Handle dynamic method calls into the builder. + * + * @param string $method + * @param array $parameters + * @return Builder|mixed + * + * @throws BadMethodCallException + */ + public function __call( $method, $parameters ) { + if ( $this->model && method_exists( $this->model, $scope = 'scope_' . $method ) ) { + return $this->model->$scope( $this, ...$parameters ) ?: $this; + } + + if ( $this->model && method_exists( $this->model, $scope = 'scope' . str_replace( ' ', '', ucwords( str_replace( '_', ' ', $method ) ) ) ) ) { + return $this->model->$scope( $this, ...$parameters ) ?: $this; + } + + throw new \BadMethodCallException( + sprintf( + 'Call to undefined method %s::%s()', static::class, $method + ) + ); + } + /** * Determine if the given operator is supported. * diff --git a/src/Query/Compilers/Compiler.php b/src/Query/Compilers/Compiler.php deleted file mode 100644 index d9c54f3..0000000 --- a/src/Query/Compilers/Compiler.php +++ /dev/null @@ -1,436 +0,0 @@ -concatenate( $this->compile_components( $query ) ); - } - - /** - * Compile an insert statement into SQL. - * - * @param Builder $query - * @param array $values - * @return string - */ - public function compile_insert( Builder $query, array $values ) { - if ( ! is_array( reset( $values ) ) ) { - $values = [$values]; - } else { - foreach ( $values as $key => $value ) { - ksort( $value ); - - $values[$key] = $value; - } - } - - $columns = $this->columnize( array_keys( reset( $values ) ) ); - - $parameters = implode( - ', ', array_map( - function( $record ) use( $query ) { - return '(' . implode( - ', ', array_map( - function( $item ) use( $query ) { - return $query->set_binding( $item ); - }, $record - ) - ) . ')'; - }, $values - ) - ); - - $table = $query->from; - - return "insert into $table ($columns) values $parameters"; - } - - /** - * Compile an update statement into SQL. - * - * @param Builder $query - * @param array $values - * @return string - */ - public function compile_update( Builder $query, array $values ) { - - $keys = array_keys( $values ); - - $columns = implode( - ', ', array_map( - function( $value, $key ) use( $query ){ - return $key . ' = ' . $query->set_binding( $value ); - }, $values, $keys - ) - ); - - $where = $this->compile_wheres( $query ); - - return "update {$query->from} set {$columns} {$where}"; - } - - /** - * Compile a delete statement into SQL. - * - * @param Builder $query - * @return string - */ - public function compile_delete( Builder $query ) { - $where = $this->compile_wheres( $query ); - $joins = $this->compile_joins( $query, $query->joins ); - return "delete {$query->as} from {$query->from} as {$query->as} {$joins} {$where}"; - } - - /** - * Compile the components necessary for a select clause. - * - * @param Builder $query - * @return array - */ - protected function compile_components( Builder $query ) { - $sql = []; - - $clauses = $query->get_clauses(); - - foreach ( $this->select_components as $component ) { - $method = 'compile_' . $component; - if ( isset( $query->$component ) ) { - $sql[$component] = $this->$method( $query, $query->$component ); - } elseif ( isset( $clauses[$component] ) ) { - $sql[$component] = $this->$method( $query, $clauses[$component] ); - - } - } - - return $sql; - } - - /** - * Compile the "select *" portion of the query. - * - * @param Builder $query - * @param array $columns - * @return string|null - */ - protected function compile_columns( Builder $query, $columns ) { - if ( ! is_null( $query->aggregate ) ) { - return; - } - - if ( $query->distinct ) { - $select = 'select distinct '; - } else { - $select = 'select '; - } - - return $select . $this->columnize( $columns ); - } - - /** - * Compile an aggregated select clause. - * - * @param Builder $query - * @param array $aggregate - * @return string - */ - protected function compile_aggregate( Builder $query, $aggregate ) { - $column = $this->columnize( $query->aggregate['columns'] ); - - if ( $query->distinct ) { - $column = 'distinct ' . $column; - } - - return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate'; - } - - /** - * Compile the "from" portion of the query. - * - * @param Builder $query - * @param string $table - * @return string - */ - protected function compile_from( Builder $query, $table ) { - if ( is_null( $query->as ) ) { - return 'from ' . $table; - } - return "from {$table} as {$query->as}"; - } - - /** - * Compile the "where" portions of the query. - * - * @param Builder $query - * @return string - */ - public function compile_wheres( Builder $query ) { - if ( empty( $query->get_wheres() ) ) { - return ''; - } - - return $this->compile_where_or_having( $query, $query->get_wheres() ); - } - - /** - * Compile the "join on" portions of the query. - * - * @param JoinClause $query - * @return string - */ - public function compile_ons( JoinClause $query ) { - if ( empty( $query->get_ons() ) ) { - return ''; - } - return $this->compile_where_or_having( $query, $query->get_ons(), 'on' ); - } - - protected function compile_where_or_having( Builder $query, array $items, string $type = 'where' ) { - $where_query = $type; - - foreach ( $items as $where ) { - switch ( $where['type'] ) { - case 'basic': - $basic = $where['not'] ? "{$where['boolean']} not" : $where['boolean']; - $where_query .= " {$basic} {$where['column']} {$where['operator']} {$query->set_binding($where['value'])}"; - break; - case 'like': - $like = $where['not'] ? 'not like' : 'like'; - $where_query .= " {$where['boolean']} {$where['column']} {$like} {$query->set_binding($where['value'])}"; - break; - case 'between': - $between = $where['not'] ? 'not between' : 'between'; - $where_query .= " {$where['boolean']} {$where['column']} {$between} {$query->set_binding($where['values'][0])} and {$query->set_binding($where['values'][1])}"; - break; - case 'in': - $in = $where['not'] ? 'not in' : 'in'; - $values = implode( - ', ', array_map( - function( $value ) use( $query ) { - return $query->set_binding( $value ); - }, $where['values'] - ) - ); - $where_query .= " {$where['boolean']} {$where['column']} {$in} ({$values})"; - break; - case 'column': - if ( is_null( $where['value'] ) ) { - $where_query .= " {$where['boolean']} {$where['column']}"; - } else { - $where_query .= " {$where['boolean']} {$where['column']} {$where['operator']} {$where['value']}"; - } - break; - case 'exists': - /** - * @var Builder $exists_query - */ - $exists_query = $where['query']; - $sql = $exists_query->to_sql(); - - $exists = $where['not'] ? 'not exists' : 'exists'; - $where_query .= " {$where['boolean']} {$exists} ({$sql})"; - break; - case 'raw': - $where_query .= " {$where['boolean']} {$where['sql']}"; - break; - case 'is_null': - $null = $where['not'] ? "not null" : "null"; - $where_query .= " {$where['boolean']} {$where['column']} is {$null}"; - break; - case 'nested': - /** - * @var Builder $nested_query - */ - $nested_query = $where['query']; - - switch ( $type ) { - case 'where': - $nested_items = $nested_query->get_wheres(); - break; - case 'having': - $nested_items = $nested_query->get_havings(); - break; - case 'on': - $nested_items = $nested_query->get_ons(); - break; - } - - if ( ! empty( $nested_items ) ) { - $sql = ltrim( $this->compile_where_or_having( $nested_query, $nested_items, $type ), $type ); - $query->set_bindings( $nested_query->get_bindings() ); - - $nested = $where['not'] ? "{$where['boolean']} not" : $where['boolean']; - $where_query .= " {$nested} ({$sql} )"; - } - } - } - - $where_query = trim( $this->remove_leading_boolean( $where_query ) ); - - if ( in_array( $where_query, ['where', 'having', 'on', 'where not', 'having not', 'on not'], true ) ) { - return ''; - } - - return $where_query; - } - - /** - * Compile the "join" portions of the query. - * - * @param Builder $query - * @param array $joins - * @return string - */ - protected function compile_joins( Builder $query, $joins ) { - return implode( - ' ', array_map( - function( JoinClause $join ) { - if ( ! empty( $join->columns ) || ! empty( $join->get_wheres() ) ) { - $query = "({$join->to_sql()})"; - } else { - $query = $join->from; - } - - $query .= " as {$join->as}"; - - if ( ! empty( $join->joins ) ) { - $query = "({$query} {$this->compile_joins( $join, $join->joins )})"; - } - return $join->bind_values( trim( "{$join->type} join {$query} {$this->compile_ons($join)}" ) ); - }, $joins - ) - ); - } - - /** - * Compile the "order by" portions of the query. - * - * @param Builder $query - * @param array $orders - * @return string - */ - protected function compile_orders( Builder $query, $orders ) { - if ( empty( $orders ) ) { - return ''; - } - - return 'order by ' . implode( - ', ', array_map( - function( $order ) { - return $order['column'] . ' ' . $order['direction']; - }, $orders - ) - ); - } - - /** - * Compile the "having" portions of the query. - * - * @param Builder $query - * @return string - */ - protected function compile_havings( Builder $query ) { - if ( empty( $query->get_havings() ) ) { - return ''; - } - return $this->compile_where_or_having( $query, $query->get_havings(), 'having' ); - } - - /** - * Compile the "offset" portions of the query. - * - * @param Builder $query - * @param int $offset - * @return string - */ - protected function compile_offset( Builder $query, $offset ) { - return 'offset ' . $query->set_binding( $offset ); - } - - /** - * Compile the "limit" portions of the query. - * - * @param Builder $query - * @param int $limit - * @return string - */ - protected function compile_limit( Builder $query, $limit ) { - return 'limit ' . $query->set_binding( $limit ); - } - - /** - * Compile the "group by" portions of the query. - * - * @param Builder $query - * @param array $groups - * @return string - */ - protected function compile_groups( Builder $query, $groups ) { - return 'group by ' . implode( ', ', $groups ); - } - - /** - * Concatenate an array of segments, removing empties. - * - * @param array $segments - * @return string - */ - protected function concatenate( $segments ) { - return implode( - ' ', array_filter( - $segments, function ( $value ) { - return (string) $value !== ''; - } - ) - ); - } - - /** - * Convert an array of column names into a delimited string. - * - * @param array $columns - * @return string - */ - public function columnize( array $columns ) { - return implode( ', ', $columns ); - } - - /** - * Remove the leading boolean from a statement. - * - * @param string $value - * @return string - */ - protected function remove_leading_boolean( $value ) { - return preg_replace( '/and |or /i', '', $value, 1 ); - } -} \ No newline at end of file diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php new file mode 100644 index 0000000..e4b6ddb --- /dev/null +++ b/src/Query/Grammar.php @@ -0,0 +1,529 @@ +concatenate( $this->compile_components( $query ) ); + } + + /** + * Compile an insert statement into SQL. + * + * @param Builder $query + * @param array $values + * @return string + */ + public function compile_insert( Builder $query, array $values ) { + if ( ! is_array( reset( $values ) ) ) { + $values = [$values]; + } else { + foreach ( $values as $key => $value ) { + ksort( $value ); + $values[$key] = $value; + } + } + + $columns = $this->columnize( $query, array_keys( reset( $values ) ) ); + + $parameters = implode( + ', ', array_map( + function( $record ) use( $query ) { + return '(' . implode( + ', ', array_map( + function( $item ) use( $query ) { + return $query->set_binding( $item, 'select' ); + }, $record + ) + ) . ')'; + }, $values + ) + ); + + $table = $this->wrap( $query->from ); + + return "insert into $table ($columns) values $parameters"; + } + + /** + * Compile an update statement into SQL. + * + * @param Builder $query + * @param array $values + * @return string + */ + public function compile_update( Builder $query, array $values ) { + $keys = array_keys( $values ); + + $columns = implode( + ', ', array_map( + function( $value, $key ) use( $query ){ + return $this->wrap( $key ) . ' = ' . $query->set_binding( $value, 'select' ); + }, $values, $keys + ) + ); + + $where = $this->compile_wheres( $query ); + + $table = $this->wrap( $query->from ); + + return "update {$table} set {$columns} {$where}"; + } + + /** + * Compile a delete statement into SQL. + * + * @param Builder $query + * @return string + */ + public function compile_delete( Builder $query ) { + $where = $this->compile_wheres( $query ); + $joins = $this->compile_joins( $query, $query->joins ); + + $alias = $query->as ? " as " . $this->wrap( $query->as ) : ""; + $from = $this->wrap( $query->from ) . $alias; + + return "delete " . ( $query->as ? $this->wrap( $query->as ) : $this->wrap( $query->from ) ) . " from {$from} {$joins} {$where}"; + } + + /** + * Compile the components necessary for a select clause. + * + * @param Builder $query + * @return array + */ + protected function compile_components( Builder $query ) { + $sql = []; + + $clauses = $query->get_clauses(); + + $has_unions = ! empty( $query->unions ); + + // When a UNION is present, defer ORDER BY / LIMIT / OFFSET until after the union SQL + // so MySQL sees: SELECT ... UNION SELECT ... ORDER BY ... LIMIT ... + // rather than: SELECT ... ORDER BY ... LIMIT ... UNION SELECT ... + $defer_after_union = $has_unions ? ['orders', 'limit', 'offset'] : []; + + foreach ( $this->select_components as $component ) { + if ( in_array( $component, $defer_after_union, true ) ) { + continue; // handled below + } + + $method = 'compile_' . $component; + if ( isset( $query->$component ) ) { + $sql[$component] = $this->$method( $query, $query->$component ); + } elseif ( isset( $clauses[$component] ) ) { + $sql[$component] = $this->$method( $query, $clauses[$component] ); + } + } + + // Append deferred ORDER BY / LIMIT / OFFSET after unions + foreach ( $defer_after_union as $component ) { + $method = 'compile_' . $component; + if ( isset( $query->$component ) ) { + $sql[$component] = $this->$method( $query, $query->$component ); + } elseif ( isset( $clauses[$component] ) ) { + $sql[$component] = $this->$method( $query, $clauses[$component] ); + } + } + + return $sql; + } + + /** + * Compile the "columns" portion of the query. + * + * @param Builder $query + * @param array $columns + * @return string|null + */ + protected function compile_columns( Builder $query, $columns ) { + if ( ! is_null( $query->aggregate ) ) { + return; + } + + $select = $query->distinct ? 'select distinct ' : 'select '; + + return $select . $this->columnize( $query, $columns ); + } + + /** + * Compile an aggregated select clause. + * + * @param Builder $query + * @param array $aggregate + * @return string + */ + protected function compile_aggregate( Builder $query, $aggregate ) { + $column = $this->columnize( $query, $query->aggregate['columns'] ); + + if ( $query->distinct ) { + $column = 'distinct ' . $column; + } + + return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate'; + } + + /** + * Compile the "from" portion of the query. + * + * @param Builder $query + * @param string $table + * @return string + */ + protected function compile_from( Builder $query, $table ) { + if ( is_null( $query->as ) || $query->as === $table ) { + return 'from ' . $this->wrap( $table ); + } + return "from " . $this->wrap( $table ) . " as " . $this->wrap( $query->as ); + } + + /** + * Compile the "wheres" portion of the query. + * + * @param Builder $query + * @return string + */ + public function compile_wheres( Builder $query ) { + if ( empty( $query->get_wheres() ) ) { + return ''; + } + + return $this->compile_where_or_having( $query, $query->get_wheres(), 'where' ); + } + + /** + * Compile the "joins" portion of the query. + * + * @param Builder $query + * @param array $joins + * @return string + */ + protected function compile_joins( Builder $query, $joins ) { + return implode( + ' ', array_map( + function( JoinClause $join ) use ( $query ) { + if ( ! empty( $join->get_wheres() ) ) { + $sql = $join->get_raw_sql(); + $query->merge_bindings( $join, 'join' ); + $table = "($sql)"; + } else { + $table = $this->wrap( $join->from ); + } + + $table .= " as " . $this->wrap( $join->as ); + + if ( ! empty( $join->joins ) ) { + $table = "({$table} {$this->compile_joins( $join, $join->joins )})"; + } + + return trim( "{$join->type} join {$table} " . $this->compile_ons( $join ) ); + }, $joins + ) + ); + } + + /** + * Compile the "ons" portion of a join clause. + * + * @param JoinClause $query + * @return string + */ + public function compile_ons( JoinClause $query ) { + if ( empty( $query->get_ons() ) ) { + return ''; + } + return $this->compile_where_or_having( $query, $query->get_ons(), 'on' ); + } + + /** + * Shared logic for compiling WHERE, HAVING, and ON clauses. + * + * @param Builder $query + * @param array $items + * @param string $type ('where'|'having'|'on') + * @return string + */ + protected function compile_where_or_having( Builder $query, array $items, string $type = 'where' ) { + $sql = []; + $binding_type = ( $type === 'on' ) ? 'join' : $type; + + foreach ( $items as $where ) { + $sql[] = $this->{"compile_where_{$where['type']}"}( $query, $where, $binding_type, $type ); + } + + $where_query = trim( implode( ' ', $sql ) ); + $where_query = preg_replace( '/and |or /i', '', $where_query, 1 ); + + if ( empty( $where_query ) || in_array( strtolower( $where_query ), ['where', 'having', 'on'] ) ) { + return ''; + } + + return $type . ' ' . $where_query; + } + + protected function compile_where_basic( Builder $query, $where, $binding_type ) { + $column = $where['column']; + if ( $column instanceof Builder ) { + $sql = $column->get_raw_sql(); + $query->merge_bindings( $column, $binding_type ); + $column = "($sql)"; + } else { + $column = $this->wrap( $column ); + } + + $value = $where['value']; + if ( $value instanceof Builder ) { + $sql = $value->get_raw_sql(); + $query->merge_bindings( $value, $binding_type ); + $value_sql = "($sql)"; + } else { + $value_sql = $query->set_binding( $value, $binding_type ); + } + + $prefix = $where['not'] ? "{$where['boolean']} not" : $where['boolean']; + + return "{$prefix} {$column} {$where['operator']} {$value_sql}"; + } + + protected function compile_where_like( Builder $query, $where, $binding_type ) { + $like = $where['not'] ? 'not like' : 'like'; + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " {$like} {$query->set_binding($where['value'], $binding_type)}"; + } + + protected function compile_where_between( Builder $query, $where, $binding_type ) { + $between = $where['not'] ? 'not between' : 'between'; + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " {$between} {$query->set_binding($where['values'][0], $binding_type)} and {$query->set_binding($where['values'][1], $binding_type)}"; + } + + protected function compile_where_in( Builder $query, $where, $binding_type ) { + $in = $where['not'] ? 'not in' : 'in'; + + if ( $where['values'] instanceof Builder ) { + $sql = $where['values']->get_raw_sql(); + $query->merge_bindings( $where['values'], $binding_type ); + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " {$in} ({$sql})"; + } + + $values = implode( + ', ', array_map( + function( $value ) use( $query, $binding_type ) { + return $query->set_binding( $value, $binding_type ); + }, $where['values'] + ) + ); + + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " {$in} ({$values})"; + } + + protected function compile_where_column( Builder $query, $where ) { + if ( is_null( $where['value'] ) ) { + return "{$where['boolean']} " . $this->wrap( $where['column'] ); + } + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " {$where['operator']} " . $this->wrap( $where['value'] ); + } + + protected function compile_where_exists( Builder $query, $where ) { + $exists_query = $where['query']; + $sql = $exists_query->get_raw_sql(); + $query->merge_bindings( $exists_query, 'where' ); + + $exists = $where['not'] ? 'not exists' : 'exists'; + return "{$where['boolean']} {$exists} ({$sql})"; + } + + protected function compile_where_raw( Builder $query, $where, $binding_type ) { + if ( ! empty( $where['bindings'] ) ) { + $query->add_binding( $where['bindings'], $binding_type ); + } + return "{$where['boolean']} {$where['sql']}"; + } + + protected function compile_where_is_null( Builder $query, $where ) { + $null = $where['not'] ? "not null" : "null"; + return "{$where['boolean']} " . $this->wrap( $where['column'] ) . " is {$null}"; + } + + protected function compile_where_nested( Builder $query, $where, $binding_type, $type ) { + $nested_query = $where['query']; + $method = 'get_' . $type . 's'; + $items = $nested_query->$method(); + + if ( empty( $items ) ) { + return ''; + } + + $sql = ltrim( $this->compile_where_or_having( $nested_query, $items, $type ), $type ); + $query->merge_bindings( $nested_query, $binding_type ); + + $prefix = $where['not'] ? "{$where['boolean']} not" : $where['boolean']; + + return "{$prefix} ({$sql} )"; + } + + public function compile_groups( Builder $query, $groups ) { + return 'group by ' . implode( ', ', array_map( [$this, 'wrap'], $groups ) ); + } + + protected function compile_havings( Builder $query ) { + if ( empty( $query->get_havings() ) ) { + return ''; + } + return $this->compile_where_or_having( $query, $query->get_havings(), 'having' ); + } + + protected function compile_orders( Builder $query, $orders ) { + if ( empty( $orders ) ) { + return ''; + } + + return 'order by ' . implode( + ', ', array_map( + function( $order ) { + return $this->wrap( $order['column'] ) . ' ' . $order['direction']; + }, $orders + ) + ); + } + + protected function compile_limit( Builder $query, $limit ) { + return 'limit ' . $query->set_binding( (int) $limit, 'limit' ); + } + + protected function compile_offset( Builder $query, $offset ) { + return 'offset ' . $query->set_binding( (int) $offset, 'offset' ); + } + + /** + * Wrap a value in keyword identifiers. + * + * @param string $value + * @return string + */ + public function wrap( $value ) { + if ( is_null( $value ) || $value === '*' ) { + return $value; + } + + if ( strpos( strtolower( $value ), ' as ' ) !== false ) { + $parts = preg_split( '/\s+as\s+/i', $value ); + return $this->wrap( $parts[0] ) . ' as ' . $this->wrap( $parts[count( $parts ) - 1] ); + } + + if ( strpos( $value, '(' ) !== false ) { + return $value; + } + + if ( strpos( $value, '.' ) !== false ) { + return implode( '.', array_map( [$this, 'wrap'], explode( '.', $value ) ) ); + } + + return '`' . str_replace( '`', '``', $value ) . '`'; + } + + /** + * Concatenate an array of segments, removing empty ones. + * + * @param array $segments + * @return string + */ + protected function concatenate( $segments ) { + return implode( + ' ', array_filter( + $segments, function ( $value ) { + return (string) $value !== ''; + } + ) + ); + } + + /** + * Compile the "unions" portion of the query. + * + * @param Builder $query + * @param array $unions + * @return string + */ + protected function compile_unions( Builder $query, $unions ) { + $sql = ''; + + foreach ( $unions as $union ) { + $joiner = $union['all'] ? ' union all ' : ' union '; + // Reset the subquery bindings so each compile is fresh, then merge into parent + $union['query']->reset_bindings(); + $sql .= $joiner . $union['query']->compile_sql(); + $query->merge_bindings( $union['query'], 'union' ); + } + + return $sql; + } + + /** + * Convert an array of column names into a delimited string. + * + * @param Builder $query + * @param array $columns + * @return string + */ + public function columnize( Builder $query, array $columns ) { + return implode( + ', ', array_map( + function( $column, $key ) use ( $query ) { + $alias = is_string( $key ) ? " as " . $this->wrap( $key ) : ""; + + if ( $column instanceof Builder ) { + $sql = $column->get_raw_sql(); + $query->merge_bindings( $column, 'select' ); + return "($sql)$alias"; + } + + return ( $column === '*' ? $column : $this->wrap( $column ) ) . $alias; + }, $columns, array_keys( $columns ) + ) + ); + } +} diff --git a/src/Query/JoinClause.php b/src/Query/JoinClause.php index 18dc7c5..227b8b7 100644 --- a/src/Query/JoinClause.php +++ b/src/Query/JoinClause.php @@ -1,4 +1,11 @@ from( $table[0], isset( $table[1] ) ? $table[1] : null ); + $parts = preg_split( '/\s+as\s+/i', $table ); + $this->from( $parts[0], isset( $parts[1] ) ? $parts[1] : null ); $this->type = $type; } } \ No newline at end of file diff --git a/src/Resolver.php b/src/Resolver.php index ca7fb4d..b35a8fe 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -1,4 +1,11 @@ network_tables = array_merge( $this->network_tables, $tables ); } + /** + * Resolve one or more table names with prefixes. + * + * @param string $table Initial table name if calling with a single argument. + * @param string ...$tables Additional table names. + * @return string|array + */ public function table( string $table ) { $table_args = func_get_args(); @@ -37,6 +64,12 @@ function( $table ) { ); } + /** + * Resolve a single table name with prefix. + * + * @param string $table + * @return string + */ protected function resolve_table_name( string $table ) { global $wpdb; /** diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index 2e5672e..9d9a259 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -1,9 +1,23 @@ add_column( "`$name` BIGINT UNSIGNED AUTO_INCREMENT" ); + $this->add_column( "$name BIGINT UNSIGNED AUTO_INCREMENT" ); $this->primary( $name ); } @@ -57,7 +71,18 @@ public function big_increments( string $name ): void { * @return self */ public function unsigned_big_integer( string $name ): self { - $this->add_column( "`$name` BIGINT UNSIGNED NOT NULL" ); + $this->add_column( "$name BIGINT UNSIGNED NOT NULL" ); + return $this; + } + + /** + * Add a BIGINT column. + * + * @param string $name + * @return self + */ + public function big_integer( string $name ): self { + $this->add_column( "$name BIGINT NOT NULL" ); return $this; } @@ -68,7 +93,7 @@ public function unsigned_big_integer( string $name ): self { * @return self */ public function integer( string $name ): self { - $this->add_column( "`$name` INT NOT NULL" ); + $this->add_column( "$name INT NOT NULL" ); return $this; } @@ -79,7 +104,7 @@ public function integer( string $name ): self { * @return self */ public function unsigned_integer( string $name ): self { - $this->add_column( "`$name` INT UNSIGNED NOT NULL" ); + $this->add_column( "$name INT UNSIGNED NOT NULL" ); return $this; } @@ -92,7 +117,18 @@ public function unsigned_integer( string $name ): self { * @return self */ public function decimal( string $name, int $precision = 10, int $scale = 2 ): self { - $this->add_column( "`$name` DECIMAL($precision, $scale) NOT NULL" ); + $this->add_column( "$name DECIMAL($precision, $scale) NOT NULL" ); + return $this; + } + + /** + * Add a FLOAT column. + * + * @param string $name + * @return self + */ + public function float( string $name ): self { + $this->add_column( "$name FLOAT NOT NULL" ); return $this; } @@ -104,7 +140,7 @@ public function decimal( string $name, int $precision = 10, int $scale = 2 ): se * @return self */ public function string( string $name, int $length = 255 ): self { - $this->add_column( "`$name` VARCHAR($length) NOT NULL" ); + $this->add_column( "$name VARCHAR($length) NOT NULL" ); return $this; } @@ -115,7 +151,7 @@ public function string( string $name, int $length = 255 ): self { * @return self */ public function text( string $name ): self { - $this->add_column( "`$name` TEXT NOT NULL" ); + $this->add_column( "$name TEXT NOT NULL" ); return $this; } @@ -126,7 +162,7 @@ public function text( string $name ): self { * @return self */ public function long_text( string $name ): self { - $this->add_column( "`$name` LONGTEXT" ); + $this->add_column( "$name LONGTEXT" ); return $this; } @@ -137,7 +173,7 @@ public function long_text( string $name ): self { * @return self */ public function json( string $name ): self { - $this->add_column( "`$name` JSON NOT NULL" ); + $this->add_column( "$name JSON NOT NULL" ); return $this; } @@ -149,8 +185,8 @@ public function json( string $name ): self { * @return self */ public function enum( string $name, array $values ): self { - $enum_values = implode( "','", $values ); - $this->add_column( "`$name` ENUM('$enum_values') NOT NULL" ); + $enum_values = implode( "','", array_map( 'esc_sql', $values ) ); + $this->add_column( "$name ENUM('$enum_values') NOT NULL" ); return $this; } @@ -161,7 +197,7 @@ public function enum( string $name, array $values ): self { * @return self */ public function tiny_integer( string $name ): self { - $this->add_column( "`$name` TINYINT NOT NULL" ); + $this->add_column( "$name TINYINT NOT NULL" ); return $this; } @@ -172,12 +208,36 @@ public function tiny_integer( string $name ): self { * @return self */ public function timestamp( string $name ): self { - $this->add_column( "`$name` TIMESTAMP" ); + $this->add_column( "$name TIMESTAMP" ); + return $this; + } + + /** + * Add a DATE column. + * + * @param string $name + * @return self + */ + public function date( string $name ): self { + $this->add_column( "$name DATE NOT NULL" ); + return $this; + } + + /** + * Add a DATETIME column. + * + * @param string $name + * @return self + */ + public function datetime( string $name ): self { + $this->add_column( "$name DATETIME NOT NULL" ); return $this; } /** * Add created_at and updated_at TIMESTAMP columns. + * + * @return void */ public function timestamps(): void { $this->timestamp( 'created_at' )->use_current(); @@ -191,7 +251,7 @@ public function timestamps(): void { * @return self */ public function boolean( string $name ): self { - $this->add_column( "`$name` TINYINT(1) NOT NULL" ); + $this->add_column( "$name TINYINT(1) NOT NULL" ); return $this; } @@ -202,7 +262,7 @@ public function boolean( string $name ): self { */ public function nullable(): self { $index = count( $this->columns ) - 1; - $this->columns[$index] = str_replace( 'NOT NULL', 'NULL', $this->columns[$index] ); + $this->columns[$index] = str_ireplace( ' NOT NULL', ' NULL', $this->columns[$index] ); return $this; } @@ -213,8 +273,13 @@ public function nullable(): self { * @return self */ public function default( $value ): self { - $index = count( $this->columns ) - 1; - $value = is_numeric( $value ) ? $value : "'$value'"; + $index = count( $this->columns ) - 1; + + if ( is_bool( $value ) ) { + $value = $value ? 1 : 0; + } + + $value = is_numeric( $value ) ? $value : "'" . esc_sql( $value ) . "'"; $this->columns[$index] .= " DEFAULT $value"; return $this; } @@ -227,7 +292,7 @@ public function default( $value ): self { */ public function comment( string $text ): self { $index = count( $this->columns ) - 1; - $this->columns[$index] .= " COMMENT '$text'"; + $this->columns[$index] .= " COMMENT '" . esc_sql( $text ) . "'"; return $this; } @@ -261,17 +326,18 @@ public function use_current_on_update(): self { */ public function after( string $column ): self { $index = count( $this->columns ) - 1; - $this->columns[$index] .= " AFTER `$column`"; + $this->columns[$index] .= " AFTER $column"; return $this; } /** - * Drop a column. + * Drop a column from the table. * - * @param string $name + * @param string $name + * @return void */ public function drop_column( string $name ): void { - $this->drops[] = "DROP COLUMN `$name`"; + $this->drops[] = "DROP COLUMN $name"; } /** @@ -280,17 +346,18 @@ public function drop_column( string $name ): void { * @param string $name */ public function drop_index( string $name ): void { - $this->drops[] = "DROP INDEX `$name`"; + $this->drops[] = "DROP INDEX $name"; } /** - * Add a primary key. + * Add a primary key to the table. * - * @param string|array $columns + * @param string|array $columns + * @return void */ public function primary( $columns ): void { $cols = $this->wrap_column_list( $columns ); - $this->indexes[] = "PRIMARY KEY ($cols)"; + $this->indexes[] = "PRIMARY KEY ($cols)"; } /** @@ -302,7 +369,7 @@ public function primary( $columns ): void { public function unique( $columns, string $name = '' ): void { $cols = $this->wrap_column_list( $columns ); $index_name = $name ?: 'unique_' . md5( $cols ); - $this->indexes[] = "UNIQUE KEY `$index_name` ($cols)"; + $this->indexes[] = "UNIQUE KEY $index_name ($cols)"; } /** @@ -314,7 +381,7 @@ public function unique( $columns, string $name = '' ): void { public function index( $columns, string $name = '' ): void { $cols = $this->wrap_column_list( $columns ); $index_name = $name ?: 'index_' . md5( $cols ); - $this->indexes[] = "KEY `$index_name` ($cols)"; + $this->indexes[] = "KEY $index_name ($cols)"; } /** @@ -325,7 +392,7 @@ public function index( $columns, string $name = '' ): void { */ protected function wrap_column_list( $columns ): string { $cols = (array) $columns; - return implode( ', ', array_map( fn( $col ) => "`$col`", $cols ) ); + return implode( ', ', $cols ); } /** @@ -358,7 +425,7 @@ public function to_sql(): string { $definitions = array_merge( $this->columns, $this->indexes ); $body = implode( ",\n ", $definitions ); - return "CREATE TABLE `{$this->table_name}` (\n $body\n) {$this->charset_collate};"; + return "CREATE TABLE {$this->table_name} (\n $body\n) {$this->charset_collate};"; } /** @@ -380,6 +447,6 @@ public function to_alter_sql(): string { $definitions = array_merge( $definitions, $this->drops ); $body = implode( ",\n ", $definitions ); - return "ALTER TABLE `{$this->table_name}`\n $body;"; + return "ALTER TABLE {$this->table_name}\n $body;"; } } \ No newline at end of file diff --git a/src/Schema/ForeignKey.php b/src/Schema/ForeignKey.php index d19905a..21f9ceb 100644 --- a/src/Schema/ForeignKey.php +++ b/src/Schema/ForeignKey.php @@ -1,9 +1,25 @@ reference_table = $wpdb->prefix . $reference_table; + $this->reference_table = ( new Resolver() )->table( $reference_table ); return $this; } diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index 9521102..c8666b5 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -1,12 +1,71 @@ table( $table_name ); + + $suppress = $wpdb->suppress_errors(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->get_results( "SELECT 1 FROM `{$full_table_name}` LIMIT 1" ); + $wpdb->suppress_errors( $suppress ); + + return empty( $wpdb->last_error ); + } + + /** + * Checks if a column exists in a given table. + * + * @param string $table_name The name of the table (without prefix). + * @param string $column The name of the column. + * @return bool + */ + public static function has_column( string $table_name, string $column ): bool { + global $wpdb; + $resolver = new Resolver(); + $full_table_name = $resolver->table( $table_name ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( "DESCRIBE `{$full_table_name}`" ); + + foreach ( $results as $row ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( $row->Field === $column ) { + return true; + } + } + return false; + } + /** * Creates a new database table with the specified name and schema definition. * @@ -27,10 +86,11 @@ public static function create( string $table_name, callable $callback, bool $ret */ global $wpdb; - $table_name = $wpdb->prefix . $table_name; + $resolver = new Resolver(); + $full_table_name = $resolver->table( $table_name ); $charset_collate = $wpdb->get_charset_collate(); - $blueprint = new Blueprint( $table_name, $charset_collate ); + $blueprint = new Blueprint( $full_table_name, $charset_collate ); $callback( $blueprint ); $sql = $blueprint->to_sql(); @@ -39,25 +99,23 @@ public static function create( string $table_name, callable $callback, bool $ret if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } - + dbDelta( $sql ); + self::apply_foreign_keys( $table_name, $blueprint->get_foreign_keys() ); } /** * Drops a database table if it exists. * - * Constructs and executes a SQL statement to drop the specified table, - * using the WordPress database prefix. If the $return parameter is true, - * the SQL statement is returned instead of being executed. - * * @param string $table_name The name of the table to drop (without prefix). * @param bool $return Optional. Whether to return the SQL statement instead of executing it. Default false. * @return string|null The SQL statement if $return is true, otherwise null. */ public static function drop_if_exists( string $table_name, bool $return = false ) { global $wpdb; - $full_table_name = $wpdb->prefix . $table_name; + $resolver = new Resolver(); + $full_table_name = $resolver->table( $table_name ); $sql = "DROP TABLE IF EXISTS `{$full_table_name}`;"; if ( $return ) return $sql; @@ -65,6 +123,24 @@ public static function drop_if_exists( string $table_name, bool $return = false $wpdb->query( $sql ); } + /** + * Drops a database table. + * + * @param string $table_name The name of the table to drop (without prefix). + * @param bool $return Optional. Whether to return the SQL statement instead of executing it. Default false. + * @return string|null The SQL statement if $return is true, otherwise null. + */ + public static function drop( string $table_name, bool $return = false ) { + global $wpdb; + $resolver = new Resolver(); + $full_table_name = $resolver->table( $table_name ); + $sql = "DROP TABLE `{$full_table_name}`;"; + if ( $return ) return $sql; + + //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $sql ); + } + /** * Renames a database table from one name to another. * @@ -75,9 +151,10 @@ public static function drop_if_exists( string $table_name, bool $return = false */ public static function rename( string $from, string $to, bool $return = false ) { global $wpdb; - $from_table = $wpdb->prefix . $from; - $to_table = $wpdb->prefix . $to; - $sql = "RENAME TABLE `{$from_table}` TO `{$to_table}`;"; + $resolver = new Resolver(); + $from_table = $resolver->table( $from ); + $to_table = $resolver->table( $to ); + $sql = "ALTER TABLE `{$from_table}` RENAME TO `{$to_table}`;"; if ( $return ) return $sql; //phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared @@ -94,7 +171,8 @@ public static function rename( string $from, string $to, bool $return = false ) */ public static function alter( string $table_name, callable $callback, bool $return = false ) { global $wpdb; - $full_table_name = $wpdb->prefix . $table_name; + $resolver = new Resolver(); + $full_table_name = $resolver->table( $table_name ); $charset_collate = $wpdb->get_charset_collate(); $blueprint = new Blueprint( $full_table_name, $charset_collate ); @@ -138,6 +216,9 @@ protected static function apply_foreign_keys( string $table_name, array $foreign $on_update = $fk->get_on_update(); $constraint = "fk_{$table_name}_{$column}"; + $resolver = new Resolver(); + $full_table = $resolver->table( $table_name ); + /** * Checks if a specific foreign key constraint exists on a given table. */ @@ -150,15 +231,15 @@ protected static function apply_foreign_keys( string $table_name, array $foreign AND TABLE_NAME = %s AND CONSTRAINT_NAME = %s", DB_NAME, - $table_name, + $full_table, $constraint ) ); if ( empty( $exists ) ) { $alter_sql = sprintf( - "ALTER TABLE `%s` ADD CONSTRAINT `%s` FOREIGN KEY (`%s`) REFERENCES %s(`%s`)%s%s;", - $table_name, + "ALTER TABLE `%s` ADD CONSTRAINT `%s` FOREIGN KEY (`%s`) REFERENCES `%s`(`%s`)%s%s;", + $full_table, $constraint, $column, $reference, diff --git a/src/Seeder.php b/src/Seeder.php new file mode 100644 index 0000000..61155a7 --- /dev/null +++ b/src/Seeder.php @@ -0,0 +1,80 @@ +resolve( $class )->run(); + } + + return $this; + } + + /** + * Run a seeder programmatically. + * + * @param string $class + * @return void + */ + public static function run_seeder( string $class ) { + ( new $class() )->run(); + } + + /** + * Resolve an instance of the given seeder class. + * + * @param string $class + * @return Seeder + */ + protected function resolve( $class ) { + if ( ! class_exists( $class ) ) { + $namespace = __NAMESPACE__; + $parts = explode( '\\', $namespace ); + $namespace = $parts[0]; + + // Check in convention namespace + $namespaced_class = "{$namespace}\\Database\\Seeders\\{$class}"; + if ( class_exists( $namespaced_class ) ) { + $class = $namespaced_class; + } + } + + if ( ! class_exists( $class ) ) { + throw new InvalidArgumentException( "Seeder class [{$class}] not found." ); + } + + return new $class(); + } +} diff --git a/tests/Assertions/SQLAssertions.php b/tests/Assertions/SQLAssertions.php deleted file mode 100644 index 6cd04f6..0000000 --- a/tests/Assertions/SQLAssertions.php +++ /dev/null @@ -1,72 +0,0 @@ -assertEquals( - $this->normalize_sql( $expected ), - $this->normalize_sql( $actual ), - $message - ); - } - - /** - * Assert SQL contains a specific clause after normalization. - * - * @param string $needle - * @param string $haystack - * @param string $message - * @return void - */ - protected function assert_sql_contains( string $needle, string $haystack, string $message = '' ): void { - $this->assertStringContainsString( - $this->normalize_sql( $needle ), - $this->normalize_sql( $haystack ), - $message - ); - } - - /** - * Normalize SQL string by removing extra whitespace and converting to lowercase. - * This makes SQL comparison more flexible and less brittle. - * - * @param string $sql - * @return string - */ - protected function normalize_sql( string $sql ): string { - // Replace multiple whitespace with single space - $normalized = preg_replace( '/\s+/', ' ', $sql ); - - // Trim and convert to lowercase for case-insensitive comparison - return trim( strtolower( $normalized ) ); - } - - /** - * Assert SQL matches a regular expression pattern. - * - * @param string $pattern - * @param string $sql - * @param string $message - * @return void - */ - protected function assert_sql_matches( string $pattern, string $sql, string $message = '' ): void { - $this->assertMatchesRegularExpression( - $pattern, - $this->normalize_sql( $sql ), - $message - ); - } -} diff --git a/tests/Fixtures/Helpers/ClauseTestHelper.php b/tests/Fixtures/Helpers/ClauseTestHelper.php deleted file mode 100644 index 1467f90..0000000 --- a/tests/Fixtures/Helpers/ClauseTestHelper.php +++ /dev/null @@ -1,41 +0,0 @@ -', '<=', '>=', '<>', '!=', 'like', 'not like']; - - /** - * Prepare value and operator for clause. - * - * @param mixed $value - * @param mixed $operator - * @param bool $use_default - * @return array - */ - protected function prepare_value_and_operator( $value, $operator, $use_default = false ) { - if ( $use_default ) { - return [$operator, '=']; - } - return [$value, $operator]; - } - - /** - * Check if operator is invalid. - * - * @param mixed $operator - * @return bool - */ - protected function invalid_operator( $operator ) { - return ! in_array( $operator, $this->operators ); - } -} diff --git a/tests/Fixtures/Models/Comment.php b/tests/Fixtures/Models/Comment.php deleted file mode 100644 index a857bd5..0000000 --- a/tests/Fixtures/Models/Comment.php +++ /dev/null @@ -1,16 +0,0 @@ - $this->faker()->user_name(), + 'name' => $this->faker()->name(), + 'email' => $this->faker()->safe_email(), + 'role' => 'user', + 'active' => true, + ]; + } + + public function admin() { + return $this->state( + [ + 'role' => 'admin', + ] + ); + } +} diff --git a/tests/Framework/Mocks/App.php b/tests/Framework/Mocks/App.php new file mode 100644 index 0000000..78f5fd0 --- /dev/null +++ b/tests/Framework/Mocks/App.php @@ -0,0 +1,25 @@ +morph_to( 'commentable' ); } +} diff --git a/tests/Framework/Mocks/MockResolver.php b/tests/Framework/Mocks/MockResolver.php new file mode 100644 index 0000000..66deeb9 --- /dev/null +++ b/tests/Framework/Mocks/MockResolver.php @@ -0,0 +1,13 @@ +prefix ) ? $wpdb->prefix : 'wptests_'; + return $prefix . $table; + } +} diff --git a/tests/Framework/Mocks/Post.php b/tests/Framework/Mocks/Post.php new file mode 100644 index 0000000..562397c --- /dev/null +++ b/tests/Framework/Mocks/Post.php @@ -0,0 +1,23 @@ +belongs_to( User::class, 'post_author' ); } + + public function comments() { + return $this->morph_many( Comment::class, 'commentable' ); } +} diff --git a/tests/Framework/Mocks/Profile.php b/tests/Framework/Mocks/Profile.php new file mode 100644 index 0000000..1bd6f3c --- /dev/null +++ b/tests/Framework/Mocks/Profile.php @@ -0,0 +1,20 @@ +belongs_to( User::class ); } +} diff --git a/tests/Framework/Mocks/User.php b/tests/Framework/Mocks/User.php new file mode 100644 index 0000000..87d58b9 --- /dev/null +++ b/tests/Framework/Mocks/User.php @@ -0,0 +1,20 @@ +has_many( Post::class, 'post_author' ); } +} diff --git a/tests/Framework/Models/TestAuditModel.php b/tests/Framework/Models/TestAuditModel.php new file mode 100644 index 0000000..b74a077 --- /dev/null +++ b/tests/Framework/Models/TestAuditModel.php @@ -0,0 +1,56 @@ + 'boolean', + 'age' => 'integer', + 'score' => 'float', + 'salary' => 'decimal:2', + 'meta' => 'json', + 'settings' => 'array', + 'raw_data' => 'object', + 'joined_at' => 'date', + 'last_login_at' => 'datetime', + 'formatted_date' => 'datetime:Y-m-d', + 'secret_code' => 'string', + ]; + + public static function get_table_name(): string { + return 'test_audit_models'; + } + + // Mutator + public function set_name_attribute( $value ) { + $this->attributes['name'] = strtoupper( $value ); + } + + // Accessor + public function get_email_attribute( $value ) { + return strtolower( $value ); + } + + // Accessor for appended attribute + public function get_full_profile_attribute() { + return $this->name . ' (' . $this->email . ')'; + } + + // Mutator for casted attribute + public function set_meta_attribute( $value ) { + if ( is_array( $value ) ) { + $value['updated_via_mutator'] = true; + } + $this->attributes['meta'] = json_encode( $value ); + } +} diff --git a/tests/Framework/Models/TestCountry.php b/tests/Framework/Models/TestCountry.php new file mode 100644 index 0000000..00f20f3 --- /dev/null +++ b/tests/Framework/Models/TestCountry.php @@ -0,0 +1,23 @@ +has_many_through( TestPost::class, TestUser::class, 'country_id', 'user_id' ); + } + + public function users() { + return $this->has_many( TestUser::class, 'country_id' ); + } + + public function profile() { + return $this->has_one_through( TestProfile::class, TestUser::class, 'country_id', 'user_id' ); + } +} diff --git a/tests/Framework/Models/TestImage.php b/tests/Framework/Models/TestImage.php new file mode 100644 index 0000000..ec89d2e --- /dev/null +++ b/tests/Framework/Models/TestImage.php @@ -0,0 +1,17 @@ +morph_to( 'imageable' ); + } +} diff --git a/tests/Framework/Models/TestNetworkUser.php b/tests/Framework/Models/TestNetworkUser.php new file mode 100644 index 0000000..d95779a --- /dev/null +++ b/tests/Framework/Models/TestNetworkUser.php @@ -0,0 +1,17 @@ +has_many( TestPost::class, 'user_id' ); + } +} diff --git a/tests/Framework/Models/TestPost.php b/tests/Framework/Models/TestPost.php new file mode 100644 index 0000000..6d64c8e --- /dev/null +++ b/tests/Framework/Models/TestPost.php @@ -0,0 +1,31 @@ +belongs_to( TestUser::class, 'user_id' ); + } + + public function network_user() { + return $this->belongs_to( TestNetworkUser::class, 'user_id' ); + } + + public function image() { + return $this->morph_one( TestImage::class, 'imageable' ); + } + + public function tags() { + return $this->morph_to_many( TestTag::class, 'taggable', 'test_taggables' ); + } +} diff --git a/tests/Framework/Models/TestProfile.php b/tests/Framework/Models/TestProfile.php new file mode 100644 index 0000000..17b6d05 --- /dev/null +++ b/tests/Framework/Models/TestProfile.php @@ -0,0 +1,19 @@ +belongs_to( TestUser::class, 'user_id' ); + } +} diff --git a/tests/Framework/Models/TestRole.php b/tests/Framework/Models/TestRole.php new file mode 100644 index 0000000..07387a3 --- /dev/null +++ b/tests/Framework/Models/TestRole.php @@ -0,0 +1,19 @@ +belongs_to_many( TestUser::class, 'test_user_roles', 'role_id', 'user_id' ); + } +} diff --git a/tests/Framework/Models/TestTag.php b/tests/Framework/Models/TestTag.php new file mode 100644 index 0000000..61f6247 --- /dev/null +++ b/tests/Framework/Models/TestTag.php @@ -0,0 +1,17 @@ +morphed_by_many( TestPost::class, 'taggable', 'test_taggables' ); + } +} diff --git a/tests/Framework/Models/TestUser.php b/tests/Framework/Models/TestUser.php new file mode 100644 index 0000000..4257351 --- /dev/null +++ b/tests/Framework/Models/TestUser.php @@ -0,0 +1,48 @@ + 'json', + 'is_active' => 'boolean', + ]; + + public static function get_table_name(): string { + return 'test_users'; + } + + public function posts() { + return $this->has_many( TestPost::class, 'user_id' ); + } + + public function profile() { + return $this->has_one( TestProfile::class, 'user_id' ); + } + + public function roles() { + return $this->belongs_to_many( TestRole::class, 'test_user_roles', 'user_id', 'role_id' ); + } + + public function image() { + return $this->morph_one( TestImage::class, 'imageable' ); + } + + public function images() { + return $this->morph_many( TestImage::class, 'imageable' ); + } + + public function latest_post() { + return $this->has_one_of_many( TestPost::class, 'user_id', 'id', 'id', 'desc' ); + } + + public function oldest_post() { + return $this->has_one_of_many( TestPost::class, 'user_id', 'id', 'id', 'asc' ); + } +} diff --git a/tests/Framework/TestCase.php b/tests/Framework/TestCase.php new file mode 100644 index 0000000..664d604 --- /dev/null +++ b/tests/Framework/TestCase.php @@ -0,0 +1,114 @@ +prefix . $table_name; + + $wpdb->suppress_errors(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $res = $wpdb->query( "SELECT 1 FROM `$table` LIMIT 0" ); + $wpdb->suppress_errors( false ); + + $this->assertNotFalse( $res, "Table [{$table}] does not exist according to SELECT check." ); + } + + /** + * Assert that a table does not exist in the database. + */ + protected function assertTableNotExists( $table_name ) { + global $wpdb; + $table = $wpdb->prefix . $table_name; + + $wpdb->suppress_errors(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $res = $wpdb->query( "SELECT 1 FROM `$table` LIMIT 0" ); + $wpdb->suppress_errors( false ); + + $this->assertFalse( $res, "Table [{$table}] should not exist according to SELECT check." ); + } + + /** + * Assert that a column exists in a table. + */ + protected function assertColumnExists( $table_name, $column_name ) { + global $wpdb; + $table = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM `{$table}` LIKE %s", $column_name ) ); + $this->assertNotEmpty( $results, "Column [{$column_name}] does not exist in table [{$table}]." ); + } + + /** + * Assert that an index exists on a table. + */ + protected function assertHasIndex( $table_name, $index_name ) { + global $wpdb; + $table = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $indexes = $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ); + + $found = false; + foreach ( $indexes as $index ) { + if ( $index['Key_name'] === $index_name ) { + $found = true; + break; + } + } + + $this->assertTrue( $found, "Index [{$index_name}] does not exist on table [{$table}]." ); + } +} diff --git a/tests/Integration/AdvancedQueryTest.php b/tests/Integration/AdvancedQueryTest.php new file mode 100644 index 0000000..fc1fb50 --- /dev/null +++ b/tests/Integration/AdvancedQueryTest.php @@ -0,0 +1,345 @@ +drop_tables(); + $this->create_tables(); + } + + public function tearDown(): void { + $this->drop_tables(); + parent::tearDown(); + } + + protected function drop_tables() { + Schema::drop_if_exists( 'test_user_roles' ); + Schema::drop_if_exists( 'test_roles' ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + Schema::drop_if_exists( 'test_tags' ); + Schema::drop_if_exists( 'test_taggables' ); + } + + protected function create_tables() { + Schema::create( + 'test_users', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + $table->string( 'title' ); + $table->integer( 'views' )->default( 0 ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_roles', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_user_roles', function( Blueprint $table ) { + $table->unsigned_big_integer( 'user_id' ); + $table->unsigned_big_integer( 'role_id' ); + } + ); + + Schema::create( + 'test_tags', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_taggables', function( Blueprint $table ) { + $table->unsigned_big_integer( 'test_tag_id' ); + $table->unsigned_big_integer( 'taggable_id' ); + $table->string( 'taggable_type' ); + } + ); + } + + /** + * Test relationship aggregates with callbacks. + */ + public function test_it_handles_relationship_aggregates_with_callbacks() { + $user = TestUser::create( ['name' => 'John Doe'] ); + TestPost::create( ['user_id' => $user->id, 'title' => 'Post 1', 'views' => 10] ); + TestPost::create( ['user_id' => $user->id, 'title' => 'Post 2', 'views' => 20] ); + TestPost::create( ['user_id' => $user->id, 'title' => 'Draft', 'views' => 100] ); // Should be filtered + + // Test Sum + $found = TestUser::query() + ->with_sum( + ['posts' => function( $query ) { + $query->where( 'title', '!=', 'Draft' ); + } + ], 'views' + ) + ->find( $user->id ); + $this->assertEquals( 30, (int) $found->posts_sum_views ); + + // Test Avg + $found = TestUser::query() + ->with_avg( + ['posts' => function( $query ) { + $query->where( 'title', '!=', 'Draft' ); + } + ], 'views' + ) + ->find( $user->id ); + $this->assertEquals( 15, (float) $found->posts_avg_views ); + } + + /** + * Test relationship filtering (where_has). + */ + public function test_it_handles_where_has_with_nested_callbacks() { + $user1 = TestUser::create( ['name' => 'Active Author'] ); + $post1 = TestPost::create( ['user_id' => $user1->id, 'title' => 'Published'] ); + $tag = TestTag::create( ['name' => 'PHP'] ); + $post1->tags()->attach( $tag->id ); + + $user2 = TestUser::create( ['name' => 'Inactive Author'] ); + TestPost::create( ['user_id' => $user2->id, 'title' => 'Draft'] ); + + // Find users who have posts with a specific tag + $query = TestUser::query()->where_has( + 'posts', function( $query ) { + $query->where_has( + 'tags', function( $q ) { + $q->where( 'name', 'PHP' ); + } + ); + } + ); + + $sql = $query->to_sql(); + // fwrite(STDERR, "\nWhereHas SQL: $sql\n"); + + $results = $query->get(); + + if ( count( $results ) !== 1 ) { + $this->fail( "Expected 1 result, got " . count( $results ) . ". SQL: $sql" ); + } + + $this->assertEquals( 'Active Author', $results[0]->name ); + } + + /** + * Test relationship filtering (doesnt_have). + */ + public function test_it_handles_doesnt_have_and_where_doesnt_have() { + $user1 = TestUser::create( ['name' => 'User with Posts'] ); + TestPost::create( ['user_id' => $user1->id, 'title' => 'Post'] ); + + $user2 = TestUser::create( ['name' => 'User without Posts'] ); + + // doesnt_have + $this->assertCount( 1, TestUser::query()->doesnt_have( 'posts' )->get() ); + $this->assertEquals( 'User without Posts', TestUser::query()->doesnt_have( 'posts' )->first()->name ); + + // where_doesnt_have with callback + $results = TestUser::query()->where_doesnt_have( + 'posts', function( $query ) { + $query->where( 'title', 'Post' ); + } + )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'User without Posts', $results[0]->name ); + } + + /** + * Test subquery in where clause using closure. + */ + public function test_it_handles_subqueries_in_where_clauses() { + TestUser::create( ['name' => 'User 1'] ); + TestUser::create( ['name' => 'User 2'] ); + + // Simplified subquery simulation: find users whose name is in a list from another table/query + $results = TestUser::query()->where( + function( $query ) { + $query->where( 'name', 'User 1' )->or_where( 'name', 'User 2' ); + } + )->get(); + + $this->assertCount( 2, $results ); + } + + /** + * Test union operations. + */ + public function test_it_can_perform_unions() { + TestUser::create( ['name' => 'User A'] ); + TestTag::create( ['name' => 'Tag 1'] ); + + $query = TestUser::query() + ->select( 'name' ) + ->where( 'name', '=', 'User A' ) + ->union( + function( $query ) { + $query->select( 'name' )->from( 'test_tags' )->where( 'name', '=', 'Tag 1' ); + } + ); + + $results = $query->get(); + + $this->assertCount( 2, $results ); + $names = array_map( fn( $u ) => $u->name, $results->all() ); + $this->assertContains( 'User A', $names ); + $this->assertContains( 'Tag 1', $names ); + } + + /** + * Test multi-query unions with multiple bindings. + */ + public function test_it_handles_multi_query_unions_with_bindings() { + TestUser::create( ['name' => 'User 1'] ); + TestTag::create( ['name' => 'Tag 1'] ); + TestRole::create( ['name' => 'Role 1'] ); + + // Three distinct tables in each UNION arm avoids MySQL's + // "Can't reopen table" limitation on temporary tables. + $query = TestUser::query() + ->select( 'name' ) + ->where( 'name', '=', 'User 1' ) + ->union_all( + function( $query ) { + $query->select( 'name' )->from( 'test_tags' )->where( 'name', '=', 'Tag 1' ); + } + ) + ->union( + function( $query ) { + $query->select( 'name' )->from( 'test_roles' )->where( 'name', '=', 'Role 1' ); + } + ); + + $results = $query->get(); + + $this->assertCount( 3, $results ); + $names = array_map( fn( $u ) => $u->name, $results->all() ); + $this->assertContains( 'User 1', $names ); + $this->assertContains( 'Tag 1', $names ); + $this->assertContains( 'Role 1', $names ); + } + + /** + * Test unions with nested relationship logic (where_has). + */ + public function test_it_handles_unions_with_nested_relationship_logic() { + // User 1 has posts; User 2 has a role. + // Test that union correctly combines two different relationship existence checks. + $user1 = TestUser::create( ['name' => 'User with Posts'] ); + TestPost::create( ['user_id' => $user1->id, 'title' => 'A Post'] ); + + $user2 = TestUser::create( ['name' => 'User with Role'] ); + $role = TestRole::create( ['name' => 'Manager'] ); + $user2->roles()->attach( $role->id ); + + // We build two separate queries and union them into a raw SQL union + // to avoid MySQL temp table collision when the same table appears in both arms. + // Each arm uses the same test_users base table but grouped by different criteria; + // we work around the limitation by running each arm separately and asserting both are individually correct. + $users_with_posts = TestUser::query()->where_has( 'posts' )->get(); + $users_with_roles = TestUser::query()->where_has( 'roles' )->get(); + + // Verify each arm returns correct data + $this->assertCount( 1, $users_with_posts ); + $this->assertEquals( 'User with Posts', $users_with_posts[0]->name ); + + $this->assertCount( 1, $users_with_roles ); + $this->assertEquals( 'User with Role', $users_with_roles[0]->name ); + + // Verify union SQL generates correctly (structural test — MySQL prevents execution with same temp table) + $query = TestUser::query()->where_has( 'posts' )->union( + function( $q ) { + $q->where_has( 'roles' ); + } + ); + $sql = $query->get_raw_sql(); + $this->assertStringContainsString( 'union', strtolower( $sql ) ); + $this->assertStringContainsString( 'exists', strtolower( $sql ) ); + } + + /** + * Test complex joins with multiple conditions. + */ + public function test_it_handles_complex_joins() { + $user = TestUser::create( ['name' => 'Join User'] ); + $post = TestPost::create( ['user_id' => $user->id, 'title' => 'Join Post', 'views' => 50] ); + + $results = TestUser::query() + ->join( + 'test_posts', function( $join ) { + $join->on( 'test_users.id', '=', 'test_posts.user_id' ) + ->where( 'test_posts.views', '>', 10 ); + } + ) + ->select( 'test_users.name', 'test_posts.title' ) + ->get(); + + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Join User', $results[0]->name ); + $this->assertEquals( 'Join Post', $results[0]->title ); + } + + /** + * Test limit and offset (pagination core). + */ + public function test_it_handles_limit_and_offset() { + TestUser::create( ['name' => 'User 1'] ); + TestUser::create( ['name' => 'User 2'] ); + TestUser::create( ['name' => 'User 3'] ); + + $results = TestUser::query()->order_by( 'id', 'asc' )->limit( 1 )->offset( 1 )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'User 2', $results[0]->name ); + } + + /** + * Test multi-column ordering. + */ + public function test_it_handles_multi_column_ordering() { + TestUser::create( ['name' => 'B', 'id' => 1] ); + TestUser::create( ['name' => 'A', 'id' => 2] ); + TestUser::create( ['name' => 'A', 'id' => 3] ); + + $results = TestUser::query() + ->order_by( 'name', 'asc' ) + ->order_by( 'id', 'desc' ) + ->get(); + + $this->assertEquals( 'A', $results[0]->name ); + $this->assertEquals( 3, $results[0]->id ); + $this->assertEquals( 'A', $results[1]->name ); + $this->assertEquals( 2, $results[1]->id ); + $this->assertEquals( 'B', $results[2]->name ); + } +} diff --git a/tests/Integration/Clauses/ClauseIntegrationTest.php b/tests/Integration/Clauses/ClauseIntegrationTest.php new file mode 100644 index 0000000..79383c2 --- /dev/null +++ b/tests/Integration/Clauses/ClauseIntegrationTest.php @@ -0,0 +1,241 @@ +big_increments( 'id' ); + $table->string( 'category' ); + $table->string( 'name' ); + $table->integer( 'price' ); + } + ); + + Schema::create( + 'clause_orders', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'product_id' ); + $table->integer( 'quantity' ); + } + ); + + // Seed + global $wpdb; + $p = $wpdb->prefix . 'clause_products'; + $wpdb->query( + //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "INSERT INTO `$p` (category, name, price) VALUES + ('Electronics', 'Phone', 500), + ('Electronics', 'Tablet', 800), + ('Books', 'Novel', 20), + ('Books', 'Textbook', 60), + ('Books', 'Dictionary', 40) + " + ); + + $o = $wpdb->prefix . 'clause_orders'; + $wpdb->query( + //phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "INSERT INTO `$o` (product_id, quantity) VALUES + (1, 3), (1, 2), (2, 1), (3, 10), (4, 5), (5, 2) + " + ); + } + + public function tear_down(): void { + Schema::drop_if_exists( 'clause_orders' ); + Schema::drop_if_exists( 'clause_products' ); + parent::tear_down(); + } + + protected function product_builder(): Builder { + $b = new Builder( new TestUser() ); + $b->from( 'clause_products' ); + return $b; + } + + protected function order_builder(): Builder { + $b = new Builder( new TestUser() ); + $b->from( 'clause_orders' ); + return $b; + } + + // ========================================================================= + // GROUP BY + // ========================================================================= + + public function test_group_by_category_returns_one_row_per_category() { + $results = $this->product_builder() + ->select( 'category' ) + ->group_by( 'category' ) + ->get(); + + $this->assertCount( 2, $results ); + $categories = array_map( fn( $r ) => $r->category, $results->all() ); + sort( $categories ); + $this->assertEquals( ['Books', 'Electronics'], $categories ); + } + + public function test_group_by_with_count() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['cnt' => 'COUNT(*)'] ) + ->group_by( 'category' ) + ->order_by( 'cnt', 'desc' ) + ->get(); + + $this->assertCount( 2, $results ); + // Books has 3, Electronics has 2 + $this->assertEquals( 'Books', $results[0]->category ); + $this->assertEquals( 3, (int) $results[0]->cnt ); + } + + public function test_group_by_with_sum() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['total_price' => 'SUM(price)'] ) + ->group_by( 'category' ) + ->order_by( 'total_price', 'desc' ) + ->get(); + + $this->assertCount( 2, $results ); + // Electronics: 500+800=1300, Books: 20+60+40=120 + $this->assertEquals( 'Electronics', $results[0]->category ); + $this->assertEquals( 1300, (int) $results[0]->total_price ); + $this->assertEquals( 120, (int) $results[1]->total_price ); + } + + // ========================================================================= + // HAVING + // ========================================================================= + + public function test_having_filters_groups_by_aggregate() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['cnt' => 'COUNT(*)'] ) + ->group_by( 'category' ) + ->having( 'cnt', '>', 2 ) + ->get(); + + // Only Books has 3 items > 2 + $this->assertCount( 1, $results ); + $this->assertEquals( 'Books', $results[0]->category ); + } + + public function test_having_with_sum_threshold() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['total_price' => 'SUM(price)'] ) + ->group_by( 'category' ) + ->having( 'total_price', '>=', 1000 ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Electronics', $results[0]->category ); + } + + public function test_having_raw() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['avg_price' => 'AVG(price)'] ) + ->group_by( 'category' ) + ->having_raw( 'AVG(price) > 100' ) + ->get(); + + // Electronics avg = (500+800)/2 = 650, Books avg = (20+60+40)/3 ≈ 40 + $this->assertCount( 1, $results ); + $this->assertEquals( 'Electronics', $results[0]->category ); + } + + public function test_or_having_includes_additional_groups() { + $results = $this->product_builder() + ->select( 'category' ) + ->add_select( ['cnt' => 'COUNT(*)'] ) + ->add_select( ['total' => 'SUM(price)'] ) + ->group_by( 'category' ) + ->having( 'cnt', '>', 2 ) + ->or_having( 'total', '>', 1000 ) + ->get(); + + // Books: cnt=3 (>2) ✓, Electronics: total=1300 (>1000) ✓ + $this->assertCount( 2, $results ); + } + + // ========================================================================= + // ON clause (JOIN + ON) + // ========================================================================= + + public function test_join_with_on_clause_returns_matching_rows() { + $results = $this->product_builder() + ->select( 'clause_products.name', 'clause_orders.quantity' ) + ->join( 'clause_orders', 'clause_products.id', '=', 'clause_orders.product_id' ) + ->order_by( 'clause_products.id' ) + ->get(); + + // Phone has 2 orders, Tablet 1, Novel 1, Textbook 1, Dictionary 1 = 6 rows + $this->assertCount( 6, $results ); + $this->assertEquals( 'Phone', $results[0]->name ); + } + + public function test_left_join_with_on_clause_includes_unmatched_rows() { + // No order for Textbook with quantity > 10 — left join should still return product + $results = $this->product_builder() + ->select( 'clause_products.name' ) + ->add_select( ['total_qty' => 'SUM(clause_orders.quantity)'] ) + ->left_join( 'clause_orders', 'clause_products.id', '=', 'clause_orders.product_id' ) + ->group_by( 'clause_products.id', 'clause_products.name' ) + ->order_by( 'clause_products.id' ) + ->get(); + + // All 5 products should appear + $this->assertCount( 5, $results ); + } + + public function test_join_with_callback_on_clause() { + $results = $this->product_builder() + ->select( 'clause_products.name', 'clause_orders.quantity' ) + ->join( + 'clause_orders', function( $join ) { + $join->on( 'clause_products.id', '=', 'clause_orders.product_id' ) + ->where( 'clause_orders.quantity', '>', 2 ); + } + ) + ->get(); + + // Phone: qty 3 ✓, qty 2 ✗ | Novel: qty 10 ✓ | Textbook: qty 5 ✓ = 3 rows + $this->assertCount( 3, $results ); + } + + // ========================================================================= + // Combined GROUP BY + HAVING + JOIN + // ========================================================================= + + public function test_group_by_having_with_join() { + $results = $this->product_builder() + ->select( 'clause_products.category' ) + ->add_select( ['total_qty' => 'SUM(clause_orders.quantity)'] ) + ->join( 'clause_orders', 'clause_products.id', '=', 'clause_orders.product_id' ) + ->group_by( 'clause_products.category' ) + ->having( 'total_qty', '>', 5 ) + ->get(); + + // Electronics: 3+2+1=6 ✓, Books: 10+5+2=17 ✓ + $this->assertCount( 2, $results ); + } +} diff --git a/tests/Integration/EdgeCasesTest.php b/tests/Integration/EdgeCasesTest.php deleted file mode 100644 index 993cb4e..0000000 --- a/tests/Integration/EdgeCasesTest.php +++ /dev/null @@ -1,180 +0,0 @@ -mockDatabaseResults( [] ); - - // Act - $results = Post::query()->where( 'id', 999999 )->get(); - - // Assert - $this->assertIsArray( $results ); - $this->assertEmpty( $results ); - } - - /** - * @test - * - * Verifies that first() returns null when no results found. - */ - public function it_returns_null_when_first_finds_nothing() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - $result = Post::query()->where( 'id', 999999 )->first(); - - // Assert - $this->assertNull( $result ); - } - - /** - * @test - * - * Verifies that whereNull handles null columns correctly. - */ - public function it_handles_where_null_correctly() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->where_null( 'deleted_at' )->get(); - - // Assert - $this->assert_sql_contains( 'where deleted_at is null', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that whereNotNull handles not null columns correctly. - */ - public function it_handles_where_not_null_correctly() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->where_not_null( 'published_at' )->get(); - - // Assert - $this->assert_sql_contains( 'where published_at is not null', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that invalid order direction throws exception. - */ - public function it_throws_exception_for_invalid_order_direction() { - // Arrange & Act & Assert - $this->expectException( InvalidArgumentException::class ); - $this->expectExceptionMessage( 'Order direction must be "asc" or "desc"' ); - - Post::query()->order_by( 'created_at', 'invalid' ); - } - - /** - * @test - * - * Verifies that pagination calculates offset correctly. - */ - public function it_calculates_pagination_offset_correctly() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->pagination( 3, 10 ); // Page 3, 10 per page - - // Assert - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'limit %d', $last_query ); - $this->assert_sql_contains( 'offset %d', $last_query ); // (3-1) * 10 = 20 - } - - /** - * @test - * - * Verifies that whereBetween handles range queries correctly. - */ - public function it_handles_where_between_correctly() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->where_between( 'views', [100, 500] )->get(); - - // Assert - $this->assert_sql_contains( 'where views between', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that whereLike handles pattern matching queries. - */ - public function it_handles_where_like_correctly() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->where_like( 'title', '%search%' )->get(); - - // Assert - $this->assert_sql_contains( 'where title like', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that distinct() generates correct SQL. - */ - public function it_handles_distinct_queries() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query()->distinct()->select( 'status' )->get(); - - // Assert - $this->assert_sql_contains( 'select distinct status', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that groupBy and having work together. - */ - public function it_handles_group_by_with_having() { - // Arrange - $this->mockDatabaseResults( [] ); - - // Act - Post::query() - ->select( ['author_id', 'COUNT(*) as post_count'] ) - ->group_by( 'author_id' ) - ->having( 'COUNT(*)', '>', 5 ) - ->get(); - - // Assert - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'group by author_id', $last_query ); - $this->assert_sql_contains( 'having count(*)', $last_query ); - } -} diff --git a/tests/Integration/Eloquent/ModelEventTest.php b/tests/Integration/Eloquent/ModelEventTest.php new file mode 100644 index 0000000..eea0bfe --- /dev/null +++ b/tests/Integration/Eloquent/ModelEventTest.php @@ -0,0 +1,163 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + } + + public function test_it_fires_model_events_during_creation() { + $observer = new class { + public function saving( $model ) { + ModelEventTest::log( 'saving' ); } + + public function creating( $model ) { + ModelEventTest::log( 'creating' ); } + + public function created( $model ) { + ModelEventTest::log( 'created' ); } + + public function saved( $model ) { + ModelEventTest::log( 'saved' ); } + }; + + TestUser::observe( $observer ); + + TestUser::create( ['name' => 'Event User'] ); + + $this->assertEquals( ['saving', 'creating', 'created', 'saved'], self::$event_log ); + } + + public function test_it_can_halt_operation_via_observer() { + $observer = new class { + public function saving( $model ) { + return false; // Halt + } + }; + + TestUser::observe( $observer ); + + $user = new TestUser( ['name' => 'Should Not Exist'] ); + $result = $user->save(); + + $this->assertFalse( $result ); + $this->assertFalse( $user->exists ); + $this->assertEmpty( TestUser::all() ); + } + + public function test_it_fires_wordpress_hooks() { + $hook_called = false; + // Try both general and class-specific hooks + add_filter( + 'wpmvc_model_saving', function( $should_save, $model ) use ( &$hook_called ) { + $hook_called = true; + return $should_save; + }, 10, 2 + ); + + TestUser::create( ['name' => 'Hook User'] ); + + $this->assertTrue( $hook_called, "The 'wpmvc_model_saving' hook should have been called." ); + } + + public function test_it_can_halt_via_wordpress_hooks() { + add_filter( + 'wpmvc_model_creating_test_users', function() { + return false; + } + ); + + $user = new TestUser( ['name' => 'Halted User'] ); + $result = $user->save(); + + $this->assertFalse( $result ); + $this->assertEmpty( TestUser::all() ); + + // Cleanup global filter to not affect other tests if they run in same process + remove_all_filters( 'wpmvc_model_creating_test_users' ); + } + + public static function log( $event ) { + self::$event_log[] = $event; + } + + public function test_it_fires_updating_and_updated_events(): void { + $observer = new class { + public function updating( $model ) { + ModelEventTest::log( 'updating' ); + } + + public function updated( $model ) { + ModelEventTest::log( 'updated' ); + } + }; + + TestUser::observe( $observer ); + $user = TestUser::create( ['name' => 'UpdateEvents'] ); + + self::$event_log = []; // reset log to only capture update events + $user->name = 'UpdateEvents Changed'; + $user->save(); + + $this->assertContains( 'updating', self::$event_log ); + $this->assertContains( 'updated', self::$event_log ); + } + + public function test_it_fires_deleting_and_deleted_events(): void { + $observer = new class { + public function deleting( $model ) { + ModelEventTest::log( 'deleting' ); + } + + public function deleted( $model ) { + ModelEventTest::log( 'deleted' ); + } + }; + + TestUser::observe( $observer ); + $user = TestUser::create( ['name' => 'DeleteEvents'] ); + + self::$event_log = []; // reset log to only capture delete events + $user->delete(); + + $this->assertContains( 'deleting', self::$event_log ); + $this->assertContains( 'deleted', self::$event_log ); + } + + public function test_observer_can_halt_deletion(): void { + $observer = new class { + public function deleting( $model ) { + return false; // prevent deletion + } + }; + + TestUser::observe( $observer ); + $user = TestUser::create( ['name' => 'Should Stay'] ); + + $result = $user->delete(); + + $this->assertFalse( $result ); + $found = TestUser::find( $user->id ); + $this->assertNotNull( $found ); + $this->assertEquals( 'Should Stay', $found->name ); + } +} diff --git a/tests/Integration/Eloquent/ModelTest.php b/tests/Integration/Eloquent/ModelTest.php new file mode 100644 index 0000000..6c0d6f5 --- /dev/null +++ b/tests/Integration/Eloquent/ModelTest.php @@ -0,0 +1,418 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->string( 'email' ); + $table->boolean( 'is_active' )->default( true ); + $table->text( 'meta' )->nullable(); + $table->timestamps(); + } + ); + } + + public function tearDown(): void { + Schema::drop_if_exists( 'test_users' ); + parent::tearDown(); + } + + public function test_it_can_create_and_retrieve_a_model() { + $user = TestUser::create( + [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ] + ); + + $this->assertNotNull( $user->id ); + $this->assertEquals( 'John Doe', $user->name ); + + $found = TestUser::find( $user->id ); + $this->assertInstanceOf( TestUser::class, $found ); + $this->assertEquals( 'john@example.com', $found->email ); + } + + public function test_it_can_update_a_model() { + $user = TestUser::create( + [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + ] + ); + + $user->name = 'Jane Smith'; + $user->save(); + + $found = TestUser::find( $user->id ); + $this->assertEquals( 'Jane Smith', $found->name ); + } + + public function test_it_can_delete_a_model() { + $user = TestUser::create( + [ + 'name' => 'Delete Me', + 'email' => 'delete@example.com', + ] + ); + + $id = $user->id; + $user->delete(); + + $found = TestUser::find( $id ); + $this->assertNull( $found ); + } + + public function test_it_can_cast_attributes() { + $user = TestUser::create( + [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'is_active' => '1', // string in DB + 'meta' => ['theme' => 'dark'], // array to be json_encoded + ] + ); + + $this->assertIsBool( $user->is_active ); + $this->assertTrue( $user->is_active ); + $this->assertIsArray( $user->meta ); + $this->assertEquals( 'dark', $user->meta['theme'] ); + + // Check DB raw value + global $wpdb; + $table = $wpdb->prefix . 'test_users'; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$table` WHERE id = %d", $user->id ) ); + $this->assertEquals( '{"theme":"dark"}', $row->meta ); + } + + /** @test */ + public function test_fillable_only_allows_fillable_attributes() { + Model::reguard(); + $model = new TestAuditModel(); + $model->fill( + [ + 'name' => 'John', + 'is_admin' => true, // Guarded + ] + ); + + $this->assertEquals( 'JOHN', $model->name ); // Mutator applied + $this->assertArrayNotHasKey( 'is_admin', $model->get_attributes() ); + } + + /** @test */ + public function test_guarded_blocks_guarded_attributes() { + Model::reguard(); + $model = new TestAuditModel(); + $model->fill( + [ + 'name' => 'John', + 'internal_notes' => 'Secret', // Guarded + ] + ); + + $this->assertArrayNotHasKey( 'internal_notes', $model->get_attributes() ); + } + + /** @test */ + public function test_unguard_allows_everything() { + Model::unguard(); + $model = new \WpMVC\Database\Tests\Framework\Models\TestAuditModel(); + $model->fill( + [ + 'is_admin' => true, + 'internal_notes' => 'Secret', + ] + ); + + $this->assertTrue( (bool) $model->is_admin ); + $this->assertEquals( 'Secret', $model->internal_notes ); + Model::reguard(); + } + + /** @test */ + public function test_comprehensive_casting() { + $model = new TestAuditModel(); + $model->fill( + [ + 'age' => '25', + 'score' => '95.5', + 'salary' => '5000.789', + 'is_active' => '1', + 'secret_code' => 12345, + 'meta' => ['key' => 'val'], + 'settings' => ['theme' => 'dark'], + 'raw_data' => '{"a":1}', + 'joined_at' => '2023-01-01', + 'last_login_at' => '2023-01-01 12:00:00', + 'formatted_date' => '2023-01-01 15:00:00', + ] + ); + + $this->assertIsInt( $model->age ); + $this->assertEquals( 25, $model->age ); + + $this->assertIsFloat( $model->score ); + $this->assertEquals( 95.5, $model->score ); + + $this->assertEquals( '5000.79', $model->salary ); // decimal:2 + + $this->assertIsBool( $model->is_active ); + $this->assertTrue( $model->is_active ); + + $this->assertIsString( $model->secret_code ); + $this->assertEquals( '12345', $model->secret_code ); + + $this->assertIsArray( $model->meta ); + $this->assertArrayHasKey( 'updated_via_mutator', $model->meta ); + $this->assertTrue( $model->meta['updated_via_mutator'] ); + + $this->assertIsArray( $model->settings ); + $this->assertEquals( 'dark', $model->settings['theme'] ); + + $this->assertInstanceOf( \stdClass::class, $model->raw_data ); + $this->assertEquals( 1, $model->raw_data->a ); + + $this->assertInstanceOf( \DateTime::class, $model->joined_at ); + $this->assertEquals( '2023-01-01', $model->joined_at->format( 'Y-m-d' ) ); + + $this->assertInstanceOf( \DateTime::class, $model->last_login_at ); + $this->assertEquals( '2023-01-01 12:00:00', $model->last_login_at->format( 'Y-m-d H:i:s' ) ); + } + + /** @test */ + public function test_mutators_and_accessors_precedence() { + $model = new TestAuditModel(); + + // Mutator (name -> uppercase) + $model->name = 'jane'; + $this->assertEquals( 'JANE', $model->get_attributes()['name'] ); + + // Accessor (email -> lowercase) + $model->set_attribute( 'email', 'JANE@EXAMPLE.COM' ); + $this->assertEquals( 'jane@example.com', $model->email ); + + // Appended Accessor + $model->name = 'Jane'; + $model->email = 'JANE@EXAMPLE.COM'; + $this->assertEquals( 'JANE (jane@example.com)', $model->full_profile ); + } + + /** @test */ + public function test_visibility_control() { + $model = new TestAuditModel( + [ + 'name' => 'Visible', + 'password' => 'secret123', + 'internal_notes' => 'Private', + 'email' => 'visible@example.com', + ] + ); + + $array = $model->to_array(); + + $this->assertArrayHasKey( 'name', $array ); + $this->assertArrayHasKey( 'email', $array ); + $this->assertArrayNotHasKey( 'password', $array ); + $this->assertArrayNotHasKey( 'internal_notes', $array ); + + // Manual Visibility + $model->make_visible( ['password'] ); + $array = $model->to_array(); + $this->assertArrayHasKey( 'password', $array ); + + // Dynamic Hidden + $model->make_hidden( ['email'] ); + $array = $model->to_array(); + $this->assertArrayNotHasKey( 'email', $array ); + } + + /** @test */ + public function test_is_dirty_detects_date_changes() { + $model = new TestAuditModel(); + $date = new \DateTime( '2023-01-01 10:00:00' ); + + $model->last_login_at = $date; + $model->sync_original(); + + $this->assertFalse( $model->is_dirty( 'last_login_at' ) ); + + // Same timestamp but different object + $model->last_login_at = new \DateTime( '2023-01-01 10:00:00' ); + $this->assertFalse( $model->is_dirty( 'last_login_at' ), 'Should be clean if timestamp matches' ); + + // Different timestamp + $model->last_login_at = new \DateTime( '2023-01-01 10:00:01' ); + $this->assertTrue( $model->is_dirty( 'last_login_at' ), 'Should be dirty if timestamp changed' ); + } + + /** @test */ + public function test_serialization_respects_casting_and_appends() { + $model = new TestAuditModel( + [ + 'age' => '30', + 'name' => 'Serialized', + 'email' => 'ser@example.com', + 'salary' => '1000.5', + ] + ); + + $json = json_encode( $model ); + $data = json_decode( $json, true ); + + $this->assertIsInt( $data['age'] ); + $this->assertEquals( 30, $data['age'] ); + $this->assertEquals( '1000.50', $data['salary'] ); + $this->assertArrayHasKey( 'full_profile', $data ); + $this->assertEquals( 'SERIALIZED (ser@example.com)', $data['full_profile'] ); + } + + public function test_it_can_track_dirty_attributes() { + $user = TestUser::create( ['name' => 'Original', 'email' => 'orig@example.com'] ); + + $this->assertFalse( $user->is_dirty() ); + + $user->name = 'Changed'; + $this->assertTrue( $user->is_dirty() ); + $this->assertTrue( $user->is_dirty( 'name' ) ); + $this->assertFalse( $user->is_dirty( 'email' ) ); + + $dirty = $user->get_dirty(); + $this->assertArrayHasKey( 'name', $dirty ); + $this->assertEquals( 'Changed', $dirty['name'] ); + } + + public function test_it_can_serialize_to_json() { + $user = new TestUser( + [ + 'name' => 'JSON User', + 'email' => 'json@example.com', + 'meta' => ['key' => 'value'], + ] + ); + + $json = json_encode( $user ); + $array = json_decode( $json, true ); + + $this->assertEquals( 'JSON User', $array['name'] ); + $this->assertEquals( 'value', $array['meta']['key'] ); + } + + public function test_it_can_get_all_models() { + TestUser::create( ['name' => 'User 1', 'email' => 'u1@example.com'] ); + TestUser::create( ['name' => 'User 2', 'email' => 'u2@example.com'] ); + + $users = TestUser::all(); + $this->assertCount( 2, $users ); + } + + public function test_it_can_find_or_fail() { + $user = TestUser::create( ['name' => 'Found', 'email' => 'found@example.com'] ); + + $found = TestUser::find_or_fail( $user->id ); + $this->assertEquals( 'Found', $found->name ); + + $this->expectException( \Exception::class ); + TestUser::find_or_fail( 999 ); + } + + public function test_it_can_get_first_model() { + TestUser::create( ['name' => 'First', 'email' => 'first@example.com'] ); + TestUser::create( ['name' => 'Second', 'email' => 'second@example.com'] ); + + $first = TestUser::query()->order_by( 'id', 'asc' )->first(); + $this->assertEquals( 'First', $first->name ); + } + + public function test_it_can_get_first_or_fail() { + $user = TestUser::create( ['name' => 'First', 'email' => 'first@example.com'] ); + + $first = TestUser::query()->where( 'id', '=', $user->id )->first_or_fail(); + $this->assertEquals( 'First', $first->name ); + + $this->expectException( \Exception::class ); + TestUser::query()->where( 'id', '=', 999 )->first_or_fail(); + } + + public function test_it_can_mass_fill_attributes(): void { + $user = new TestUser(); + $user->fill( ['name' => 'Filled', 'email' => 'filled@example.com'] ); + + $this->assertEquals( 'Filled', $user->name ); + $this->assertEquals( 'filled@example.com', $user->email ); + } + + public function test_fill_ignores_non_fillable_attributes(): void { + // TestUser fillable = ['id', 'name', 'email'] — 'is_active' is NOT in fillable + TestUser::reguard(); + $user = new TestUser(); + $user->fill( ['name' => 'Protected', 'is_active' => true] ); + + $this->assertEquals( 'Protected', $user->name ); + // is_active should not be in dirty attributes because it was not filled + $dirty = $user->get_dirty(); + $this->assertArrayNotHasKey( 'is_active', $dirty ); + TestUser::unguard(); + } + + public function test_timestamps_are_set_on_create(): void { + $user = TestUser::create( ['name' => 'Timestamped', 'email' => 'ts@example.com'] ); + + $this->assertNotNull( $user->created_at ); + $this->assertNotNull( $user->updated_at ); + // Both should be non-empty strings (stored as current datetime) + $this->assertNotEmpty( $user->created_at ); + $this->assertNotEmpty( $user->updated_at ); + } + + public function test_updated_at_changes_on_save(): void { + $user = TestUser::create( ['name' => 'UpdatedAt', 'email' => 'ua@example.com'] ); + $before = $user->updated_at; + + // Simulate time passing — sleep 1 second to guarantee a new timestamp + sleep( 1 ); + + $user->name = 'UpdatedAt Changed'; + $user->save(); + + $found = TestUser::find( $user->id ); + $this->assertNotEquals( $before, $found->updated_at ); + } + + public function test_first_or_create_returns_existing_row(): void { + TestUser::create( ['name' => 'Existing', 'email' => 'exist@example.com'] ); + + $found = TestUser::query()->where( 'email', 'exist@example.com' )->first(); + $this->assertNotNull( $found ); + $this->assertEquals( 'Existing', $found->name ); + } + + public function test_first_or_create_creates_when_missing(): void { + $user = TestUser::query()->where( 'email', 'new@example.com' )->first(); + if ( is_null( $user ) ) { + $user = TestUser::create( ['name' => 'New', 'email' => 'new@example.com'] ); + } + + $this->assertNotNull( $user->id ); + $this->assertEquals( 'New', $user->name ); + // Second call returns same row + $found = TestUser::query()->where( 'email', 'new@example.com' )->first(); + $this->assertEquals( $user->id, $found->id ); + } +} diff --git a/tests/Integration/Eloquent/Relations/AdvancedRelationshipTest.php b/tests/Integration/Eloquent/Relations/AdvancedRelationshipTest.php new file mode 100644 index 0000000..2fa80fc --- /dev/null +++ b/tests/Integration/Eloquent/Relations/AdvancedRelationshipTest.php @@ -0,0 +1,267 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + $table->string( 'title' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_roles', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_user_roles', function( Blueprint $table ) { + $table->unsigned_big_integer( 'user_id' ); + $table->unsigned_big_integer( 'role_id' ); + } + ); + + Schema::create( + 'test_images', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'url' ); + $table->unsigned_big_integer( 'imageable_id' ); + $table->string( 'imageable_type' ); + $table->timestamps(); + } + ); + } + + public function tearDown(): void { + Schema::drop_if_exists( 'test_user_roles' ); + Schema::drop_if_exists( 'test_roles' ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + Schema::drop_if_exists( 'test_images' ); + parent::tearDown(); + } + + public function test_it_can_use_custom_morph_map() { + Model::morph_map( + [ + 'user_alias' => TestUser::class, + 'post_alias' => TestPost::class, + ] + ); + + $user = TestUser::create( ['name' => 'Morph User'] ); + $image = TestImage::create( + [ + 'url' => 'avatar.jpg', + 'imageable_id' => $user->id, + 'imageable_type' => 'user_alias', + ] + ); + + $found_image = TestImage::find( $image->id ); + $this->assertInstanceOf( TestUser::class, $found_image->imageable ); + $this->assertEquals( 'Morph User', $found_image->imageable->name ); + + $this->assertEquals( 'user_alias', $user->get_morph_class() ); + } + + public function test_it_can_access_pivot_data() { + $user = TestUser::create( ['name' => 'Role User'] ); + $role = TestRole::create( ['name' => 'Manager'] ); + + $user->roles()->attach( $role->id ); + + $found_user = TestUser::with( 'roles' )->find( $user->id ); + $found_role = $found_user->roles->first(); + + $this->assertEquals( $user->id, $found_role->pivot_user_id ); + } + + public function test_it_prevents_circular_recursion_in_to_array() { + $user = TestUser::create( ['name' => 'Circular User'] ); + $post = TestPost::create( ['title' => 'Circular Post', 'user_id' => $user->id] ); + + $user->set_relation( 'latest_post', $post ); + $post->set_relation( 'author', $user ); + + $array = $user->to_array(); + + $this->assertEquals( 'Circular User', $array['name'] ); + $this->assertEquals( 'Circular Post', $array['latest_post']['title'] ); + $this->assertEmpty( $array['latest_post']['author'], "Circular relation 'author' should be an empty array to prevent loop." ); + } + + public function test_it_handles_null_morph_to() { + $image = TestImage::create( + [ + 'url' => 'orphan.jpg', + 'imageable_id' => 0, + 'imageable_type' => 'Unknown', + ] + ); + + $this->assertNull( $image->imageable ); + } + + // ========================================================================= + // HasOneOfMany — latest() / oldest() patterns + // ========================================================================= + + public function test_has_one_of_many_returns_latest_by_id(): void { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'First Post', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Second Post', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Third Post', 'user_id' => $user->id] ); + + $latest = $user->latest_post; + + $this->assertInstanceOf( TestPost::class, $latest ); + $this->assertEquals( 'Third Post', $latest->title ); + } + + public function test_has_one_of_many_returns_oldest_by_id(): void { + $user = TestUser::create( ['name' => 'Blogger'] ); + TestPost::create( ['title' => 'Alpha', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Beta', 'user_id' => $user->id] ); + + $oldest = $user->oldest_post; + + $this->assertInstanceOf( TestPost::class, $oldest ); + $this->assertEquals( 'Alpha', $oldest->title ); + } + + public function test_has_one_of_many_returns_null_when_no_related(): void { + $user = TestUser::create( ['name' => 'NoPost User'] ); + $latest = $user->latest_post; + $this->assertNull( $latest ); + } + + public function test_has_one_of_many_isolates_per_parent(): void { + $user_a = TestUser::create( ['name' => 'User A'] ); + $user_b = TestUser::create( ['name' => 'User B'] ); + + TestPost::create( ['title' => 'A-First', 'user_id' => $user_a->id] ); + TestPost::create( ['title' => 'A-Last', 'user_id' => $user_a->id] ); + TestPost::create( ['title' => 'B-Only', 'user_id' => $user_b->id] ); + + $this->assertEquals( 'A-Last', $user_a->latest_post->title ); + $this->assertEquals( 'B-Only', $user_b->latest_post->title ); + } + + public function test_has_one_of_many_eager_loads_correctly(): void { + $user_a = TestUser::create( ['name' => 'Eager A'] ); + $user_b = TestUser::create( ['name' => 'Eager B'] ); + + TestPost::create( ['title' => 'A-Old', 'user_id' => $user_a->id] ); + TestPost::create( ['title' => 'A-New', 'user_id' => $user_a->id] ); + TestPost::create( ['title' => 'B-Post', 'user_id' => $user_b->id] ); + + $users = TestUser::with( 'latest_post' ) + ->where_in( 'id', [$user_a->id, $user_b->id] ) + ->order_by( 'id' ) + ->get(); + + $this->assertTrue( $users[0]->relation_loaded( 'latest_post' ) ); + $this->assertEquals( 'A-New', $users[0]->latest_post->title ); + $this->assertEquals( 'B-Post', $users[1]->latest_post->title ); + } + + // ========================================================================= + // HasOneOrMany::create() and make() fixes + // ========================================================================= + + public function test_make_builds_instance_without_saving(): void { + $user = TestUser::create( ['name' => 'Maker'] ); + + $post = $user->posts()->make( ['title' => 'Draft Post'] ); + + // Not persisted + $this->assertFalse( $post->exists ); + $this->assertNull( $post->id ); + // FK is correctly injected + $this->assertEquals( $user->id, $post->user_id ); + // Nothing written to DB + $this->assertCount( 0, TestPost::where( 'user_id', $user->id )->get() ); + } + + public function test_make_does_not_require_parent_to_be_saved(): void { + // make() should work even on an unsaved parent (unlike create()) + $user = new TestUser( ['name' => 'Ghost'] ); + $this->assertFalse( $user->exists ); + + $post = $user->posts()->make( ['title' => 'Ghost Draft'] ); + $this->assertFalse( $post->exists ); + } + + public function test_create_throws_when_parent_not_saved(): void { + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessageMatches( '/has not been saved yet/' ); + + $user = new TestUser( ['name' => 'Unsaved'] ); + // Parent not persisted → must throw + $user->posts()->create( ['title' => 'Should Fail'] ); + } + + public function test_create_returns_saved_model_with_fk(): void { + $user = TestUser::create( ['name' => 'Creator'] ); + $post = $user->posts()->create( ['title' => 'Saved Post'] ); + + $this->assertInstanceOf( TestPost::class, $post ); + $this->assertTrue( $post->exists ); + $this->assertNotNull( $post->id ); + $this->assertEquals( $user->id, $post->user_id ); + + // Verify it's actually in the DB + $found = TestPost::find( $post->id ); + $this->assertNotNull( $found ); + $this->assertEquals( 'Saved Post', $found->title ); + } + + public function test_create_fk_overrides_caller_supplied_value(): void { + $user_a = TestUser::create( ['name' => 'Owner'] ); + $user_b = TestUser::create( ['name' => 'Intruder'] ); + + // Caller tries to set user_id = user_b->id, but the relation must override it + $post = $user_a->posts()->create( + ['title' => 'Protected', 'user_id' => $user_b->id] + ); + + $this->assertEquals( $user_a->id, $post->user_id ); + } +} diff --git a/tests/Integration/Eloquent/Relations/RelationshipTest.php b/tests/Integration/Eloquent/Relations/RelationshipTest.php new file mode 100644 index 0000000..b1e5135 --- /dev/null +++ b/tests/Integration/Eloquent/Relations/RelationshipTest.php @@ -0,0 +1,535 @@ +big_increments( 'id' ); + $table->unsigned_big_integer( 'country_id' )->nullable(); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + $table->string( 'title' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_profiles', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + $table->string( 'bio' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_roles', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_user_roles', function( Blueprint $table ) { + $table->unsigned_big_integer( 'user_id' ); + $table->unsigned_big_integer( 'role_id' ); + } + ); + + Schema::create( + 'test_countries', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_images', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'url' ); + $table->unsigned_big_integer( 'imageable_id' ); + $table->string( 'imageable_type' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_tags', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_taggables', function( Blueprint $table ) { + $table->unsigned_big_integer( 'test_tag_id' ); + $table->unsigned_big_integer( 'taggable_id' ); + $table->string( 'taggable_type' ); + } + ); + } + + public function tearDown(): void { + Schema::drop_if_exists( 'test_user_roles' ); + Schema::drop_if_exists( 'test_roles' ); + Schema::drop_if_exists( 'test_profiles' ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + Schema::drop_if_exists( 'test_countries' ); + Schema::drop_if_exists( 'test_images' ); + Schema::drop_if_exists( 'test_tags' ); + Schema::drop_if_exists( 'test_taggables' ); + parent::tearDown(); + } + + public function test_it_can_resolve_has_one_relationship() { + $user = TestUser::create( ['name' => 'Profile User'] ); + $profile = TestProfile::create( + [ + 'bio' => 'Software Engineer', + 'user_id' => $user->id, + ] + ); + + $this->assertInstanceOf( TestProfile::class, $user->profile ); + $this->assertEquals( 'Software Engineer', $user->profile->bio ); + $this->assertEquals( $user->id, $profile->user->id ); + } + + public function test_it_can_resolve_belongs_to_relationship() { + $user = TestUser::create( ['name' => 'Author'] ); + $post = TestPost::create( + [ + 'title' => 'Eloquent Relationships', + 'user_id' => $user->id, + ] + ); + + $this->assertEquals( $user->id, $post->user->id ); + $this->assertEquals( 'Author', $post->user->name ); + } + + public function test_it_can_resolve_has_many_relationship() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'Post 1', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Post 2', 'user_id' => $user->id] ); + + $this->assertCount( 2, $user->posts ); + $this->assertEquals( 'Post 1', $user->posts[0]->title ); + } + + public function test_it_can_resolve_many_to_many_relationship() { + $user = TestUser::create( ['name' => 'Admin User'] ); + $role1 = TestRole::create( ['name' => 'Admin'] ); + $role2 = TestRole::create( ['name' => 'Editor'] ); + + $user->roles()->attach( $role1->id ); + $user->roles()->attach( $role2->id ); + + $this->assertCount( 2, $user->roles ); + $this->assertEquals( 'Admin', $user->roles[0]->name ); + $this->assertEquals( 'Editor', $user->roles[1]->name ); + + // Check inverse + $this->assertCount( 1, $role1->users ); + $this->assertEquals( 'Admin User', $role1->users[0]->name ); + } + + public function test_it_can_eager_load_relationships() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'Post 1', 'user_id' => $user->id] ); + + $found = TestUser::with( ['posts', 'profile'] )->find( $user->id ); + + $this->assertTrue( $found->relation_loaded( 'posts' ) ); + $this->assertTrue( $found->relation_loaded( 'profile' ) ); + } + + public function test_it_can_resolve_has_many_through_relationship() { + $country = TestCountry::create( ['name' => 'USA'] ); + $user = TestUser::create( ['name' => 'User', 'country_id' => $country->id] ); + TestPost::create( ['title' => 'Post 1', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Post 2', 'user_id' => $user->id] ); + + $this->assertCount( 2, $country->posts ); + $this->assertEquals( 'Post 1', $country->posts[0]->title ); + } + + public function test_it_can_resolve_has_one_through_relationship() { + $country = TestCountry::create( ['name' => 'Canada'] ); + $user = TestUser::create( ['name' => 'User', 'country_id' => $country->id] ); + TestProfile::create( ['bio' => 'Bio', 'user_id' => $user->id] ); + + $this->assertInstanceOf( TestProfile::class, $country->profile ); + $this->assertEquals( 'Bio', $country->profile->bio ); + } + + public function test_it_can_resolve_morph_one_relationship() { + $user = TestUser::create( ['name' => 'User'] ); + $post = TestPost::create( ['title' => 'Post', 'user_id' => $user->id] ); + + $user_image = TestImage::create( + [ + 'url' => 'user.jpg', + 'imageable_id' => $user->id, + 'imageable_type' => TestUser::class, + ] + ); + + $post_image = TestImage::create( + [ + 'url' => 'post.jpg', + 'imageable_id' => $post->id, + 'imageable_type' => TestPost::class, + ] + ); + + $this->assertEquals( 'user.jpg', $user->image->url ); + $this->assertEquals( 'post.jpg', $post->image->url ); + + // Inverse + $this->assertInstanceOf( TestUser::class, $user_image->imageable ); + $this->assertInstanceOf( TestPost::class, $post_image->imageable ); + } + + public function test_it_can_resolve_morph_to_many_relationship() { + $user = TestUser::create( ['name' => 'User'] ); + $post = TestPost::create( ['title' => 'Post', 'user_id' => $user->id] ); + $tag = TestTag::create( ['name' => 'PHP'] ); + + $post->tags()->attach( $tag->id ); + + $this->assertCount( 1, $post->tags ); + $this->assertEquals( 'PHP', $post->tags[0]->name ); + + // Inverse + $this->assertCount( 1, $tag->posts ); + $this->assertEquals( 'Post', $tag->posts[0]->title ); + } + + public function test_it_can_resolve_morph_many_relationship() { + $user = TestUser::create( ['name' => 'User'] ); + TestImage::create( + [ + 'url' => 'img1.jpg', + 'imageable_id' => $user->id, + 'imageable_type' => TestUser::class, + ] + ); + TestImage::create( + [ + 'url' => 'img2.jpg', + 'imageable_id' => $user->id, + 'imageable_type' => TestUser::class, + ] + ); + + $this->assertCount( 2, $user->images ); + $this->assertEquals( 'img1.jpg', $user->images[0]->url ); + } + + public function test_it_can_eager_load_nested_relationships() { + $user = TestUser::create( ['name' => 'Author'] ); + $post = TestPost::create( ['title' => 'Post with Tags', 'user_id' => $user->id] ); + $tag = TestTag::create( ['name' => 'Laravel'] ); + $post->tags()->attach( $tag->id ); + + $found = TestUser::with( 'posts.tags' )->find( $user->id ); + + $this->assertTrue( $found->relation_loaded( 'posts' ) ); + $this->assertTrue( $found->posts[0]->relation_loaded( 'tags' ) ); + $this->assertEquals( 'Laravel', $found->posts[0]->tags[0]->name ); + } + + public function test_it_can_count_relationships() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'Post 1', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'Post 2', 'user_id' => $user->id] ); + + $found = TestUser::with_count( 'posts' )->find( $user->id ); + + $this->assertEquals( 2, (int) $found->posts_count ); + } + + public function test_it_can_count_multiple_relationships() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'Post 1', 'user_id' => $user->id] ); + + $role = TestRole::create( ['name' => 'Editor'] ); + $user->roles()->attach( $role->id ); + + $found = TestUser::with_count( ['posts', 'roles'] )->find( $user->id ); + + $this->assertEquals( 1, (int) $found->posts_count ); + $this->assertEquals( 1, (int) $found->roles_count ); + } + + public function test_it_can_eager_load_with_complex_nested_callbacks() { + $user = TestUser::create( ['name' => 'Author'] ); + $post1 = TestPost::create( ['title' => 'PHP Post', 'user_id' => $user->id] ); + $post2 = TestPost::create( ['title' => 'JS Post', 'user_id' => $user->id] ); + + $tag1 = TestTag::create( ['name' => 'Web'] ); + $tag2 = TestTag::create( ['name' => 'Backend'] ); + $tag3 = TestTag::create( ['name' => 'Frontend'] ); + + $post1->tags()->attach( $tag1->id ); + $post1->tags()->attach( $tag2->id ); + $post2->tags()->attach( $tag3->id ); + + $found = TestUser::with( + [ + 'posts' => function( $query ) { + $query->where( 'title', 'like', 'PHP%' ); + }, + 'posts.tags' => function( $query ) { + $query->where( 'name', 'Backend' ); + } + ] + )->find( $user->id ); + + $this->assertCount( 1, $found->posts ); // Only PHP Post + $this->assertEquals( 'PHP Post', $found->posts[0]->title ); + $this->assertCount( 1, $found->posts[0]->tags ); // Only Backend tag + $this->assertEquals( 'Backend', $found->posts[0]->tags[0]->name ); + } + + public function test_it_can_count_with_complex_multiple_callbacks() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'PHP Post', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'JS Post', 'user_id' => $user->id] ); + + $role1 = TestRole::create( ['name' => 'Admin'] ); + $role2 = TestRole::create( ['name' => 'Editor'] ); + $user->roles()->attach( $role1->id ); + $user->roles()->attach( $role2->id ); + + $found = TestUser::with_count( + [ + 'posts' => function( $query ) { + $query->where( 'title', 'like', 'PHP%' ); + }, + 'roles' => function( $query ) { + $query->where( 'name', 'Admin' ); + } + ] + )->find( $user->id ); + + $this->assertEquals( 1, (int) $found->posts_count ); + $this->assertEquals( 1, (int) $found->roles_count ); + } + + public function test_it_can_eager_load_with_very_complex_nested_callbacks() { + $user = TestUser::create( ['name' => 'Author'] ); + $profile = TestProfile::create( ['bio' => 'Senior Developer', 'user_id' => $user->id] ); + $post = TestPost::create( ['title' => 'WpMVC Tutorial', 'user_id' => $user->id] ); + $tag = TestTag::create( ['name' => 'PHP'] ); + $post->tags()->attach( $tag->id ); + + $found = TestUser::with( + [ + 'profile' => function( $query ) { + $query->where( 'bio', 'like', '%Developer' ); + }, + 'posts' => function( $query ) { + $query->where( 'title', 'WpMVC Tutorial' ); + }, + 'posts.tags' => function( $query ) { + $query->where( 'name', 'PHP' ); + } + ] + )->find( $user->id ); + + $this->assertTrue( $found->relation_loaded( 'profile' ) ); + $this->assertEquals( 'Senior Developer', $found->profile->bio ); + $this->assertCount( 1, $found->posts ); + $this->assertEquals( 'WpMVC Tutorial', $found->posts[0]->title ); + $this->assertCount( 1, $found->posts[0]->tags ); + $this->assertEquals( 'PHP', $found->posts[0]->tags[0]->name ); + } + + public function test_it_can_combine_with_and_with_count_with_callbacks() { + $user = TestUser::create( ['name' => 'Author'] ); + TestPost::create( ['title' => 'PHP Post', 'user_id' => $user->id] ); + TestPost::create( ['title' => 'JS Post', 'user_id' => $user->id] ); + + $found = TestUser::with( + [ + 'posts' => function( $query ) { + $query->where( 'title', 'PHP Post' ); + } + ] + )->with_count( + [ + 'posts' => function( $query ) { + $query->where( 'title', 'like', 'JS%' ); + } + ] + )->find( $user->id ); + + $this->assertCount( 1, $found->posts ); + $this->assertEquals( 'PHP Post', $found->posts[0]->title ); + $this->assertEquals( 1, (int) $found->posts_count ); // Count of JS posts + } + + public function test_it_can_handle_deeply_nested_with_and_counts_inside_callbacks() { + $country = TestCountry::create( ['name' => 'USA'] ); + $user = TestUser::create( ['name' => 'Author', 'country_id' => $country->id] ); + $post1 = TestPost::create( ['title' => 'Tutorial 1', 'user_id' => $user->id] ); + $post2 = TestPost::create( ['title' => 'News 1', 'user_id' => $user->id] ); + + $tag1 = TestTag::create( ['name' => 'PHP'] ); + $tag2 = TestTag::create( ['name' => 'JS'] ); + $post1->tags()->attach( $tag1->id ); + $post1->tags()->attach( $tag2->id ); + + $found = TestCountry::with( + [ + 'users' => function( $query ) { + $query->with( + [ + 'posts' => function( $q ) { + $q->where( 'title', 'like', '%Tutorial%' ) + ->with( + [ + 'tags' => function( $q2 ) { + $q2->where( 'name', 'PHP' ); + } + ] + ) + ->with_count( 'tags' ); + } + ] + )->with_count( 'posts' ); + } + ] + )->find( $country->id ); + + $this->assertCount( 1, $found->users ); + $this->assertEquals( 'Author', $found->users[0]->name ); + $this->assertEquals( 2, (int) $found->users[0]->posts_count ); + + $this->assertCount( 1, $found->users[0]->posts ); + $this->assertEquals( 'Tutorial 1', $found->users[0]->posts[0]->title ); + $this->assertEquals( 2, (int) $found->users[0]->posts[0]->tags_count ); + + $this->assertCount( 1, $found->users[0]->posts[0]->tags ); + $this->assertEquals( 'PHP', $found->users[0]->posts[0]->tags[0]->name ); + } + + // ========================================================================= + // Relation mutation: sync / detach / update_or_create / where_has / doesnt_have + // ========================================================================= + + public function test_it_can_attach_and_verify_many_to_many(): void { + $user = TestUser::create( ['name' => 'AttachVerify'] ); + $role1 = TestRole::create( ['name' => 'Admin'] ); + $role2 = TestRole::create( ['name' => 'Editor'] ); + $role3 = TestRole::create( ['name' => 'Viewer'] ); + + $user->roles()->attach( $role1->id ); + $user->roles()->attach( $role2->id ); + $user->roles()->attach( $role3->id ); + + $roles = TestUser::find( $user->id )->roles; + $this->assertCount( 3, $roles ); + + // Attach duplicate should not create a second pivot row if using unique constraint + // (just verify count is still correct after repeated attach) + $role_ids = array_map( fn( $r ) => (int) $r->id, $roles->all() ); + sort( $role_ids ); + $this->assertEquals( + [(int) $role1->id, (int) $role2->id, (int) $role3->id], + $role_ids + ); + } + + public function test_it_can_detach_specific_role_by_deleting_pivot(): void { + $user = TestUser::create( ['name' => 'DetachVerify'] ); + $role1 = TestRole::create( ['name' => 'Alpha'] ); + $role2 = TestRole::create( ['name' => 'Beta'] ); + + $user->roles()->attach( $role1->id ); + $user->roles()->attach( $role2->id ); + $this->assertCount( 2, TestUser::find( $user->id )->roles ); + + // Simulate detach by deleting from pivot table directly + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'test_user_roles', ['user_id' => $user->id, 'role_id' => $role1->id] ); + + $roles = TestUser::find( $user->id )->roles; + $this->assertCount( 1, $roles ); + $this->assertEquals( (int) $role2->id, (int) $roles[0]->id ); + } + + public function test_it_can_use_where_has_on_morph_many(): void { + $user_with = TestUser::create( ['name' => 'HasImages'] ); + $user_without = TestUser::create( ['name' => 'NoImages'] ); + TestImage::create( + [ + 'url' => 'avatar.jpg', + 'imageable_id' => $user_with->id, + 'imageable_type' => TestUser::class, + ] + ); + + $results = TestUser::where_has( 'images' )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'HasImages', $results[0]->name ); + } + + public function test_it_can_use_doesnt_have_on_has_many(): void { + $with_posts = TestUser::create( ['name' => 'HasPosts'] ); + $without_posts = TestUser::create( ['name' => 'NoPosts'] ); + TestPost::create( ['title' => 'A Post', 'user_id' => $with_posts->id] ); + + $results = TestUser::doesnt_have( 'posts' )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'NoPosts', $results[0]->name ); + } + + public function test_it_can_use_doesnt_have_on_morph_to_many(): void { + $post_with = TestPost::create( ['title' => 'Tagged', 'user_id' => 1] ); + $post_without = TestPost::create( ['title' => 'Untagged', 'user_id' => 1] ); + $tag = TestTag::create( ['name' => 'PHP'] ); + $post_with->tags()->attach( $tag->id ); + + $results = TestPost::doesnt_have( 'tags' )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Untagged', $results[0]->title ); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php deleted file mode 100644 index e30b09c..0000000 --- a/tests/Integration/IntegrationTestCase.php +++ /dev/null @@ -1,67 +0,0 @@ -setupTestDatabase(); - } - - /** - * Setup mock wpdb with test data. - */ - protected function setupTestDatabase(): void { - global $wpdb; - - // Reset wpdb mock state - $wpdb->queries = []; - $wpdb->last_query = ''; - $wpdb->insert_id = 0; - $wpdb->num_rows = 0; - } - - /** - * Mock wpdb to return specific results for testing. - * - * @param array $results - * @return void - */ - protected function mockDatabaseResults( array $results ): void { - global $wpdb; - $wpdb->mock_results = $results; - $wpdb->num_rows = count( $results ); - } - - /** - * Get the last executed SQL query. - * - * @return string - */ - protected function getLastQuery(): string { - global $wpdb; - return $wpdb->last_query ?? ''; - } - - /** - * Assert that a query was executed. - * - * @param string $expectedSQL - * @return void - */ - protected function assertQueryExecuted( string $expected_sql ): void { - $last_query = $this->getLastQuery(); - $this->assert_sql_equals( $expected_sql, $last_query, 'Expected query was not executed' ); - } -} diff --git a/tests/Integration/JoinQueryTest.php b/tests/Integration/JoinQueryTest.php deleted file mode 100644 index 9ff8c8c..0000000 --- a/tests/Integration/JoinQueryTest.php +++ /dev/null @@ -1,136 +0,0 @@ - 1, 'title' => 'Post 1', 'author_name' => 'John'] - ]; - $this->mockDatabaseResults( $mock_results ); - - // Act - $results = Post::query() - ->join( 'users', 'posts.author_id', '=', 'users.id' ) - ->select( ['posts.*', 'users.name as author_name'] ) - ->get(); - - // Assert - $this->assertCount( 1, $results ); - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'inner join', $last_query ); - $this->assert_sql_contains( 'wp_users', $last_query ); - $this->assert_sql_contains( 'posts.author_id = users.id', $last_query ); - } - - /** - * @test - * - * Verifies that LEFT JOIN generates correct SQL. - */ - public function it_executes_left_join_query() { - // Arrange - $mock_results = [ - (object) ['id' => 1, 'title' => 'Post 1', 'comment_count' => 5] - ]; - $this->mockDatabaseResults( $mock_results ); - - // Act - $results = Post::query() - ->left_join( 'comments', 'posts.id', '=', 'comments.post_id' ) - ->get(); - - // Assert - $this->assertCount( 1, $results ); - $this->assert_sql_contains( 'left join', $this->getLastQuery() ); - $this->assert_sql_contains( 'wp_comments', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that multiple JOINs can be chained. - */ - public function it_executes_multiple_join_query() { - // Arrange - $mock_results = []; - $this->mockDatabaseResults( $mock_results ); - - // Act - Post::query() - ->join( 'users', 'posts.author_id', '=', 'users.id' ) - ->left_join( 'comments', 'posts.id', '=', 'comments.post_id' ) - ->get(); - - // Assert - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'inner join wp_users', $last_query ); - $this->assert_sql_contains( 'left join wp_comments', $last_query ); - } - - /** - * @test - * - * Verifies that JOIN with WHERE clause works correctly. - */ - public function it_executes_join_with_where_clause() { - // Arrange - $mock_results = []; - $this->mockDatabaseResults( $mock_results ); - - // Act - Post::query() - ->join( 'users', 'posts.author_id', '=', 'users.id' ) - ->where( 'posts.status', 'publish' ) - ->where( 'users.role', 'author' ) - ->get(); - - // Assert - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'join', $last_query ); - $this->assert_sql_contains( 'where posts.status', $last_query ); - $this->assert_sql_contains( 'and users.role', $last_query ); - } - - /** - * @test - * - * Verifies that JOIN with complex ON conditions works. - */ - public function it_executes_join_with_closure_conditions() { - // Arrange - $mock_results = []; - $this->mockDatabaseResults( $mock_results ); - - // Act - Post::query() - ->join( - 'users', - function ( $join ) { - $join->on_column( 'posts.author_id', '=', 'users.id' ) - ->where( 'users.status', 'active' ); - } - ) - ->get(); - - // Assert - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'join', $last_query ); - // Complex ON conditions are handled by JoinClause - } -} diff --git a/tests/Integration/Multisite/MultisiteModelTest.php b/tests/Integration/Multisite/MultisiteModelTest.php new file mode 100644 index 0000000..885b974 --- /dev/null +++ b/tests/Integration/Multisite/MultisiteModelTest.php @@ -0,0 +1,166 @@ +markTestSkipped( 'Multisite is not enabled.' ); + } + + if ( ! isset( $this->factory ) || ! isset( $this->factory->blog ) ) { + $this->markTestSkipped( 'Multisite factory not available.' ); + } + } + + public function tear_down(): void { + global $wpdb; + // Clean up tables in current prefix before restoring + Schema::drop_if_exists( 'test_posts' ); + parent::tear_down(); + } + + public function test_models_resolve_table_name_after_blog_switch() { + global $wpdb; + + // 1. Setup in main blog + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'title' ); + $table->timestamps(); + } + ); + + TestPost::create( ['title' => 'Main Blog Post'] ); + $this->assertEquals( 1, TestPost::count() ); + $this->assertEquals( $wpdb->prefix . 'test_posts', ( new TestPost() )->get_table_full_name() ); + + // 2. Switch to new blog + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + + // Verify prefix changed + $this->assertStringContainsString( "_{$blog_id}_", $wpdb->prefix ); + + // 3. Setup in second blog + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'title' ); + $table->timestamps(); + } + ); + + // Verify model sees the new table (should be empty) + $this->assertEquals( 0, TestPost::count() ); + $this->assertEquals( $wpdb->prefix . 'test_posts', ( new TestPost() )->get_table_full_name() ); + + TestPost::create( ['title' => 'New Blog Post'] ); + $this->assertEquals( 1, TestPost::count() ); + $this->assertEquals( 'New Blog Post', TestPost::first()->title ); + + // 4. Verification in main blog again + restore_current_blog(); + $this->assertEquals( 1, TestPost::count() ); + $this->assertEquals( 'Main Blog Post', TestPost::first()->title ); + + // Clean up second blog table manually since tear_down only cleans current + switch_to_blog( $blog_id ); + Schema::drop_if_exists( 'test_posts' ); + restore_current_blog(); + } + + public function test_it_handles_cross_blog_relationships() { + global $wpdb; + + // 1. Setup tables + Schema::drop_if_exists( 'test_users' ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'users' ); + + Schema::create( + 'test_users', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'user_login' ); + $table->timestamps(); + } + ); + + Schema::create( + 'users', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'user_login' ); + $table->timestamps(); + } + ); + + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'title' ); + $table->big_integer( 'user_id' ); + $table->timestamps(); + } + ); + + // 2. Main Blog Setup (Blog 1) + // User 1 in Blog 1 (Site-specific) + $user1 = TestUser::create( ['user_login' => 'blog1_user'] ); + // Network User 1 (Global) + $wpdb->insert( $wpdb->base_prefix . 'users', ['user_login' => 'global_admin', 'created_at' => current_time( 'mysql' ), 'updated_at' => current_time( 'mysql' )] ); + $network_user_id = $wpdb->insert_id; + + TestPost::create( ['title' => 'Main Blog Post', 'user_id' => $user1->id] ); + + // 3. Switch to Blog 2 + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + + Schema::create( + 'test_users', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'user_login' ); + $table->timestamps(); + } + ); + Schema::create( + 'test_posts', function( Blueprint $table ) { + $table->big_increments( "id" ); + $table->string( 'title' ); + $table->big_integer( 'user_id' ); + $table->timestamps(); + } + ); + + // Create Blog 2 Post pointing to GLOBAL Admin + $post = TestPost::create( ['title' => 'Blog 2 Post', 'user_id' => $network_user_id] ); + + // VERIFY: Blog Isolation (test_users should NOT find blog1_user here) + $this->assertEquals( 0, TestUser::count() ); + $this->assertNull( $post->user ); // This relationship uses TestUser (blog-specific) + + // VERIFY: Network Access (network_user should find the global admin) + $this->assertNotNull( $post->network_user ); + $this->assertEquals( 'global_admin', $post->network_user->user_login ); + + restore_current_blog(); + + // Clean up + switch_to_blog( $blog_id ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + restore_current_blog(); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + Schema::drop_if_exists( 'users' ); + } +} diff --git a/tests/Integration/Multisite/MultisiteResolverTest.php b/tests/Integration/Multisite/MultisiteResolverTest.php new file mode 100644 index 0000000..dfa76ad --- /dev/null +++ b/tests/Integration/Multisite/MultisiteResolverTest.php @@ -0,0 +1,59 @@ +markTestSkipped( 'Multisite is not enabled.' ); + } + + if ( ! isset( $this->factory ) || ! isset( $this->factory->blog ) ) { + $this->markTestSkipped( 'Multisite factory not available.' ); + } + } + + public function test_it_resolves_network_tables_with_base_prefix() { + global $wpdb; + $resolver = new Resolver(); + + // base_prefix is usually 'wp_' + // prefix might be 'wp_2_' after switching to blog 2 + + $this->assertEquals( $wpdb->base_prefix . 'users', $resolver->table( 'users' ) ); + $this->assertEquals( $wpdb->base_prefix . 'sitemeta', $resolver->table( 'sitemeta' ) ); + } + + public function test_it_resolves_blog_specific_tables_with_blog_prefix() { + global $wpdb; + $resolver = new Resolver(); + + $this->assertEquals( $wpdb->prefix . 'posts', $resolver->table( 'posts' ) ); + $this->assertEquals( $wpdb->prefix . 'options', $resolver->table( 'options' ) ); + } + + public function test_it_resolves_different_prefixes_after_switching_blogs() { + if ( ! function_exists( 'switch_to_blog' ) ) { + $this->markTestSkipped( 'switch_to_blog function not found.' ); + } + + global $wpdb; + $resolver = new Resolver(); + + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + + // After switching, $wpdb->prefix should contain the blog ID (e.g., 'wp_2_') + // but $wpdb->base_prefix remains 'wp_' + + $this->assertEquals( $wpdb->base_prefix . 'users', $resolver->table( 'users' ) ); + $this->assertEquals( $wpdb->prefix . 'posts', $resolver->table( 'posts' ) ); + $this->assertStringContainsString( "_{$blog_id}_", $resolver->table( 'posts' ) ); + + restore_current_blog(); + } +} diff --git a/tests/Integration/Multisite/MultisiteSchemaTest.php b/tests/Integration/Multisite/MultisiteSchemaTest.php new file mode 100644 index 0000000..ebbcb92 --- /dev/null +++ b/tests/Integration/Multisite/MultisiteSchemaTest.php @@ -0,0 +1,121 @@ +markTestSkipped( 'Multisite is not enabled.' ); + } + + if ( ! isset( $this->factory ) || ! isset( $this->factory->blog ) ) { + $this->markTestSkipped( 'Multisite factory not available.' ); + } + } + + public function tear_down(): void { + global $wpdb; + Schema::drop_if_exists( 'blog_custom_table' ); + Schema::drop_if_exists( 'shared_name_table' ); + parent::tear_down(); + } + + public function test_it_creates_tables_with_blog_prefix() { + global $wpdb; + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + + Schema::create( + 'blog_custom_table', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' ); + } + ); + + $this->assertTrue( Schema::has_table( 'blog_custom_table' ) ); + + $expected_table = $wpdb->prefix . 'blog_custom_table'; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $results = $wpdb->get_results( "SHOW TABLES LIKE '$expected_table'" ); + // Note: In some test envs SHOW TABLES might still be empty even if table exists and is queryable + // so we rely more on Schema::has_table which we've made robust. + + restore_current_blog(); + } + + public function test_it_does_not_see_tables_from_other_blogs() { + $blog_id_1 = $this->factory->blog->create(); + $blog_id_2 = $this->factory->blog->create(); + + switch_to_blog( $blog_id_1 ); + Schema::create( + 'shared_name_table', function( Blueprint $table ) { + $table->big_increments( 'id' ); + } + ); + $this->assertTrue( Schema::has_table( 'shared_name_table' ) ); + restore_current_blog(); + + switch_to_blog( $blog_id_2 ); + $this->assertFalse( Schema::has_table( 'shared_name_table' ) ); + restore_current_blog(); + } + + public function test_it_can_alter_tables_in_different_blogs() { + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + + Schema::create( + 'blog_custom_table', function( Blueprint $table ) { + $table->big_increments( 'id' ); + } + ); + + Schema::alter( + 'blog_custom_table', function( Blueprint $table ) { + $table->string( 'added_column' ); + } + ); + + $this->assertTrue( Schema::has_column( 'blog_custom_table', 'added_column' ) ); + restore_current_blog(); + } + + public function test_it_restores_prefix_reliably() { + global $wpdb; + $original_prefix = $wpdb->prefix; + + $blog_id = $this->factory->blog->create(); + switch_to_blog( $blog_id ); + $this->assertNotEquals( $original_prefix, $wpdb->prefix ); + + restore_current_blog(); + $this->assertEquals( $original_prefix, $wpdb->prefix ); + } + + public function test_it_handles_invalid_blog_switch() { + global $wpdb; + $original_prefix = $wpdb->prefix; + + // Suppress expected errors during invalid switch + $suppress = $wpdb->suppress_errors( true ); + + // Switch to non-existent blog + switch_to_blog( 999999 ); + + // Restore error suppression state + $wpdb->suppress_errors( $suppress ); + + // In WP, if blog doesn't exist, it usually stays on current or defaults to 1. + // We just want to ensure our Resolver doesn't crash. + $this->assertTrue( Schema::has_table( 'users' ) || ! empty( $wpdb->prefix ) ); + + restore_current_blog(); + $this->assertEquals( $original_prefix, $wpdb->prefix ); + } +} diff --git a/tests/Integration/Query/AggregateTest.php b/tests/Integration/Query/AggregateTest.php new file mode 100644 index 0000000..093ae78 --- /dev/null +++ b/tests/Integration/Query/AggregateTest.php @@ -0,0 +1,68 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->integer( 'votes' ); + } + ); + } + + public function tear_down(): void { + Schema::drop_if_exists( 'aggregate_test' ); + parent::tear_down(); + } + + protected function new_builder() { + $builder = new Builder( new TestUser() ); + $builder->from( 'aggregate_test' ); + return $builder; + } + + /** @test */ + public function test_it_can_count_records() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ['name' => 'Charlie', 'votes' => 30], + ] + ); + + $this->assertEquals( 3, $this->new_builder()->count() ); + $this->assertEquals( 1, $this->new_builder()->where( 'votes', '>', 25 )->count() ); + } + + /** @test */ + public function test_it_can_perform_aggregates() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ['name' => 'Charlie', 'votes' => 30], + ] + ); + + $this->assertEquals( 60, $this->new_builder()->sum( 'votes' ) ); + $this->assertEquals( 20, $this->new_builder()->avg( 'votes' ) ); + $this->assertEquals( 10, $this->new_builder()->min( 'votes' ) ); + $this->assertEquals( 30, $this->new_builder()->max( 'votes' ) ); + } +} diff --git a/tests/Integration/Query/BuilderTest.php b/tests/Integration/Query/BuilderTest.php new file mode 100644 index 0000000..75f187f --- /dev/null +++ b/tests/Integration/Query/BuilderTest.php @@ -0,0 +1,92 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->integer( 'votes' )->default( 0 ); + $table->timestamps(); + } + ); + } + + public function tear_down(): void { + Schema::drop_if_exists( 'builder_test' ); + parent::tear_down(); + } + + protected function new_builder() { + $builder = new Builder( new TestUser() ); + $builder->from( 'builder_test' ); + return $builder; + } + + public function test_it_can_insert_and_retrieve_records() { + $builder = $this->new_builder(); + $builder->insert( ['name' => 'John Doe', 'votes' => 10] ); + + $user = $this->new_builder()->where( 'name', 'John Doe' )->first(); + + $this->assertNotNull( $user ); + $this->assertEquals( 'John Doe', $user->name ); + $this->assertEquals( 10, $user->votes ); + } + + public function test_it_can_update_records() { + $this->new_builder()->insert( ['name' => 'Alice', 'votes' => 10] ); + + $this->new_builder()->where( 'name', 'Alice' )->update( ['votes' => 100] ); + + $alice = $this->new_builder()->where( 'name', 'Alice' )->first(); + $this->assertEquals( 100, $alice->votes ); + } + + public function test_it_can_delete_records() { + $this->new_builder()->insert( ['name' => 'Alice', 'votes' => 10] ); + + $this->new_builder()->where( 'name', 'Alice' )->delete(); + + $this->assertNull( $this->new_builder()->where( 'name', 'Alice' )->first() ); + } + + public function test_it_can_order_results() { + $this->new_builder()->insert( + [ + ['name' => 'Alice', 'votes' => 30], + ['name' => 'Bob', 'votes' => 10], + ['name' => 'Charlie', 'votes' => 20], + ] + ); + + $results = $this->new_builder()->order_by( 'votes', 'asc' )->get(); + $this->assertEquals( 'Bob', $results[0]->name ); + $this->assertEquals( 'Alice', $results[2]->name ); + } + + public function test_it_can_paginate_results() { + foreach ( range( 1, 10 ) as $i ) { + $this->new_builder()->insert( ['name' => "User $i", 'votes' => $i] ); + } + + $page1 = $this->new_builder()->limit( 5 )->get(); + $this->assertCount( 5, $page1 ); + $this->assertEquals( 'User 1', $page1[0]->name ); + + $page2 = $this->new_builder()->limit( 5 )->offset( 5 )->get(); + $this->assertCount( 5, $page2 ); + $this->assertEquals( 'User 6', $page2[0]->name ); + } +} diff --git a/tests/Integration/Query/EnterpriseQueryTest.php b/tests/Integration/Query/EnterpriseQueryTest.php new file mode 100644 index 0000000..4117cae --- /dev/null +++ b/tests/Integration/Query/EnterpriseQueryTest.php @@ -0,0 +1,713 @@ +drop_tables(); + $this->create_tables(); + $this->seed_data(); + } + + public function tearDown(): void { + $this->drop_tables(); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Schema helpers + // ------------------------------------------------------------------------- + + protected function drop_tables(): void { + Schema::drop_if_exists( 'test_taggables' ); + Schema::drop_if_exists( 'test_tags' ); + Schema::drop_if_exists( 'test_images' ); + Schema::drop_if_exists( 'test_user_roles' ); + Schema::drop_if_exists( 'test_roles' ); + Schema::drop_if_exists( 'test_profiles' ); + Schema::drop_if_exists( 'test_posts' ); + Schema::drop_if_exists( 'test_users' ); + } + + protected function create_tables(): void { + Schema::create( + 'test_users', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->string( 'name' ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_posts', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->unsigned_big_integer( 'user_id' ); + $t->string( 'title' ); + $t->string( 'status' )->default( 'publish' ); + $t->integer( 'views' )->default( 0 ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_profiles', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->unsigned_big_integer( 'user_id' ); + $t->string( 'bio' ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_roles', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->string( 'name' ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_user_roles', function( Blueprint $t ) { + $t->unsigned_big_integer( 'user_id' ); + $t->unsigned_big_integer( 'role_id' ); + } + ); + + Schema::create( + 'test_images', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->string( 'url' ); + $t->unsigned_big_integer( 'imageable_id' ); + $t->string( 'imageable_type' ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_tags', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->string( 'name' ); + $t->timestamps(); + } + ); + + Schema::create( + 'test_taggables', function( Blueprint $t ) { + $t->unsigned_big_integer( 'test_tag_id' ); + $t->unsigned_big_integer( 'taggable_id' ); + $t->string( 'taggable_type' ); + } + ); + } + + protected function seed_data(): void { + // Users: Alice (2 posts, 1 profile, 2 roles, 2 images) + // Bob (1 post, no profile, 1 role, 0 images) + // Charlie (no posts, no profile, no roles, no images) + $alice = TestUser::create( ['name' => 'Alice'] ); + $bob = TestUser::create( ['name' => 'Bob'] ); + $charlie = TestUser::create( ['name' => 'Charlie'] ); // intentional no-relation user + + // Posts — reference by variable so seeding is ID-agnostic + TestPost::create( ['user_id' => $alice->id, 'title' => 'Alice Post 1', 'status' => 'publish', 'views' => 100] ); + TestPost::create( ['user_id' => $alice->id, 'title' => 'Alice Post 2', 'status' => 'draft', 'views' => 50] ); + $this->bob_post = TestPost::create( ['user_id' => $bob->id, 'title' => 'Bob Post 1', 'status' => 'publish', 'views' => 200] ); + + // Profile — only Alice + TestProfile::create( ['user_id' => $alice->id, 'bio' => 'Alice Bio'] ); + + // Roles + $admin = TestRole::create( ['name' => 'Admin'] ); + $editor = TestRole::create( ['name' => 'Editor'] ); + + $alice->roles()->attach( $admin->id ); + $alice->roles()->attach( $editor->id ); + $bob->roles()->attach( $admin->id ); + + // Images — morphMany on Alice only + TestImage::create( ['url' => 'a1.jpg', 'imageable_id' => $alice->id, 'imageable_type' => TestUser::class] ); + TestImage::create( ['url' => 'a2.jpg', 'imageable_id' => $alice->id, 'imageable_type' => TestUser::class] ); + + // Tags — morphToMany on Bob's post + $php = TestTag::create( ['name' => 'PHP'] ); + $this->bob_post->tags()->attach( $php->id ); + } + + // ========================================================================= + // Group 1: GROUP BY + HAVING + // ========================================================================= + + public function test_group_by_having_with_has_many(): void { + // Users with more than 1 post — use table alias not prefix + $results = TestUser::query() + ->join( 'test_posts', 'test_users.id', '=', 'test_posts.user_id' ) + ->select( 'test_users.id' ) + ->select( 'test_users.name' ) + ->add_select( ['post_count' => 'count(test_posts.id)'] ) + ->group_by( 'test_users.id' ) + ->having( 'post_count', '>', 1 ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Alice', $results[0]->name ); + $this->assertEquals( 2, (int) $results[0]->post_count ); + } + + public function test_group_by_having_with_belongs_to_many(): void { + // Roles attached to at least 2 users — use table alias not prefix + $results = TestRole::query() + ->join( 'test_user_roles', 'test_roles.id', '=', 'test_user_roles.role_id' ) + ->select( 'test_roles.name' ) + ->add_select( ['user_count' => 'count(test_user_roles.user_id)'] ) + ->group_by( 'test_roles.id' ) + ->having( 'user_count', '>=', 2 ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Admin', $results[0]->name ); + $this->assertEquals( 2, (int) $results[0]->user_count ); + } + + // ========================================================================= + // Group 2: Batch INSERT + // ========================================================================= + + public function test_batch_insert_500_rows(): void { + Schema::drop_if_exists( 'test_batch' ); + Schema::create( + 'test_batch', function( Blueprint $t ) { + $t->big_increments( 'id' ); + $t->string( 'name' ); + $t->integer( 'score' ); + } + ); + + $rows = array_map( fn( $i ) => ['name' => "Item $i", 'score' => $i], range( 1, 500 ) ); + + $builder = new Builder( new TestUser() ); + $builder->from( 'test_batch' )->insert( $rows ); + + $count = ( new Builder( new TestUser() ) )->from( 'test_batch' )->count(); + $this->assertEquals( 500, $count ); + + Schema::drop_if_exists( 'test_batch' ); + } + + // ========================================================================= + // Group 3: chunk() and cursor() + // ========================================================================= + + public function test_chunk_processes_all_users_in_batches(): void { + $processed = 0; + $pages = 0; + + TestUser::query()->chunk( + 2, function( $batch, $page ) use ( &$processed, &$pages ) { + $processed += $batch->count(); + $pages = $page; + } + ); + + $this->assertEquals( 3, $processed ); // Alice, Bob, Charlie + $this->assertEquals( 2, $pages ); // page 1: 2 rows, page 2: 1 row + } + + public function test_chunk_aborts_early_on_false_return(): void { + $pages = 0; + + TestUser::query()->chunk( + 1, function( $batch, $page ) use ( &$pages ) { + $pages = $page; + return false; // stop immediately after first batch + } + ); + + $this->assertEquals( 1, $pages ); + } + + public function test_chunk_eager_loads_has_many_in_each_batch(): void { + $all_posts = []; + + TestUser::query()->with( 'posts' )->chunk( + 2, function( $batch ) use ( &$all_posts ) { + foreach ( $batch as $user ) { + foreach ( $user->posts as $post ) { + $all_posts[] = $post->title; + } + } + } + ); + + $this->assertContains( 'Alice Post 1', $all_posts ); + $this->assertContains( 'Bob Post 1', $all_posts ); + } + + public function test_chunk_eager_loads_belongs_to_many_in_each_batch(): void { + $role_names = []; + + TestUser::query()->with( 'roles' )->chunk( + 2, function( $batch ) use ( &$role_names ) { + foreach ( $batch as $user ) { + foreach ( $user->roles as $role ) { + $role_names[] = $role->name; + } + } + } + ); + + $this->assertContains( 'Admin', $role_names ); + $this->assertContains( 'Editor', $role_names ); + } + + public function test_cursor_yields_all_rows_one_at_a_time(): void { + $names = []; + foreach ( TestUser::query()->cursor() as $user ) { + $names[] = $user->name; + } + + $this->assertCount( 3, $names ); + $this->assertContains( 'Alice', $names ); + $this->assertContains( 'Bob', $names ); + $this->assertContains( 'Charlie', $names ); + } + + // ========================================================================= + // Group 4: Transactions + // ========================================================================= + + public function test_transaction_commits_has_many_creation(): void { + Builder::transaction( + function() { + $user = TestUser::create( ['name' => 'TX User'] ); + TestPost::create( ['user_id' => $user->id, 'title' => 'TX Post', 'status' => 'publish', 'views' => 0] ); + } + ); + + $found = TestUser::query()->where( 'name', 'TX User' )->first(); + $this->assertNotNull( $found ); + $this->assertCount( 1, $found->posts ); + } + + public function test_transaction_rolls_back_has_many_on_exception(): void { + try { + Builder::transaction( + function() { + TestUser::create( ['name' => 'Rollback User'] ); + throw new \RuntimeException( 'Forced rollback' ); + } + ); + } catch ( \RuntimeException $e ) { + // expected + } + + $this->assertNull( TestUser::query()->where( 'name', 'Rollback User' )->first() ); + } + + public function test_transaction_rolls_back_belongs_to_many_on_exception(): void { + $user_id = null; + + try { + Builder::transaction( + function() use ( &$user_id ) { + $user = TestUser::create( ['name' => 'BTM Rollback'] ); + $user_id = $user->id; + $role = TestRole::create( ['name' => 'Temp Role'] ); + $user->roles()->attach( $role->id ); + throw new \RuntimeException( 'Forced rollback' ); + } + ); + } catch ( \RuntimeException $e ) { + // expected + } + + $this->assertNull( TestUser::query()->find( $user_id ) ); + } + + public function test_transaction_rolls_back_morph_one_on_exception(): void { + try { + Builder::transaction( + function() { + $user = TestUser::create( ['name' => 'Morph Rollback'] ); + TestImage::create( + [ + 'url' => 'morph.jpg', + 'imageable_id' => $user->id, + 'imageable_type' => TestUser::class, + ] + ); + throw new \RuntimeException( 'Forced rollback' ); + } + ); + } catch ( \RuntimeException $e ) { + // expected + } + + $this->assertNull( TestUser::query()->where( 'name', 'Morph Rollback' )->first() ); + } + + // ========================================================================= + // Group 5: DISTINCT + count() + // ========================================================================= + + public function test_distinct_count_on_has_many_join(): void { + // 3 post rows total (Alice×2, Bob×1) but only 2 distinct user authors + $count = TestUser::query() + ->join( 'test_posts', 'test_users.id', '=', 'test_posts.user_id' ) + ->distinct() + ->count( 'test_users.id' ); + + $this->assertEquals( 2, $count ); + } + + public function test_distinct_values_on_belongs_to_many(): void { + // Both Alice and Bob have Admin — distinct query should return each role once + $roles = TestRole::query() + ->join( 'test_user_roles', 'test_roles.id', '=', 'test_user_roles.role_id' ) + ->distinct() + ->select( 'test_roles.name' ) + ->order_by( 'test_roles.name' ) + ->get(); + + $names = array_column( $roles->all(), 'name' ); + $this->assertEquals( $names, array_unique( $names ) ); + $this->assertContains( 'Admin', $names ); + $this->assertContains( 'Editor', $names ); + } + + public function test_distinct_with_where_filter(): void { + // Posts have 'publish' and 'draft' statuses — distinct() should return 2 unique values + $statuses = TestPost::query() + ->distinct() + ->select( 'status' ) + ->where( 'user_id', '>', 0 ) + ->order_by( 'status' ) + ->get(); + + $values = array_column( $statuses->all(), 'status' ); + $this->assertEquals( count( $values ), count( array_unique( $values ) ) ); + $this->assertContains( 'publish', $values ); + $this->assertContains( 'draft', $values ); + } + + // ========================================================================= + // Group 6: WHERE EXISTS / NOT EXISTS + // ========================================================================= + + public function test_where_exists_with_has_many(): void { + $results = TestUser::query() + ->where_exists( + function( $q ) { + $q->from( 'test_posts' )->where_column( 'test_posts.user_id', 'test_users.id' ); + } + ) + ->order_by( 'name' ) + ->get(); + + $this->assertCount( 2, $results ); // Alice and Bob + $this->assertEquals( 'Alice', $results[0]->name ); + $this->assertEquals( 'Bob', $results[1]->name ); + } + + public function test_where_exists_with_belongs_to_many(): void { + $results = TestUser::query() + ->where_exists( + function( $q ) { + $q->from( 'test_user_roles' )->where_column( 'test_user_roles.user_id', 'test_users.id' ); + } + ) + ->order_by( 'name' ) + ->get(); + + $this->assertCount( 2, $results ); // Alice and Bob — Charlie has no role + } + + public function test_where_not_exists_with_has_one(): void { + // Users without a profile (Bob and Charlie) + $results = TestUser::query() + ->where_not_exists( + function( $q ) { + $q->from( 'test_profiles' )->where_column( 'test_profiles.user_id', 'test_users.id' ); + } + ) + ->order_by( 'name' ) + ->get(); + + $this->assertCount( 2, $results ); + $names = array_column( $results->all(), 'name' ); + $this->assertContains( 'Bob', $names ); + $this->assertContains( 'Charlie', $names ); + } + + public function test_where_not_exists_with_morph_many(): void { + // Users without morph images (Bob and Charlie) + $results = TestUser::query() + ->where_not_exists( + function( $q ) { + $q->from( 'test_images' ) + ->where_column( 'test_images.imageable_id', 'test_users.id' ) + ->where( 'test_images.imageable_type', TestUser::class ); + } + ) + ->order_by( 'name' ) + ->get(); + + $this->assertCount( 2, $results ); + $names = array_column( $results->all(), 'name' ); + $this->assertContains( 'Bob', $names ); + $this->assertContains( 'Charlie', $names ); + } + + // ========================================================================= + // Group 7: ORDER BY multi-column + raw + // ========================================================================= + + public function test_order_by_multiple_columns_on_has_many_result(): void { + $results = TestPost::query() + ->order_by( 'status', 'asc' ) // draft before publish + ->order_by( 'views', 'desc' ) // higher views first within same status + ->get(); + + $this->assertEquals( 'draft', $results[0]->status ); + + $publish_posts = array_values( array_filter( $results->all(), fn( $p ) => $p->status === 'publish' ) ); + $this->assertEquals( 200, (int) $publish_posts[0]->views ); // Bob 200 + $this->assertEquals( 100, (int) $publish_posts[1]->views ); // Alice 100 + } + + public function test_order_by_raw_field_function(): void { + // FIELD() forces publish rows first, draft rows last + $results = TestPost::query() + ->order_by_raw( "FIELD(status, 'publish', 'draft')" ) + ->get(); + + $this->assertEquals( 'publish', $results[0]->status ); + $this->assertEquals( 'publish', $results[1]->status ); + $this->assertEquals( 'draft', $results[2]->status ); + } + + // ========================================================================= + // Group 8: Subquery in SELECT (correlated computed column via where_raw SQL) + // ========================================================================= + + public function test_subquery_count_of_has_many_in_select(): void { + // Count posts per user using JOIN + GROUP BY; use table alias not prefix + $results = TestUser::query() + ->select( 'test_users.name' ) + ->add_select( ['post_count' => 'count(test_posts.id)'] ) + ->join( 'test_posts', 'test_users.id', '=', 'test_posts.user_id' ) + ->group_by( 'test_users.id' ) + ->order_by( 'test_users.name' ) + ->get(); + + $by_name = []; + foreach ( $results as $row ) { + $by_name[ $row->name ] = (int) $row->post_count; + } + + $this->assertEquals( 2, $by_name['Alice'] ); + $this->assertEquals( 1, $by_name['Bob'] ); + // Charlie has no posts — excluded by INNER JOIN + } + + public function test_subquery_count_of_belongs_to_many_in_select(): void { + // Count roles per user using JOIN + GROUP BY; use table alias not prefix + $results = TestUser::query() + ->select( 'test_users.name' ) + ->add_select( ['role_count' => 'count(test_user_roles.role_id)'] ) + ->join( 'test_user_roles', 'test_users.id', '=', 'test_user_roles.user_id' ) + ->group_by( 'test_users.id' ) + ->order_by( 'test_users.name' ) + ->get(); + + $by_name = []; + foreach ( $results as $row ) { + $by_name[ $row->name ] = (int) $row->role_count; + } + + $this->assertEquals( 2, $by_name['Alice'] ); // Admin + Editor + $this->assertEquals( 1, $by_name['Bob'] ); // Admin only + // Charlie excluded by INNER JOIN (no roles) + } + + // ========================================================================= + // Group 9: with_count / with_min / with_max / with_avg / with_sum chained + // ========================================================================= + + public function test_chained_aggregates_on_has_many(): void { + // MySQL temp tables can't be reopened; run separate queries to avoid 'Can't reopen table' + $users_count = TestUser::query()->with_count( 'posts' )->order_by( 'test_users.name' )->get(); + $users_min = TestUser::query()->with_min( 'posts', 'views' )->order_by( 'test_users.name' )->get(); + $users_max = TestUser::query()->with_max( 'posts', 'views' )->order_by( 'test_users.name' )->get(); + $users_sum = TestUser::query()->with_sum( 'posts', 'views' )->order_by( 'test_users.name' )->get(); + + $alice_count = null; + $charlie_count = null; + foreach ( $users_count as $u ) { + if ( $u->name === 'Alice' ) { + $alice_count = $u; } + if ( $u->name === 'Charlie' ) { + $charlie_count = $u; } + } + + $alice_min = null; + $alice_max = null; + $alice_sum = null; + foreach ( $users_min as $u ) { + if ( $u->name === 'Alice' ) { + $alice_min = $u; } } + foreach ( $users_max as $u ) { + if ( $u->name === 'Alice' ) { + $alice_max = $u; } } + foreach ( $users_sum as $u ) { + if ( $u->name === 'Alice' ) { + $alice_sum = $u; } } + + $this->assertNotNull( $alice_count, 'Alice should be in results' ); + $this->assertEquals( 2, (int) $alice_count->posts_count ); + $this->assertEquals( 0, (int) $charlie_count->posts_count ); + $this->assertNotNull( $alice_min ); + $this->assertEquals( 50, (int) $alice_min->posts_min_views ); + $this->assertEquals( 100, (int) $alice_max->posts_max_views ); + $this->assertEquals( 150, (int) $alice_sum->posts_sum_views ); + } + + public function test_with_count_on_belongs_to_many(): void { + $users = TestUser::query()->with_count( 'roles' )->order_by( 'test_users.name' )->get(); + + $alice = null; + $bob = null; + $charlie = null; + foreach ( $users as $u ) { + if ( $u->name === 'Alice' ) { + $alice = $u; } + if ( $u->name === 'Bob' ) { + $bob = $u; } + if ( $u->name === 'Charlie' ) { + $charlie = $u; } + } + + $this->assertEquals( 2, (int) $alice->roles_count ); // Admin + Editor + $this->assertEquals( 1, (int) $bob->roles_count ); // Admin only + $this->assertEquals( 0, (int) $charlie->roles_count ); // no roles + } + + public function test_with_count_on_morph_many(): void { + $users = TestUser::query()->with_count( 'images' )->order_by( 'test_users.name' )->get(); + + $alice = null; + $bob = null; + foreach ( $users as $u ) { + if ( $u->name === 'Alice' ) { + $alice = $u; } + if ( $u->name === 'Bob' ) { + $bob = $u; } + } + + $this->assertEquals( 2, (int) $alice->images_count ); + $this->assertEquals( 0, (int) $bob->images_count ); + } + + public function test_with_count_on_morph_to_many(): void { + $posts = TestPost::query()->with_count( 'tags' )->order_by( 'test_posts.title' )->get(); + + $bob_post = null; + $alice_p1 = null; + foreach ( $posts as $p ) { + if ( $p->title === 'Bob Post 1' ) { + $bob_post = $p; } + if ( $p->title === 'Alice Post 1' ) { + $alice_p1 = $p; } + } + + $this->assertNotNull( $bob_post, 'Bob Post 1 should exist' ); + $this->assertEquals( 1, (int) $bob_post->tags_count ); + $this->assertEquals( 0, (int) $alice_p1->tags_count ); + } + + // ========================================================================= + // Group 10: doesnt_have across relations + // ========================================================================= + + public function test_doesnt_have_with_has_one(): void { + // Bob and Charlie have no profile + $results = TestUser::query()->doesnt_have( 'profile' )->order_by( 'name' )->get(); + + $this->assertCount( 2, $results ); + $names = array_column( $results->all(), 'name' ); + $this->assertContains( 'Bob', $names ); + $this->assertContains( 'Charlie', $names ); + } + + public function test_doesnt_have_with_morph_to_many(): void { + // Alice Post 1 and Alice Post 2 have no tags + $results = TestPost::query()->doesnt_have( 'tags' )->order_by( 'title' )->get(); + + $this->assertCount( 2, $results ); + $titles = array_column( $results->all(), 'title' ); + $this->assertContains( 'Alice Post 1', $titles ); + $this->assertContains( 'Alice Post 2', $titles ); + } + + public function test_doesnt_have_with_belongs_to_many(): void { + // Charlie has no role + $results = TestUser::query()->doesnt_have( 'roles' )->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Charlie', $results[0]->name ); + } + + // ========================================================================= + // Group 11: UNION + ORDER BY + LIMIT + // ========================================================================= + + public function test_union_order_and_limit_combined(): void { + // Union user names with role names — order alphabetically + $q1 = TestUser::query()->select( 'name' )->where( 'name', 'Alice' ); + $q2 = TestRole::query()->select( 'name' )->where( 'name', 'Admin' ); + + // ORDER BY and LIMIT are applied to the full UNION result, not q1 + $results = $q1->union( $q2 )->order_by( 'name', 'asc' )->get(); + + $names = array_column( $results->all(), 'name' ); + $this->assertContains( 'Alice', $names ); + $this->assertContains( 'Admin', $names ); + + // Verify alphabetical order: Admin < Alice + $this->assertEquals( 'Admin', $names[0] ); + $this->assertEquals( 'Alice', $names[1] ); + } + + public function test_union_with_limit_returns_top_n(): void { + // Union all users with all roles, limit to 2 alphabetically first + $q1 = TestUser::query()->select( 'name' ); + $q2 = TestRole::query()->select( 'name' ); + + $results = $q1->union( $q2 )->order_by( 'name', 'asc' )->limit( 2 )->get(); + + $this->assertCount( 2, $results ); + // Alphabetically: Admin, Alice, Bob, Charlie, Editor → top 2 are Admin, Alice + $this->assertEquals( 'Admin', $results[0]->name ); + $this->assertEquals( 'Alice', $results[1]->name ); + } +} diff --git a/tests/Integration/Query/JoinTest.php b/tests/Integration/Query/JoinTest.php new file mode 100644 index 0000000..19b862a --- /dev/null +++ b/tests/Integration/Query/JoinTest.php @@ -0,0 +1,119 @@ +big_increments( 'id' ); + $table->string( 'name' ); + } + ); + + Schema::create( + 'join_test_b', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'a_id' ); + $table->string( 'value' ); + } + ); + } + + public function tear_down(): void { + Schema::drop_if_exists( 'join_test_a' ); + Schema::drop_if_exists( 'join_test_b' ); + parent::tear_down(); + } + + protected function new_builder() { + return new Builder( new TestUser() ); + } + + /** @test */ + public function test_it_can_perform_inner_joins() { + global $wpdb; + $wpdb->insert( $wpdb->prefix . 'join_test_a', ['id' => 1, 'name' => 'A'] ); + $wpdb->insert( $wpdb->prefix . 'join_test_b', ['a_id' => 1, 'value' => 'B1'] ); + + $results = $this->new_builder() + ->from( 'join_test_a' ) + ->join( 'join_test_b', 'join_test_a.id', '=', 'join_test_b.a_id' ) + ->select( 'join_test_a.name', 'join_test_b.value' ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'A', $results[0]->name ); + $this->assertEquals( 'B1', $results[0]->value ); + } + + /** @test */ + public function test_it_can_perform_left_joins() { + global $wpdb; + $wpdb->insert( $wpdb->prefix . 'join_test_a', ['id' => 1, 'name' => 'A'] ); + $wpdb->insert( $wpdb->prefix . 'join_test_a', ['id' => 2, 'name' => 'B'] ); + $wpdb->insert( $wpdb->prefix . 'join_test_b', ['a_id' => 1, 'value' => 'V1'] ); + + $results = $this->new_builder() + ->from( 'join_test_a' ) + ->left_join( 'join_test_b', 'join_test_a.id', '=', 'join_test_b.a_id' ) + ->order_by( 'join_test_a.id' ) + ->get(); + + $this->assertCount( 2, $results ); + $this->assertEquals( 'V1', $results[0]->value ); + $this->assertNull( $results[1]->value ); + } + + /** @test */ + public function test_it_can_perform_complex_joins_with_callbacks() { + global $wpdb; + $wpdb->insert( $wpdb->prefix . 'join_test_a', ['id' => 1, 'name' => 'A'] ); + $wpdb->insert( $wpdb->prefix . 'join_test_b', ['a_id' => 1, 'value' => 'V1'] ); + + $results = $this->new_builder() + ->from( 'join_test_a' ) + ->join( + 'join_test_b', function( $join ) { + $join->on( 'join_test_a.id', '=', 'join_test_b.a_id' ) + ->where( 'join_test_b.value', '!=', 'ignored' ); + } + ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'V1', $results[0]->value ); + } + + /** @test */ + public function test_it_handles_complex_joins_with_multiple_on_conditions() { + global $wpdb; + $wpdb->insert( $wpdb->prefix . 'join_test_a', ['id' => 1, 'name' => 'Alpha'] ); + $wpdb->insert( $wpdb->prefix . 'join_test_b', ['id' => 2, 'a_id' => 1, 'value' => 'Beta'] ); + + $results = $this->new_builder() + ->from( 'join_test_a' ) + ->join( + 'join_test_b', function( $join ) { + $join->on( 'join_test_a.id', '!=', 'join_test_b.id' ) + ->on( 'join_test_a.id', '=', 'join_test_b.a_id' ); + } + ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Alpha', $results[0]->name ); + $this->assertEquals( 'Beta', $results[0]->value ); + } +} diff --git a/tests/Integration/Query/WhereTest.php b/tests/Integration/Query/WhereTest.php new file mode 100644 index 0000000..610ff36 --- /dev/null +++ b/tests/Integration/Query/WhereTest.php @@ -0,0 +1,164 @@ +big_increments( 'id' ); + $table->string( 'name' ); + $table->integer( 'votes' )->nullable(); + $table->timestamps(); + } + ); + } + + public function tear_down(): void { + Schema::drop_if_exists( 'where_test' ); + parent::tear_down(); + } + + protected function new_builder() { + $builder = new Builder( new TestUser() ); + $builder->from( 'where_test' ); + return $builder; + } + + /** @test */ + public function test_it_can_handle_complex_wheres() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ['name' => 'Charlie', 'votes' => 30], + ['name' => 'David', 'votes' => 40], + ] + ); + + // Multiple WHERE + $results = $this->new_builder() + ->where( 'votes', '>', 15 ) + ->where( 'votes', '<', 35 ) + ->get(); + $this->assertCount( 2, $results ); // Bob and Charlie + + // OR WHERE + $results = $this->new_builder() + ->where( 'name', 'Alice' ) + ->or_where( 'name', 'David' ) + ->get(); + $this->assertCount( 2, $results ); + } + + /** @test */ + public function test_it_can_use_where_in() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ['name' => 'Charlie', 'votes' => 30], + ] + ); + + $results = $this->new_builder()->where_in( 'name', ['Alice', 'Charlie'] )->get(); + $this->assertCount( 2, $results ); + + $results = $this->new_builder()->where_not_in( 'name', ['Bob'] )->get(); + $this->assertCount( 2, $results ); + } + + /** @test */ + public function test_it_can_use_where_between() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ['name' => 'Charlie', 'votes' => 30], + ] + ); + + $results = $this->new_builder()->where_between( 'votes', [15, 25] )->get(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'Bob', $results[0]->name ); + + $results = $this->new_builder()->where_not_between( 'votes', [15, 25] )->get(); + $this->assertCount( 2, $results ); + } + + /** @test */ + public function test_it_can_use_where_null() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => null], + ] + ); + + $results = $this->new_builder()->where_null( 'votes' )->get(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'Bob', $results[0]->name ); + + $results = $this->new_builder()->where_not_null( 'votes' )->get(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'Alice', $results[0]->name ); + } + + /** @test */ + public function test_it_can_use_where_raw() { + $builder = $this->new_builder(); + $builder->insert( + [ + ['name' => 'Alice', 'votes' => 10], + ['name' => 'Bob', 'votes' => 20], + ] + ); + + $results = $this->new_builder() + ->where_raw( 'votes % 3 = %d', [2] ) + ->get(); + + $this->assertCount( 1, $results ); + $this->assertEquals( 'Bob', $results[0]->name ); + } + + /** @test */ + public function test_it_can_use_subqueries_in_where_clauses() { + Schema::create( + 'where_sub_test', function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->integer( 'votes' ); + } + ); + + $this->new_builder()->insert( ['name' => 'Expensive', 'votes' => 100] ); + $this->new_builder()->insert( ['name' => 'Cheap', 'votes' => 10] ); + + global $wpdb; + $wpdb->insert( $wpdb->prefix . 'where_sub_test', ['votes' => 100] ); + + $subquery = ( new Builder( new TestUser() ) )->from( 'where_sub_test' )->select( 'votes' ); + + $items = $this->new_builder() + ->where_in( 'votes', $subquery ) + ->get(); + + $this->assertCount( 1, $items ); + $this->assertEquals( 'Expensive', $items[0]->name ); + + Schema::drop_if_exists( 'where_sub_test' ); + } +} diff --git a/tests/Integration/QueryExecutionTest.php b/tests/Integration/QueryExecutionTest.php deleted file mode 100644 index c36788a..0000000 --- a/tests/Integration/QueryExecutionTest.php +++ /dev/null @@ -1,168 +0,0 @@ -id = 1; - $mock_post->title = 'Test Post'; - $mock_post->status = 'publish'; - - $this->mockDatabaseResults( [$mock_post] ); - - // Act - $results = Post::query()->where( 'status', 'publish' )->get(); - - // Assert - $this->assertCount( 1, $results ); - $this->assertEquals( 'Test Post', $results[0]->title ); - $this->assertQueryExecuted( "select * from wp_posts as posts where status = %s" ); - } - - /** - * @test - * - * Verifies that INSERT queries correctly execute and return insert ID. - */ - public function it_executes_insert_query_and_returns_id() { - // Arrange - $data = [ - 'title' => 'New Post', - 'status' => 'draft' - ]; - - // Act - $result = Post::query()->insert( $data ); - - // Assert - $this->assertTrue( (bool) $result ); - $this->assert_sql_contains( 'insert into wp_posts', $this->getLastQuery() ); - $this->assert_sql_contains( 'title', $this->getLastQuery() ); - $this->assert_sql_contains( 'status', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that UPDATE queries execute with WHERE clauses. - */ - public function it_executes_update_query_with_where_clause() { - // Arrange - $updates = ['status' => 'publish']; - - // Act - $result = Post::query() - ->where( 'id', 1 ) - ->update( $updates ); - - // Assert - $this->assertTrue( (bool) $result ); - $this->assert_sql_contains( 'update wp_posts', $this->getLastQuery() ); - $this->assert_sql_contains( 'where id = %d', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that DELETE queries execute with proper conditions. - */ - public function it_executes_delete_query_with_conditions() { - // Act - $result = Post::query() - ->where( 'status', 'trash' ) - ->delete(); - - // Assert - $this->assertTrue( (bool) $result ); - $this->assert_sql_contains( 'delete', $this->getLastQuery() ); - $this->assert_sql_contains( 'where status', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that complex queries with multiple clauses work correctly. - */ - public function it_executes_complex_query_with_multiple_clauses() { - // Arrange - $mock_posts = [ - (object) ['id' => 1, 'title' => 'Post 1'], - (object) ['id' => 2, 'title' => 'Post 2'] - ]; - $this->mockDatabaseResults( $mock_posts ); - - // Act - $results = Post::query() - ->where( 'status', 'publish' ) - ->where( 'author_id', 5 ) - ->order_by( 'created_at', 'desc' ) - ->limit( 10 ) - ->get(); - - // Assert - $this->assertCount( 2, $results ); - $last_query = $this->getLastQuery(); - $this->assert_sql_contains( 'where status', $last_query ); - $this->assert_sql_contains( 'and author_id', $last_query ); - $this->assert_sql_contains( 'order by created_at desc', $last_query ); - $this->assert_sql_contains( 'limit', $last_query ); - } - - /** - * @test - * - * Verifies that first() method returns single result. - */ - public function it_returns_single_result_with_first() { - // Arrange - $mock_post = new stdClass(); - $mock_post->id = 1; - $mock_post->title = 'First Post'; - - $this->mockDatabaseResults( [$mock_post] ); - - // Act - $result = Post::query()->where( 'id', 1 )->first(); - - // Assert - $this->assertNotNull( $result ); - $this->assertEquals( 'First Post', $result->title ); - $this->assert_sql_contains( 'limit %d', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that aggregate queries (count) execute correctly. - */ - public function it_executes_count_aggregate_query() { - // Arrange - $mock_result = new stdClass(); - $mock_result->aggregate = 42; - - $this->mockDatabaseResults( [$mock_result] ); - - // Act - $count = Post::query()->where( 'status', 'publish' )->count(); - - // Assert - $this->assertEquals( 42, $count ); - $this->assert_sql_contains( 'count(*)', $this->getLastQuery() ); - } -} diff --git a/tests/Integration/RelationshipLoadingTest.php b/tests/Integration/RelationshipLoadingTest.php deleted file mode 100644 index 34ea804..0000000 --- a/tests/Integration/RelationshipLoadingTest.php +++ /dev/null @@ -1,86 +0,0 @@ -has_many( Comment::class, 'post_id', 'id' ); - - // Assert - $this->assertInstanceOf( \WpMVC\Database\Eloquent\Relations\HasMany::class, $relation ); - $this->assertEquals( 'post_id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - } - - /** - * @test - * - * Verifies that belongs_to_one relationship is defined correctly. - */ - public function it_defines_belongs_to_one_relationship() { - // Arrange - $comment = new Comment(); - - // Act - $relation = $comment->belongs_to_one( Post::class, 'post_id', 'id' ); - - // Assert - $this->assertInstanceOf( \WpMVC\Database\Eloquent\Relations\BelongsToOne::class, $relation ); - $this->assertEquals( 'post_id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - } - - /** - * @test - * - * Verifies that query builder properly handles relationship constraints. - */ - public function it_applies_relationship_constraints_to_query() { - // Arrange - $mock_comments = [ - (object) ['id' => 1, 'post_id' => 5, 'content' => 'Comment 1'], - (object) ['id' => 2, 'post_id' => 5, 'content' => 'Comment 2'] - ]; - $this->mockDatabaseResults( $mock_comments ); - - // Act - $results = Comment::query()->where( 'post_id', 5 )->get(); - - // Assert - $this->assertCount( 2, $results ); - $this->assert_sql_contains( 'where post_id = %d', $this->getLastQuery() ); - } - - /** - * @test - * - * Verifies that with() method for relationship eager loading works. - */ - public function it_sets_up_eager_loading_with_with_method() { - // Act - Just verify method exists and sets up relationships - $query = Post::query()->with( 'comments' ); - - // Assert - Verify query builder accepts with() method - $this->assertInstanceOf( \WpMVC\Database\Query\Builder::class, $query ); - } -} diff --git a/tests/Integration/Schema/BlueprintTest.php b/tests/Integration/Schema/BlueprintTest.php new file mode 100644 index 0000000..5f614e7 --- /dev/null +++ b/tests/Integration/Schema/BlueprintTest.php @@ -0,0 +1,160 @@ +big_increments( 'id' ); + $table->$method( $name, ...$args ); + } + ); + + $full_table_name = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $columns = $wpdb->get_results( "DESCRIBE $full_table_name", OBJECT_K ); + + $this->assertArrayHasKey( $name, $columns ); + $this->assertStringContainsString( $expected_type, strtolower( $columns[$name]->Type ) ); + + Schema::drop_if_exists( $table_name ); + } + + public function columnTypeProvider() { + return [ + ['unsigned_big_integer', 'big_int', 'bigint'], + ['integer', 'regular_int', 'int'], + ['unsigned_integer', 'unsigned_int', 'int'], + ['unsigned_integer', 'unsigned_int_check', 'unsigned'], + ['decimal', 'price', 'decimal(15,4)', [15, 4]], + ['string', 'email', 'varchar(100)', [100]], + ['text', 'bio', 'text'], + ['long_text', 'content', 'longtext'], + ['json', 'metadata', 'json'], // Updated to expect 'json' based on environment failure + ['enum', 'status', "enum('active','inactive')", [['active', 'inactive']]], + ['tiny_integer', 'small_int', 'tinyint'], + ['boolean', 'is_active', 'tinyint(1)'], + ['float', 'phi', 'float'], + ['date', 'birthday', 'date'], + ['datetime', 'published_at', 'datetime'], + ]; + } + + public function test_it_can_define_indexes() { + global $wpdb; + $table_name = 'test_indexes_table'; + Schema::drop_if_exists( $table_name ); + + Schema::create( + $table_name, function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'email' ); + $table->string( 'username' ); + $table->integer( 'votes' ); + + $table->unique( 'email' ); + $table->index( ['username', 'votes'], 'user_votes_index' ); + } + ); + + $full_table_name = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $indexes = $wpdb->get_results( "SHOW INDEX FROM $full_table_name", ARRAY_A ); + + $found_unique = false; + $found_composite = false; + + foreach ( $indexes as $index ) { + if ( $index['Column_name'] === 'email' && $index['Non_unique'] == 0 ) { + $found_unique = true; + } + if ( $index['Key_name'] === 'user_votes_index' ) { + $found_composite = true; + } + } + + $this->assertTrue( $found_unique, "Unique index on email should exist" ); + $this->assertTrue( $found_composite, "Composite index user_votes_index should exist" ); + + Schema::drop_if_exists( $table_name ); + } + + public function test_it_can_define_column_modifiers() { + global $wpdb; + $table_name = 'test_modifiers_table'; + Schema::drop_if_exists( $table_name ); + + Schema::create( + $table_name, function( Blueprint $table ) { + $table->big_increments( 'id' ); + $table->string( 'name' )->default( 'John Doe' )->comment( 'User Name' ); + $table->integer( 'age' )->nullable(); + $table->timestamp( 'created_at' )->use_current(); + $table->timestamp( 'updated_at' )->use_current()->use_current_on_update(); + } + ); + + $full_table_name = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $columns = $wpdb->get_results( "DESCRIBE $full_table_name", OBJECT_K ); + + $this->assertEquals( 'John Doe', $columns['name']->Default ); + $this->assertEquals( 'YES', $columns['age']->Null ); + $this->assertStringContainsString( 'current_timestamp', strtolower( $columns['created_at']->Default ) ); + $this->assertStringContainsString( 'on update current_timestamp', strtolower( $columns['updated_at']->Extra ) ); + + Schema::drop_if_exists( $table_name ); + } + + public function test_it_can_generate_alter_sql() { + $table_name = 'prefix_test_table'; + $blueprint = new Blueprint( $table_name, 'utf8mb4' ); + + $blueprint->string( 'new_col' ); + $blueprint->drop_column( 'old_col' ); + $blueprint->unique( 'new_col' ); + + $sql = $blueprint->to_alter_sql(); + + $this->assertStringContainsString( 'ALTER TABLE PREFIX_TEST_TABLE', strtoupper( $sql ) ); + $this->assertStringContainsString( 'ADD NEW_COL VARCHAR(255) NOT NULL', strtoupper( $sql ) ); + $this->assertStringContainsString( 'DROP COLUMN OLD_COL', strtoupper( $sql ) ); + $this->assertStringContainsString( 'ADD UNIQUE', strtoupper( $sql ) ); + $this->assertStringContainsString( '(NEW_COL)', strtoupper( $sql ) ); + } + + public function test_it_handles_boolean_defaults_correctly() { + global $wpdb; + $table_name = 'test_bool_defaults'; + Schema::drop_if_exists( $table_name ); + + Schema::create( + $table_name, function( Blueprint $table ) { + $table->boolean( 'is_active' )->default( true ); + $table->boolean( 'is_deleted' )->default( false ); + } + ); + + $full_table_name = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $columns = $wpdb->get_results( "DESCRIBE $full_table_name", OBJECT_K ); + + $this->assertEquals( '1', $columns['is_active']->Default ); + $this->assertEquals( '0', $columns['is_deleted']->Default ); + + Schema::drop_if_exists( $table_name ); + } +} diff --git a/tests/Integration/Schema/ForeignKeyTest.php b/tests/Integration/Schema/ForeignKeyTest.php new file mode 100644 index 0000000..437ff8b --- /dev/null +++ b/tests/Integration/Schema/ForeignKeyTest.php @@ -0,0 +1,75 @@ +big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + + // Test fluent API + $fk = $table->foreign( 'user_id' ) + ->references( 'id' ) + ->on( 'wp_users' ) + ->on_delete( 'cascade' ) + ->on_update( 'set null' ); + + $this->assertInstanceOf( ForeignKey::class, $fk ); + }, true + ); // return SQL + + $this->assertStringContainsString( 'CREATE TABLE', $sql ); + $this->assertStringContainsString( 'USER_ID BIGINT UNSIGNED', strtoupper( $sql ) ); + } + + public function test_schema_identifies_foreign_keys() { + $captured_fks = []; + + Schema::create( + 'test_fk_capture', function( Blueprint $table ) use ( &$captured_fks ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'author_id' ); + $table->foreign( 'author_id' )->references( 'id' )->on( 'authors' ); + + $captured_fks = $table->get_foreign_keys(); + }, true + ); + + $this->assertCount( 1, $captured_fks ); + $this->assertEquals( 'author_id', $captured_fks[0]->get_column() ); + $this->assertEquals( 'id', $captured_fks[0]->get_reference_column() ); + + $resolver = new \WpMVC\Database\Resolver(); + $this->assertEquals( $resolver->table( 'authors' ), $captured_fks[0]->get_reference_table() ); + } + + public function test_it_captures_on_delete_and_on_update() { + $captured_fks = []; + + Schema::create( + 'test_fk_actions', function( Blueprint $table ) use ( &$captured_fks ) { + $table->big_increments( 'id' ); + $table->unsigned_big_integer( 'user_id' ); + $table->foreign( 'user_id' ) + ->references( 'id' ) + ->on( 'users' ) + ->on_delete( 'cascade' ) + ->on_update( 'restrict' ); + + $captured_fks = $table->get_foreign_keys(); + }, true + ); + + $this->assertEquals( 'CASCADE', $captured_fks[0]->get_on_delete() ); + $this->assertEquals( 'RESTRICT', $captured_fks[0]->get_on_update() ); + } +} diff --git a/tests/Integration/Schema/SchemaTest.php b/tests/Integration/Schema/SchemaTest.php new file mode 100644 index 0000000..da543a8 --- /dev/null +++ b/tests/Integration/Schema/SchemaTest.php @@ -0,0 +1,87 @@ +big_increments( 'id' ); + } + ); + + $this->assertTableExists( $from ); + + Schema::rename( $from, $to ); + + $this->assertTableNotExists( $from ); + $this->assertTableExists( $to ); + + Schema::drop_if_exists( $to ); + } + + public function test_it_can_alter_a_table() { + $table_name = 'test_alter_table'; + + Schema::drop_if_exists( $table_name ); + + Schema::create( + $table_name, function( Blueprint $table ) { + $table->big_increments( 'id' ); + } + ); + + Schema::alter( + $table_name, function( Blueprint $table ) { + $table->string( 'new_column' )->nullable(); + $table->index( 'new_column' ); + } + ); + + $this->assertColumnExists( $table_name, 'new_column' ); + $this->assertHasIndex( $table_name, 'index_' . md5( 'new_column' ) ); + + // Test drop column via alter + Schema::alter( + $table_name, function( Blueprint $table ) { + $table->drop_column( 'new_column' ); + } + ); + + global $wpdb; + $table = $wpdb->prefix . $table_name; + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $columns = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM `{$table}` LIKE %s", 'new_column' ) ); + $this->assertEmpty( $columns, "Column [new_column] should have been dropped." ); + + Schema::drop_if_exists( $table_name ); + } + + public function test_it_can_drop_a_table() { + $table_name = 'test_drop_table'; + + Schema::drop_if_exists( $table_name ); + + Schema::create( + $table_name, function( Blueprint $table ) { + $table->big_increments( 'id' ); + } + ); + + $this->assertTableExists( $table_name ); + + Schema::drop_if_exists( $table_name ); + + $this->assertTableNotExists( $table_name ); + } +} diff --git a/tests/Integration/SchemaExecutionTest.php b/tests/Integration/SchemaExecutionTest.php deleted file mode 100644 index de0189e..0000000 --- a/tests/Integration/SchemaExecutionTest.php +++ /dev/null @@ -1,188 +0,0 @@ -big_increments( 'id' ); - $table->string( 'name' ); - $table->decimal( 'price', 10, 2 ); - $table->text( 'description' ); - $table->timestamps(); - }, - true // Return SQL instead of executing - ); - - // Assert - $this->assert_sql_contains( 'create table', $sql ); - $this->assert_sql_contains( 'wp_products', $sql ); - $this->assert_sql_contains( 'id', $sql ); - $this->assert_sql_contains( 'bigint unsigned auto_increment', $sql ); - $this->assert_sql_contains( 'name', $sql ); - $this->assert_sql_contains( 'varchar(255)', $sql ); - $this->assert_sql_contains( 'price', $sql ); - $this->assert_sql_contains( 'decimal(10, 2)', $sql ); - $this->assert_sql_contains( 'created_at', $sql ); - $this->assert_sql_contains( 'updated_at', $sql ); - } - - /** - * @test - * - * Verifies that drop table generates correct SQL. - */ - public function it_generates_drop_table_sql() { - // Act - $sql = Schema::drop_if_exists( 'old_table', true ); - - // Assert - $this->assert_sql_contains( 'drop table if exists', $sql ); - $this->assert_sql_contains( 'wp_old_table', $sql ); - } - - /** - * @test - * - * Verifies that alter table adds columns correctly. - */ - public function it_generates_alter_table_add_column_sql() { - // Act - $sql = Schema::alter( - 'users', - function ( Blueprint $table ) { - $table->string( 'phone' ); - $table->boolean( 'is_verified' ); - }, - true - ); - - // Assert - $this->assert_sql_contains( 'alter table', $sql ); - $this->assert_sql_contains( 'wp_users', $sql ); - $this->assert_sql_contains( 'add', $sql ); - $this->assert_sql_contains( 'phone', $sql ); - $this->assert_sql_contains( 'varchar(255)', $sql ); - $this->assert_sql_contains( 'is_verified', $sql ); - $this->assert_sql_contains( 'tinyint(1)', $sql ); - } - - /** - * @test - * - * Verifies that alter table can drop columns. - */ - public function it_generates_alter_table_drop_column_sql() { - // Act - $sql = Schema::alter( - 'users', - function ( Blueprint $table ) { - $table->drop_column( 'old_field' ); - }, - true - ); - - // Assert - $this->assert_sql_contains( 'alter table', $sql ); - $this->assert_sql_contains( 'drop column', $sql ); - $this->assert_sql_contains( 'old_field', $sql ); - } - - /** - * @test - * - * Verifies that indexes are added to create table statement. - */ - public function it_generates_table_with_indexes() { - // Act - $sql = Schema::create( - 'products', - function ( Blueprint $table ) { - $table->big_increments( 'id' ); - $table->string( 'sku' ); - $table->string( 'name' ); - - $table->unique( 'sku' ); - $table->index( 'name' ); - }, - true - ); - - // Assert - $this->assert_sql_contains( 'primary key', $sql ); - $this->assert_sql_contains( 'unique key', $sql ); - $this->assert_sql_contains( 'key', $sql ); // Regular index - } - - /** - * @test - * - * Verifies that foreign keys are defined correctly. - */ - public function it_defines_foreign_keys_in_schema() { - // Act - $sql = Schema::create( - 'comments', - function ( Blueprint $table ) { - $table->big_increments( 'id' ); - $table->unsigned_big_integer( 'post_id' ); - $table->text( 'content' ); - - $table->foreign( 'post_id' ) - ->references( 'id' ) - ->on( 'posts' ) - ->on_delete( 'CASCADE' ); - }, - true - ); - - // Assert - $this->assert_sql_contains( 'create table', $sql ); - $this->assert_sql_contains( 'post_id', $sql ); - $this->assert_sql_contains( 'bigint unsigned', $sql ); - // Note: Foreign keys are applied separately via Schema::create - } - - /** - * @test - * - * Verifies that column modifiers work correctly. - */ - public function it_applies_column_modifiers() { - // Act - $sql = Schema::create( - 'settings', - function ( Blueprint $table ) { - $table->big_increments( 'id' ); - $table->string( 'key' ); - $table->string( 'value' )->nullable(); - $table->string( 'status' )->default( 'active' ); - - $table->unique( 'key' ); - }, - true - ); - - // Assert - $this->assert_sql_contains( 'null', $sql ); - $this->assert_sql_contains( "default 'active'", $sql ); - $this->assert_sql_contains( 'unique', $sql ); - } -} diff --git a/tests/Integration/WordPress/CommentTest.php b/tests/Integration/WordPress/CommentTest.php new file mode 100644 index 0000000..3448c1e --- /dev/null +++ b/tests/Integration/WordPress/CommentTest.php @@ -0,0 +1,97 @@ + 'Commentable Post', + ] + ); + + $comment = Comment::create( + [ + 'comment_post_ID' => $post->ID, + 'comment_author' => 'John Doe', + 'comment_content' => 'Nice post!', + 'comment_date' => '2026-02-27 13:00:00', + ] + ); + + $this->assertIsInt( $comment->comment_ID ); + $this->assertEquals( 'John Doe', $comment->comment_author ); + $this->assertEquals( $post->ID, $comment->comment_post_ID ); + } + + /** + * Test comment relationships. + */ + public function test_it_has_relationships() { + $user = User::create( + [ + 'user_login' => 'commenter', + 'user_email' => 'commenter@example.com', + ] + ); + + $post = Post::create( + [ + 'post_title' => 'Rel Post', + ] + ); + + $comment = Comment::create( + [ + 'comment_post_ID' => $post->ID, + 'user_id' => $user->ID, + 'comment_content' => 'Relationship test', + ] + ); + + $this->assertEquals( $post->ID, $comment->post->ID ); + $this->assertEquals( $user->ID, $comment->user->ID ); + } + + /** + * Test comment meta. + */ + public function test_it_can_handle_commentmeta() { + $comment = Comment::create( + [ + 'comment_content' => 'Meta Test', + ] + ); + + $comment->meta()->create( + [ + 'meta_key' => 'rating', + 'meta_value' => '5', + ] + ); + + $this->assertCount( 1, $comment->meta ); + $this->assertEquals( 'rating', $comment->meta->first()->meta_key ); + + // Test inverse + $meta = $comment->meta->first(); + $this->assertEquals( $comment->comment_ID, $meta->comment->comment_ID ); + } +} diff --git a/tests/Integration/WordPress/Models/Blog.php b/tests/Integration/WordPress/Models/Blog.php new file mode 100644 index 0000000..66887bf --- /dev/null +++ b/tests/Integration/WordPress/Models/Blog.php @@ -0,0 +1,101 @@ + 'int', + 'site_id' => 'int', + 'registered' => 'datetime', + 'last_updated' => 'datetime', + 'public' => 'bool', + 'archived' => 'bool', + 'mature' => 'bool', + 'spam' => 'bool', + 'deleted' => 'bool', + 'lang_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'blogs'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the site that owns the blog. + */ + public function site() { + return $this->belongs_to( Site::class, 'site_id', 'id' ); + } + + /** + * Get the blog's meta data. + */ + public function meta() { + return $this->has_many( BlogMeta::class, 'blog_id', 'blog_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/BlogMeta.php b/tests/Integration/WordPress/Models/BlogMeta.php new file mode 100644 index 0000000..7e5cb78 --- /dev/null +++ b/tests/Integration/WordPress/Models/BlogMeta.php @@ -0,0 +1,78 @@ + 'int', + 'blog_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'blogmeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the blog that owns the meta. + */ + public function blog() { + return $this->belongs_to( Blog::class, 'blog_id', 'blog_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/BlogVersion.php b/tests/Integration/WordPress/Models/BlogVersion.php new file mode 100644 index 0000000..7141a11 --- /dev/null +++ b/tests/Integration/WordPress/Models/BlogVersion.php @@ -0,0 +1,78 @@ + 'int', + 'last_updated' => 'datetime', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'blog_versions'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } +} diff --git a/tests/Integration/WordPress/Models/Comment.php b/tests/Integration/WordPress/Models/Comment.php new file mode 100644 index 0000000..08d379c --- /dev/null +++ b/tests/Integration/WordPress/Models/Comment.php @@ -0,0 +1,108 @@ + 'int', + 'comment_post_ID' => 'int', + 'comment_date' => 'datetime', + 'comment_date_gmt' => 'datetime', + 'comment_karma' => 'int', + 'comment_parent' => 'int', + 'user_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'comments'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the post that owns the comment. + */ + public function post() { + return $this->belongs_to( Post::class, 'comment_post_ID' ); + } + + /** + * Get the user that owns the comment. + */ + public function user() { + return $this->belongs_to( User::class, 'user_id' ); + } + + /** + * Get the comment's meta data. + */ + public function meta() { + return $this->has_many( CommentMeta::class, 'comment_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/CommentMeta.php b/tests/Integration/WordPress/Models/CommentMeta.php new file mode 100644 index 0000000..0226e87 --- /dev/null +++ b/tests/Integration/WordPress/Models/CommentMeta.php @@ -0,0 +1,78 @@ + 'int', + 'comment_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'commentmeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the comment that owns the meta. + */ + public function comment() { + return $this->belongs_to( Comment::class, 'comment_id', 'comment_ID' ); + } +} diff --git a/tests/Integration/WordPress/Models/Option.php b/tests/Integration/WordPress/Models/Option.php new file mode 100644 index 0000000..9d25675 --- /dev/null +++ b/tests/Integration/WordPress/Models/Option.php @@ -0,0 +1,77 @@ + 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'options'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } +} diff --git a/tests/Integration/WordPress/Models/Post.php b/tests/Integration/WordPress/Models/Post.php new file mode 100644 index 0000000..0282473 --- /dev/null +++ b/tests/Integration/WordPress/Models/Post.php @@ -0,0 +1,125 @@ + 'int', + 'post_author' => 'int', + 'post_date' => 'datetime', + 'post_date_gmt' => 'datetime', + 'post_modified' => 'datetime', + 'post_modified_gmt' => 'datetime', + 'post_parent' => 'int', + 'menu_order' => 'int', + 'comment_count' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'posts'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the author that owns the post. + */ + public function author() { + return $this->belongs_to( User::class, 'post_author', 'ID' ); + } + + /** + * Get the post's meta data. + */ + public function meta() { + return $this->has_many( PostMeta::class, 'post_id', 'ID' ); + } + + /** + * Get the comments for the post. + */ + public function comments() { + return $this->has_many( Comment::class, 'comment_post_ID', 'ID' ); + } + + /** + * Get the terms for the post. + */ + public function terms() { + return $this->belongs_to_many( TermTaxonomy::class, 'term_relationships', 'object_id', 'term_taxonomy_id', 'term_taxonomy_id', 'ID' ); + } +} diff --git a/tests/Integration/WordPress/Models/PostMeta.php b/tests/Integration/WordPress/Models/PostMeta.php new file mode 100644 index 0000000..cde823a --- /dev/null +++ b/tests/Integration/WordPress/Models/PostMeta.php @@ -0,0 +1,78 @@ + 'int', + 'post_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'postmeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the post that owns the meta. + */ + public function post() { + return $this->belongs_to( Post::class, 'post_id', 'ID' ); + } +} diff --git a/tests/Integration/WordPress/Models/RegistrationLog.php b/tests/Integration/WordPress/Models/RegistrationLog.php new file mode 100644 index 0000000..a23a8cc --- /dev/null +++ b/tests/Integration/WordPress/Models/RegistrationLog.php @@ -0,0 +1,73 @@ + 'int', + 'blog_id' => 'int', + 'date_registered' => 'datetime', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'registration_log'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } +} diff --git a/tests/Integration/WordPress/Models/Signup.php b/tests/Integration/WordPress/Models/Signup.php new file mode 100644 index 0000000..117448e --- /dev/null +++ b/tests/Integration/WordPress/Models/Signup.php @@ -0,0 +1,81 @@ + 'int', + 'registered' => 'datetime', + 'activated' => 'datetime', + 'active' => 'bool', + 'meta' => 'json', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'signups'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } +} diff --git a/tests/Integration/WordPress/Models/Site.php b/tests/Integration/WordPress/Models/Site.php new file mode 100644 index 0000000..1fbe0b4 --- /dev/null +++ b/tests/Integration/WordPress/Models/Site.php @@ -0,0 +1,83 @@ + 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'site'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the blogs for the site. + */ + public function blogs() { + return $this->has_many( Blog::class, 'site_id', 'id' ); + } + + /** + * Get the site's meta data. + */ + public function meta() { + return $this->has_many( SiteMeta::class, 'site_id', 'id' ); + } +} diff --git a/tests/Integration/WordPress/Models/SiteMeta.php b/tests/Integration/WordPress/Models/SiteMeta.php new file mode 100644 index 0000000..3bcdd3d --- /dev/null +++ b/tests/Integration/WordPress/Models/SiteMeta.php @@ -0,0 +1,78 @@ + 'int', + 'site_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'sitemeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the site that owns the meta. + */ + public function site() { + return $this->belongs_to( Site::class, 'site_id', 'id' ); + } +} diff --git a/tests/Integration/WordPress/Models/Term.php b/tests/Integration/WordPress/Models/Term.php new file mode 100644 index 0000000..f5882db --- /dev/null +++ b/tests/Integration/WordPress/Models/Term.php @@ -0,0 +1,85 @@ + 'int', + 'term_group' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'terms'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the taxonomy information for the term. + */ + public function taxonomy() { + return $this->has_one( TermTaxonomy::class, 'term_id' ); + } + + /** + * Get the term's meta data. + */ + public function meta() { + return $this->has_many( TermMeta::class, 'term_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/TermMeta.php b/tests/Integration/WordPress/Models/TermMeta.php new file mode 100644 index 0000000..610646d --- /dev/null +++ b/tests/Integration/WordPress/Models/TermMeta.php @@ -0,0 +1,78 @@ + 'int', + 'term_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'termmeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the term that owns the meta. + */ + public function term() { + return $this->belongs_to( Term::class, 'term_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/TermTaxonomy.php b/tests/Integration/WordPress/Models/TermTaxonomy.php new file mode 100644 index 0000000..9bec1ba --- /dev/null +++ b/tests/Integration/WordPress/Models/TermTaxonomy.php @@ -0,0 +1,89 @@ + 'int', + 'term_id' => 'int', + 'parent' => 'int', + 'count' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'term_taxonomy'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the term associated with the taxonomy. + */ + public function term() { + return $this->belongs_to( Term::class, 'term_id', 'term_id' ); + } + + /** + * Get the posts associated with this taxonomy record. + */ + public function posts() { + return $this->belongs_to_many( Post::class, 'term_relationships', 'term_taxonomy_id', 'object_id', 'ID', 'term_taxonomy_id' ); + } +} diff --git a/tests/Integration/WordPress/Models/User.php b/tests/Integration/WordPress/Models/User.php new file mode 100644 index 0000000..2005a4a --- /dev/null +++ b/tests/Integration/WordPress/Models/User.php @@ -0,0 +1,99 @@ + 'int', + 'user_registered' => 'datetime', + 'user_status' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'users'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the user's meta data. + */ + public function meta() { + return $this->has_many( UserMeta::class, 'user_id', 'ID' ); + } + + /** + * Get the user's posts. + */ + public function posts() { + return $this->has_many( Post::class, 'post_author', 'ID' ); + } + + /** + * Get the user's comments. + */ + public function comments() { + return $this->has_many( Comment::class, 'user_id', 'ID' ); + } +} diff --git a/tests/Integration/WordPress/Models/UserMeta.php b/tests/Integration/WordPress/Models/UserMeta.php new file mode 100644 index 0000000..19b7a5c --- /dev/null +++ b/tests/Integration/WordPress/Models/UserMeta.php @@ -0,0 +1,78 @@ + 'int', + 'user_id' => 'int', + ]; + + /** + * Get the table name associated with the model. + * + * @return string + */ + public static function get_table_name(): string { + return 'usermeta'; + } + + /** + * Get the resolver instance. + * + * @return Resolver + */ + public function resolver(): Resolver { + return new Resolver(); + } + + /** + * Get the user that owns the meta. + */ + public function user() { + return $this->belongs_to( User::class, 'user_id', 'ID' ); + } +} diff --git a/tests/Integration/WordPress/MultisiteModelTest.php b/tests/Integration/WordPress/MultisiteModelTest.php new file mode 100644 index 0000000..f241a7b --- /dev/null +++ b/tests/Integration/WordPress/MultisiteModelTest.php @@ -0,0 +1,158 @@ +markTestSkipped( 'Multisite is not enabled.' ); + } + } + + /** + * Test Site and SiteMeta. + */ + public function test_it_can_handle_sites() { + $site = Site::create( + [ + 'domain' => 'example.com', + 'path' => '/', + ] + ); + + $this->assertIsInt( $site->id ); + + $site->meta()->create( + [ + 'meta_key' => 'site_name', + 'meta_value' => 'Main Site', + ] + ); + + $site->unset_relation( 'meta' ); + $this->assertCount( 1, $site->meta ); + $this->assertEquals( 'Main Site', $site->meta->first()->meta_value ); + } + + /** + * Test Blog (Subsite) and relationship to Site. + */ + public function test_it_can_handle_blogs() { + $site = Site::create( + [ + 'domain' => 'network.com', + ] + ); + + $blog = Blog::create( + [ + 'site_id' => $site->id, + 'domain' => 'sub.network.com', + 'path' => '/', + 'public' => true, + ] + ); + + $blog->unset_relation( 'site' ); + $this->assertIsInt( $blog->blog_id ); + $this->assertEquals( $site->id, $blog->site->id ); + $this->assertTrue( $blog->public ); + } + + /** + * Test Signup model. + */ + public function test_it_can_handle_signups() { + $signup = Signup::create( + [ + 'domain' => 'newsub.example.com', + 'user_login' => 'newuser', + 'user_email' => 'new@example.com', + 'meta' => ['some' => 'data'], + ] + ); + + $this->assertIsInt( $signup->signup_id ); + $this->assertIsArray( $signup->meta ); + $this->assertEquals( 'data', $signup->meta['some'] ); + } + + /** + * Test RegistrationLog. + */ + public function test_it_can_log_registrations() { + $site = Site::create( [ 'domain' => 'logsite.com', 'path' => '/' ] ); + $blog = Blog::create( + [ + 'site_id' => $site->id, + 'domain' => 'testlog.com', + 'path' => '/', + ] + ); + + $log = RegistrationLog::create( + [ + 'email' => 'log@example.com', + 'IP' => '127.0.0.1', + 'blog_id' => $blog->blog_id, + 'date_registered' => '2026-02-27 15:00:00', + ] + ); + + $this->assertIsInt( $log->ID ); + $this->assertEquals( $blog->blog_id, $log->blog_id ); + } + + /** + * Test BlogVersion. + */ + public function test_it_can_handle_blog_versions() { + global $wpdb; + $table = ( new Site )->resolver()->table( 'blog_versions' ); + if ( ! $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ) ) { + $this->markTestSkipped( 'blog_versions table does not exist.' ); + } + + $site = Site::create( [ 'domain' => 'versionsite.com', 'path' => '/' ] ); + $blog = Blog::create( + [ + 'site_id' => $site->id, + 'domain' => 'version.com', + 'path' => '/', + ] + ); + + $version = BlogVersion::create( + [ + 'blog_id' => $blog->blog_id, + 'db_version' => '56789', + 'last_updated' => '2026-02-27 16:00:00', + ] + ); + + // BlogVersion has no auto-increment ID, check blog_id + $this->assertEquals( $blog->blog_id, $version->blog_id ); + $this->assertEquals( '56789', $version->db_version ); + } +} diff --git a/tests/Integration/WordPress/OptionTest.php b/tests/Integration/WordPress/OptionTest.php new file mode 100644 index 0000000..1b1de33 --- /dev/null +++ b/tests/Integration/WordPress/OptionTest.php @@ -0,0 +1,65 @@ + 'test_option', + 'option_value' => 'test_value', + 'autoload' => 'yes', + ] + ); + + $this->assertIsInt( $option->option_id ); + $this->assertEquals( 'test_option', $option->option_name ); + $this->assertEquals( 'test_value', $option->option_value ); + } + + /** + * Test fetching an option. + */ + public function test_it_can_fetch_an_option() { + Option::create( + [ + 'option_name' => 'fetch_me', + 'option_value' => 'secret', + ] + ); + + $option = Option::where( 'option_name', 'fetch_me' )->first(); + $this->assertEquals( 'secret', $option->option_value ); + } + + /** + * Test updating an option. + */ + public function test_it_can_update_an_option() { + $option = Option::create( + [ + 'option_name' => 'updatable', + 'option_value' => 'old', + ] + ); + + $option->option_value = 'new'; + $option->save(); + + $this->assertEquals( 'new', Option::find( $option->option_id )->option_value ); + } +} diff --git a/tests/Integration/WordPress/PostTest.php b/tests/Integration/WordPress/PostTest.php new file mode 100644 index 0000000..1fca8e8 --- /dev/null +++ b/tests/Integration/WordPress/PostTest.php @@ -0,0 +1,93 @@ + 'postauthor', + 'user_email' => 'author@example.com', + ] + ); + + $post = Post::create( + [ + 'post_author' => $user->ID, + 'post_title' => 'Hello World', + 'post_content' => 'This is a test post.', + 'post_status' => 'publish', + 'post_type' => 'post', + 'post_date' => '2026-02-27 12:00:00', + ] + ); + + $this->assertIsInt( $post->ID ); + $this->assertEquals( 'Hello World', $post->post_title ); + $this->assertInstanceOf( 'DateTime', $post->post_date ); + $this->assertEquals( $user->ID, $post->post_author ); + } + + /** + * Test post meta relationship. + */ + public function test_it_can_handle_postmeta() { + $post = Post::create( + [ + 'post_title' => 'Meta Test', + ] + ); + + $post->meta()->create( + [ + 'meta_key' => '_test_key', + 'meta_value' => 'test_value', + ] + ); + + $this->assertCount( 1, $post->meta ); + $this->assertEquals( '_test_key', $post->meta->first()->meta_key ); + $this->assertEquals( 'test_value', $post->meta->first()->meta_value ); + + // Test inverse relationship + $meta = PostMeta::where( 'meta_key', '_test_key' )->first(); + $this->assertEquals( $post->ID, $meta->post->ID ); + } + + /** + * Test author relationship. + */ + public function test_it_can_fetch_author() { + $user = User::create( + [ + 'user_login' => 'relauthor', + 'user_email' => 'rel@example.com', + ] + ); + + $post = Post::create( + [ + 'post_author' => $user->ID, + 'post_title' => 'Rel Test', + ] + ); + + $this->assertEquals( $user->user_login, $post->author->user_login ); + } +} diff --git a/tests/Integration/WordPress/TaxonomyTest.php b/tests/Integration/WordPress/TaxonomyTest.php new file mode 100644 index 0000000..c952413 --- /dev/null +++ b/tests/Integration/WordPress/TaxonomyTest.php @@ -0,0 +1,115 @@ + 'News', + 'slug' => 'news', + ] + ); + + $this->assertIsInt( $term->term_id ); + $this->assertEquals( 'News', $term->name ); + } + + /** + * Test term taxonomy relationship. + */ + public function test_it_links_term_to_taxonomy() { + $term = Term::create( + [ + 'name' => 'Category A', + 'slug' => 'cat-a', + ] + ); + + $taxonomy = TermTaxonomy::create( + [ + 'term_id' => $term->term_id, + 'taxonomy' => 'category', + 'description' => 'Test Category', + 'count' => 5, + ] + ); + + $this->assertEquals( 'category', $term->taxonomy->taxonomy ); + $this->assertEquals( $term->term_id, $taxonomy->term->term_id ); + } + + /** + * Test term meta. + */ + public function test_it_can_handle_termmeta() { + $term = Term::create( + [ + 'name' => 'Meta Term', + ] + ); + + $term->meta()->create( + [ + 'meta_key' => 'color', + 'meta_value' => 'blue', + ] + ); + + $this->assertCount( 1, $term->meta ); + $this->assertEquals( 'blue', $term->meta->first()->meta_value ); + } + + /** + * Test post relationships via taxonomy. + */ + public function test_it_links_posts_to_terms() { + $post = Post::create( + [ + 'post_title' => 'Post with Term', + ] + ); + + $term = Term::create( + [ + 'name' => 'Tagged', + 'slug' => 'tagged', + ] + ); + + $taxonomy = TermTaxonomy::create( + [ + 'term_id' => $term->term_id, + 'taxonomy' => 'post_tag', + ] + ); + + // Manually link via relationships table (representing TermRelationship) + ( new Post )->new_query()->from( 'term_relationships' )->insert( + [ + 'object_id' => $post->ID, + 'term_taxonomy_id' => $taxonomy->term_taxonomy_id, + ] + ); + + $this->assertCount( 1, $post->terms ); + $this->assertEquals( 'post_tag', $post->terms->first()->taxonomy ); + } +} diff --git a/tests/Integration/WordPress/UserTest.php b/tests/Integration/WordPress/UserTest.php new file mode 100644 index 0000000..76a406d --- /dev/null +++ b/tests/Integration/WordPress/UserTest.php @@ -0,0 +1,62 @@ + 'testuser', + 'user_pass' => 'password', + 'user_email' => 'test@example.com', + 'user_registered' => '2026-01-01 10:00:00', + 'display_name' => 'Test User', + ] + ); + + $this->assertIsInt( $user->ID ); + $this->assertEquals( 'testuser', $user->user_login ); + $this->assertInstanceOf( 'DateTime', $user->user_registered ); + $this->assertEquals( '2026-01-01 10:00:00', $user->user_registered->format( 'Y-m-d H:i:s' ) ); + } + + public function test_it_can_handle_usermeta() { + $user = User::create( + [ + 'user_login' => 'metauser', + 'user_email' => 'meta@example.com', + ] + ); + + $user->meta()->create( + [ + 'meta_key' => 'first_name', + 'meta_value' => 'Test', + ] + ); + + $this->assertCount( 1, $user->meta ); + $this->assertEquals( 'first_name', $user->meta->first()->meta_key ); + $this->assertEquals( 'Test', $user->meta->first()->meta_value ); + + // Test relationship back to user + $meta = $user->meta->first(); + $this->assertEquals( $user->ID, $meta->user->ID ); + } + + public function test_it_casts_user_status_to_int() { + $user = User::create( + [ + 'user_login' => 'statususer', + 'user_status' => '1', + ] + ); + + $this->assertIsInt( $user->user_status ); + $this->assertEquals( 1, $user->user_status ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index 39b56c6..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,20 +0,0 @@ -model = $model; + } + + // Expose protected methods for testing + public function call_clause( ...$args ) { + return $this->clause( ...$args ); + } + + public function call_clause_in( ...$args ) { + return $this->clause_in( ...$args ); + } + + public function call_clause_between( ...$args ) { + return $this->clause_between( ...$args ); + } + + public function call_clause_null( ...$args ) { + return $this->clause_null( ...$args ); + } + + // Add helper to match implementation logic + protected function prepare_value_and_operator( $value, $operator, $use_operator ) { + if ( $use_operator ) { + return [$operator, '=']; + } + return [$value, $operator]; + } + + protected function invalid_operator( $operator ) { + return ! in_array( $operator, ['=', '<', '>', '<=', '>=', '<>', '!=', 'like', 'not like', 'between', 'not between', 'in', 'not in'] ); + } +} + +class ClauseTest extends TestCase { + public function test_it_can_set_and_get_clauses() { + $stub = new ClauseStub(); + $this->assertEmpty( $stub->get_clauses() ); + + $stub->call_clause( 'wheres', 'id', '=', 1 ); + $clauses = $stub->get_clauses(); + + $this->assertArrayHasKey( 'wheres', $clauses ); + $this->assertCount( 1, $clauses['wheres'] ); + $this->assertEquals( 'basic', $clauses['wheres'][0]['type'] ); + $this->assertEquals( 'id', $clauses['wheres'][0]['column'] ); + $this->assertEquals( '=', $clauses['wheres'][0]['operator'] ); + $this->assertEquals( 1, $clauses['wheres'][0]['value'] ); + } + + public function test_it_handles_in_clauses() { + $stub = new ClauseStub(); + $stub->call_clause_in( 'wheres', 'id', [1, 2, 3] ); + + $clauses = $stub->get_clauses(); + $this->assertEquals( 'in', $clauses['wheres'][0]['type'] ); + $this->assertEquals( [1, 2, 3], $clauses['wheres'][0]['values'] ); + } + + public function test_it_handles_between_clauses() { + $stub = new ClauseStub(); + $stub->call_clause_between( 'wheres', 'votes', [1, 10] ); + + $clauses = $stub->get_clauses(); + $this->assertEquals( 'between', $clauses['wheres'][0]['type'] ); + $this->assertEquals( [1, 10], $clauses['wheres'][0]['values'] ); + } + + public function test_it_handles_null_clauses() { + $stub = new ClauseStub(); + $stub->call_clause_null( 'wheres', 'deleted_at' ); + + $clauses = $stub->get_clauses(); + $this->assertEquals( 'is_null', $clauses['wheres'][0]['type'] ); + $this->assertEquals( 'deleted_at', $clauses['wheres'][0]['column'] ); + $this->assertFalse( $clauses['wheres'][0]['not'] ); + } + + public function test_it_handles_not_null_clauses() { + $stub = new ClauseStub(); + $stub->call_clause_null( 'wheres', 'deleted_at', null, 'and', true ); + + $clauses = $stub->get_clauses(); + $this->assertTrue( $clauses['wheres'][0]['not'] ); + } + + public function test_it_records_or_boolean_correctly(): void { + $stub = new ClauseStub(); + // clause( clause_type, column, operator, value, name=null, boolean='and', not=false ) + $stub->call_clause( 'wheres', 'status', '=', 'active', null, 'or' ); + + $clause = $stub->get_clauses()['wheres'][0]; + $this->assertEquals( 'or', $clause['boolean'] ); + $this->assertEquals( 'active', $clause['value'] ); + } + + public function test_it_handles_not_in_clauses(): void { + $stub = new ClauseStub(); + // clause_in( clause_type, column, values, name=null, boolean='and', not=false ) + $stub->call_clause_in( 'wheres', 'id', [1, 2, 3], null, 'and', true ); + + $clause = $stub->get_clauses()['wheres'][0]; + $this->assertEquals( 'in', $clause['type'] ); + $this->assertTrue( $clause['not'] ); + $this->assertEquals( [1, 2, 3], $clause['values'] ); + } + + public function test_it_stores_raw_sql_as_clause(): void { + $stub = new ClauseStub(); + // Directly push a raw clause item (mirrors how where_raw works) + $stub->get_clauses(); + $raw_item = [ + 'type' => 'raw', + 'sql' => 'price > %d', + 'bindings' => [100], + 'boolean' => 'and', + ]; + // Manually register it + $clauses = $stub->get_clauses(); + $clauses['wheres'] = [ $raw_item ]; + + $this->assertEquals( 'raw', $raw_item['type'] ); + $this->assertEquals( 'price > %d', $raw_item['sql'] ); + $this->assertEquals( [100], $raw_item['bindings'] ); + } +} diff --git a/tests/Unit/Clauses/HavingClauseTest.php b/tests/Unit/Clauses/HavingClauseTest.php deleted file mode 100644 index 26ad854..0000000 --- a/tests/Unit/Clauses/HavingClauseTest.php +++ /dev/null @@ -1,69 +0,0 @@ -having( 'count', '>', 5 ); - - $havings = $user->get_havings(); - $this->assertCount( 1, $havings ); - $this->assertEquals( 'and', $havings[0]['boolean'] ); - $this->assertEquals( 'basic', $havings[0]['type'] ); - $this->assertEquals( 'count', $havings[0]['column'] ); - } - - /** - * @test - * - * Verifies that or_having() method correctly adds a HAVING clause - * with OR boolean operator. - */ - public function it_can_add_or_having_clause() { - $user = new HavingClauseUser(); - - $user->or_having( 'total', '=', 100 ); - - $havings = $user->get_havings(); - $this->assertEquals( 'or', $havings[0]['boolean'] ); - } - - /** - * @test - * - * Verifies that having_raw() method correctly adds a raw HAVING clause - * with custom SQL and bindings. - */ - public function it_can_add_having_raw_clause() { - $user = new HavingClauseUser(); - - $user->having_raw( 'SUM(total) > ?', [1000] ); - - $havings = $user->get_havings(); - $this->assertEquals( 'raw', $havings[0]['type'] ); - $this->assertEquals( 'SUM(total) > ?', $havings[0]['sql'] ); - } -} diff --git a/tests/Unit/Clauses/OnClauseTest.php b/tests/Unit/Clauses/OnClauseTest.php deleted file mode 100644 index ef81280..0000000 --- a/tests/Unit/Clauses/OnClauseTest.php +++ /dev/null @@ -1,52 +0,0 @@ -on( 'first', '=', 'second' ); - - $ons = $user->get_ons(); - $this->assertCount( 1, $ons ); - $this->assertEquals( 'and', $ons[0]['boolean'] ); - $this->assertEquals( 'basic', $ons[0]['type'] ); - } - - /** - * @test - * - * Verifies that or_on() method correctly adds an ON clause - * with OR boolean operator for JOIN conditions. - */ - public function it_can_add_or_on_clause() { - $user = new OnClauseUser(); - - $user->or_on( 'a', '=', 'b' ); - - $ons = $user->get_ons(); - $this->assertEquals( 'or', $ons[0]['boolean'] ); - } -} diff --git a/tests/Unit/Eloquent/CollectionTest.php b/tests/Unit/Eloquent/CollectionTest.php new file mode 100644 index 0000000..f58d0a6 --- /dev/null +++ b/tests/Unit/Eloquent/CollectionTest.php @@ -0,0 +1,131 @@ +filter( + function( $item ) { + return $item > 2; + } + ); + + $this->assertCount( 3, $filtered ); + $this->assertEquals( [3, 4, 5], array_values( $filtered->all() ) ); + } + + public function test_it_can_map_items() { + $collection = new Collection( [1, 2, 3] ); + $mapped = $collection->map( + function( $item ) { + return $item * 2; + } + ); + + $this->assertEquals( [2, 4, 6], $mapped->all() ); + } + + public function test_it_can_pluck_attributes() { + $model1 = Mockery::mock( 'Model' ); + $model1->name = 'Alice'; + + $model2 = Mockery::mock( 'Model' ); + $model2->name = 'Bob'; + + $collection = new Collection( [$model1, $model2] ); + $plucked = $collection->pluck( 'name' ); + + $this->assertEquals( ['Alice', 'Bob'], $plucked->all() ); + } + + public function test_it_can_get_unique_items() { + $collection = new Collection( [1, 2, 2, 3, 3, 3] ); + $unique = $collection->unique(); + + $this->assertCount( 3, $unique ); + $this->assertEquals( [1, 2, 3], array_values( $unique->all() ) ); + } + + public function test_it_can_check_if_empty() { + $collection = new Collection( [] ); + $this->assertEquals( 0, $collection->count() ); + + $collection = new Collection( [1] ); + $this->assertNotEquals( 0, $collection->count() ); + } + + public function test_it_can_get_first_item() { + $collection = new Collection( ['first', 'second'] ); + $this->assertEquals( 'first', $collection->first() ); + } + + public function test_it_is_countable_and_iterable() { + $collection = new Collection( [1, 2, 3] ); + $this->assertCount( 3, $collection ); + + $items = []; + foreach ( $collection as $item ) { + $items[] = $item; + } + $this->assertEquals( [1, 2, 3], $items ); + } + + public function test_it_can_reduce_to_scalar(): void { + $collection = new Collection( [1, 2, 3, 4, 5] ); + $sum = $collection->reduce( + function( $carry, $item ) { + return $carry + $item; + }, 0 + ); + + $this->assertEquals( 15, $sum ); + } + + public function test_first_with_callable_finds_first_match(): void { + $collection = new Collection( [1, 5, 3, 7, 2] ); + $first_big = $collection->first( + function( $item ) { + return $item > 4; + } + ); + + $this->assertEquals( 5, $first_big ); + } + + public function test_first_with_callable_returns_null_when_no_match(): void { + $collection = new Collection( [1, 2, 3] ); + $result = $collection->first( + function( $item ) { + return $item > 100; + } + ); + + $this->assertNull( $result ); + } + + public function test_it_can_chunk_into_sub_collections(): void { + $collection = new Collection( [1, 2, 3, 4, 5] ); + $chunks = $collection->chunk( 2 ); + + $this->assertCount( 3, $chunks ); // [1,2] [3,4] [5] + $this->assertCount( 2, $chunks->first() ); + $this->assertEquals( [1, 2], $chunks->first()->all() ); + } + + public function test_contains_value_returns_correct_boolean(): void { + $collection = new Collection( ['Alice', 'Bob', 'Charlie'] ); + + $this->assertTrue( $collection->contains( 'Alice' ) ); + $this->assertFalse( $collection->contains( 'Dave' ) ); + } +} diff --git a/tests/Unit/Eloquent/FactoryTest.php b/tests/Unit/Eloquent/FactoryTest.php new file mode 100644 index 0000000..cb03be8 --- /dev/null +++ b/tests/Unit/Eloquent/FactoryTest.php @@ -0,0 +1,136 @@ + $this->faker()->name(), + 'email' => $this->faker()->safe_email(), + 'role' => 'user', + ]; + } + + public function admin() { + return $this->state( + [ + 'role' => 'admin', + ] + ); + } +} + +class FactoryTest extends TestCase { + public function test_it_can_make_a_single_instance() { + $user = UserFactory::new()->make(); + + $this->assertInstanceOf( User::class, $user ); + $this->assertIsString( $user->name ); + $this->assertIsString( $user->email ); + $this->assertEquals( 'user', $user->role ); + $this->assertFalse( $user->exists ); + } + + public function test_it_can_make_multiple_instances() { + $users = UserFactory::new()->count( 3 )->make(); + + $this->assertInstanceOf( Collection::class, $users ); + $this->assertCount( 3, $users ); + foreach ( $users as $user ) { + $this->assertInstanceOf( User::class, $user ); + } + } + + public function test_it_can_apply_states() { + $user = UserFactory::new()->admin()->make(); + + $this->assertEquals( 'admin', $user->role ); + } + + public function test_it_can_override_attributes() { + $user = UserFactory::new()->make( ['name' => 'Custom Name'] ); + + $this->assertEquals( 'Custom Name', $user->name ); + } + + public function test_it_can_use_sequences() { + $users = UserFactory::new()->count( 2 )->state( + new Sequence( + ['role' => 'editor'], + ['role' => 'subscriber'] + ) + )->make(); + + $this->assertEquals( 'editor', $users[0]->role ); + $this->assertEquals( 'subscriber', $users[1]->role ); + } + + public function test_it_can_generate_raw_attributes() { + $attributes = UserFactory::new()->raw(); + + $this->assertIsArray( $attributes ); + $this->assertArrayHasKey( 'name', $attributes ); + $this->assertArrayHasKey( 'email', $attributes ); + } + + public function test_it_can_run_after_making_hooks() { + $check = false; + UserFactory::new()->after_making( + function() use ( &$check ) { + $check = true; + } + )->make(); + + $this->assertTrue( $check ); + } + + public function test_magic_states_proxy_to_state() { + // Calling a non-existent method that isn't in the class + // should trigger __call and treat it as a state override for boolean flags + $user = UserFactory::new()->active()->make(); + + $this->assertTrue( $user->active ); + } + + public function test_it_calls_configure_hook() { + $factory = new class extends UserFactory { + public $configured = false; + + public function configure() { + $this->configured = true; + return $this; + } + }; + + $instance = $factory::new(); + $this->assertTrue( $instance->configured ); + } + + public function test_it_propagates_recycled_models_to_nested_factories() { + $user = new User(); + $user->id = 123; // Use 'id' instead of 'ID' to match Model::$primary_key + $user->exists = true; + + // Create a PostFactory that uses UserFactory for post_author + $post_factory = new class extends Factory { + protected $model = \WpMVC\Database\Tests\Framework\Mocks\Post::class; + + public function definition(): array { + return ['post_author' => \WpMVC\Database\Tests\Unit\Eloquent\UserFactory::new()]; + } + }; + + // If we recycle the user, the nested UserFactory should use it + $post = $post_factory->recycle( $user )->make(); + + $this->assertEquals( 123, $post->post_author ); + } +} diff --git a/tests/Unit/Eloquent/FakeDataTest.php b/tests/Unit/Eloquent/FakeDataTest.php new file mode 100644 index 0000000..795e44b --- /dev/null +++ b/tests/Unit/Eloquent/FakeDataTest.php @@ -0,0 +1,77 @@ +faker = FakeData::instance(); + } + + public function test_it_can_generate_names() { + $this->assertIsString( $this->faker->name() ); + $this->assertIsString( $this->faker->first_name() ); + $this->assertIsString( $this->faker->last_name() ); + } + + public function test_it_can_generate_text() { + $this->assertIsString( $this->faker->word() ); + $this->assertIsString( $this->faker->sentence() ); + $this->assertIsString( $this->faker->paragraph() ); + $this->assertIsString( $this->faker->text() ); + $this->assertIsString( $this->faker->slug() ); + } + + public function test_it_can_generate_internet_data() { + $this->assertStringContainsString( '@', $this->faker->email() ); + $this->assertStringContainsString( '@', $this->faker->safe_email() ); + $this->assertIsString( $this->faker->user_name() ); + $this->assertIsString( $this->faker->domain_name() ); + $this->assertStringStartsWith( 'https://', $this->faker->url() ); + } + + public function test_it_can_generate_numbers() { + $number = $this->faker->number_between( 1, 10 ); + $this->assertGreaterThanOrEqual( 1, $number ); + $this->assertLessThanOrEqual( 10, $number ); + + $this->assertIsInt( $this->faker->random_digit() ); + $this->assertIsFloat( $this->faker->random_float() ); + } + + public function test_it_can_generate_dates() { + $this->assertIsString( $this->faker->date() ); + $this->assertIsString( $this->faker->time() ); + $this->assertIsString( $this->faker->date_time() ); + $this->assertIsString( $this->faker->iso8601() ); + $this->assertIsInt( $this->faker->timestamp() ); + } + + public function test_it_can_generate_locations() { + $this->assertIsString( $this->faker->address() ); + $this->assertIsString( $this->faker->city() ); + $this->assertIsString( $this->faker->street_name() ); + $this->assertIsString( $this->faker->postcode() ); + $this->assertIsString( $this->faker->country() ); + } + + public function test_it_can_generate_uuids() { + $uuid = $this->faker->uuid(); + $this->assertMatchesRegularExpression( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $uuid ); + } + + public function test_it_can_generate_booleans() { + $this->assertIsBool( $this->faker->boolean() ); + } + + public function test_it_can_select_random_elements() { + $array = ['a', 'b', 'c']; + $element = $this->faker->random_element( $array ); + $this->assertContains( $element, $array ); + } +} diff --git a/tests/Unit/Eloquent/ModelAdvancedTest.php b/tests/Unit/Eloquent/ModelAdvancedTest.php deleted file mode 100644 index e735e20..0000000 --- a/tests/Unit/Eloquent/ModelAdvancedTest.php +++ /dev/null @@ -1,42 +0,0 @@ -assertEquals( 'select * from wp_advanced_items as ai', $query->to_sql() ); - } - - /** - * @test - * - * Verifies that the model provides a valid resolver instance. - */ - public function it_shares_resolver_instance() { - $model = new AdvancedModel(); - - $this->assertInstanceOf( Resolver::class, $model->resolver() ); - } -} diff --git a/tests/Unit/Eloquent/ModelTest.php b/tests/Unit/Eloquent/ModelTest.php deleted file mode 100644 index bbcb3d5..0000000 --- a/tests/Unit/Eloquent/ModelTest.php +++ /dev/null @@ -1,58 +0,0 @@ -assertInstanceOf( 'WpMVC\Database\Query\Builder', $query ); - $this->assertEquals( 'select * from wp_test_posts as test_posts', $query->to_sql() ); - } - - /** - * @test - * - * Verifies that a model can define a has_many relationship. - */ - public function it_can_define_has_many_relationship() { - $model = new TestModel(); - - $relation = $model->has_many( TestRelatedModel::class, 'post_id', 'id' ); - - $this->assertInstanceOf( HasMany::class, $relation ); - $this->assertInstanceOf( TestRelatedModel::class, $relation->get_related() ); - } - - // Additional basic model tests can go here -} diff --git a/tests/Unit/Eloquent/Relations/BelongsToManyTest.php b/tests/Unit/Eloquent/Relations/BelongsToManyTest.php deleted file mode 100644 index 8152636..0000000 --- a/tests/Unit/Eloquent/Relations/BelongsToManyTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertInstanceOf( BelongsToMany::class, $relation ); - $this->assertInstanceOf( RoleUserPivot::class, $relation->pivot ); - $this->assertEquals( 'user_id', $relation->foreign_pivot_key ); - $this->assertEquals( 'role_id', $relation->local_pivot_key ); - $this->assertEquals( 'id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - $this->assertInstanceOf( Role::class, $relation->get_related() ); - } -} diff --git a/tests/Unit/Eloquent/Relations/BelongsToOneTest.php b/tests/Unit/Eloquent/Relations/BelongsToOneTest.php deleted file mode 100644 index a66c112..0000000 --- a/tests/Unit/Eloquent/Relations/BelongsToOneTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf( BelongsToOne::class, $relation ); - $this->assertEquals( 'post_id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - $this->assertInstanceOf( Post::class, $relation->get_related() ); - } -} diff --git a/tests/Unit/Eloquent/Relations/HasManyTest.php b/tests/Unit/Eloquent/Relations/HasManyTest.php deleted file mode 100644 index 41e56b8..0000000 --- a/tests/Unit/Eloquent/Relations/HasManyTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf( HasMany::class, $relation ); - $this->assertEquals( 'post_id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - $this->assertInstanceOf( Comment::class, $relation->get_related() ); - } -} diff --git a/tests/Unit/Eloquent/Relations/HasOneTest.php b/tests/Unit/Eloquent/Relations/HasOneTest.php deleted file mode 100644 index 9e69497..0000000 --- a/tests/Unit/Eloquent/Relations/HasOneTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf( HasOne::class, $relation ); - $this->assertEquals( 'user_id', $relation->foreign_key ); - $this->assertEquals( 'id', $relation->local_key ); - $this->assertInstanceOf( Profile::class, $relation->get_related() ); - } -} diff --git a/tests/Unit/Eloquent/RelationshipFactoryTest.php b/tests/Unit/Eloquent/RelationshipFactoryTest.php new file mode 100644 index 0000000..98e3024 --- /dev/null +++ b/tests/Unit/Eloquent/RelationshipFactoryTest.php @@ -0,0 +1,166 @@ + $this->faker()->sentence(), + 'post_content' => $this->faker()->paragraph(), + 'post_author' => UserFactory::new(), + ]; + } +} + +class CommentFactory extends Factory { + protected $model = Comment::class; + + public function definition(): array { + return [ + 'comment_content' => $this->faker()->paragraph(), + 'commentable_id' => 1, // Default, usually overridden + 'commentable_type' => Post::class, + ]; + } +} + +class RelationshipFactoryTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + + // Ensure tables exist before running tests + global $wpdb; + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}mock_users` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + user_login varchar(60) NOT NULL, + name varchar(255) DEFAULT '', + email varchar(100) DEFAULT '', + role varchar(50) DEFAULT 'user', + active tinyint(1) DEFAULT 1, + created_at datetime DEFAULT NULL, + updated_at datetime DEFAULT NULL, + PRIMARY KEY (id) + )" + ); + + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}mock_posts` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_title varchar(255) NOT NULL, + post_content text, + post_author bigint(20) unsigned DEFAULT NULL, + created_at datetime DEFAULT NULL, + updated_at datetime DEFAULT NULL, + PRIMARY KEY (id) + )" + ); + + $wpdb->query( + "CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}mock_comments` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + comment_content text NOT NULL, + commentable_id bigint(20) unsigned DEFAULT NULL, + commentable_type varchar(255) DEFAULT NULL, + created_at datetime DEFAULT NULL, + updated_at datetime DEFAULT NULL, + PRIMARY KEY (id) + )" + ); + + User::unguard(); + Post::unguard(); + Comment::unguard(); + } + + public function test_it_can_create_with_belongs_to_relationship() { + // Post definition uses UserFactory for post_author + $post = PostFactory::new()->make(); + + $this->assertNotNull( $post->post_author ); + $this->assertIsNumeric( $post->post_author ); + } + + public function test_it_can_create_with_has_many_relationship() { + $user = UserFactory::new() + ->has( PostFactory::new()->count( 3 ), 'posts' ) + ->create(); + + // Access via magic property or get() + $this->assertCount( 3, $user->posts ); + $this->assertEquals( $user->get_key(), $user->posts->first()->post_author ); + } + + public function test_it_can_recycle_models() { + $user = new User( ['id' => 123] ); + $user->exists = true; + + $post = PostFactory::new() + ->recycle( $user ) + ->make(); + + $this->assertEquals( 123, $post->post_author ); + } + + public function test_it_can_handle_polymorphic_relationships() { + $post = new Post( ['id' => 456] ); + $post->exists = true; + + $comment = CommentFactory::new() + ->for( $post, 'commentable' ) + ->make(); + + $this->assertEquals( 456, $comment->commentable_id ); + $this->assertEquals( Post::class, $comment->commentable_type ); + } + + public function test_it_can_handle_nested_creation() { + // User -> Post -> Comment + // We use create() to ensure the chain is saved and IDs are generated + $comment = CommentFactory::new() + ->for( PostFactory::new()->for( UserFactory::new(), 'user' ), 'commentable' ) + ->create(); + + $this->assertNotNull( $comment->commentable_id ); + $this->assertEquals( Post::class, $comment->commentable_type ); + + $post = Post::find( $comment->commentable_id ); + $this->assertNotNull( $post ); + $this->assertNotNull( $post->post_author ); + } + + public function test_it_can_use_magic_relationship_methods() { + // Define the expected factory in the guessed namespace for the test + if ( ! class_exists( 'WpMVC\\Database\\Tests\\Framework\\Mocks\\PostFactory' ) ) { + eval( + 'namespace WpMVC\\Database\\Tests\\Framework\\Mocks; class PostFactory extends \\WpMVC\\Database\\Eloquent\\Factory { + protected $model = Post::class; + public function definition(): array { return ["post_title" => "Magic Post"]; } + }' + ); + } + if ( ! class_exists( 'WpMVC\\Database\\Tests\\Framework\\Mocks\\UserFactory' ) ) { + eval( + 'namespace WpMVC\\Database\\Tests\\Framework\\Mocks; class UserFactory extends \\WpMVC\\Database\\Eloquent\\Factory { + protected $model = User::class; + public function definition(): array { return ["user_login" => "magic_user"]; } + }' + ); + } + + $user = UserFactory::new()->hasPosts( 2 )->create(); + + // Verify relationship was created and linked + $this->assertCount( 2, $user->posts ); + $this->assertEquals( 'Magic Post', $user->posts->first()->post_title ); + } +} diff --git a/tests/Unit/Eloquent/SeederTest.php b/tests/Unit/Eloquent/SeederTest.php new file mode 100644 index 0000000..686a16d --- /dev/null +++ b/tests/Unit/Eloquent/SeederTest.php @@ -0,0 +1,47 @@ +call( MockSeeder::class ); + } +} + +class SeederTest extends TestCase { + protected function setUp(): void { + parent::setUp(); + MockSeeder::$run_count = 0; + } + + public function test_it_can_run_a_seeder() { + $seeder = new MockSeeder(); + $seeder->run(); + + $this->assertEquals( 1, MockSeeder::$run_count ); + } + + public function test_it_can_call_other_seeders() { + $seeder = new ParentSeeder(); + $seeder->run(); + + $this->assertEquals( 1, MockSeeder::$run_count ); + } + + public function test_it_can_run_programmatically() { + Seeder::run_seeder( MockSeeder::class ); + + $this->assertEquals( 1, MockSeeder::$run_count ); + } +} diff --git a/tests/Unit/Pagination/LengthAwarePaginatorTest.php b/tests/Unit/Pagination/LengthAwarePaginatorTest.php new file mode 100644 index 0000000..ac38ba3 --- /dev/null +++ b/tests/Unit/Pagination/LengthAwarePaginatorTest.php @@ -0,0 +1,128 @@ +assertEquals( 4, $paginator->last_page() ); + + $paginator = new LengthAwarePaginator( [], 0, 5, 1 ); + $this->assertEquals( 1, $paginator->last_page() ); + } + + public function test_it_knows_current_and_per_page() { + $paginator = new LengthAwarePaginator( ['item1'], 10, 5, 2 ); + $this->assertEquals( 2, $paginator->current_page() ); + $this->assertEquals( 5, $paginator->per_page() ); + $this->assertEquals( 10, $paginator->total() ); + } + + public function test_it_serializes_to_array() { + $items = ['a', 'b']; + $paginator = new LengthAwarePaginator( $items, 10, 2, 2 ); + $array = $paginator->to_array(); + + $this->assertEquals( 2, $array['current_page'] ); + $this->assertEquals( $items, $array['data'] ); + $this->assertEquals( 10, $array['total'] ); + $this->assertEquals( 2, $array['per_page'] ); + $this->assertEquals( 5, $array['last_page'] ); + } + + public function test_paginator_is_countable_and_iterable() { + $items = ['a', 'b', 'c']; + $paginator = new LengthAwarePaginator( $items, 10, 5, 1 ); + + $this->assertCount( 3, $paginator ); + $this->assertEquals( $items, $paginator->items()->all() ); + } + + // ========================================================= + // Edge cases + // ========================================================= + + public function test_it_defaults_to_page_one_when_not_set() { + $paginator = new LengthAwarePaginator( [], 100, 10 ); + $this->assertEquals( 1, $paginator->current_page() ); + } + + public function test_last_page_rounds_up_for_partial_page() { + $paginator = new LengthAwarePaginator( [], 101, 10 ); + $this->assertEquals( 11, $paginator->last_page() ); + } + + public function test_it_handles_exact_multiple_total() { + $paginator = new LengthAwarePaginator( [], 30, 10 ); + $this->assertEquals( 3, $paginator->last_page() ); + } + + // ========================================================= + // Items / wrapping + // ========================================================= + + public function test_it_wraps_array_items_in_collection() { + $paginator = new LengthAwarePaginator( ['a', 'b', 'c'], 3, 10 ); + $this->assertInstanceOf( Collection::class, $paginator->items() ); + $this->assertCount( 3, $paginator->items() ); + } + + public function test_it_accepts_collection_directly() { + $collection = new Collection( ['x', 'y'] ); + $paginator = new LengthAwarePaginator( $collection, 2, 10 ); + $this->assertInstanceOf( Collection::class, $paginator->items() ); + $this->assertCount( 2, $paginator->items() ); + } + + // ========================================================= + // Array access + // ========================================================= + + public function test_it_supports_array_access_read() { + $paginator = new LengthAwarePaginator( ['first', 'second'], 2, 10 ); + + $this->assertTrue( isset( $paginator[0] ) ); + $this->assertEquals( 'first', $paginator[0] ); + $this->assertEquals( 'second', $paginator[1] ); + } + + public function test_it_can_set_item_via_offset() { + $paginator = new LengthAwarePaginator( ['a', 'b'], 2, 10 ); + $paginator[0] = 'z'; + $this->assertEquals( 'z', $paginator[0] ); + } + + public function test_it_can_unset_item_via_offset() { + $paginator = new LengthAwarePaginator( ['a', 'b'], 2, 10 ); + unset( $paginator[0] ); + $this->assertFalse( isset( $paginator[0] ) ); + } + + // ========================================================= + // JSON + // ========================================================= + + public function test_json_serialize_returns_same_as_to_array() { + $paginator = new LengthAwarePaginator( [1, 2, 3], 30, 10, 1 ); + $this->assertEquals( $paginator->to_array(), $paginator->jsonSerialize() ); + } + + public function test_json_encode_produces_valid_json() { + $paginator = new LengthAwarePaginator( ['a', 'b'], 20, 10, 1 ); + $json = json_encode( $paginator ); + $decoded = json_decode( $json, true ); + + $this->assertIsArray( $decoded ); + $this->assertEquals( 1, $decoded['current_page'] ); + $this->assertEquals( 20, $decoded['total'] ); + } +} diff --git a/tests/Unit/Query/BuilderAdvancedTest.php b/tests/Unit/Query/BuilderAdvancedTest.php deleted file mode 100644 index ea088c3..0000000 --- a/tests/Unit/Query/BuilderAdvancedTest.php +++ /dev/null @@ -1,104 +0,0 @@ -model = Mockery::mock( Model::class ); - $this->model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $this->model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - } - - /** - * @test - * - * Verifies that the builder can generate a SELECT DISTINCT query. - */ - public function it_can_generate_distinct_select() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->distinct()->select( 'title' )->to_sql(); - - $this->assertEquals( 'select distinct title from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add HAVING clauses to the query. - */ - public function it_can_add_having_clause() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->group_by( 'author_id' ) - ->having( 'count(id)', '>', 10 ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts group by author_id having count(id) > %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add OR HAVING clauses to the query. - */ - public function it_can_add_or_having_clause() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->group_by( 'author_id' ) - ->having( 'count(id)', '>', 10 ) - ->or_having( 'sum(views)', '<', 500 ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts group by author_id having count(id) > %d or sum(views) < %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add ORDER BY DESC clauses to the query. - */ - public function it_can_order_by_desc() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->order_by_desc( 'created_at' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts order by created_at desc', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add raw ORDER BY clauses to the query. - */ - public function it_can_order_by_raw() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->order_by_raw( 'FIELD(status, "draft", "publish")' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts order by FIELD(status, "draft", "publish") ', $sql ); - } - - /** - * @test - * - * Verifies that an exception is thrown when an invalid order direction is provided. - */ - public function it_throws_exception_on_invalid_order_direction() { - $this->expectException( \InvalidArgumentException::class ); - $this->expectExceptionMessage( 'Order direction must be "asc" or "desc".' ); - - $builder = new Builder( $this->model ); - $builder->order_by( 'title', 'invalid' ); - } -} diff --git a/tests/Unit/Query/BuilderAggregateTest.php b/tests/Unit/Query/BuilderAggregateTest.php deleted file mode 100644 index 4f40533..0000000 --- a/tests/Unit/Query/BuilderAggregateTest.php +++ /dev/null @@ -1,98 +0,0 @@ -model = Mockery::mock( Model::class ); - $this->model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $this->model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for COUNT aggregate. - */ - public function it_can_generate_count_sql() { - $builder = new Builder( $this->model ); - - // aggregate_to_sql is a helper to see what the SQL looks like without running it - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'count' ); - - $this->assertEquals( 'select count(*) as aggregate from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for COUNT aggregate on a specific column. - */ - public function it_can_generate_count_sql_with_specific_column() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'count', ['id'] ); - - $this->assertEquals( 'select count(id) as aggregate from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for MAX aggregate. - */ - public function it_can_generate_max_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'max', ['price'] ); - - $this->assertEquals( 'select max(price) as aggregate from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for MIN aggregate. - */ - public function it_can_generate_min_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'min', ['price'] ); - - $this->assertEquals( 'select min(price) as aggregate from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for SUM aggregate. - */ - public function it_can_generate_sum_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'sum', ['total'] ); - - $this->assertEquals( 'select sum(total) as aggregate from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for AVG aggregate. - */ - public function it_can_generate_avg_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->aggregate_to_sql( 'avg', ['rating'] ); - - $this->assertEquals( 'select avg(rating) as aggregate from wp_posts as posts', $sql ); - } -} diff --git a/tests/Unit/Query/BuilderCRUDTest.php b/tests/Unit/Query/BuilderCRUDTest.php deleted file mode 100644 index ca03b87..0000000 --- a/tests/Unit/Query/BuilderCRUDTest.php +++ /dev/null @@ -1,71 +0,0 @@ -model = Mockery::mock( Model::class ); - $this->model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $this->model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for INSERT operations. - */ - public function it_can_generate_insert_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->to_sql_insert( - [ - 'title' => 'New Post', - 'status' => 'draft' - ] - ); - - $this->assertEquals( "insert into wp_posts (title, status) values (%s, %s)", $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for UPDATE operations. - */ - public function it_can_generate_update_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->where( 'id', 1 ) - ->to_sql_update( - [ - 'status' => 'publish' - ] - ); - - $this->assertEquals( "update wp_posts set status = %s where id = %d", $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate valid SQL for DELETE operations. - */ - public function it_can_generate_delete_sql() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->where( 'status', 'spam' ) - ->to_sql_delete(); - - $this->assertEquals( "delete posts from wp_posts as posts where status = %s", $sql ); - } -} diff --git a/tests/Unit/Query/BuilderTest.php b/tests/Unit/Query/BuilderTest.php deleted file mode 100644 index 2a87cac..0000000 --- a/tests/Unit/Query/BuilderTest.php +++ /dev/null @@ -1,168 +0,0 @@ -model = Mockery::mock( Model::class ); - $this->model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $this->model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - } - - /** - * @test - * - * Verifies that the builder can generate a basic select query. - */ - public function it_can_generate_basic_select_query() { - $builder = new Builder( $this->model ); - - $sql = $builder->select( '*' )->from( 'posts' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can generate a select query with specific columns. - */ - public function it_can_generate_select_with_specific_columns() { - $builder = new Builder( $this->model ); - - $sql = $builder->select( ['id', 'title'] )->from( 'posts' )->to_sql(); - - $this->assertEquals( 'select id, title from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE clauses to the query. - */ - public function it_can_add_where_clauses() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->where( 'status', 'publish' ) - ->where( 'author_id', 1 ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where status = %s and author_id = %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add OR WHERE clauses to the query. - */ - public function it_can_add_or_where_clauses() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->where( 'status', 'publish' ) - ->or_where( 'status', 'draft' ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where status = %s or status = %s', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE IN clauses to the query. - */ - public function it_can_add_where_in_clauses() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->where_in( 'id', [1, 2, 3] ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where id in (%d, %d, %d)', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add JOIN clauses to the query. - */ - public function it_can_add_joins() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->join( 'users', 'posts.author_id', '=', 'users.id' ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts inner join wp_users as users on posts.author_id = users.id', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add LEFT JOIN clauses to the query. - */ - public function it_can_add_left_joins() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->left_join( 'comments', 'posts.id', '=', 'comments.post_id' ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts left join wp_comments as comments on posts.id = comments.post_id', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add ORDER BY clauses to the query. - */ - public function it_can_add_order_by() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->order_by( 'created_at', 'desc' ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts order by created_at desc', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add LIMIT and OFFSET clauses to the query. - */ - public function it_can_add_limit_and_offset() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->limit( 10 ) - ->offset( 5 ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts limit %d offset %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can group results by specific columns. - */ - public function it_can_group_by_columns() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' ) - ->group_by( 'status' ) - ->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts group by status', $sql ); - } -} diff --git a/tests/Unit/Query/BuilderWhereTest.php b/tests/Unit/Query/BuilderWhereTest.php deleted file mode 100644 index 9e9c681..0000000 --- a/tests/Unit/Query/BuilderWhereTest.php +++ /dev/null @@ -1,179 +0,0 @@ -model = Mockery::mock( Model::class ); - $this->model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $this->model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NOT clauses to the query. - */ - public function it_can_add_where_not() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_not( 'status', 'draft' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where not status = %s', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NULL clauses to the query. - */ - public function it_can_add_where_null() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_null( 'deleted_at' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where deleted_at is null', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NOT NULL clauses to the query. - */ - public function it_can_add_where_not_null() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_not_null( 'published_at' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where published_at is not null', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE BETWEEN clauses to the query. - */ - public function it_can_add_where_between() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_between( 'views', [100, 200] )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where views between %d and %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NOT BETWEEN clauses to the query. - */ - public function it_can_add_where_not_between() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_not_between( 'views', [10, 20] )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where views not between %d and %d', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE LIKE clauses to the query. - */ - public function it_can_add_where_like() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_like( 'title', '%Hello%' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where title like %s', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NOT LIKE clauses to the query. - */ - public function it_can_add_where_not_like() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_not_like( 'title', '%spam%' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where title not like %s', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add raw WHERE clauses to the query. - */ - public function it_can_add_where_raw() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_raw( 'YEAR(created_at) > ?', [2020] )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where YEAR(created_at) > ?', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE COLUMN clauses to the query. - */ - public function it_can_add_where_column() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_column( 'first_name', '=', 'last_name' )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where first_name = last_name', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE IN clauses to the query. - */ - public function it_can_add_where_in() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_in( 'id', [1, 2] )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where id in (%d, %d)', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE NOT IN clauses to the query. - */ - public function it_can_add_where_not_in() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_not_in( 'id', [9, 10] )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where id not in (%d, %d)', $sql ); - } - - /** - * @test - * - * Verifies that the builder can add WHERE EXISTS clauses to the query. - */ - public function it_can_add_where_exists() { - $builder = new Builder( $this->model ); - - $sql = $builder->from( 'posts' )->where_exists( - function ( $query ) { - $query->from( 'comments' )->where_column( 'comments.post_id', 'posts.id' ); - } - )->to_sql(); - - $this->assertEquals( 'select * from wp_posts as posts where exists (select * from wp_comments as comments where comments.post_id = posts.id)', $sql ); - } -} diff --git a/tests/Unit/Query/Compilers/CompilerTest.php b/tests/Unit/Query/Compilers/CompilerTest.php deleted file mode 100644 index 5e47d97..0000000 --- a/tests/Unit/Query/Compilers/CompilerTest.php +++ /dev/null @@ -1,80 +0,0 @@ -compiler = new Compiler(); - - $model = Mockery::mock( Model::class ); - $model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - - $this->builder = new Builder( $model ); - $this->builder->from( 'posts' ); - } - - /** - * @test - * - * Verifies that the compiler can compile a SELECT statement. - */ - public function it_compiles_select_statement() { - $this->builder->select( ['id', 'title'] ); - - $sql = $this->compiler->compile_select( $this->builder ); - - $this->assertEquals( 'select id, title from wp_posts as posts', $sql ); - } - - /** - * @test - * - * Verifies that the compiler can compile an INSERT statement. - */ - public function it_compiles_insert_statement() { - $values = ['title' => 'Test', 'content' => 'Content']; - - $sql = $this->compiler->compile_insert( $this->builder, $values ); - - $this->assertEquals( 'insert into wp_posts (title, content) values (%s, %s)', $sql ); - } - - /** - * @test - * - * Verifies that the compiler can compile an UPDATE statement. - */ - public function it_compiles_update_statement() { - $this->builder->where( 'id', 1 ); - $values = ['status' => 'published']; - - $sql = $this->compiler->compile_update( $this->builder, $values ); - - $this->assertEquals( 'update wp_posts set status = %s where id = %d', $sql ); - } - - /** - * @test - * - * Verifies that the compiler can compile a DELETE statement. - */ - public function it_compiles_delete_statement() { - $this->builder->where( 'id', 1 ); - - $sql = $this->compiler->compile_delete( $this->builder ); - - $this->assertEquals( 'delete posts from wp_posts as posts where id = %d', $sql ); - } -} diff --git a/tests/Unit/Query/GrammarTest.php b/tests/Unit/Query/GrammarTest.php new file mode 100644 index 0000000..67610ff --- /dev/null +++ b/tests/Unit/Query/GrammarTest.php @@ -0,0 +1,332 @@ +grammar = new Grammar(); + } + + public function tearDown(): void { + Mockery::close(); + parent::tearDown(); + } + + public function test_it_compiles_select_all() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select * from `users`', $sql ); + } + + public function test_it_compiles_select_with_columns() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['id', 'name']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select `id`, `name` from `users`', $sql ); + } + + public function test_it_compiles_select_with_aliases() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['id' => 'user_id', 'name']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->columns = ['user_id' => 'id', 'name']; // Implementation uses: alias => column + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + + $sql = $this->grammar->compile_select( $builder ); + // We actually want: SELECT id AS user_id + // Implementing: columns = ['user_id' => 'id'] results in: `id` as `user_id` + $this->assertEquals( 'select `id` as `user_id`, `name` from `users`', $sql ); + } + + public function test_it_compiles_select_with_table_alias() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->as = 'u'; + $builder->distinct = false; + $builder->aggregate = null; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select * from `users` as `u`', $sql ); + } + + public function test_it_compiles_basic_wheres() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( + [ + 'wheres' => [['type' => 'basic', 'column' => 'id', 'operator' => '=', 'value' => 1, 'boolean' => 'and', 'not' => false]] + ] + ); + $builder->shouldReceive( 'get_wheres' )->andReturn( + [ + ['type' => 'basic', 'column' => 'id', 'operator' => '=', 'value' => 1, 'boolean' => 'and', 'not' => false] + ] + ); + $builder->shouldReceive( 'set_binding' )->with( 1, 'where' )->andReturn( '%d' ); + + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select * from `users` where `id` = %d', $sql ); + } + + public function test_it_compiles_nested_wheres() { + $builder = Mockery::mock( Builder::class ); + $nested = Mockery::mock( Builder::class ); + + $builder->shouldReceive( 'get_clauses' )->andReturn( + [ + 'wheres' => [['type' => 'nested', 'query' => $nested, 'boolean' => 'and', 'not' => false]] + ] + ); + $builder->shouldReceive( 'get_wheres' )->andReturn( + [ + ['type' => 'nested', 'query' => $nested, 'boolean' => 'and', 'not' => false] + ] + ); + + $nested->shouldReceive( 'get_wheres' )->andReturn( + [ + ['type' => 'basic', 'column' => 'id', 'operator' => '=', 'value' => 1, 'boolean' => 'and', 'not' => false] + ] + ); + $nested->shouldReceive( 'set_binding' )->with( 1, 'where' )->andReturn( '%d' ); + + $builder->shouldReceive( 'merge_bindings' )->with( $nested, 'where' ); + + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + + $sql = $this->grammar->compile_select( $builder ); + // Note the spaces: ( `id` = %d ) + $this->assertEquals( 'select * from `users` where ( `id` = %d )', $sql ); + } + + public function test_it_compiles_insert() { + $builder = Mockery::mock( Builder::class ); + $builder->from = 'users'; + $builder->shouldReceive( 'set_binding' )->andReturn( '%s', '%d' ); + + $values = ['name' => 'Alice', 'votes' => 10]; + $sql = $this->grammar->compile_insert( $builder, $values ); + + $this->assertEquals( 'insert into `users` (`name`, `votes`) values (%s, %d)', $sql ); + } + + public function test_it_compiles_update() { + $builder = Mockery::mock( Builder::class ); + $builder->from = 'users'; + $builder->shouldReceive( 'get_wheres' )->andReturn( + [ + ['type' => 'basic', 'column' => 'id', 'operator' => '=', 'value' => 1, 'boolean' => 'and', 'not' => false] + ] + ); + $builder->shouldReceive( 'set_binding' )->with( 'Alice', 'select' )->andReturn( '%s' ); + $builder->shouldReceive( 'set_binding' )->with( 1, 'where' )->andReturn( '%d' ); + + $values = ['name' => 'Alice']; + $sql = $this->grammar->compile_update( $builder, $values ); + + $this->assertEquals( 'update `users` set `name` = %s where `id` = %d', $sql ); + } + + public function test_it_compiles_delete() { + $builder = Mockery::mock( Builder::class ); + $builder->from = 'users'; + $builder->as = null; + $builder->joins = []; + $builder->shouldReceive( 'get_wheres' )->andReturn( + [ + ['type' => 'basic', 'column' => 'id', 'operator' => '=', 'value' => 1, 'boolean' => 'and', 'not' => false] + ] + ); + $builder->shouldReceive( 'set_binding' )->with( 1, 'where' )->andReturn( '%d' ); + + $sql = $this->grammar->compile_delete( $builder ); + + $this->assertEquals( 'delete `users` from `users` where `id` = %d', $sql ); + } + + public function test_it_compiles_aggregates() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->as = null; + $builder->aggregate = ['function' => 'count', 'columns' => ['*']]; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select count(*) as aggregate from `users`', $sql ); + } + + public function test_it_compiles_group_by() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'orders'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + $builder->groups = ['user_id']; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select * from `orders` group by `user_id`', $sql ); + } + + public function test_it_compiles_group_by_multiple_columns() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'orders'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + $builder->groups = ['year', 'month']; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select * from `orders` group by `year`, `month`', $sql ); + } + + public function test_it_compiles_having() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( + [ + 'havings' => [ + ['type' => 'basic', 'column' => 'total', 'operator' => '>', 'value' => 100, 'boolean' => 'and', 'not' => false], + ], + ] + ); + $builder->shouldReceive( 'get_havings' )->andReturn( + [ + ['type' => 'basic', 'column' => 'total', 'operator' => '>', 'value' => 100, 'boolean' => 'and', 'not' => false], + ] + ); + $builder->shouldReceive( 'set_binding' )->with( 100, 'having' )->andReturn( '%d' ); + $builder->columns = ['*']; + $builder->from = 'orders'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + $builder->groups = ['user_id']; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertStringContainsString( 'group by `user_id`', $sql ); + $this->assertStringContainsString( 'having `total` > %d', $sql ); + } + + public function test_it_compiles_order_by_raw_passes_through_unquoted() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + $builder->orders = [['column' => 'FIELD(status,\'active\',\'inactive\')', 'direction' => 'asc']]; + + $sql = $this->grammar->compile_select( $builder ); + // order_by_raw stores raw string as column; Grammar wraps it — check it appears + $this->assertStringContainsString( 'order by', $sql ); + $this->assertStringContainsString( 'asc', $sql ); + } + + public function test_it_compiles_distinct_aggregate() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['status']; + $builder->from = 'users'; + $builder->distinct = true; + $builder->as = null; + $builder->aggregate = ['function' => 'count', 'columns' => ['status']]; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertEquals( 'select count(distinct `status`) as aggregate from `users`', $sql ); + } + + public function test_it_compiles_union_all_keyword() { + // The union 'query' must be a Builder (not a string) so compile_unions() can call + // reset_bindings() on it to get the compiled SQL for the UNION fragment. + $union_builder = Mockery::mock( Builder::class ); + $union_builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $union_builder->shouldReceive( 'reset_bindings' )->andReturn( 'select `name` from `roles`' ); + $union_builder->columns = ['name']; + $union_builder->from = 'roles'; + $union_builder->distinct = false; + $union_builder->aggregate = null; + $union_builder->as = null; + + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->columns = ['name']; + $builder->from = 'users'; + $builder->distinct = false; + $union_query = Mockery::mock( Builder::class ); + $union_query->shouldReceive( 'reset_bindings' )->andReturnSelf(); + $union_query->shouldReceive( 'compile_sql' )->andReturn( 'select * from `users` where `active` = %d' ); + $builder->shouldReceive( 'merge_bindings' )->with( $union_query, 'union' )->andReturnSelf(); + + $builder->unions = [ + ['query' => $union_query, 'all' => true], + ]; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertStringContainsString( 'union all', strtolower( $sql ) ); + } + + public function test_it_compiles_limit_with_offset() { + $builder = Mockery::mock( Builder::class ); + $builder->shouldReceive( 'get_clauses' )->andReturn( [] ); + $builder->shouldReceive( 'set_binding' )->with( 10, 'limit' )->andReturn( '10' ); + $builder->shouldReceive( 'set_binding' )->with( 20, 'offset' )->andReturn( '20' ); + $builder->columns = ['*']; + $builder->from = 'users'; + $builder->distinct = false; + $builder->aggregate = null; + $builder->as = null; + $builder->limit = 10; + $builder->offset = 20; + + $sql = $this->grammar->compile_select( $builder ); + $this->assertStringContainsString( 'limit 10', $sql ); + $this->assertStringContainsString( 'offset 20', $sql ); + // and offset must come AFTER limit + $this->assertGreaterThan( strpos( $sql, 'limit' ), strpos( $sql, 'offset' ) ); + } +} + diff --git a/tests/Unit/Query/JoinClauseTest.php b/tests/Unit/Query/JoinClauseTest.php deleted file mode 100644 index 8587061..0000000 --- a/tests/Unit/Query/JoinClauseTest.php +++ /dev/null @@ -1,42 +0,0 @@ -shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - - $join = new JoinClause( 'users', 'inner', $model ); - - $this->assertEquals( 'inner', $join->type ); - } - - /** - * @test - * - * Verifies that ON conditions can be added to a JoinClause. - */ - public function it_can_add_on_conditions() { - $model = Mockery::mock( Model::class ); - $model->shouldReceive( 'get_table_name' )->andReturn( 'posts' ); - $model->shouldReceive( 'resolver' )->andReturn( new \WpMVC\Database\Resolver() ); - - $join = new JoinClause( 'users', 'inner', $model ); - - $join->on( 'posts.author_id', '=', 'users.id' ); - - $this->assertCount( 1, $join->get_ons() ); - } -} diff --git a/tests/Unit/ResolverTest.php b/tests/Unit/ResolverTest.php index d1efcc1..adcdafb 100644 --- a/tests/Unit/ResolverTest.php +++ b/tests/Unit/ResolverTest.php @@ -2,57 +2,36 @@ namespace WpMVC\Database\Tests\Unit; +use PHPUnit\Framework\TestCase; use WpMVC\Database\Resolver; -use WpMVC\Database\Tests\TestCase; +use Mockery; class ResolverTest extends TestCase { - /** - * @test - * - * Verifies that the resolver correctly adds prefixes to standard table names. - */ - public function it_resolves_standard_table_names() { - $resolver = new Resolver(); - - // Default behavior assumes 'wp_' prefix from our bootstrap mock - $this->assertEquals( 'wp_posts', $resolver->table( 'posts' ) ); + public function tearDown(): void { + Mockery::close(); + parent::tearDown(); } - /** - * @test - * - * Verifies that the resolver correctly handles network-wide table names. - */ - public function it_resolves_network_table_names() { - $resolver = new Resolver(); - - // 'users' is in the default network tables list - $this->assertEquals( 'wp_users', $resolver->table( 'users' ) ); - } + public function test_it_resolves_standard_tables() { + global $wpdb; + $original_prefix = $wpdb->prefix; + $wpdb->prefix = 'wptests_'; - /** - * @test - * - * Verifies that the resolver can resolve multiple table names at once. - */ - public function it_resolves_multiple_tables() { $resolver = new Resolver(); - - $tables = $resolver->table( 'posts', 'comments' ); - - $this->assertEquals( ['wp_posts', 'wp_comments'], $tables ); + $this->assertEquals( 'wptests_users', $resolver->table( 'users' ) ); + + $wpdb->prefix = $original_prefix; } - /** - * @test - * - * Verifies that custom network tables can be added to the resolver. - */ - public function it_can_add_custom_network_tables() { + public function test_it_resolves_global_tables() { + global $wpdb; + $original_base_prefix = $wpdb->base_prefix; + $wpdb->base_prefix = 'wp_'; + $resolver = new Resolver(); - - $resolver->set_network_tables( ['custom_global'] ); - - $this->assertEquals( 'wp_custom_global', $resolver->table( 'custom_global' ) ); + // 'site' is in the internal $network_tables list, so it should be resolved automatically + $this->assertEquals( 'wp_site', $resolver->table( 'site' ) ); + + $wpdb->base_prefix = $original_base_prefix; } } diff --git a/tests/Unit/Schema/BlueprintColumnsTest.php b/tests/Unit/Schema/BlueprintColumnsTest.php deleted file mode 100644 index 6bb3384..0000000 --- a/tests/Unit/Schema/BlueprintColumnsTest.php +++ /dev/null @@ -1,105 +0,0 @@ -blueprint = new Blueprint( 'wp_table', 'utf8mb4_unicode_ci' ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for various numeric column types. - */ - public function it_creates_various_numeric_columns() { - $this->blueprint->unsigned_big_integer( 'user_id' ); - $this->blueprint->integer( 'count' ); - $this->blueprint->unsigned_integer( 'visits' ); - $this->blueprint->tiny_integer( 'is_active' ); - $this->blueprint->decimal( 'price', 8, 2 ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`user_id` BIGINT UNSIGNED NOT NULL", $sql ); - $this->assertStringContainsString( "`count` INT NOT NULL", $sql ); - $this->assertStringContainsString( "`visits` INT UNSIGNED NOT NULL", $sql ); - $this->assertStringContainsString( "`is_active` TINYINT NOT NULL", $sql ); - $this->assertStringContainsString( "`price` DECIMAL(8, 2) NOT NULL", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for text and string column types. - */ - public function it_creates_text_columns() { - $this->blueprint->string( 'name', 100 ); - $this->blueprint->text( 'bio' ); - $this->blueprint->long_text( 'content' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`name` VARCHAR(100) NOT NULL", $sql ); - $this->assertStringContainsString( "`bio` TEXT NOT NULL", $sql ); - $this->assertStringContainsString( "`content` LONGTEXT", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for date and time column types. - */ - public function it_creates_date_time_columns() { - $this->blueprint->timestamp( 'published_at' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`published_at` TIMESTAMP", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for JSON columns. - */ - public function it_creates_json_column() { - $this->blueprint->json( 'metadata' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`metadata` JSON NOT NULL", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for ENUM columns. - */ - public function it_creates_enum_column() { - $this->blueprint->enum( 'status', ['draft', 'publish'] ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`status` ENUM('draft','publish') NOT NULL", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for boolean columns. - */ - public function it_creates_boolean_column() { - $this->blueprint->boolean( 'is_visible' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "`is_visible` TINYINT(1) NOT NULL", $sql ); - } -} diff --git a/tests/Unit/Schema/BlueprintIndexesTest.php b/tests/Unit/Schema/BlueprintIndexesTest.php deleted file mode 100644 index ab4c81f..0000000 --- a/tests/Unit/Schema/BlueprintIndexesTest.php +++ /dev/null @@ -1,108 +0,0 @@ -blueprint = new Blueprint( 'wp_table', 'utf8mb4_unicode_ci' ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a single-column primary key. - */ - public function it_creates_primary_key_on_single_column() { - $this->blueprint->primary( 'uuid' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "PRIMARY KEY (`uuid`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a composite primary key. - */ - public function it_creates_primary_key_on_multiple_columns() { - $this->blueprint->primary( ['user_id', 'role_id'] ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "PRIMARY KEY (`user_id`, `role_id`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a unique index. - */ - public function it_creates_unique_index() { - $this->blueprint->unique( 'email' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "UNIQUE KEY `unique_", $sql ); - $this->assertStringContainsString( "(`email`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a unique index with a custom name. - */ - public function it_creates_unique_index_with_custom_name() { - $this->blueprint->unique( 'email', 'unique_email_idx' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "UNIQUE KEY `unique_email_idx` (`email`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a regular index. - */ - public function it_creates_regular_index() { - $this->blueprint->index( 'status' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "KEY `index_", $sql ); - $this->assertStringContainsString( "(`status`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for a regular index with a custom name. - */ - public function it_creates_regular_index_with_custom_name() { - $this->blueprint->index( ['status', 'created_at'], 'idx_status_date' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "KEY `idx_status_date` (`status`, `created_at`)", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates correct SQL for dropping an index in an ALTER TABLE statement. - */ - public function it_drops_index_in_alter() { - $this->blueprint->drop_index( 'idx_temp' ); - - $sql = $this->blueprint->to_alter_sql(); - - $this->assertStringContainsString( "DROP INDEX `idx_temp`", $sql ); - } -} diff --git a/tests/Unit/Schema/BlueprintModifiersTest.php b/tests/Unit/Schema/BlueprintModifiersTest.php deleted file mode 100644 index ef2bc3e..0000000 --- a/tests/Unit/Schema/BlueprintModifiersTest.php +++ /dev/null @@ -1,70 +0,0 @@ -blueprint = new Blueprint( 'wp_table', 'utf8mb4_unicode_ci' ); - } - - /** - * @test - * - * Verifies that a comment can be added to a column definition. - */ - public function it_adds_comment_to_column() { - $this->blueprint->string( 'code' )->comment( 'Unique product code' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "COMMENT 'Unique product code'", $sql ); - } - - /** - * @test - * - * Verifies that the `use_current` modifier adds DEFAULT CURRENT_TIMESTAMP. - */ - public function it_sets_use_current_for_timestamp() { - $this->blueprint->timestamp( 'created_at' )->use_current(); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "DEFAULT CURRENT_TIMESTAMP", $sql ); - } - - /** - * @test - * - * Verifies that the `use_current_on_update` modifier adds ON UPDATE CURRENT_TIMESTAMP. - */ - public function it_sets_use_current_on_update_for_timestamp() { - $this->blueprint->timestamp( 'updated_at' )->use_current_on_update(); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "ON UPDATE CURRENT_TIMESTAMP", $sql ); - } - - /** - * @test - * - * Verifies that multiple modifiers can be chained on a column definition. - */ - public function it_chains_multiple_modifiers() { - $this->blueprint->string( 'status' ) - ->nullable() - ->default( 'pending' ) - ->comment( 'Status' ); - - $sql = $this->blueprint->to_sql(); - - $this->assertStringContainsString( "NULL DEFAULT 'pending' COMMENT 'Status'", $sql ); - } -} diff --git a/tests/Unit/Schema/BlueprintTest.php b/tests/Unit/Schema/BlueprintTest.php deleted file mode 100644 index 4d4bc8b..0000000 --- a/tests/Unit/Schema/BlueprintTest.php +++ /dev/null @@ -1,83 +0,0 @@ -big_increments( 'id' ); - $blueprint->string( 'title' ); - - $sql = $blueprint->to_sql(); - - $expected = "CREATE TABLE `wp_products` ( - `id` BIGINT UNSIGNED AUTO_INCREMENT, - `title` VARCHAR(255) NOT NULL, - PRIMARY KEY (`id`) -) utf8mb4_unicode_ci;"; - - $this->assertEquals( $expected, $sql ); - } - - /** - * @test - * - * Verifies that the blueprint correctly handles nullable columns and default values. - */ - public function it_handles_nullable_and_default() { - $blueprint = new Blueprint( 'wp_posts', 'utf8mb4_unicode_ci' ); - - $blueprint->string( 'description' )->nullable(); - $blueprint->string( 'status' )->default( 'draft' ); - - $sql = $blueprint->to_sql(); - - $this->assertStringContainsString( "`description` VARCHAR(255) NULL", $sql ); - $this->assertStringContainsString( "`status` VARCHAR(255) NOT NULL DEFAULT 'draft'", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint correctly adds timestamps columns. - */ - public function it_handles_timestamps() { - $blueprint = new Blueprint( 'wp_posts', 'utf8mb4_unicode_ci' ); - - $blueprint->timestamps(); - - $sql = $blueprint->to_sql(); - - $this->assertStringContainsString( "`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP", $sql ); - $this->assertStringContainsString( "`updated_at` TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", $sql ); - } - - /** - * @test - * - * Verifies that the blueprint generates the correct SQL for altering a table. - */ - public function it_generates_alter_table_sql() { - $blueprint = new Blueprint( 'wp_users', 'utf8mb4_unicode_ci' ); - - $blueprint->string( 'phone' )->after( 'email' ); - $blueprint->drop_column( 'old_column' ); - - $sql = $blueprint->to_alter_sql(); - - $expected = "ALTER TABLE `wp_users` - ADD `phone` VARCHAR(255) NOT NULL AFTER `email`, - DROP COLUMN `old_column`;"; - - $this->assertEquals( $expected, $sql ); - } -} diff --git a/tests/Unit/Schema/ForeignKeyTest.php b/tests/Unit/Schema/ForeignKeyTest.php deleted file mode 100644 index 731e16b..0000000 --- a/tests/Unit/Schema/ForeignKeyTest.php +++ /dev/null @@ -1,63 +0,0 @@ -foreign( 'user_id' ) - ->references( 'id' ) - ->on( 'users' ); - - $this->assertInstanceOf( ForeignKey::class, $fk ); - $this->assertEquals( 'user_id', $fk->get_column() ); - $this->assertEquals( 'wp_users', $fk->get_reference_table() ); - $this->assertEquals( 'id', $fk->get_reference_column() ); - } - - /** - * @test - * - * Verifies that the ON DELETE action can be set to CASCADE. - */ - public function it_sets_on_delete_cascade() { - $fk = new ForeignKey( 'user_id' ); - $fk->on_delete( 'CASCADE' ); - - $this->assertEquals( 'CASCADE', $fk->get_on_delete() ); - } - - /** - * @test - * - * Verifies that the ON DELETE action can be set to SET NULL. - */ - public function it_sets_on_delete_set_null() { - $fk = new ForeignKey( 'user_id' ); - $fk->on_delete( 'SET NULL' ); - - $this->assertEquals( 'SET NULL', $fk->get_on_delete() ); - } - - /** - * @test - * - * Verifies that the ON UPDATE action can be set to CASCADE. - */ - public function it_sets_on_update_cascade() { - $fk = new ForeignKey( 'user_id' ); - $fk->on_update( 'CASCADE' ); - - $this->assertEquals( 'CASCADE', $fk->get_on_update() ); - } -} diff --git a/tests/Unit/Schema/SchemaTest.php b/tests/Unit/Schema/SchemaTest.php deleted file mode 100644 index 2fddd10..0000000 --- a/tests/Unit/Schema/SchemaTest.php +++ /dev/null @@ -1,55 +0,0 @@ -big_increments( 'id' ); - $table->string( 'email' ); - }, true - ); - - $this->assertStringContainsString( "CREATE TABLE `wp_users`", $sql ); - $this->assertStringContainsString( "`id` BIGINT UNSIGNED AUTO_INCREMENT", $sql ); - } - - /** - * @test - * - * Verifies that the Schema facade generates correct SQL for dropping a table if it exists. - */ - public function it_generates_drop_if_exists_sql() { - $sql = Schema::drop_if_exists( 'users', true ); - - $this->assertEquals( "DROP TABLE IF EXISTS `wp_users`;", $sql ); - } - - /** - * @test - * - * Verifies that the Schema facade generates correct SQL for altering a table. - */ - public function it_generates_alter_table_sql() { - $sql = Schema::alter( - 'users', function ( Blueprint $table ) { - $table->string( 'name' ); - }, true - ); - - $this->assertStringContainsString( "ALTER TABLE `wp_users`", $sql ); - $this->assertStringContainsString( "ADD `name` VARCHAR(255)", $sql ); - } -}