diff --git a/.gitattributes b/.gitattributes index 08f4280..3ed6878 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,6 @@ /.vscode export-ignore lefthook.yml export-ignore +.php-cs-fixer.php export-ignore .gitattributes export-ignore .gitignore export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore index c131794..59bab05 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ vendor/ .php-cs-fixer.cache tests.config.php .phpunit.result.cache +.phpunit.cache/ +phpunit.phar composer.lock +.superpowers/ diff --git a/README.md b/README.md index b77953a..c2e3f93 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,47 @@ -### WPKit/Database +# wp-database ---- +A small ActiveRecord ORM with a fluent query and schema builder on top of +WordPress' `$wpdb`. -# usage +## Install -1. Add this repository in composer.json +```jsonc +// composer.json +"repositories": [ + { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-database" } +] +``` +```bash +composer require bitapps/wp-database:dev-main ``` -"repositories": [ + +## Quick start + +```php +use BitApps\WPDatabase\Model; + +class Contact extends Model +{ + protected $fillable = ['name', 'email']; + + public function deals() { - "type": "vcs", - "url": "https://github.com/Bit-Apps-Pro/wp-database" + return $this->hasMany(Deal::class, 'contact_id', 'id'); } - ] -``` +} -2. Then install the package +Contact::insert(['name' => 'Ada', 'email' => 'ada@x.com']); +$active = Contact::where('is_active', 1) + ->withCount('deals') + ->orderBy('name')->asc() + ->get(); // Collection of Contact models ``` -composer require bitapps/wp-database:dev-main -``` + +## Documentation + +- **[Usage guide](docs/usage.md)** — models, query builder, relationships, + casts, events, transactions, and more. +- **[Schema builder](docs/schema.md)** — table creation, columns, indexes, and migrations. +- **[Breaking changes](docs/breaking-changes.md)** — upgrade notes. diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md new file mode 100644 index 0000000..2391de2 --- /dev/null +++ b/docs/breaking-changes.md @@ -0,0 +1,440 @@ +# Breaking Changes — v2.0 + +This release (next tag after `1.11`) contains **backward-incompatible** changes +to the query builder, model, relations and schema blueprint. Because the public +API and runtime behavior change, it is a **major** version bump. + +- [1. Summary](#1-summary) +- [2. Breaking changes](#2-breaking-changes) +- [3. Behavioral changes](#3-behavioral-changes) +- [4. New features](#4-new-features) +- [5. Deprecations](#5-deprecations) +- [6. Migration checklist](#6-migration-checklist) + +--- + +## 1. Summary + +| # | Area | Change | Impact | +|---|------|--------|--------| +| 1 | Model | `get()` returns `Collection`, not `array` | High | +| 2 | QueryBuilder | `update()` no longer chainable, runs immediately | High | +| 3 | QueryBuilder | `save()` returns `Model\|false` (was `bool`/`int`) | High | +| 4 | QueryBuilder | `select()` back-tick quotes columns; raw exprs break | High | +| 5 | QueryBuilder | `delete()` with no `WHERE` throws (was: table wipe) | High | +| 6 | Model | NULL values no longer cast | Medium | +| 7 | QueryBuilder | `raw()`/`exec()` non-SELECT return changed | Medium | +| 8 | QueryBuilder | `with()` signature changed (old `Closure`-only form gone) | Medium | +| 9 | QueryBuilder | `withCount()` removed → relation aggregate | Medium | +| 10 | Relations | `addRelation()` signature `string` → `array`, void | Medium | +| 11 | Blueprint | `binary()` removed | Low | +| 12 | QueryBuilder | exception type/message changed in `exec()` | Low | + +--- + +## 2. Breaking changes + +### 2.1 `Model::get()` / query results return a `Collection`, not an `array` + +Multi-row reads now return `BitApps\WPDatabase\Collection` instead of a plain +PHP array. `find()` always returns a `Collection` (even for a single PK); use +`findOne()` or `->first()` for a single model. Empty results return `[]`. + +```php +// Before +$users = User::where('active', 1)->get(); // array +is_array($users); // true +array_map($fn, $users); // OK + +// After +$users = User::where('active', 1)->get(); // Collection +is_array($users); // false ❌ +array_map($fn, $users); // TypeError ❌ +``` + +**Why it breaks:** `is_array()` checks fail; native `array_*` functions reject +the object. + +**Migration:** `Collection` implements `ArrayAccess`, `IteratorAggregate`, +`Countable`, `JsonSerializable` — so `foreach`, `$users[0]`, `count($users)` and +`json_encode($users)` keep working. Replace native array calls with collection +methods: + +```php +$users->map($fn); // instead of array_map +$users->filter($fn); // instead of array_filter +$users->pluck('id'); // column extraction +$users->first(); // first item (or matching callback) +$users->last(); +$users->reduce($fn, $initial); +$users->all(); // underlying plain array +$users->toArray(); // array of model arrays +``` + +--- + +### 2.2 `QueryBuilder::update()` is no longer chainable and executes immediately + +```php +// Before — returned $this, deferred execution +$qb->update(['name' => 'x'])->where('id', 1)->save(); + +// After — executes now, returns result (not $this) +User::where('id', 1)->update(['name' => 'x']); // runs UPDATE immediately +``` + +If the bound model **already exists**, `update()` now delegates to `save()` +(dirty-attribute update). On a fresh model it builds the update from all +attributes and executes. + +**Why it breaks:** any chain calling a method *after* `update()` breaks — +`update()` no longer returns the builder. + +**Migration:** set conditions **before** `update()`; drop trailing +`->save()`/`->exec()`. + +--- + +### 2.3 `QueryBuilder::save()` return value changed + +```php +// Before +$result = $model->save(); +// insert -> bool(true) ; update -> int rows_affected + +// After +$result = $model->save(); +// success -> the Model instance ; failure -> bool(false) +``` + +**Why it breaks:** code relying on `true` / `rows_affected` int now receives a +Model object on success. + +**Migration:** treat the return as truthy for success; read affected rows via +`Connection::prop('rows_affected')` if needed. + +--- + +### 2.4 `select()` back-tick qualifies columns — raw expressions break + +`select()` and `addSelect()` pass every column through `prepareColumnName()`, +which wraps the name as `` `table`.`column` `` unless it already contains a `.`. + +```php +// Before +->select('COUNT(*) as total') // emitted: COUNT(*) as total + +// After +->select('COUNT(*) as total') // emitted: `COUNT(*) as total` ❌ invalid SQL +``` + +**Why it breaks:** any raw SQL expression, function call, or alias passed to +`select()` is now quoted as a single identifier. + +**Migration:** use `selectRaw()` for expressions; plain columns need no change: + +```php +->selectRaw('COUNT(*) as total') +->selectRaw('SUM(amount) as amt', $bindings) +``` + +--- + +### 2.5 `delete()` with no `WHERE` clause no longer wipes the table + +A delete producing no `WHERE` clause now returns empty SQL, and `exec()` throws +instead of running `DELETE FROM table` (which previously emptied the table). + +```php +// Before +User::delete(); // ⚠️ deleted every row + +// After +User::delete(); // throws RuntimeException('SQL query is empty') +``` + +**Why it breaks (intentionally):** prevents accidental full-table deletes. + +**Migration:** add an explicit condition — `User::where('id', $id)->delete()`. To +truly empty a table, use a raw `TRUNCATE`/`DELETE` query. + +--- + +### 2.6 NULL values are no longer cast + +`castTo()` short-circuits and returns `null` when the value is `null`, instead of +running it through the configured cast. + +```php +// casts = ['count' => 'int', 'flag' => 'bool'] + +// Before +$m->count; // null -> (int) null => 0 +$m->flag; // null -> false + +// After +$m->count; // null (unchanged) +$m->flag; // null (unchanged) +``` + +**Why it breaks:** nullable cast columns now surface `null` rather than the +zero-value (`0`, `false`, `''`, epoch `DateTime`). + +**Migration:** null-check or coalesce — `$m->count ?? 0`. + +--- + +### 2.7 `raw()` / `exec()` return value for non-SELECT queries + +`raw()` returns the last result set **only** for `SELECT` queries. For +non-SELECT statements (INSERT/UPDATE/DELETE/DDL) it returns the underlying +`Connection::query()` result instead of `last_result`. + +**Migration:** for write statements, check truthiness / `Connection::prop()` +rather than expecting a result set. + +--- + +### 2.8 `QueryBuilder::with()` signature changed + +`with()` stays on `QueryBuilder` but gains a richer signature and is also +reachable statically/instance via the model (see §4.4). + +```php +// Before +public function with($relation) // string | Closure + +// After +public function with($relation, $callback = null) // string | array, optional Closure +``` + +Eager-loading works in every call style: + +```php +User::with('posts')->get(); +User::with('posts', fn ($q) => $q->where('published', 1))->get(); +User::with(['posts', 'profile'])->get(); +``` + +**Why it breaks:** the old single-arg `Closure` form (`with(fn ($q) => …)`) is +gone — pass the relation name first, the constraint second. + +--- + +### 2.9 `QueryBuilder::withCount()` removed — now a relation aggregate + +The old no-arg `withCount()` that appended `COUNT(*) as count` to the select is +gone. `withCount()` now lives in `Relations`, **requires a relation name**, and +emits a correlated sub-select. + +```php +// Before +->withCount()->get(); // COUNT(*) as count + +// After +User::withCount('posts')->get(); // adds posts_count sub-select +``` + +**Migration:** for a plain row count use `->count()`; for relation counts use +`withCount($relation)`. See §4.2 for the full aggregate family. + +--- + +### 2.10 `addRelation()` signature changed + +```php +// Before +public function addRelation($relation) // string; resolved method, returned query + +// After +public function addRelation(array $relation) // merges relation array, returns void +``` + +**Why it breaks:** callers passing a string, or using the return value, break. + +**Migration:** use `with()` / the new relation methods instead of calling +`addRelation()` directly. + +--- + +### 2.11 `Blueprint::binary()` removed + +The `binary()` column modifier was deleted. + +```php +$table->string('hash')->binary(); // ❌ Call to undefined method +``` + +**Migration:** express the binary/collation requirement another way (e.g. a raw +column type) until a replacement is provided. + +--- + +### 2.12 Exception type and message changed in `exec()` + +A null/empty prepared query now throws `RuntimeException('SQL query is empty')` +instead of `Exception('SQL query is null')`. + +`RuntimeException` extends `Exception`, so `catch (\Exception $e)` still works. +Code matching on the **message** string, or catching the base `Exception` type +specifically for this case, should be reviewed. + +--- + +## 3. Behavioral changes + +Not signature breaks, but observable runtime differences. + +- **`count()` now counts the primary key** (`COUNT(pk)`) via the new + `aggregate()` helper, instead of `COUNT(*)`. Rows with a NULL primary key are + no longer counted. Returns `int`; a real `0` is preserved (not coerced to `null`). +- **`min()` / `max()` run on a clone** of the builder, so calling them no longer + mutates the current query's `select`/`selectRaw` state. +- **Bulk insert id collection fixed** — inserted ids are now derived from + `rows_affected` starting at `lastInsertId()` (previously off by one / partial). + Bulk inserts now return all created models. +- **NULL columns persist as SQL `NULL`** in insert/update/upsert, instead of + being coerced to an empty string `''`. +- **`paginate()`** defaults `select` to `*` when empty and computes the count + before applying limit/offset; pagination with explicit `select` columns and + count no longer conflict. +- **`Model` now fires lifecycle events** during read/write — see §4.3. A model + with custom logic in overridden write paths may observe new event callbacks. +- **Internal layout:** the three traits moved to `BitApps\WPDatabase\Concerns` + and SELECT compilation extracted to `BitApps\WPDatabase\Query\Grammar`. Public + classes are unchanged; only code importing those internals directly is affected. + +--- + +## 4. New features + +### 4.1 `Collection` + +Returned from multi-row reads. Methods: `make()`, `all()`, `map()`, `filter()`, +`reduce()`, `pluck()`, `first()`, `last()`, `reverse()`, `toArray()`, plus +`Countable` / `ArrayAccess` / `IteratorAggregate` / `JsonSerializable`. + +### 4.2 Relation aggregates & existence + +Added to the `Relations` trait (all usable statically via the model): + +```php +User::withCount('posts')->get(); // posts_count +User::withMin('posts.score')->get(); +User::withMax('posts.score')->get(); +User::withAvg('posts.score')->get(); +User::withSum('posts.score')->get(); +User::withExists('posts')->get(); // bool-cast posts_exists +User::whereHas('posts')->get(); // filter by existence +User::whereHas('posts', fn ($q) => $q->where('published', 1))->get(); +User::withWhereHas('posts', $cb)->get(); // filter + eager-load +``` + +Relation names support an `as` alias: `withCount('posts as total_posts')`. + +### 4.3 Model events (`HasEvents` trait) + +**Subscribable events** — register in a model's `boot()` via the named static +registrars: `retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, +`deleted`. **Boot hooks** — override the protected static methods `booting()` +(runs before `boot()`) and `booted()` (runs after) instead of using a registrar. + +```php +protected static function boot() +{ + static::saving(fn ($model) => /* ... */); + static::deleted(MyDeletedHandler::class); // class with handle() +} +``` + +A pre-event (`saving`/`updating`/`deleting`) returning `false` aborts the query. + +> `creating`/`created` events fire internally but have no public registrar — +> use `saving`/`saved` instead, which fire on both insert and update. + +> ⚠️ The trait **reserves** the method names `boot`, `booting`, `booted`, +> `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, +> `$registeredEvents`, `$booted` on subclasses. A child model already defining +> any of these will collide. + +### 4.4 Relation eager-loading lives on `QueryBuilder` (callable statically) + +`with()`, `withCount()`, `withMin/Max/Avg/Sum/Exists()`, `whereHas()`, +`withWhereHas()` are defined on `QueryBuilder` and forwarded from the model via +`__call`/`__callStatic`. All three call styles work and produce identical SQL: + +```php +User::with('posts')->get(); // static +User::where('id', '>', 0)->with('posts')->get(); // chained on the builder +(new User())->with('posts')->get(); // instance +``` + +> **Why on the builder, not the model:** PHP only routes through `__callStatic` +> for methods that are *not* declared on the class. While these methods lived on +> the model's `Relations` trait, `Model::with(...)` threw +> `Error: Non-static method ...::with() cannot be called statically`. Moving +> them to `QueryBuilder` (where `where()`/`select()` already live) restores +> static access. Relation *definitions* (`hasMany`, `belongsTo`, …) remain on +> the model. + +**Return type:** these now return a `QueryBuilder` (previously the trait +versions returned the `Model`). Both are chainable to `->get()`, and forward +unknown methods to each other, so existing chains keep working. + +**IDE navigation:** `Model` carries `@mixin \BitApps\WPDatabase\QueryBuilder` +(instead of a hand-maintained `@method static` list), so Ctrl/Cmd+Click on a +forwarded call jumps to the real `QueryBuilder` method. For a fully +type-navigable entry that works identically across IDEs, start the chain with +the new real static `Model::query()`: + +```php +User::query()->with('posts')->where('active', 1)->get(); +``` + +### 4.5 Other additions + +- **QueryBuilder:** `addSelect()`, `selectRaw()`, `orderByRaw()`, `upsert()`, + `when()`, `toSql()`, `clone()`, `aggregate()`, `prepareColumnName()`, + `withCast()` (chainable), and `__call()` forwarding to the bound model. +- **Model:** `query()` (canonical static builder entry), `toArray()`, + `getPrefix()`, `withCast(array $casts)`, `bool`/`boolean` cast. +- **Connection:** `startTransaction()`, `commit()`, `rollback()`. +- **Blueprint:** `unique($column = null)` — optional arg (backward compatible) + for composite/explicit unique indexes. +- **QueryBuilder:** `static $TIME_ZONE` to set the timezone statically; `$select` + / `$selectRaw` are now `public` (were `protected`). + +--- + +## 5. Deprecations + +| Deprecated | Replacement | +|------------|-------------| +| `QueryBuilder::startTransaction()` | `Connection::startTransaction()` | +| `QueryBuilder::commit()` | `Connection::commit()` | +| `QueryBuilder::rollback()` | `Connection::rollback()` | + +Still functional, but migrate — they may be removed in a future release. + +--- + +## 6. Migration checklist + +- [ ] Replace `is_array()` / `array_*` on query results with `Collection` + methods (§2.1). +- [ ] Remove method calls chained **after** `update()`; set `where()` before it + (§2.2). +- [ ] Update code reading `save()`'s return as `bool`/int; it returns the model + now (§2.3). +- [ ] Move raw expressions out of `select()` into `selectRaw()` (§2.4). +- [ ] Ensure every `delete()` has a `WHERE`; replace intentional table wipes + with raw SQL (§2.5). +- [ ] Null-check / coalesce values from nullable cast columns (§2.6). +- [ ] Review non-SELECT `raw()` return handling (§2.7). +- [ ] Replace the old single-arg `with(Closure)` form and the old `withCount()` + no-arg call (§2.8, §2.9). +- [ ] Replace direct `addRelation(string)` calls with `with()` (§2.10). +- [ ] Remove `binary()` column modifiers (§2.11). +- [ ] Review `catch`/message matching around the SQL-empty exception (§2.12). +- [ ] Check subclasses for name collisions with the `HasEvents` trait (§4.3). +- [ ] Migrate transaction calls from `QueryBuilder` to `Connection` (§5). diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..3708a4b --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,347 @@ +# Schema Builder Reference + +Use `Schema` (a thin facade over `Blueprint`) to create, alter and drop MySQL tables from +PHP. By default the table name is used as-is (no prefix is applied); call +`Schema::withPrefix($prefix)` to apply one. + +See [Defining models](usage.md#defining-models) for the model-side `$timestamps` and +`$soft_deletes` properties that rely on the columns described here. + +--- + +- [Creating tables](#creating-tables) +- [Column types](#column-types) + - [String / binary](#string--binary) + - [Text / blob](#text--blob) + - [Numeric](#numeric) + - [Enumerated](#enumerated) + - [Date / time](#date--time) +- [Column modifiers](#column-modifiers) +- [Column helpers](#column-helpers) + - [id()](#id) + - [increments()](#increments) + - [string()](#string) + - [timestamps()](#timestamps) + - [softDeletes()](#softdeletes) +- [Foreign keys](#foreign-keys) +- [Altering & dropping](#altering--dropping) +- [Table operations](#table-operations) + +--- + +## Creating tables + +```php +use BitApps\WPDatabase\Schema; + +Schema::create('orders', function ($table) { + $table->id(); + $table->bigint('user_id')->unsigned() + ->foreign('users', 'id') + ->onDelete()->cascade(); + $table->varchar('status', 32)->defaultValue('pending'); + $table->decimal('total')->nullable(); + $table->timestamps(); +}); +``` + +The callback receives a `Blueprint` instance. Column-definition methods return the same +`Blueprint`, so modifiers chain directly: + +```php +$table->varchar('slug', 100)->nullable()->unique(); +``` + +--- + +## Column types + +All types listed below are registered in `Blueprint::isValidType()` and exposed as magic +methods via `Blueprint::__call()`. The first argument is always the column name; the +optional second argument sets the length (or, for `ENUM`/`SET`, the list of allowed values +as an array). + +### String / binary + +| Method | SQL type | Notes | +|---|---|---| +| `char($name, $length = null)` | `CHAR` | | +| `varchar($name, $length = null)` | `VARCHAR` | | +| `binary($name, $length = null)` | `BINARY` | Column **type** — see note | +| `varbinary($name, $length = null)` | `VARBINARY` | Column **type** — see note | +| `json($name, $length = null)` | `JSON` | | + +> **`binary` / `varbinary` are column types, not modifiers.** These define columns +> with the `BINARY` / `VARBINARY` SQL type. A chained `binary()` *modifier* (which +> would have flagged an existing column as binary) was removed in an earlier refactor; +> only the type declarations remain. Use `$table->binary('data', 16)` to define a +> `BINARY(16)` column. + +### Text / blob + +| Method | SQL type | +|---|---| +| `tinytext($name)` | `TINYTEXT` | +| `text($name)` | `TEXT` | +| `mediumtext($name)` | `MEDIUMTEXT` | +| `longtext($name)` | `LONGTEXT` | +| `tinyblob($name)` | `TINYBLOB` | +| `blob($name)` | `BLOB` | +| `mediumblob($name)` | `MEDIUMBLOB` | +| `longblob($name)` | `LONGBLOB` | + +### Numeric + +| Method | SQL type | Notes | +|---|---|---| +| `bit($name, $length = null)` | `BIT` | | +| `tinyint($name, $length = null)` | `TINYINT` | | +| `bool($name)` | `BOOL` | | +| `boolean($name)` | `BOOLEAN` | Alias of `bool` | +| `smallint($name, $length = null)` | `SMALLINT` | | +| `mediumint($name, $length = null)` | `MEDIUMINT` | | +| `int($name, $length = null)` | `INT` | | +| `integer($name, $length = null)` | `INTEGER` | Alias of `int` | +| `bigint($name, $length = null)` | `BIGINT` | | +| `float($name, $length = null)` | `FLOAT` | | +| `double($name, $length = null)` | `DOUBLE` | | +| `double_precision($name, $length = null)` | `DOUBLE PRECISION` | Underscore maps to space | +| `decimal($name, $length = null)` | `DECIMAL` | | +| `dec($name, $length = null)` | `DEC` | Alias of `decimal` | + +### Enumerated + +| Method | SQL type | Notes | +|---|---|---| +| `enum($name, array $enum)` | `ENUM` | Second arg is an array of allowed string values | +| `set($name, array $set)` | `SET` | Second arg is an array of allowed string values | + +```php +$table->enum('status', ['draft', 'published', 'archived']); +$table->set('permissions', ['read', 'write', 'delete']); +``` + +### Date / time + +| Method | SQL type | +|---|---| +| `date($name)` | `DATE` | +| `datetime($name)` | `DATETIME` | +| `timestamp($name)` | `TIMESTAMP` | +| `time($name)` | `TIME` | +| `year($name)` | `YEAR` | + +--- + +## Column modifiers + +Chain any of these immediately after a type call. Each returns the `Blueprint` so multiple +modifiers can be stacked. + +| Modifier | Effect | +|---|---| +| `nullable()` | Emits `NULL` instead of `NOT NULL`. | +| `defaultValue($value)` | Adds `DEFAULT $value`. Integers are emitted unquoted; `CURRENT_TIMESTAMP` and `NULL` are also unquoted; all other strings are single-quoted automatically. | +| `unsigned()` | Adds `UNSIGNED` (meaningful on numeric columns). | +| `zeroFill()` | Adds `ZEROFILL`. | +| `primary()` | Registers the column in the `PRIMARY KEY` clause and forces `NOT NULL`. | +| `unique($column = null)` | Registers a `UNIQUE INDEX`. Without an argument it indexes the current column. Pass an explicit column name or an array of column names to create a named or composite unique index. | +| `index($type = null)` | Registers an `INDEX` on the current column. Pass an index-type prefix string (e.g. `'FULLTEXT '`) to customise the index clause. | +| `length($length)` | Sets the column length. Accepts an array for `ENUM`/`SET` allowed values — each element is single-quoted and the list is comma-joined automatically. | + +--- + +## Column helpers + +Higher-level helpers that expand to one or more fully configured column definitions. + +### id() + +```php +$table->id(); +``` + +Defines `id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT` and registers it as the `PRIMARY KEY`. +Shorthand for: + +```php +$table->bigint('id')->unsigned()->increments()->primary(); +``` + +### increments() + +```php +$table->bigint('sort_order')->increments(); +// or pass a name to create the column and apply AUTO_INCREMENT in one call: +$table->increments('sort_order'); // creates BIGINT sort_order NOT NULL AUTO_INCREMENT +``` + +Adds `AUTO_INCREMENT` to the most recently defined column. When called with a column name +argument it first creates a `BIGINT` column with that name, then applies `AUTO_INCREMENT`. + +### string() + +```php +$table->string('email'); +$table->string('slug')->unique(); +``` + +Defines a `VARCHAR(255)` column. Equivalent to `$table->varchar($name)->length(255)`. + +### timestamps() + +```php +$table->timestamps(); +``` + +Adds two nullable `TIMESTAMP` columns — `created_at` and `updated_at` — both defaulting +to `NULL`. + +The model-side `$timestamps = true` property (see +[Defining models](usage.md#defining-models)) instructs the ORM to auto-populate these +columns on every insert and update. The table must contain these two columns for +`$timestamps` to function. + +### softDeletes() + +```php +$table->softDeletes(); +``` + +Adds a single nullable `TIMESTAMP` column named `deleted_at`, defaulting to `NULL`. + +The model-side `$soft_deletes = true` property (see +[Defining models](usage.md#defining-models)) instructs `delete()` to set `deleted_at` +rather than removing the row. **Reads are not filtered** — soft-deleted rows are returned +by `all()` and every query; append `->whereNull('deleted_at')` manually to exclude them. + +--- + +## Foreign keys + +Foreign key constraints are declared by chaining on the column that holds the FK value: + +```php +$table->bigint('user_id')->unsigned() + ->foreign('users', 'id') // references users(id) + ->onDelete()->cascade() // ON DELETE CASCADE + ->onUpdate()->restrict(); // ON UPDATE RESTRICT +``` + +| Method | Description | +|---|---| +| `foreign($ref, $refCol)` | Declares a `FOREIGN KEY` from the current column to column `$refCol` on table `$ref`. `$ref` is used as-is — pass the full (already-prefixed) table name; no prefix is applied. | +| `onDelete()` | Selects the `ON DELETE` slot for the next action modifier. | +| `onUpdate()` | Selects the `ON UPDATE` slot for the next action modifier. | +| `cascade()` | Applies `CASCADE`. If `onDelete()` or `onUpdate()` was called first, only that slot is set; otherwise both `ON DELETE` and `ON UPDATE` are set to `CASCADE`. | +| `restrict()` | Applies `RESTRICT`. Same scoping rules as `cascade()`. | +| `setNull()` | Applies `SET NULL`. Same scoping rules as `cascade()`. | + +--- + +## Altering & dropping + +Use `Schema::edit` to alter an existing table. Inside the callback, call the same column +definitions as in `Schema::create` to add columns, and use the drop/rename helpers to +modify or remove existing ones. + +```php +Schema::edit('orders', function ($table) { + $table->varchar('reference', 64)->nullable(); // ADD COLUMN + $table->tinyint('priority')->defaultValue(0); // ADD COLUMN + $table->dropColumn('legacy_notes'); // DROP COLUMN + $table->dropTimestamps(); // DROP created_at + updated_at +}); +``` + +### Modifying an existing column + +Chain `change()` on a column definition inside `Schema::edit` to emit `CHANGE COLUMN` +instead of `ADD COLUMN`: + +```php +Schema::edit('orders', function ($table) { + $table->varchar('reference', 128)->change(); // CHANGE COLUMN — widen the length +}); +``` + +> ⚠️ **Known bug:** `change()` currently emits malformed SQL (`ADD COLUMN CHANGE COLUMN …`) +> because `addColumnQuery()` unconditionally prepends `ADD COLUMN` in edit mode and then +> also prepends `CHANGE COLUMN` when the `change` flag is set. Do not rely on `change()` +> in production until this is fixed. + +### Drop helpers + +Only `dropColumn` and `renameColumn` produce complete SQL when called as a direct static +call (e.g. `Schema::dropColumn('orders', 'legacy_notes')`). The remaining helpers — +`dropTimestamps`, `dropIndex`, `dropUnique`, `dropForeign`, and `dropPrimary` — must be +used inside a `Schema::edit()` callback; a direct static call only emits the +`ALTER TABLE` header with no `DROP` clause. + +| Method | Emitted SQL | +|---|---| +| `dropColumn($column)` | `DROP $column` | +| `dropTimestamps()` | `DROP COLUMN created_at, DROP COLUMN updated_at` | +| `dropIndex($indexes)` | `DROP INDEX \`name\`` — accepts a single name string or an array of names | +| `dropUnique($indexes)` | Alias of `dropIndex` — unique indexes are dropped the same way as regular indexes, by name | +| `dropForeign($keys)` | `DROP FOREIGN KEY \`name\`` — accepts a single key name string or an array | +| `dropPrimary()` | `DROP PRIMARY KEY` | + +### renameColumn() + +```php +// Direct call — renames a single column on the table +Schema::renameColumn('orders', 'fname', 'first_name'); +// Emits: ALTER TABLE `orders` CHANGE fname first_name +// (wp_ prefix appears only if withPrefix was used, e.g. Schema::withPrefix('wp_')->renameColumn(...)) +``` + +Renames a column via MySQL's `CHANGE` clause. Note that this implementation emits +`CHANGE old_name new_name` without repeating the column definition, which is accepted +by MySQL 8.0+ (which supports bare `RENAME COLUMN`) but may require the full type +specification on older MySQL / MariaDB versions. + +--- + +## Table operations + +All methods are available as static calls (`Schema::create(...)`) and as instance calls +on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic`. + +| Method | Description | +|---|---| +| `Schema::create($table, Closure $callback)` | `CREATE TABLE IF NOT EXISTS`. The callback receives the `Blueprint`. | +| `Schema::edit($table, Closure $callback)` | `ALTER TABLE`. Use to add columns, drop columns, add/drop indexes and foreign keys. | +| `Schema::drop($table)` | `DROP TABLE IF EXISTS`. | +| `Schema::rename($table, $newName)` | `ALTER TABLE … RENAME TO`. Both names are used as-is unless `Schema::withPrefix()` is used (no prefix by default). | +| `Schema::withPrefix($prefix)` | Sets the prefix applied for this call (there is no prefix by default). Returns a `Schema` instance; chain `.create()`, `.edit()`, etc. | + +```php +// Override prefix — table resolves as "custom_orders" +// (without withPrefix, the bare name "orders" is used — no prefix is applied automatically) +Schema::withPrefix('custom_')->create('orders', function ($table) { + $table->id(); + $table->string('reference'); + $table->timestamps(); +}); +``` + +--- + +## Limitations & known issues + +- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` defaults to + `null`, not `''`; no prefix is prepended unless you call `Schema::withPrefix()` first. + To use the WordPress prefix, pass it explicitly: + `Schema::withPrefix($wpdb->prefix)->create(...)`. + +- **`change()` is broken — emits malformed SQL.** `addColumnQuery()` prepends + `ADD COLUMN` for every column in edit mode, then also prepends `CHANGE COLUMN` when the + `change` flag is set, producing `ADD COLUMN CHANGE COLUMN …`. Avoid `change()` in + production until this is fixed. + +- **`dropTimestamps()`, `dropIndex()`, `dropUnique()`, `dropForeign()`, `dropPrimary()` + must be used inside a `Schema::edit()` callback.** A direct static call (e.g. + `Schema::dropTimestamps('orders')`) only sets the `ALTER TABLE` header and emits no + `DROP` clause, producing a syntax error. Only `dropColumn()` and `renameColumn()` + produce complete SQL when called directly. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..8a8b408 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,723 @@ +# wp-database — Usage Guide + +A small ActiveRecord ORM + fluent query/schema builder on top of WordPress' +`$wpdb`. Models map to tables, queries are built fluently, results come back as +hydrated model objects inside a `Collection`. + +Intended audience: WordPress plugin developers comfortable with PHP and Composer. +The API is modelled on Eloquent's; prior Eloquent experience is helpful but not +required — every method used in this guide is documented in full here. + +- [Install](#install) +- [Setup](#setup) +- [Defining models](#defining-models) +- [Creating records](#creating-records) +- [Reading records](#reading-records) +- [The query builder](#the-query-builder) +- [Collections](#collections) +- [Updating records](#updating-records) +- [Deleting records](#deleting-records) +- [Upsert](#upsert) +- [Attribute casting](#attribute-casting) +- [Relationships](#relationships) +- [Relation aggregates & existence](#relation-aggregates--existence) +- [Model events](#model-events) +- [Transactions](#transactions) +- [Raw queries](#raw-queries) +- [Schema builder](#schema-builder) +- [Static vs instance calls (IDE notes)](#static-vs-instance-calls-ide-notes) +- [Breaking changes](#breaking-changes) +- [Limitations & known issues](#limitations--known-issues) + +--- + +## Install + +Add the repository and require the package: + +```jsonc +// composer.json +"repositories": [ + { "type": "vcs", "url": "https://github.com/Bit-Apps-Pro/wp-database" } +] +``` + +```bash +composer require bitapps/wp-database:dev-main +``` + +Namespace: `BitApps\WPDatabase`. (When bundled into a plugin with Strauss/ +php-scoper the namespace is prefixed, e.g. `BitApps\YourPlugin\Deps\BitApps\WPDatabase`.) + +--- + +## Setup + +The package wraps the global `$wpdb`, so it works inside WordPress with no +connection config. Optionally set a plugin-specific table prefix once during +boot — it is appended after the WordPress prefix (`wp_` + plugin prefix): + +```php +use BitApps\WPDatabase\Connection; + +Connection::setPluginPrefix('myplugin_'); // tables resolve as wp_myplugin_* +``` + +Other `Connection` helpers: + +```php +Connection::getPrefix(); // wp_ + plugin prefix +Connection::wpPrefix(); // wp_ +Connection::enableQuery(); // start collecting executed queries +Connection::queries(); // array of executed queries (after enableQuery) +Connection::errors(); // array of query errors +``` + +You can also pin a timezone for generated timestamps (`created_at`/`updated_at`): + +```php +use BitApps\WPDatabase\QueryBuilder; + +QueryBuilder::$TIME_ZONE = 'UTC'; // otherwise wp_timezone_string()/gmt_offset is used +``` + +--- + +## Defining models + +Extend `Model`. Table name is auto-derived from the class name (pluralised, +snake_cased) unless `$table` is set. The WordPress + plugin prefix is added +automatically. + +```php +use BitApps\WPDatabase\Model; + +class Contact extends Model +{ + protected $table = 'contacts'; // optional; default derived from class name + protected $primaryKey = 'id'; // default 'id' + protected $prefix = ''; // overrides the plugin prefix when non-empty + + public $timestamps = true; // auto-maintain created_at / updated_at + + // Mass-assignment allow-list. Omit to allow all attributes. + protected $fillable = ['first_name', 'last_name', 'email', 'meta']; + + // Attribute casts (see "Attribute casting"). + protected $casts = [ + 'meta' => 'array', + 'is_active' => 'bool', + ]; + + // Soft deletes: delete() sets deleted_at instead of removing the row. + // NOTE: reads are NOT filtered — soft-deleted rows appear in all() and every query. + public $soft_deletes = true; +} +``` + +| Property | Purpose | +|---|---| +| `$table` | Table name (without prefix). Auto-derived from the class name (pluralised, snake_cased) if unset. | +| `$primaryKey` | Primary key column (default `id`). | +| `$prefix` | When non-empty, **replaces** the plugin prefix for this model only. Table resolves to `$wpdb->prefix . $prefix . $tableName`. Leave empty (the default) to use the plugin prefix set via `Connection::setPluginPrefix()`. | +| `$fillable` | Mass-assignment allow-list. Unset = allow all non-timestamp, non-PK attributes. | +| `$casts` | Map of column → cast type. See [Attribute casting](#attribute-casting). | +| `$timestamps` | Auto-set `created_at`/`updated_at` on insert/update (declared `true` in the base `Model`; set to `false` to disable). Requires the columns to exist — see [`timestamps()` in `docs/schema.md`](schema.md#timestamps). | +| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` and every query. See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). | + +--- + +## Creating records + +```php +// Instantiate, set, save +$contact = new Contact(); +$contact->first_name = 'Ada'; +$contact->email = 'ada@example.com'; +$saved = $contact->save(); // returns the saved Model on success, false on failure + +// Single-row insert — returns the created Model on success, false on failure +$contact = Contact::insert([ + 'first_name' => 'Ada', + 'email' => 'ada@example.com', +]); + +// Bulk insert (array of rows) — returns a Collection of created models +$created = Contact::insert([ + ['first_name' => 'Ada', 'email' => 'ada@x.com'], + ['first_name' => 'Grace','email' => 'grace@x.com'], +]); +``` + +`save()` inserts when the model is new and updates (dirty attributes only) when +it already exists. On success it returns the model; on failure, `false`. + +--- + +## Reading records + +```php +// find() applies WHERE conditions and returns a Collection — not a single Model +Contact::find(1); // by PK → Collection (or [] if none) +Contact::find(['email' => 'a@x.com']); // by attributes → Collection + +// Use findOne() or first() when you want exactly one Model back +Contact::findOne(['email' => 'a@x.com']); // → single Model or [] +Contact::where('email', 'a@x.com')->first(); // → single Model or [] + +Contact::first(); // first row → single Model or [] +Contact::all(); // all rows → Collection +Contact::get(); // all rows → Collection +Contact::get(['id', 'email']); // only some columns + +Contact::where('is_active', 1)->get(); // filtered → Collection +``` + +- `find()` sets WHERE conditions and calls `get()` — it always returns a **`Collection`** + (or `[]` when the result is empty, `false` on a database error). It does **not** return a + single Model, even when called with a single primary key. +- For a single-row read use `findOne(['col' => $val])` or chain `->first()` on a builder. + Both call `LIMIT 1` internally and return a `Model` on success or `[]` on miss. +- A query returning multiple rows returns a [`Collection`](#collections). + +--- + +## The query builder + +Every static call on a model opens a builder; chain freely. Use `toSql()` to +inspect the SQL without executing. + +### Select + +```php +Contact::select('id', 'email')->get(); +Contact::select(['id', 'email'])->get(); +Contact::addSelect('phone')->get(); // add to existing select +Contact::selectRaw('COUNT(*) as total')->get(); // raw expression (NOT select()) +Contact::selectRaw('SUM(amount) as amt', [])->get(); +``` + +> Pass raw SQL expressions to `selectRaw()`, not `select()` — `select()` +> back-tick-quotes its arguments as column identifiers. + +### Where + +```php +Contact::where('email', 'a@x.com')->get(); // col = value +Contact::where('age', '>=', 18)->get(); // col op value +Contact::where('id', [1, 2, 3])->get(); // value array → IN (...) +Contact::where('deleted_at', null)->get(); // null → IS NULL + +Contact::where('a', 1)->where('b', 2)->get(); // AND +Contact::where('a', 1)->orWhere('b', 2)->get(); // OR + +// Grouped / nested conditions via a closure +Contact::where('active', 1) + ->where(function ($q) { + $q->where('city', 'NYC')->orWhere('city', 'LA'); + }) + ->get(); + +// Helpers +Contact::whereIn('id', [1, 2, 3])->get(); +Contact::whereNull('deleted_at')->get(); +Contact::whereNotNull('email')->get(); +Contact::whereBetween('age', 18, 65)->get(); +Contact::orWhereBetween('score', 0, 50)->get(); + +// LIKE — pass the operator explicitly; the value is bound safely via %s +Contact::where('email', 'LIKE', '%@x.com')->get(); + +// Raw +Contact::whereRaw('YEAR(created_at) = %d', [2026])->get(); +Contact::orWhereRaw('email LIKE %s', ['%@x.com'])->get(); +``` + +### Conditional clauses + +```php +Contact::query() + ->when($search, fn ($q, $search) => $q->whereRaw('name LIKE %s', ["%{$search}%"])) + ->when($onlyActive, fn ($q) => $q->where('is_active', 1), fn ($q) => $q->where('is_active', 0)) + ->get(); +``` + +### Ordering, grouping, having + +```php +Contact::orderBy('created_at')->desc()->get(); +Contact::orderBy('name')->asc()->get(); +Contact::orderByRaw('FIELD(status, %s, %s)', ['new', 'open'])->get(); + +Contact::groupBy('city')->having('city', '!=', '')->get(); +``` + +> A bare `desc()` or `asc()` call without a prior `orderBy()` defaults the order +> column to the model's primary key — e.g. `Contact::desc()->get()` emits +> `ORDER BY id DESC`. + +### Joins + +```php +Contact::query() + ->join('orders', 'orders.contact_id', '=', 'contacts.id') + ->leftJoin('notes', 'notes.contact_id', '=', 'contacts.id') + ->get(); +// also: rightJoin(), fullJoin(), crossJoin(), on(), orOn() +``` + +> **Joins are currently unreliable.** The join implementation has known bugs: +> the joined table name is double-prefixed (`wp_wp_*`), ON-clause columns are not +> adjusted when an alias is present, and `prepareOn` reuses the mutated column +> value for the second-column lookup. Use raw SQL (`whereRaw` / `raw()`) until +> these are fixed. See [Limitations](#limitations--known-issues). + +### Limit / offset / pagination + +```php +Contact::take(10)->skip(20)->get(); // LIMIT 10 OFFSET 20 + +$page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20); +// [ +// 'data' => Collection, +// 'pages' => 5, +// 'total' => 93, +// 'current_total' => 20, +// 'current_page' => 1, +// 'last_page' => 5, +// 'per_page' => 20, +// ] +``` + +### Aggregates + +```php +Contact::where('is_active', 1)->count(); // int — always (returns 0, not null, on no rows) +Contact::max('score'); // mixed|null — null when the result set is empty +Contact::min('score'); // mixed|null — null when the result set is empty +``` + +### Inspecting the SQL + +```php +Contact::where('is_active', 1)->toSql(); // build the SQL string, do not run +Contact::where('is_active', 1)->prepare(); // prepared SQL (placeholders bound) +``` + +--- + +## Collections + +Multi-row reads return `BitApps\WPDatabase\Collection`, which implements +`ArrayAccess`, `IteratorAggregate`, `Countable` and `JsonSerializable` — so +`foreach`, `$c[0]`, `count($c)` and `json_encode($c)` all work. + +```php +$contacts = Contact::where('is_active', 1)->get(); + +// Wrap an existing array in a Collection +$collection = Collection::make($items); + +$contacts->map(fn ($c) => $c->email); +$contacts->filter(fn ($c) => $c->score > 50); +$contacts->pluck('email'); // Collection of emails +$contacts->first(); // first item (optional callback + default) +$contacts->last(); +$contacts->reduce(fn ($carry, $c) => $carry + $c->score, 0); +$contacts->reverse(); +$contacts->all(); // underlying plain array +$contacts->toArray(); // array of model arrays +$contacts->count(); +``` + +--- + +## Updating records + +```php +// On an existing model — set attributes then save; returns Model or false +$contact = Contact::findOne(['id' => 1]); +$contact->email = 'new@x.com'; +$contact->save(); // returns the Model on success, false on failure + +// Conditional bulk update — executes immediately, returns int (rows affected) or false +Contact::where('is_active', 0)->update(['status' => 'archived']); +``` + +> `update()` on a builder executes immediately (it is not chainable). Set conditions +> **before** calling it. When updating a model instance through `save()`, the return +> value is the `Model` on success or `false` on failure. + +--- + +## Deleting records + +```php +Contact::where('id', 1)->delete(); // DELETE ... WHERE id = 1 + +$contact = Contact::findOne(['id' => 1]); +$contact->delete(); // deletes that row + +Contact::destroy([1, 2, 3]); // delete by primary keys +``` + +- A `delete()` that produces **no WHERE clause** throws + `RuntimeException('SQL query is empty')` — a guard against wiping the table. + Use a raw `TRUNCATE` if you really mean to empty it. +- With `$soft_deletes = true`, `delete()` sets `deleted_at` instead of removing + the row. **Reads are not filtered** — soft-deleted rows are returned by `all()` + and every query; there is no automatic scope. See [Limitations](#limitations--known-issues). + +--- + +## Upsert + +Insert rows, updating on duplicate key. Pass a single row or an array of rows; +the optional second argument lists the columns to update on conflict (defaults +to all provided columns). + +```php +Contact::query()->upsert( + ['email' => 'a@x.com', 'first_name' => 'Ada'], // unique key: email + ['first_name'] // columns to update on duplicate +); + +Contact::query()->upsert([ + ['email' => 'a@x.com', 'first_name' => 'Ada'], + ['email' => 'b@x.com', 'first_name' => 'Bob'], +]); +``` + +> **MySQL only** — `upsert()` emits `INSERT … ON DUPLICATE KEY UPDATE` and will not +> work on other databases. +> +> **`updated_at` quirk** — when `updated_at` is in the conflict-update set, the +> generated SQL maps `updated_at = VALUES(created_at)` rather than +> `VALUES(updated_at)`. This is a known implementation detail; the timestamp will +> reflect the value written to `created_at` at insert time, not a separate +> `updated_at` value. See [Limitations](#limitations--known-issues). + +--- + +## Attribute casting + +Declare `$casts` to convert attribute values during `fill()` — both when hydrating query results and during mass-assignment. Supported types: + +| Cast | Result | +|---|---| +| `int` | `(int)` value | +| `string` | `(string)` value | +| `bool` / `boolean` | `(bool)` value | +| `array` | `json_decode($value, true)` | +| `object` | `json_decode($value)` | +| `date` | `DateTime` from `Y-m-d H:i:s` | + +```php +protected $casts = [ + 'meta' => 'array', + 'is_active' => 'bool', + 'created_at' => 'date', +]; +``` + +`NULL` values are returned as `null` (not cast). Add casts at runtime with +`withCast()`, which returns the builder and is chainable: + +```php +Contact::query()->withCast(['is_active' => 'bool'])->get(); +``` + +--- + +## Relationships + +Define relationships as methods on the model. The relation method returns a +query for the related model. + +**Key convention:** `$foreignKey` is the column on the *related* model's table; +`$localKey` is the column on the *calling* model's table. Always supply both +explicitly when your column names differ from the ORM default +(`{callerTable}_id` / `id`). + +```php +class Contact extends Model +{ + public function deals() + { + // foreignKey='contact_id' lives on the deals table + // localKey='id' lives on the contacts table + // Generated predicate: WHERE deals.contact_id IN (SELECT id FROM contacts) + return $this->hasMany(Deal::class, 'contact_id', 'id'); + } + + public function profile() + { + // foreignKey='contact_id' on the profiles table + // localKey='id' on the contacts table + return $this->hasOne(Profile::class, 'contact_id', 'id'); + } +} + +class Deal extends Model +{ + public function contact() + { + // Deal.contact_id references Contact.id + // foreignKey='id' lives on the contacts (related) table + // localKey='contact_id' lives on the deals (this) table + // Generated predicate: WHERE contacts.id IN (SELECT contact_id FROM deals) + return $this->belongsTo(Contact::class, 'id', 'contact_id'); + } +} +``` + +> **`hasOne` and `belongsTo` are identical.** `hasOne()` is a direct alias of +> `belongsTo()` — both set the `oneToOne` relation type and return a single +> model. There is no separate reverse-direction implementation; the direction is +> determined entirely by the keys you provide and which model calls the method. + +Available: `hasOne()`, `hasMany()`, `belongsTo()`, `belongsToMany()` — each +takes `($model, $foreignKey = null, $localKey = null)`. + +> **`belongsToMany` is non-functional for pivot tables.** The method is +> declared but contains no pivot-table join logic. Calling it will not produce a +> many-to-many query through an intermediate table. See +> [Limitations](#limitations--known-issues). + +### Eager loading + +```php +Contact::with('deals')->get(); // load deals for each contact +Contact::with(['deals', 'profile'])->get(); // multiple relations + +// Constrain the eager-loaded relation +Contact::with('deals', function ($q) { + $q->where('status', 'open'); +})->get(); + +// Alias the loaded relation key on the result model +Contact::with('deals as open_deals')->get(); + +// Access loaded relations +foreach (Contact::with('deals')->get() as $contact) { + foreach ($contact->deals as $deal) { /* ... */ } +} +``` + +--- + +## Relation aggregates & existence + +```php +Contact::withCount('deals')->get(); // adds `deals_count` +Contact::withSum('deals.amount')->get(); // adds `deals_sum` +Contact::withAvg('deals.amount')->get(); // adds `deals_avg` +Contact::withMin('deals.amount')->get(); // adds `deals_min` +Contact::withMax('deals.amount')->get(); // adds `deals_max` +Contact::withExists('deals')->get(); // adds bool-cast `deals_exists` + +// Filter by relation existence +Contact::whereHas('deals')->get(); +Contact::whereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); + +// Filter by existence AND eager-load the same relation +Contact::withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get(); +``` + +Aggregate columns are aliased `_` by default +(e.g. `deals_count`, `deals_sum`). Pass `'relation as alias'` to use a custom +column name: + +```php +Contact::withCount('deals as total_deals')->get(); // adds `total_deals` +Contact::withSum('deals.amount as revenue')->get(); // adds `revenue` +``` + +For `withMin`, `withMax`, `withAvg`, and `withSum` the column to aggregate must +be specified as `'relation.column'`; passing just the relation name defaults to +`*`, which is meaningful only for `withCount` and `withExists`. + +--- + +## Model events + +Models fire lifecycle events. Register handlers in a static `boot()` using the +event registrars. A pre-event (`saving`, `updating`, `deleting`) that returns +`false` aborts the operation. `saving`/`saved` fire on both insert and update. + +```php +class Contact extends Model +{ + protected static function boot() + { + static::saving(function ($model) { + if (!$model->email) { + return false; // abort the save + } + }); + + static::saved(fn ($model) => Log::info("saved {$model->id}")); + + // A handler can also be a class with a handle() method + static::deleted(SyncDeletion::class); + } +} +``` + +**Subscribable events** (registered via Closure using the methods below): +`retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, `deleted`. + +**Boot hooks** — override these protected static methods instead of registering +a Closure: `booting()` (runs before `boot()`), `booted()` (runs after `boot()`). +The `boot()` method itself is the standard entry point for registering event +handlers. + +> `creating`/`created` events fire internally but have no public registrar — +> they cannot be subscribed via `static::creating(...)` / `static::created(...)`. +> Use `saving`/`saved` instead, which fire on both insert and update. + +> The `HasEvents` trait reserves the method names `boot`, `booting`, `booted`, +> `fireEvent`, `fireCustomEvent`, `registerEvent` and the properties `$events`, +> `$registeredEvents`, `$booted`. + +--- + +## Transactions + +```php +use BitApps\WPDatabase\Connection; + +Connection::startTransaction(); +try { + Contact::insert([...]); + Deal::insert([...]); + Connection::commit(); +} catch (\Throwable $e) { + Connection::rollback(); + throw $e; +} +``` + +> The equivalent methods on `QueryBuilder` are deprecated — use `Connection`. + +--- + +## Raw queries + +```php +// SELECT → returns result rows +Contact::query()->raw('SELECT * FROM wp_contacts WHERE id = %d', [1]); + +// Non-SELECT → returns the wpdb query result +Contact::query()->raw('UPDATE wp_contacts SET is_active = 1 WHERE id = %d', [1]); +``` + +Placeholders use `$wpdb` conventions (`%d`, `%s`, `%f`) with the bindings array. + +--- + +## Schema builder + +Use `Schema` (a facade over `Blueprint`) to create, alter and drop tables. By +default the table name is used as-is — call `Schema::withPrefix($prefix)` to +apply a prefix. + +```php +use BitApps\WPDatabase\Schema; + +Schema::create('contacts', function ($table) { + $table->id(); + $table->string('email'); + $table->string('first_name')->nullable(); + $table->bool('is_active')->defaultValue(1); + $table->timestamps(); +}); +``` + +For the full reference — column types, modifiers, foreign keys, `Schema::edit`, +`Schema::drop`, `Schema::rename`, and prefix scoping — see +[Schema builder reference](schema.md). + +--- + +## Static vs instance calls (IDE notes) + +All builder/relation methods can be called three ways: + +```php +Contact::where('id', 1)->get(); // static (magic via __callStatic) +Contact::query()->where('id', 1)->get(); // real static entry — best IDE support +(new Contact())->where('id', 1)->get(); // instance +``` + +- `Model::query()` is a **real** static method returning a `QueryBuilder`; start + chains with it for autocomplete **and** Ctrl/Cmd+Click navigation that work in + every editor (PhpStorm and VS Code/Intelephense). +- Terse static calls (`Contact::where()`) are forwarded through + `__call`/`__callStatic`. `@method static` docblocks give autocomplete; the + `@mixin QueryBuilder` annotation gives PhpStorm navigation to the real method. + +--- + +## Breaking changes + +If you are upgrading, read [breaking-changes.md](breaking-changes.md) — it +covers the `Collection` return type, `update()`/`save()` behavior, `select()` +quoting, the `delete()`-without-WHERE guard, null casting, and the relation +method relocation. + +--- + +## Limitations & known issues + +- **Joins are broadly unreliable.** `join()` in `QueryBuilder` always prepends + `Connection::wpPrefix()` (plus the model prefix) onto the supplied table name, + causing a double prefix (`wp_wp_*`) unconditionally — not only when a plugin + prefix is set. Separately, `prepareOn()` reuses the mutated column value for the + second-column lookup, and ON-clause columns are not adjusted when an alias is + present. Workaround: write raw JOIN clauses via `raw()` and apply your own + prefix via `Connection::getPrefix()`. + +- **`belongsToMany` is declared but non-functional.** The method exists but + contains no pivot-table join logic. Calling it does not produce a + many-to-many query through an intermediate table. Workaround: model the pivot + as an explicit intermediate model with `hasMany` on each side. + +- **`belongsTo` and `hasOne` are the same alias; key naming is reversed from + Laravel.** Both set the same `oneToOne` relation. The `$foreignKey` argument + is the column on the **related** table and `$localKey` is the column on the + **calling** model's table — the opposite of Laravel's convention. Ensure you + supply both arguments explicitly to avoid confusion. + +- **Soft delete is write-only.** `softDeletes()` adds a `deleted_at` column and + `delete()` sets it, but there is no global scope to filter soft-deleted rows + from queries. Every `get()` / `find()` returns soft-deleted rows alongside + live ones. Workaround: add `->whereNull('deleted_at')` to every read query. + +- **`upsert` is MySQL-only.** It generates `INSERT … ON DUPLICATE KEY UPDATE`, + which is not portable to other databases. Additionally, the generated SQL sets + `updated_at = VALUES(created_at)` instead of `VALUES(updated_at)`, so the + timestamp may be wrong on update. Workaround: use separate `insert` + `update` + calls where portability or correct timestamps are required. + +- **`creating`/`created` events cannot be subscribed.** These event names fire + internally during `insert()` but `HasEvents` provides no registrar methods for + them. Calling `static::creating(fn ...)` or `static::created(fn ...)` in + `boot()` will throw a fatal error. Use `saving`/`saved` instead — they fire + on both insert and update. + +- **Bulk `insert()` may return a bare array, not a `Collection`.** When the + post-insert re-query that hydrates the inserted rows fails, the fallback path + returns a plain PHP array of IDs rather than a `Collection`. Code that calls + Collection methods on the return value of `insert()` will break in that case. + Workaround: check `is_array()` on the result or use `Collection::make()` to + wrap it defensively. + +- **Schema builder does not auto-apply the table prefix.** `Schema::$prefix` + defaults to `null`, not `''`; table names are used as-is unless you call + `Schema::withPrefix()` explicitly. See [Schema builder reference](schema.md). + +- **`change()` column modifier is broken: emits malformed `ADD COLUMN CHANGE COLUMN …` + SQL.** The `addColumnQuery()` method prepends `ADD COLUMN` in edit mode and also + prepends `CHANGE COLUMN` when the `change` flag is set, producing invalid SQL. + Avoid `change()` in production until this is fixed. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3f12279 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Blueprint.php b/src/Blueprint.php index 6ac9bf8..1be96bf 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -405,13 +405,6 @@ public function zeroFill() return $this; } - public function binary() - { - $this->columns[$this->columnIndex]['props'][] = 'BINARY'; - - return $this; - } - public function primary() { $this->primaryKey[] = $this->columns[$this->columnIndex]['name']; @@ -434,9 +427,13 @@ public function index($type = null) return $this; } - public function unique() + public function unique($column = null) { - $this->uniqueIndex[] = $this->columns[$this->columnIndex]['name']; + if (\is_null($column)) { + $this->uniqueIndex[] = $this->columns[$this->columnIndex]['name']; + } else { + $this->uniqueIndex[] = $column; + } return $this; } @@ -753,10 +750,14 @@ private function addUniqueIndexQuery() $query = ''; foreach ($this->uniqueIndex as $key => $uniqueColumn) { - $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; + if (\is_array($uniqueColumn)) { + $query .= "\nUNIQUE INDEX " . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),'; + } else { + $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),"; + } } - return $query; + return rtrim($query, ','); } private function addForeignKeyQuery() @@ -767,14 +768,6 @@ private function addForeignKeyQuery() $query = ''; foreach ($this->foreignKeys as $fkId => $foreignKey) { - /* $query .= "\nCONSTRAINT f_c_{$this->table}_{$fkId} " - ." FOREIGN KEY f_key_{$this->table}_{$fkId} ({$foreignKey['column']})" - ." REFERENCES {$foreignKey['ref']} ({$foreignKey['ref_col']})" - . (isset($foreignKey['onUpdate']) ? " ON DELETE {$foreignKey['onUpdate']}" : null) - . (isset($foreignKey['onUpdate']) ? " ON UPDATE {$foreignKey['onUpdate']}" : null) - . (isset($foreignKey['both']) ? " ON DELETE {$foreignKey['both']} ON UPDATE {$foreignKey['both']}" : null) - . ","; */ - $query .= " FOREIGN KEY ({$foreignKey['column']}) REFERENCES {$foreignKey['ref']} "; $query .= "({$foreignKey['ref_col']})"; $query .= (isset($foreignKey['onDelete']) ? " ON DELETE {$foreignKey['onDelete']}" : null); diff --git a/src/Collection.php b/src/Collection.php new file mode 100644 index 0000000..913bac1 --- /dev/null +++ b/src/Collection.php @@ -0,0 +1,133 @@ +items = $items; + } + + public static function make($items = []) + { + return new static(\is_array($items) ? $items : [$items]); + } + + public function all() + { + return $this->items; + } + + public function map(callable $callback) + { + return new static(array_map($callback, $this->items)); + } + + public function filter(callable $callback) + { + return new static(array_filter($this->items, $callback, ARRAY_FILTER_USE_BOTH)); + } + + public function reduce(callable $callback, $initial = null) + { + return array_reduce($this->items, $callback, $initial); + } + + public function pluck($key) + { + return new static(array_map(function ($item) use ($key) { + if (\is_array($item)) { + return $item[$key] ?? null; + } + + if ($item instanceof Model) { + return $item->getAttribute($key); + } + + return \is_object($item) ? $item->{$key} ?? null : null; + }, $this->items)); + } + + public function first(?callable $callback = null, $default = null) + { + foreach ($this->items as $item) { + if ($callback === null || $callback($item)) { + return $item; + } + } + + return $default; + } + + public function last(?callable $callback = null, $default = null) + { + return $this->reverse()->first($callback, $default); + } + + public function reverse() + { + return new static(array_reverse($this->items)); + } + + public function toArray() + { + return array_map(function ($value) { + return \is_object($value) && method_exists($value, 'toArray') ? $value->toArray() : $value; + }, $this->items); + } + + public function jsonSerialize():array + { + return $this->toArray(); + } + + #[ReturnTypeWillChange] + public function count() + { + return \count($this->items); + } + + #[ReturnTypeWillChange] + public function getIterator() + { + return new ArrayIterator($this->items); + } + + #[ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->items[$offset]); + } + + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->items[$offset] ?? null; + } + + #[ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + if ($offset === null) { + $this->items[] = $value; + } else { + $this->items[$offset] = $value; + } + } + + #[ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->items[$offset]); + } +} diff --git a/src/Concerns/HasEvents.php b/src/Concerns/HasEvents.php new file mode 100644 index 0000000..80cdf52 --- /dev/null +++ b/src/Concerns/HasEvents.php @@ -0,0 +1,82 @@ +events[$event])) { + return $this->fireCustomEvent($this->events[$event], $model); + } + } + + public function fireCustomEvent($callback, $model) + { + if ($callback instanceof Closure) { + return $callback($model); + } elseif (class_exists($callback) && method_exists($callback, 'handle')) { + return (new $callback($model))->handle(); + } + } + + protected static function registerEvent($event, Closure $callback) + { + static::$registeredEvents[static::class . $event] = $callback; + } + + protected static function retrieved(Closure $callback) + { + static::registerEvent('retrieved', $callback); + } + + protected static function saving(Closure $callback) + { + static::registerEvent('saving', $callback); + } + + protected static function saved(Closure $callback) + { + static::registerEvent('saved', $callback); + } + + protected static function updating(Closure $callback) + { + static::registerEvent('updating', $callback); + } + + protected static function updated(Closure $callback) + { + static::registerEvent('updated', $callback); + } + + protected static function deleting(Closure $callback) + { + static::registerEvent('deleting', $callback); + } + + protected static function deleted(Closure $callback) + { + static::registerEvent('deleted', $callback); + } +} diff --git a/src/Concerns/QueriesRelationships.php b/src/Concerns/QueriesRelationships.php new file mode 100644 index 0000000..5e6366a --- /dev/null +++ b/src/Concerns/QueriesRelationships.php @@ -0,0 +1,296 @@ +_model->addRelation($this->resolveRelations($relation, $callback)); + + return $this; + } + + /** + * Adds a relation count sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withCount($relation) + { + return $this->withAggregate($relation, '*', 'count'); + } + + /** + * Adds a relation min sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withMin($relation) + { + return $this->withAggregate($relation, '*', 'min'); + } + + /** + * Adds a relation max sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withMax($relation) + { + return $this->withAggregate($relation, '*', 'max'); + } + + /** + * Adds a relation avg sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withAvg($relation) + { + return $this->withAggregate($relation, '*', 'avg'); + } + + /** + * Adds a relation sum sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withSum($relation) + { + return $this->withAggregate($relation, '*', 'sum'); + } + + /** + * Adds a relation exists sub query to this query. + * + * @param string|array $relation + * + * @return $this + */ + public function withExists($relation) + { + return $this->withAggregate($relation, '*', 'exists'); + } + + /** + * Adds an aggregate sub query for the given relation(s) to this query. + * + * The aggregated column may be expressed inline as `relation.column` + * (e.g. `withSum('posts.amount')`); otherwise the `$column` fallback is used. + * + * @param string|array $relation + * @param mixed $column fallback column when a token carries none + * @param string $function + * + * @return $this + */ + public function withAggregate($relation, $column, $function) + { + if (empty($relation)) { + return $this; + } + + if (empty($this->select)) { + $this->select = ["`{$this->_model->getTable()}`.*"]; + } + + [$relations, $columns] = $this->normalizeAggregateRelations((array) $relation, $column); + + foreach ($this->_model->prepareRelation($relations) as $relationName => $relationalQuery) { + [$name, $alias] = $this->_model->prepareRelationName($relationName); + if (\is_null($alias)) { + $alias = strtolower($name . '_' . $function); + } + + $aggregateColumn = $columns[$relationName]; + + $this->correlate($relationalQuery); + + if ($function === 'exists') { + $query = $relationalQuery->select($aggregateColumn)->prepare(); + $this->selectRaw("exists({$query}) as `{$alias}`")->withCast([$alias => 'bool']); + } else { + if ($aggregateColumn !== '*') { + $aggregateColumn = $relationalQuery->prepareColumnName($aggregateColumn); + } + $query = $relationalQuery->selectRaw(sprintf('%s(%s)', $function, $aggregateColumn))->prepare(); + $this->selectRaw("({$query}) as `{$alias}`"); + } + } + + return $this; + } + + /** + * Filters the query to models having the given relation. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return $this + */ + public function whereHas($relation, $callback = null) + { + foreach ($this->resolveRelations($relation, $callback) as $relationalQuery) { + $this->correlate($relationalQuery); + $this->whereRaw('exists(' . $relationalQuery->select('*')->prepare() . ')'); + } + + return $this; + } + + /** + * Filters by relation existence and eager loads the same relation. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return $this + */ + public function withWhereHas($relation, $callback = null) + { + $relations = $this->resolveRelations($relation, $callback); + + foreach ($relations as $relationalQuery) { + $existsQuery = $this->correlate($relationalQuery->newQuery()); + $this->whereRaw('exists(' . $existsQuery->select('*')->prepare() . ')'); + } + + $this->_model->addRelation($relations); + + return $this; + } + + /** + * Splits relation tokens into the relation expression (kept for resolution, + * including any `as `) and the column to aggregate, preserving the + * int-keyed-string vs string-keyed-closure shapes that prepareRelation expects. + * + * @param array $relation + * @param mixed $defaultColumn + * + * @return array{0: array, 1: array} + */ + private function normalizeAggregateRelations(array $relation, $defaultColumn) + { + $relations = []; + $columns = []; + + foreach ($relation as $key => $value) { + $positional = \is_int($key) && \is_string($value); + [$relationExpr, $column] = $this->splitAggregateColumn($positional ? $value : (string) $key, $defaultColumn); + + $columns[$relationExpr] = $column; + if ($positional) { + $relations[] = $relationExpr; + } else { + $relations[$relationExpr] = $value; + } + } + + return [$relations, $columns]; + } + + /** + * Parses a `relation[.column][ as alias]` token. + * + * @param string $expr + * @param mixed $defaultColumn used when the token carries no `.column` + * + * @return array{0: string, 1: mixed} [relationExpr, column] + */ + private function splitAggregateColumn($expr, $defaultColumn) + { + $alias = ''; + $body = $expr; + if (preg_match('/^(.*?)\s+as\s+(.*)$/i', $expr, $matches)) { + $body = $matches[1]; + $alias = trim($matches[2]); + } + + if (strpos($body, '.') !== false) { + [$relation, $column] = explode('.', $body, 2); + $column = trim($column); + } else { + $relation = $body; + $column = $defaultColumn; + } + + $relation = trim($relation); + $relationExpr = $alias !== '' ? $relation . ' as ' . $alias : $relation; + + return [$relationExpr, $column]; + } + + /** + * Resolves relation names (and an optional constraint) into prepared + * relational queries. + * + * @param string|array $relation + * @param null|Closure $callback + * + * @return array + */ + private function resolveRelations($relation, $callback) + { + if (\is_string($relation)) { + return $this->_model->prepareRelation([$relation => $callback]); + } + + return $this->_model->prepareRelation((array) $relation); + } + + /** + * Adds the parent/child key correlation predicate to a relational query. + * + * @return QueryBuilder + */ + private function correlate(QueryBuilder $relationalQuery) + { + $relationKey = $relationalQuery->getModel()->getActiveRelationKey(); + + $relationalQuery->whereRaw( + $this->prepareColumnName($relationKey['localKey']) + . '=' . $relationalQuery->prepareColumnName($relationKey['foreignKey']) + ); + + return $relationalQuery; + } +} diff --git a/src/Relations.php b/src/Concerns/Relations.php similarity index 69% rename from src/Relations.php rename to src/Concerns/Relations.php index 0a2d26c..a41f1d0 100644 --- a/src/Relations.php +++ b/src/Concerns/Relations.php @@ -4,7 +4,11 @@ * Class For Database Relations. */ -namespace BitApps\WPDatabase; +namespace BitApps\WPDatabase\Concerns; + +use BitApps\WPDatabase\Model; +use BitApps\WPDatabase\QueryBuilder; +use Closure; if (!\defined('ABSPATH')) { exit; @@ -108,13 +112,9 @@ public function getRelations() return $this->_relations; } - public function addRelation($relation) + public function addRelation(array $relation) { - if (method_exists($this, $relation)) { - $this->_relations[$relation] = $this->{$relation}(); - - return $this->_relations[$relation]; - } + $this->_relations = array_merge($this->_relations, $relation); } public function getRelationalKeys() @@ -122,6 +122,58 @@ public function getRelationalKeys() return $this->_relationKeys; } + /** + * Returns the {localKey, foreignKey} pair for this model's active relation. + * + * @return array + */ + public function getActiveRelationKey() + { + return $this->getRelationalKeys()[$this->getRelateAs()]; + } + + /** + * Prepares relations to query + * + * @param array $relations + * + * @return array + */ + public function prepareRelation(array $relations): array + { + $preparedRelation = []; + foreach ($relations as $key => $value) { + if (\is_int($key) && \is_string($value) && ($method = explode(' ', $value)[0]) && method_exists($this, $method)) { + $preparedRelation[$value] = $this->{$method}(); + unset($relations[$key]); + } + } + + foreach ($relations as $key => $value) { + if (\is_string($key) && ($method = explode(' ', $key)[0]) && method_exists($this, $method)) { + $preparedRelation[$key] = $this->{$method}(); + if ($value instanceof Closure) { + $value($preparedRelation[$key]); + } + } + } + + return $preparedRelation; + } + + public function prepareRelationName(string $relationName): array + { + $name = $relationName; + $alias = null; + $nameChunk = explode(' ', $relationName); + + if (\count($nameChunk) === 3 && strtolower($nameChunk[1]) === 'as') { + $alias = $nameChunk[2]; + } + + return [$name, $alias]; + } + private function getRelationKeys($foreignKey, $localKey) { if (!$foreignKey) { @@ -142,8 +194,7 @@ private function retrieveRelateData(QueryBuilder $query) if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { $parentQuery = clone $query; - $relationKey = $relationQuery->getModel() - ->getRelationalKeys()[$relationQuery->getModel()->getRelateAs()]; + $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $relationQuery->whereRaw( $relationKey['foreignKey'] @@ -169,8 +220,7 @@ private function setRelatedData(Model $model) $relations = $this->getRelations(); if (\count($relations) > 0) { foreach ($relations as $relationName => $relationQuery) { - $relationKey = $relationQuery->getModel() - ->getRelationalKeys()[$relationQuery->getModel()->getRelateAs()]; + $relationKey = $relationQuery->getModel()->getActiveRelationKey(); $data = isset( $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] @@ -180,7 +230,9 @@ private function setRelatedData(Model $model) $data = $data[0]; } - $model->setAttribute($relationName, $data); + [$name, $alias] = $this->prepareRelationName($relationName); + + $model->setAttribute(\is_null($alias) ? $name : $alias, $data); } } } diff --git a/src/Connection.php b/src/Connection.php index 14d5cae..b248cae 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -132,6 +132,36 @@ public static function prop($var) } } + /** + * Starts transaction + * + * @return bool + */ + public static function startTransaction() + { + return self::query('START TRANSACTION'); + } + + /** + * Commits current transaction + * + * @return bool + */ + public static function commit() + { + return self::query('COMMIT'); + } + + /** + * Rollback previously execute query + * + * @return void + */ + public static function rollback() + { + return self::query('ROLLBACK'); + } + private static function _forwadCall($instance, $method, $args) { if (self::$_isLogEnabled && $method === 'query') { diff --git a/src/Model.php b/src/Model.php index 97aec23..012e1da 100644 --- a/src/Model.php +++ b/src/Model.php @@ -8,8 +8,10 @@ use ArrayAccess; -use DateTime; +use BitApps\WPDatabase\Concerns\HasEvents; +use BitApps\WPDatabase\Concerns\Relations; +use DateTime; use JsonSerializable; use ReturnTypeWillChange; use RuntimeException; @@ -17,63 +19,80 @@ /** * Abstract class for model. * - * @method static QueryBuilder from($_from) - * @method static Model getModel() - * @method static QueryBuilder queryFor($_for) - * @method static QueryBuilder newQuery() - * @method static void addBindings($bindings) - * @method static array getBindings() - * @method static Model|array|false all($columns = ['*']) - * @method static QueryBuilder select($columns = ['*']) - * @method static Model|array|false get($columns = ['*']) - * @method static Model|bool first() - * @method static Model|array|false find($attributes) - * @method static Model|bool findOne($attributes) - * @method static string|string[]|null getConditions(QueryBuilder $query, $type = 'where') - * @method static string prepareOperatorForWhere($clause) - * @method static QueryBuilder where(...$params) - * @method static QueryBuilder whereIn(...$params) - * @method static QueryBuilder orWhere(...$params) - * @method static QueryBuilder whereRaw($sql, $bindings = []) - * @method static QueryBuilder whereNull($column) - * @method static QueryBuilder whereNotNull($column) - * @method static QueryBuilder whereBetween($column, $start, $end) - * @method static QueryBuilder orWhereBetween($column, $start, $end) - * @method static QueryBuilder paginate($perPage = 10, $pageNo = 0) - * @method static QueryBuilder groupBy($columns) - * @method static QueryBuilder having(...$params) - * @method static QueryBuilder orHaving(...$params) - * @method static QueryBuilder join($table, $first_column, $operator = null, $second_column = null, $type = 'INNER') - * @method static QueryBuilder leftJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder rightJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder fullJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder crossJoin($table, $first_column, $operator = null, $second_column = null) - * @method static QueryBuilder on($first_column, $operator = null, $second_column = null, $bool = 'AND') - * @method static QueryBuilder orOn($first_column, $operator = null, $second_column = null) - * @method static string getOrderBy() - * @method static QueryBuilder orderBy($column) - * @method static QueryBuilder asc() - * @method static QueryBuilder desc() - * @method static object|null raw($sql, $bindings = []) - * @method static QueryBuilder take($count) - * @method static QueryBuilder skip($count) - * @method static QueryBuilder getOffset() - * @method static Model|array|false insert($attributes = []) - * @method static QueryBuilder update($attributes = []) - * @method static string|bool destroy($ids = []) - * @method static bool save() - * @method static QueryBuilder withCount() - * @method static null|int count() - * @method static bool|string delete() - * @method static QueryBuilder with($relation) - * @method static void startTransaction() - * @method static void commit() - * @method static void rollback() - * @method static string prepare($sql = null) + * Query-builder calls (where/select/get/with/withCount/...) are forwarded to + * {@see QueryBuilder} through __call/__callStatic. + * + * IDE support: the @method tags below give autocomplete in every editor + * (PhpStorm + VS Code/Intelephense); the @mixin lets PhpStorm Ctrl+Click jump + * to the real definitions. For autocomplete AND Ctrl+Click that work + * everywhere, start chains with the real static {@see Model::query()}. + * + * @method static QueryBuilder from($_from) + * @method static Model getModel() + * @method static QueryBuilder queryFor($_for) + * @method static QueryBuilder newQuery() + * @method static void addBindings($bindings) + * @method static array getBindings() + * @method static Model|array|false all($columns = ['*']) + * @method static QueryBuilder select($columns = ['*']) + * @method static QueryBuilder addSelect($columns) + * @method static QueryBuilder selectRaw($column, array $bindings = []) + * @method static Model|array|false get($columns = ['*']) + * @method static Model|bool first() + * @method static Model|array|false find($attributes) + * @method static Model|bool findOne($attributes) + * @method static QueryBuilder where(...$params) + * @method static QueryBuilder whereIn(...$params) + * @method static QueryBuilder orWhere(...$params) + * @method static QueryBuilder whereRaw($sql, $bindings = []) + * @method static QueryBuilder orWhereRaw($sql, $bindings = []) + * @method static QueryBuilder whereNull($column) + * @method static QueryBuilder whereNotNull($column) + * @method static QueryBuilder whereBetween($column, $start, $end) + * @method static QueryBuilder orWhereBetween($column, $start, $end) + * @method static QueryBuilder when($value = null, ?callable $callback = null, ?callable $default = null) + * @method static array paginate($pageNo = 0, $perPage = 10) + * @method static QueryBuilder groupBy($columns) + * @method static QueryBuilder having(...$params) + * @method static QueryBuilder orHaving(...$params) + * @method static QueryBuilder join($table, $first_column, $operator = null, $second_column = null, $type = 'INNER') + * @method static QueryBuilder leftJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder rightJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder fullJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder crossJoin($table, $first_column, $operator = null, $second_column = null) + * @method static QueryBuilder orderBy($column) + * @method static QueryBuilder orderByRaw($query, $bindings = []) + * @method static QueryBuilder asc() + * @method static QueryBuilder desc() + * @method static object|null raw($sql, $bindings = []) + * @method static QueryBuilder take($count) + * @method static QueryBuilder skip($count) + * @method static Model|array|false insert($attributes = []) + * @method static Model|bool update($attributes = []) + * @method static string|bool destroy($ids = []) + * @method static Model|bool save() + * @method static Model|bool upsert(array $values, ?array $update = null) + * @method static QueryBuilder with(string|array $relation, ?Closure $callback = null) + * @method static QueryBuilder withCount(string|array $relation) + * @method static QueryBuilder withMin(string|array $relation) + * @method static QueryBuilder withMax(string|array $relation) + * @method static QueryBuilder withAvg(string|array $relation) + * @method static QueryBuilder withSum(string|array $relation) + * @method static QueryBuilder withExists(string|array $relation) + * @method static QueryBuilder whereHas(string|array $relation, ?Closure $callback = null) + * @method static QueryBuilder withWhereHas(string|array $relation, ?Closure $callback = null) + * @method static int count() + * @method static mixed max($column) + * @method static mixed min($column) + * @method static bool|string delete() + * @method static string toSql() + * @method static string prepare($sql = null) + * + * @mixin \BitApps\WPDatabase\QueryBuilder */ abstract class Model implements ArrayAccess, JsonSerializable { - use Relations; + use Relations, HasEvents; public $timestamps = true; @@ -91,6 +110,8 @@ abstract class Model implements ArrayAccess, JsonSerializable protected $dirty = []; + protected static $booted = []; + private static $_instance; private $_tableWithoutPrefix; @@ -142,6 +163,8 @@ public function __construct($attributes = []) $this->primaryKey = 'id'; } + $this->bootIfNotBooted(); + if (\is_array($attributes)) { $this->fill($attributes); } else { @@ -359,26 +382,34 @@ public function getInstanceFromBuilder($result, $setAttribute = false) return false; } + if (\count($result) === 0) { + return []; + } + $this->retrieveRelateData($this->getQueryBuilder()); if (\count($result) == 1 && $setAttribute) { $this->fill((array) $result[0], true); $this->setExists(true); $this->setRelatedData($this); $this->setExists(true); + $this->fireEvent('retrieved'); return $this; } - return array_map( - function ($row) { - $model = clone $this; - $model->fill((array) $row, true); - $this->setRelatedData($model); - $model->setExists(true); - - return $model; - }, - $result + return new Collection( + array_map( + function ($row) { + $model = clone $this; + $model->fill((array) $row, true); + $this->setRelatedData($model); + $model->setExists(true); + $this->fireEvent('retrieved', $model); + + return $model; + }, + $result + ) ); } @@ -392,6 +423,16 @@ public function newQuery() return new QueryBuilder($this); } + /** + * Canonical, IDE-navigable entry point to the query builder. + * + * @return QueryBuilder + */ + public static function query() + { + return (new static())->newQuery(); + } + #[ReturnTypeWillChange] public function offsetExists($offset) { @@ -418,6 +459,11 @@ public function offsetUnset($offset) #[ReturnTypeWillChange] public function jsonSerialize() + { + return $this->toArray(); + } + + public function toArray() { if (!$this->exists()) { return []; @@ -426,6 +472,49 @@ public function jsonSerialize() return $this->attributes; } + public function withCast(array $casts) + { + if (!isset($this->casts)) { + $this->casts = []; + } + + $this->casts = array_merge($this->casts, $casts); + + return $this; + } + + /** + * Check if the model needs to be booted and if so, do it. + * + * @return void + */ + protected function bootIfNotBooted() + { + if (! isset(static::$booted[static::class])) { + static::$booted[static::class] = true; + + $this->fireEvent('booting'); + + static::booting(); + static::boot(); + static::booted(); + + $this->fireEvent('booted'); + } + } + + protected static function booting() + { + } + + protected static function boot() + { + } + + protected static function booted() + { + } + private static function getInstance() { if (\is_null(self::$_instance)) { @@ -480,6 +569,16 @@ private function castToString($value) return (string) $value; } + private function castToBool($value) + { + return \boolval($value); + } + + private function castToBoolean($value) + { + return $this->castToBool($value); + } + private function castToDate($value) { return DateTime::createFromFormat('Y-m-d H:i:s', $value); diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php new file mode 100644 index 0000000..2fbbe0a --- /dev/null +++ b/src/Query/Grammar.php @@ -0,0 +1,342 @@ +resetBindings(); + + $sql = 'SELECT ' . implode(',', $query->select); + $sql .= $this->prepareRawSelect($query); + $sql .= ' FROM ' . $query->getTable(); + $sql .= $this->getFrom($query); + $sql .= $this->getJoin($query); + $sql .= $this->getWhere($query); + $sql .= $this->getGroupBy($query); + $sql .= $this->getHaving($query); + $sql .= $this->getOrderBy($query); + $sql .= $this->getLimit($query); + $sql .= $this->getOffset($query); + + return trim($sql); + } + + /** + * Returns the processed WHERE clause (empty string when there is none). + * + * @return string + */ + public function getWhere(QueryBuilder $query) + { + $sql = $this->getConditions($query); + if (empty($sql)) { + return ''; + } + + return " WHERE {$sql}"; + } + + /** + * Returns the SQL for the JOIN clauses. + * + * @return string + */ + public function getJoin(QueryBuilder $query) + { + $sql = ''; + $joins = $query->getJoins(); + if (empty($joins)) { + return $sql; + } + + foreach ($joins as $join) { + $sql .= ' ' . $join['type'] . ' JOIN ' . $join['table'] + . ' ON ' . $this->processConditions($query, $join['on']); + } + + return $sql; + } + + /** + * Processes a list of conditions (where/having/join-on) into SQL. + * + * @param array $conditions + * @param null|string $type + * + * @return string + */ + private function processConditions(QueryBuilder $query, $conditions, $type = null) + { + $sql = ''; + if (\is_array($conditions) && \count($conditions) > 0) { + foreach ($conditions as $clause) { + if (isset($clause['bool'])) { + $sql .= ' ' . $clause['bool']; + } else { + $sql .= ' AND'; + } + + if (isset($clause['raw'])) { + $sql .= ' ' . $clause['raw']; + $query->addBindings($clause['bindings']); + + continue; + } + + if (isset($clause['query']) && !\is_null($type)) { + $clause['query']->resetBindings(); + $sql .= ' (' . $this->getConditions($clause['query'], $type) . ')'; + $query->addBindings($clause['query']->getBindings()); + + continue; + } + + $sql .= $this->prepareColumnForWhere($query, $clause); + $sql .= $this->prepareOperatorForWhere($clause); + $sql .= $this->prepareValueForWhere($query, $clause); + } + + $sql = $this->removeLeadingBool($sql); + } + + return $sql; + } + + /** + * Returns the processed conditions for the given clause list. + * + * @param string $type + * + * @return string + */ + private function getConditions(QueryBuilder $query, $type = 'where') + { + return $this->processConditions($query, $query->getClauseList($type), $type); + } + + /** + * Returns the SQL for the GROUP BY clause. + * + * @return string + */ + private function getGroupBy(QueryBuilder $query) + { + $groupBy = $query->getGroupByList(); + if (empty($groupBy)) { + return ''; + } + + return ' GROUP BY ' . implode(',', $groupBy); + } + + /** + * Returns the SQL for the HAVING clause. + * + * @return string + */ + private function getHaving(QueryBuilder $query) + { + $sql = $this->getConditions($query, 'having'); + if (empty($sql)) { + return ''; + } + + return " HAVING {$sql}"; + } + + /** + * Returns the SQL for the ORDER BY clause. + * + * @return string + */ + private function getOrderBy(QueryBuilder $query) + { + $sql = ''; + $orderBy = $query->getOrderByList(); + if (empty($orderBy)) { + return $sql; + } + + foreach ($orderBy as $order) { + if (isset($order['raw'])) { + $sql .= $order['raw'] . ', '; + $query->addBindings($order['bindings']); + } elseif (isset($order['column'])) { + $sql .= $order['column'] . ' ' . $order['direction'] . ', '; + } + } + + return ' ORDER BY ' . rtrim($sql, ', '); + } + + /** + * Returns the table alias fragment for the FROM clause. + * + * @return string|null + */ + private function getFrom(QueryBuilder $query) + { + $alias = $query->getFromAlias(); + + return isset($alias) ? " {$alias}" : null; + } + + /** + * Returns the LIMIT fragment for the query. + * + * @return string + */ + private function getLimit(QueryBuilder $query) + { + $limit = $query->getLimitValue(); + + return isset($limit) ? " LIMIT {$limit}" : ''; + } + + /** + * Returns the OFFSET fragment for the query. + * + * @return string + */ + private function getOffset(QueryBuilder $query) + { + $limit = $query->getLimitValue(); + $offset = $query->getOffsetValue(); + + return isset($limit) && isset($offset) ? " OFFSET {$offset}" : ''; + } + + /** + * Compiles the raw select columns and merges their bindings. + * + * @return string + */ + private function prepareRawSelect(QueryBuilder $query) + { + $sql = ''; + if (!empty($query->selectRaw['columns'])) { + $sql = \count($query->select) ? ', ' : ''; + $sql .= implode(', ', $query->selectRaw['columns']); + } + + if (!empty($query->selectRaw['bindings'])) { + $query->addBindings($query->selectRaw['bindings']); + } + + return $sql; + } + + /** + * Prepares the column part of a where clause. + * + * @param array $clause + * + * @return string|null + */ + private function prepareColumnForWhere(QueryBuilder $query, $clause) + { + if (isset($clause['column'])) { + return ' ' . $query->prepareColumnName($clause['column']); + } + } + + /** + * Prepares the operator part of a where clause. + * + * @param array $clause + * + * @return string + */ + private function prepareOperatorForWhere($clause) + { + $sql = ''; + if (!isset($clause['column'])) { + return $sql; + } + + if (isset($clause['operator'])) { + $sql .= ' ' . $clause['operator']; + } elseif (\is_array($clause['value'])) { + $sql .= ' IN '; + } elseif (\is_null($clause['value'])) { + $sql = ' IS NULL'; + } else { + $sql .= ' = '; + } + + return $sql; + } + + /** + * Prepares the value part of a where clause, registering bindings. + * + * @param array $clause + * + * @return string + */ + private function prepareValueForWhere(QueryBuilder $query, $clause) + { + $sql = ''; + if (isset($clause['secondColumn'])) { + return ' ' . $clause['secondColumn']; + } + + if (!isset($clause['value'])) { + return $sql; + } + + if (\is_array($clause['value'])) { + $sql .= ' ('; + foreach ($clause['value'] as $value) { + $sql .= $query->getValueType($value) . ','; + $query->addBindings($value); + } + + $sql = rtrim($sql, ',') . ')'; + } elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) { + $sql .= ' ' . $clause['value']; + } elseif (isset($clause['operator']) && strtoupper($clause['operator']) === 'LIKE') { + $sql .= ' %s'; + $query->addBindings($clause['value']); + } elseif (!\is_null($clause['value'])) { + $sql .= ' ' . $query->getValueType($clause['value']); + $query->addBindings($clause['value']); + } + + return $sql; + } + + /** + * Removes a single leading AND/OR boolean from a condition string. + * + * @param string $sql + * + * @return string + */ + private function removeLeadingBool($sql) + { + return preg_replace('/and |or /i', '', $sql, 1); + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 37bfbb0..43fde14 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -2,14 +2,18 @@ namespace BitApps\WPDatabase; -use Closure; +use BitApps\WPDatabase\Concerns\QueriesRelationships; +use BitApps\WPDatabase\Query\Grammar; +use Closure; use DateTime; use DateTimeZone; -use Exception; +use RuntimeException; class QueryBuilder { + use QueriesRelationships; + public const UPDATE = 'Update'; public const INSERT = 'Insert'; @@ -18,8 +22,19 @@ class QueryBuilder public const SELECT = 'Select'; + public const RAW = 'Raw'; + public const TIME_FORMAT = 'Y-m-d H:i:s'; + public static $TIME_ZONE; + + public $select = []; + + public $selectRaw = [ + 'columns' => [], + 'bindings' => [], + ]; + protected $table; protected $limit; @@ -42,8 +57,6 @@ class QueryBuilder protected $bindings = []; - protected $select = []; - protected $insert = []; protected $update = []; @@ -62,6 +75,8 @@ class QueryBuilder private $_method; + private $_grammar; + /** * Constructs QueryBuilder * @@ -83,6 +98,37 @@ public function __clone() $this->bindings = []; } + public function __call($name, $arguments) + { + if (method_exists($this->_model, $name)) { + return $this->_model->{$name}(...$arguments); + } + + throw new RuntimeException('Call to undefined method ' . __CLASS__ . '::' . esc_html($name) . '()'); + } + + /** + * Clone this class with all conditions + */ + public function clone() + { + return clone $this; + } + + /** + * Adds casts to the bound model at runtime, keeping the builder chainable. + * + * @param array $casts + * + * @return $this + */ + public function withCast(array $casts) + { + $this->_model->withCast($casts); + + return $this; + } + /** * Sets alias for table * @@ -112,7 +158,7 @@ public function getModel() * * @param string $_for * - * @return void + * @return self */ public function queryFor($_for) { @@ -131,6 +177,16 @@ public function newQuery() return new QueryBuilder($this->_model); } + /** + * Returns the SQL grammar used to compile select statements. + * + * @return Grammar + */ + public function grammar() + { + return $this->_grammar ??= new Grammar(); + } + /** * Add bindings for this query * @@ -161,6 +217,98 @@ public function getBindings() return $this->bindings; } + /** + * Resets the bindings collected for this query. + * + * @return void + */ + public function resetBindings() + { + $this->bindings = []; + } + + /** + * Returns the (prefixed) table name for this query. + * + * @return string + */ + public function getTable() + { + return $this->table; + } + + /** + * Returns the clause list (where/having) for the given type. + * + * @param string $type + * + * @return array + */ + public function getClauseList($type) + { + return $type === 'having' ? $this->having : $this->where; + } + + /** + * Returns the join definitions for this query. + * + * @return array + */ + public function getJoins() + { + return $this->joins; + } + + /** + * Returns the group by columns for this query. + * + * @return array + */ + public function getGroupByList() + { + return $this->groupBy; + } + + /** + * Returns the order by definitions for this query. + * + * @return array + */ + public function getOrderByList() + { + return $this->orderBy; + } + + /** + * Returns the limit value for this query. + * + * @return int|string|null + */ + public function getLimitValue() + { + return $this->limit; + } + + /** + * Returns the offset value for this query. + * + * @return int|string|null + */ + public function getOffsetValue() + { + return $this->offset; + } + + /** + * Returns the table alias set via from(). + * + * @return string|null + */ + public function getFromAlias() + { + return $this->_from; + } + /** * Get all rows * @@ -173,6 +321,20 @@ public function all($columns = ['*']) return $this->get($columns); } + public function prepareColumnName(string $column) + { + if (strpos($column, '.') !== false) { + return $column; + } + + $table = "`{$this->table}`."; + if ($column != '*') { + $column = "`{$column}`"; + } + + return $table . $column; + } + /** * Selects column for query * @@ -182,7 +344,51 @@ public function all($columns = ['*']) */ public function select($columns = ['*']) { - $this->select = !\is_array($columns) ? \func_get_args() : $columns; + $select = \is_array($columns) ? $columns : \func_get_args(); + + $this->select = []; + foreach ($select as $column) { + $this->select[] = $this->prepareColumnName($column); + } + + return $this; + } + + /** + * Adds column to select list + * + * @param array|string $columns + * + * @return $this + */ + public function addSelect($columns) + { + $select = !\is_array($columns) ? \func_get_args() : $columns; + + foreach ($select as $column) { + if (\in_array($column, $this->select, true)) { + continue; + } + $this->select[] = $this->prepareColumnName($column); + } + + return $this; + } + + /** + * Selects raw query as column for query + * + * @param string $column + * @param array $bindings + * + * @return $this + */ + public function selectRaw($column, array $bindings = []) + { + $this->selectRaw['columns'][] = $column; + if (!empty($bindings)) { + $this->selectRaw['bindings'] = array_merge($this->selectRaw['bindings'], $bindings); + } return $this; } @@ -196,9 +402,9 @@ public function select($columns = ['*']) */ public function get($columns = ['*']) { - $columns = isset($columns) && \is_array($columns) ? $columns : \func_get_args(); + $columns = \is_array($columns) ? $columns : \func_get_args(); if (empty($this->select) || $columns !== ['*']) { - $this->select = $columns; + $this->select($columns); } $this->_method = self::SELECT; @@ -255,46 +461,6 @@ public function findOne($attributes) return $this->first(); } - /** - * Get processed conditions - * - * @param QueryBuilder $query - * @param string $type - * - * @return string|string[]|null - */ - public function getConditions(QueryBuilder $query, $type = 'where') - { - return $this->processConditions($query->{$type}, $type); - } - - /** - * Prepare operator for where clause - * - * @param array $clause - * - * @return void - */ - public function prepareOperatorForWhere($clause) - { - $sql = ''; - if (!isset($clause['column'])) { - return $sql; - } - - if (isset($clause['operator'])) { - $sql .= ' ' . $clause['operator']; - } elseif (\is_array($clause['value'])) { - $sql .= ' IN '; - } elseif (\is_null($clause['value'])) { - $sql = ' IS NULL'; - } else { - $sql .= ' = '; - } - - return $sql; - } - /** * Set where clause * @@ -464,13 +630,15 @@ public function orWhereBetween($column, $start, $end) */ public function paginate($pageNo = 0, $perPage = 10) { - $selectedColumns = empty($this->select) ? ['*'] : $this->select; + if (empty($this->select)) { + $this->select = ['*']; + } - $totalItems = (int) $this->count(); + $totalItems = (int) $this->count(); $offset = ($pageNo > 1) ? ($pageNo * $perPage) - $perPage : 0; - - $data = $this->take($perPage)->skip($offset)->get($selectedColumns); + + $data = $this->take($perPage)->skip($offset)->get(); $pages = ceil($totalItems / $perPage); @@ -654,36 +822,35 @@ public function orOn($firstColumn, $operator = null, $secondColumn = null) } /** - * Returns order by clause sql + * Sets order by * - * @return string + * @param string $column + * + * @return $this */ - public function getOrderBy() + public function orderBy($column) { - $sql = ''; - if (empty($this->orderBy)) { - return $sql; - } - - foreach ($this->orderBy as $order) { - $sql .= $order['column'] . ' ' . $order['direction'] . ', '; - } + $this->orderBy[] = [ + 'column' => $column, + 'direction' => 'ASC', + ]; - return ' ORDER BY ' . rtrim($sql, ', '); + return $this; } /** - * Sets order by + * Sets order by raw query * - * @param string $column + * @param string $query + * @param array $bindings * * @return $this */ - public function orderBy($column) + public function orderByRaw($query, $bindings = []) { $this->orderBy[] = [ - 'column' => $column, - 'direction' => 'ASC', + 'raw' => $query, + 'bindings' => $bindings, ]; return $this; @@ -739,7 +906,25 @@ public function desc() */ public function raw($sql, $bindings = []) { - return $this->exec(Connection::prepare($sql, $bindings)); + $this->_method = self::RAW; + $this->raw = $sql; + $this->bindings = $bindings; + + $result = $this->exec(); + + if (preg_match('/^SELECT/i', trim($sql))) { + $result = Connection::prop('last_result'); + } + + $this->raw = ''; + unset($this->_method); + + return $result; + } + + public function prepareRaw() + { + return $this->raw; } /** @@ -770,16 +955,6 @@ public function skip($count) return $this; } - /** - * Returns processed offset for query - * - * @return string|null - */ - public function getOffset() - { - return isset($this->limit) && isset($this->offset) ? " OFFSET {$this->offset}" : ''; - } - /** * Run insert query for model * @@ -810,11 +985,14 @@ public function insert($attributes = []) */ public function update($attributes = []) { - $this->_method = self::UPDATE; $this->_model->fill($attributes); + if ($this->_model->exists()) { + return $this->save(); + } + $this->update = $this->prepareAttributeForSaveOrUpdate(true); - return $this; + return $this->exec(); } /** @@ -839,6 +1017,10 @@ public function destroy($ids = []) */ public function save() { + if ($this->_model->fireEvent('saving') === false) { + return false; + } + $columns = $this->prepareAttributeForSaveOrUpdate($this->_model->exists()); $pk = $this->_model->getPrimaryKey(); if ($this->_model->exists()) { @@ -857,67 +1039,55 @@ public function save() $this->update = $columns; - $this->exec(); + if ($this->exec()) { + $this->_model->fireEvent('saved'); + + return $this->_model; + } - return Connection::prop('rows_affected'); + return false; } $this->insert = $columns; $this->exec(); if ($insertId = $this->lastInsertId()) { $this->_model->setAttribute($pk, $insertId); + $this->_model->fireEvent('saved'); - return true; + return $this->_model; } return false; } - /** - * Set count for select - * - * @return $this - */ - public function withCount() - { - $this->select[] = 'COUNT(*) as count'; - - return $this; - } - /** * Get counts for current model * - * @return int|null + * @return int */ public function count() { - $this->select = ['COUNT(*) as count']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); - - return \is_array($result) && !empty($result[0]->count) ? $result[0]->count : null; + return (int) $this->aggregate('COUNT', $this->_model->getPrimaryKey()); } public function max($column) { - $this->select = ['MAX(' . $column . ') as max']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); - - return \is_array($result) && !empty($result[0]->max) ? $result[0]->max : null; + return $this->aggregate('MAX', $column); } public function min($column) { - $this->select = ['MIN(' . $column . ') as min']; - $this->_method = 'Select'; - $result = $this->exec(); - unset($this->select); + return $this->aggregate('MIN', $column); + } - return \is_array($result) && !empty($result[0]->min) ? $result[0]->min : null; + public function aggregate($function, $column) + { + $query = $this->clone(); + $query->select = []; + $query->selectRaw = ['columns' => [], 'bindings' => []]; + $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec(); + + return \is_array($result) && isset($result[0]->{$function}) ? $result[0]->{$function} : null; } public function delete() @@ -930,27 +1100,11 @@ public function delete() return $this->exec(); } - /** - * Adds relation for model - * - * @param string|Closure $relation - * - * @return $this - */ - public function with($relation) - { - $args = \func_get_args(); - $relationalQuery = $this->_model->addRelation($relation); - if ($relationalQuery && \func_num_args() === 2 && $args[1] instanceof Closure) { - $args[1]($relationalQuery); - } - - return $this; - } - /** * Starts transaction * + * @deprecated Use Connection::startTransaction() instead + * * @return bool */ public function startTransaction() @@ -961,6 +1115,8 @@ public function startTransaction() /** * Commits current transaction * + * @deprecated Use Connection::commit() instead + * * @return bool */ public function commit() @@ -971,6 +1127,8 @@ public function commit() /** * Rollback previously execute query * + * @deprecated Use Connection::rollback() instead + * * @return void */ public function rollback() @@ -987,60 +1145,153 @@ public function rollback() */ public function prepare($sql = null) { - if (\is_null($sql) && isset($this->_method)) { - $sql = $this->{'prepare' . $this->_method}(); + if (\is_null($sql)) { + $sql = $this->toSql(); } return empty($this->bindings) || strpos($sql, '%') === false ? $sql : Connection::prepare($sql, $this->bindings); - } /** - * Process conditions - * - * @param array $conditions - * @param string $type + * Prepares current query string * * @return string */ - protected function processConditions($conditions, $type = null) + public function toSql() { $sql = ''; - if (\is_array($conditions) && \count($conditions) > 0) { - foreach ($conditions as $clause) { - if (isset($clause['bool'])) { - $sql .= ' ' . $clause['bool']; - } else { - $sql .= ' AND'; - } + if (isset($this->_method)) { + switch ($this->_method) { + case self::SELECT: + $sql = $this->grammar()->compileSelect($this); - if (isset($clause['raw'])) { - $sql .= ' ' . $clause['raw']; - $this->addBindings($clause['bindings']); + break; + case self::INSERT: + $sql = $this->prepareInsert(); - continue; - } + break; + case self::UPDATE: + $sql = $this->prepareUpdate(); - if (isset($clause['query']) && !\is_null($type)) { - $sql .= ' (' . $clause['query']->getConditions($clause['query'], $type) . ')'; - $this->addBindings($clause['query']->getBindings()); + break; + case self::DELETE: + $sql = $this->prepareDelete(); - continue; - } + break; + case self::RAW: + $sql = $this->prepareRaw(); - $sql .= $this->prepareColumnForWhere($clause); - $sql .= $this->prepareOperatorForWhere($clause); - $sql .= $this->prepareValueForWhere($clause, $this); + break; } - - $sql = $this->removeLeadingBool($sql); + } elseif (!empty($this->select) || !empty($this->selectRaw)) { + $this->_method = self::SELECT; + $sql = $this->grammar()->compileSelect($this); } return $sql; } + public function when($value = null, ?callable $callback = null, ?callable $default = null) + { + $value = $value instanceof Closure ? $value($this) : $value; + + if ($value) { + return $callback($this, $value) ?? $this; + } elseif ($default) { + return $default($this, $value) ?? $this; + } + + return $this; + } + + public function upsert(array $values, ?array $update = null) + { + if (!\is_array(reset($values))) { + $values = [$values]; + } + + if (\is_null($update)) { + $update = array_keys($values[0]); + } + + $this->bindings = []; + $columns = array_keys($values[0]); + sort($columns); + $createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps && !\in_array('created_at', $columns); + if ($createdAt) { + $columns[] = 'created_at'; + } + $sql = 'INSERT INTO ' . $this->table; + $sql .= ' (' . implode(', ', $columns) . ')'; + + $sql .= ' VALUES '; + $insertAbleValues = []; + foreach ($values as $row) { + ksort($row); + if ($createdAt) { + $row['created_at'] = $this->currentTimestamp(); + } + + $rowValues = array_values($row); + $insertAbleValues[] = ' (' + . implode( + ', ', + array_map( + function ($value) { + if (\is_null($value)) { + return 'NULL'; + } + + $this->bindings[] = $value; + + return $this->getValueType($value); + }, + $rowValues + ) + ) . ')'; + } + + $sql .= empty($insertAbleValues) ? ' default values' : ' ' . implode(',', $insertAbleValues); + $sql .= ' ON DUPLICATE KEY UPDATE '; + if (\in_array('created_at', $update, true)) { + $update = array_diff($update, ['created_at']); + $update[] = 'updated_at'; + } + $update = array_map(function ($column) { + if ($column === 'updated_at') { + return $column . ' = VALUES(created_at)'; + } + + return $column . ' = VALUES(' . $column . ')'; + }, $update); + $sql .= implode(', ', $update); + $sql .= ';'; + + return $this->raw($sql, $this->bindings); + } + + /** + * Returns types + * + * @param mixed $value + * + * @return string + */ + public function getValueType($value) + { + $placeHolder = '%s'; + + if (\gettype($value) == 'integer') { + $placeHolder = '%d'; + } elseif (\gettype($value) == 'double') { + $placeHolder = '%f'; + } + + return $placeHolder; + } + /** * Prepares conditional * @@ -1093,33 +1344,6 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where') return $conditions; } - /** - * Removes leading and | or - * - * @param string $sql - * - * @return string - */ - protected function removeLeadingBool($sql) - { - return preg_replace('/and |or /i', '', $sql, 1); - } - - /** - * Returns processed sql for where clause - * - * @return string - */ - protected function getWhere() - { - $sql = $this->getConditions($this); - if (empty($sql)) { - return ''; - } - - return " WHERE {$sql}"; - } - /** * Prepares where conditions * @@ -1136,20 +1360,6 @@ protected function prepareWhere($params, $bool = 'AND') } } - /** - * Returns sql for group by clause - * - * @return string - */ - protected function getGroupBy() - { - if (empty($this->groupBy)) { - return ''; - } - - return ' GROUP BY ' . implode(',', $this->groupBy); - } - /** * Prepare having * @@ -1168,40 +1378,6 @@ protected function prepareHaving($params, $bool = 'AND') return $this; } - /** - * Return sql for having clause - * - * @return string - */ - protected function getHaving() - { - $sql = $this->getConditions($this, 'having'); - if (empty($sql)) { - return ''; - } - - return " HAVING {$sql}"; - } - - /** - * Returns sql for join - * - * @return string - */ - protected function getJoin() - { - $sql = ''; - if (empty($this->joins)) { - return $sql; - } - - foreach ($this->joins as $join) { - $sql .= ' ' . $join['type'] . ' JOIN ' . $join['table'] . ' ON ' . $this->processConditions($join['on']); - } - - return $sql; - } - /** * Prepares on * @@ -1226,33 +1402,24 @@ protected function prepareOn($table, $column, $operator, $secondColumn, $bool = } /** - * Returns types - * - * @param mixed $value + * Helper function, to get current timestamp * * @return string */ - protected function getValueType($value) + protected function currentTimestamp() { - $placeHolder = '%s'; + $timezoneString = $this->getTimeZone(); - if (\gettype($value) == 'integer') { - $placeHolder = '%d'; - } elseif (\gettype($value) == 'double') { - $placeHolder = '%f'; - } + $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); - return $placeHolder; + return $dateTime->format(self::TIME_FORMAT); } - /** - * Helper function, to get current timestamp - * - * @return string - */ - protected function currentTimestamp() + protected function getTimeZone() { - if (\function_exists('wp_timezone_string')) { + if (isset(static::$TIME_ZONE)) { + $timezoneString = static::$TIME_ZONE; + } elseif (\function_exists('wp_timezone_string')) { $timezoneString = wp_timezone_string(); } elseif (!($timezoneString = get_option('timezone_string'))) { $offset = (float) get_option('gmt_offset'); @@ -1266,9 +1433,7 @@ protected function currentTimestamp() $timezoneString = sprintf('%s%02d:%02d', $sign, $absHour, $absMins); } - $dateTime = new DateTime('now', new DateTimeZone($timezoneString)); - - return $dateTime->format(self::TIME_FORMAT); + return $timezoneString; } /** @@ -1306,12 +1471,10 @@ private function bulkInsert($attributes) ', ', array_map( function ($value) { - if (\is_null($value)) { return 'NULL'; } - $this->bindings[] = $value; return $this->getValueType($value); @@ -1325,10 +1488,10 @@ function ($value) { if ($this->raw($sql, $this->bindings) !== false) { $nextID = $this->lastInsertId(); - $ids[] = $nextID; - $affectedRows = Connection::prop('rows_affected') - 1; + $ids = []; + $affectedRows = Connection::prop('rows_affected'); while ($affectedRows--) { - $ids[] = $nextID + 1; + $ids[] = $nextID++; } if ( @@ -1348,80 +1511,6 @@ function ($value) { return false; } - /** - * Table alias for select query - * - * @return string - */ - private function getFrom() - { - return isset($this->_from) ? " {$this->_from}" : null; - } - - /** - * Prepare column for where clause - * - * @param array $clause - * - * @return void - */ - private function prepareColumnForWhere($clause) - { - if (isset($clause['column'])) { - return ' ' . $clause['column']; - } - } - - /** - * Prepare value for where clause - * - * @param array $clause - * @param self $query - * - * @return string - */ - private function prepareValueForWhere($clause, self $query) - { - $sql = ''; - if (isset($clause['secondColumn'])) { - return ' ' . $clause['secondColumn']; - } - - if (!isset($clause['value'])) { - return $sql; - } - - if (\is_array($clause['value'])) { - $sql .= ' ('; - foreach ($clause['value'] as $value) { - $sql .= $this->getValueType($value) . ','; - $query->addBindings($value); - } - - $sql = rtrim($sql, ',') . ')'; - } elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) { - $sql .= ' ' . $clause['value']; - } elseif (isset($clause['operator']) && strtoupper($clause['operator'] === 'LIKE')) { - $sql .= ' %s'; - $query->addBindings($clause['value']); - } elseif (!\is_null($clause['value'])) { - $sql .= ' ' . $query->getValueType($clause['value']); - $query->addBindings($clause['value']); - } - - return $sql; - } - - /** - * Returns limit part for query - * - * @return string|null - */ - private function getLimit() - { - return isset($this->limit) ? " LIMIT {$this->limit}" : ''; - } - /** * Prepares columns and value * @@ -1431,9 +1520,12 @@ private function getLimit() */ private function prepareAttributeForSaveOrUpdate($isUpdate = false) { - if ($isUpdate) { + if ($isUpdate && $this->_model->exists()) { $columnsToPrepare = array_keys($this->_model->getDirtyAttributes()); $this->bindings = []; + } elseif ($isUpdate && !$this->_model->exists()) { + $columnsToPrepare = array_keys($this->_model->getAttributes()); + $this->bindings = []; } else { $columnsToPrepare = array_keys($this->_model->getAttributes()); } @@ -1454,9 +1546,9 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) $this->bindings[] = \is_array($this->_model->{$column}) || \is_object($this->_model->{$column}) ? wp_json_encode($this->_model->{$column}) : $this->_model->{$column}; - } elseif (\is_null($this->_model->{$column})) { + } elseif (\is_null($this->_model->{$column})) { $this->bindings[] = null; - }else { + } else { $this->bindings[] = ''; } } @@ -1466,27 +1558,6 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false) return $columnsToPrepare; } - /** - * Prepares select statement - * - * @return string - */ - private function prepareSelect() - { - $this->bindings = []; - $sql = 'SELECT ' . implode(',', $this->select) . ' FROM ' . $this->table; - $sql .= $this->getFrom(); - $sql .= $this->getJoin(); - $sql .= $this->getWhere($this); - $sql .= $this->getGroupBy(); - $sql .= $this->getHaving(); - $sql .= $this->getOrderBy(); - $sql .= $this->getLimit(); - $sql .= $this->getOffset(); - - return trim($sql); - } - /** * Prepares insert statement * @@ -1525,12 +1596,10 @@ function ($value, $key) { private function prepareUpdate() { $sql = 'UPDATE ' . $this->table; - $sql .= $this->getJoin(); + $sql .= $this->grammar()->getJoin($this); $sql .= ' SET '; $columnCount = \count($this->update); foreach ($this->update as $key => $column) { - // $sql .= $column . ' = ' . $this->getValueType($this->bindings[$key]); - if (\is_null($this->bindings[$key])) { $sql .= $column . ' = NULL'; unset($this->bindings[$key]); @@ -1543,7 +1612,7 @@ private function prepareUpdate() } } - $sql .= $this->getWhere($this); + $sql .= $this->grammar()->getWhere($this); return $sql; } @@ -1555,14 +1624,20 @@ private function prepareUpdate() */ private function prepareDelete() { - if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { - return $this->update(['deleted_at' => $this->currentTimestamp()])->prepareUpdate(); + $whereClause = $this->grammar()->getWhere($this); + + if (empty($whereClause)) { + return ''; } - $sql = 'DELETE FROM ' . $this->table; - $sql .= $this->getWhere($this); + if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) { + $timestamp = $this->currentTimestamp(); + array_unshift($this->bindings, $timestamp); - return $sql; + return 'UPDATE ' . $this->table . ' SET deleted_at = ' . $this->getValueType($timestamp) . $whereClause; + } + + return 'DELETE FROM ' . $this->table . $whereClause; } /** @@ -1574,21 +1649,68 @@ private function prepareDelete() */ private function exec($sql = null) { + if ($this->dispatchEvent('pre') === false) { + return false; + } if (\is_null($sql)) { $sql = $this->prepare($sql); } - - if (\is_null($sql)) { - throw new Exception('SQL query is null'); + if (empty($sql)) { + throw new RuntimeException('SQL query is empty'); } - Connection::query($sql); + $this->bindings = []; + + $result = Connection::query($sql); if (!empty(Connection::prop('last_error'))) { return false; } + $this->dispatchEvent('post'); + + return $this->_method === self::SELECT ? Connection::prop('last_result') : $result; + } - return Connection::prop('last_result'); + /** + * Dispatches model event + * + * @param string $type pre|post + */ + private function dispatchEvent($type) + { + $prefix = null; + $suffix = null; + + if ($type === 'pre') { + $suffix = 'ing'; + } else { + $suffix = 'ed'; + } + + switch ($this->_method) { + case self::INSERT: + if (\count($this->_model->getAttributes())) { + $prefix = 'creat'; + } + + break; + case self::UPDATE: + if ($this->_model->exists()) { + $prefix = 'updat'; + } + + break; + case self::DELETE: + if ($this->_model->exists()) { + $prefix = 'delet'; + } + + break; + } + + if (!\is_null($prefix)) { + return $this->_model->fireEvent($prefix . $suffix); + } } /** diff --git a/tests/AggregateZeroTest.php b/tests/AggregateZeroTest.php new file mode 100644 index 0000000..6c386d1 --- /dev/null +++ b/tests/AggregateZeroTest.php @@ -0,0 +1,42 @@ +resolver = function () { + return [(object) ['COUNT' => '0']]; + }; + + $this->assertSame(0, User::query()->count()); + } + + public function testMinReturnsZeroValue(): void + { + $GLOBALS['wpdb']->resolver = function () { + return [(object) ['MIN' => '0']]; + }; + + $this->assertSame('0', User::query()->min('score')); + } +} diff --git a/tests/BuilderEntryTest.php b/tests/BuilderEntryTest.php new file mode 100644 index 0000000..e7af10d --- /dev/null +++ b/tests/BuilderEntryTest.php @@ -0,0 +1,41 @@ +assertInstanceOf(QueryBuilder::class, User::query()); + } + + public function testQueryReturnsIndependentInstances(): void + { + $this->assertNotSame(User::query(), User::query()); + } + + public function testQueryIsChainable(): void + { + $sql = User::query()->where('id', 1)->toSql(); + + $this->assertStringContainsString('FROM wp_users', $sql); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testQueryChainsRelationMethods(): void + { + $this->assertSame( + User::withCount('posts')->toSql(), + User::query()->withCount('posts')->toSql() + ); + } +} diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php new file mode 100644 index 0000000..16a0828 --- /dev/null +++ b/tests/EagerLoadIntegrationTest.php @@ -0,0 +1,55 @@ +resolver = function ($sql) { + if (strpos($sql, 'wp_posts') !== false) { + return [ + (object) ['id' => 10, 'user_id' => 1], + (object) ['id' => 11, 'user_id' => 1], + (object) ['id' => 12, 'user_id' => 2], + ]; + } + + return [ + (object) ['id' => 1], + (object) ['id' => 2], + ]; + }; + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testStaticWithEagerLoadsAndGroupsRelatedRows(): void + { + $users = User::with('posts')->get(); + + $this->assertInstanceOf(Collection::class, $users); + $this->assertCount(2, $users); + + $first = $users[0]; + $second = $users[1]; + + $this->assertCount(2, $first->posts, 'user 1 should have 2 posts'); + $this->assertCount(1, $second->posts, 'user 2 should have 1 post'); + $this->assertInstanceOf(Post::class, $first->posts[0]); + } +} diff --git a/tests/Fixtures/AccessorModel.php b/tests/Fixtures/AccessorModel.php new file mode 100644 index 0000000..96a094d --- /dev/null +++ b/tests/Fixtures/AccessorModel.php @@ -0,0 +1,19 @@ + 'boolean']; +} diff --git a/tests/Fixtures/EventUser.php b/tests/Fixtures/EventUser.php new file mode 100644 index 0000000..4130f04 --- /dev/null +++ b/tests/Fixtures/EventUser.php @@ -0,0 +1,31 @@ +id; + }); + } +} diff --git a/tests/Fixtures/SoftPost.php b/tests/Fixtures/SoftPost.php new file mode 100644 index 0000000..67e64a2 --- /dev/null +++ b/tests/Fixtures/SoftPost.php @@ -0,0 +1,16 @@ +hasMany(Post::class, 'user_id', 'id'); + } +} diff --git a/tests/GeminiReviewFixesTest.php b/tests/GeminiReviewFixesTest.php new file mode 100644 index 0000000..55c5929 --- /dev/null +++ b/tests/GeminiReviewFixesTest.php @@ -0,0 +1,74 @@ +upsert(['first_name' => 'Ada', 'email' => 'a@x.com']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('(email, first_name)', $sql, 'columns must be sorted to match the ksorted row values'); + $this->assertStringContainsString("('a@x.com', 'Ada')", $sql); + } + + /** upsert: an explicit `created_at` in the update list must be rewritten to `updated_at`. */ + public function testUpsertSwapsCreatedAtForUpdatedAtOnDuplicate(): void + { + User::query()->upsert( + ['email' => 'a@x.com', 'created_at' => '2020-01-01 00:00:00'], + ['email', 'created_at'] + ); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('updated_at = VALUES(created_at)', $sql); + $this->assertStringNotContainsString('created_at = VALUES(created_at)', $sql); + } + + /** The documented `boolean` cast must convert the value, like `bool`. */ + public function testBooleanCastConvertsValue(): void + { + $model = new CastModel(['flag' => '1']); + + $this->assertSame(true, $model->flag); + } + + /** Collection::pluck must resolve dynamic (accessor) attributes on models. */ + public function testPluckResolvesAccessorAttribute(): void + { + $collection = new Collection([new AccessorModel(['id' => 1])]); + + $this->assertSame(['L'], $collection->pluck('label')->all()); + } + + /** withCast() on the builder must stay chainable (return the QueryBuilder). */ + public function testWithCastOnBuilderIsChainable(): void + { + $this->assertInstanceOf(QueryBuilder::class, User::query()->withCast(['flag' => 'bool'])); + } +} diff --git a/tests/ModelEventsTest.php b/tests/ModelEventsTest.php new file mode 100644 index 0000000..1460639 --- /dev/null +++ b/tests/ModelEventsTest.php @@ -0,0 +1,49 @@ +name = 'Ada'; + + $result = $user->save(); + + $this->assertTrue(EventUser::$savingCalled, 'saving handler should have run'); + $this->assertSame([], $GLOBALS['wpdb']->queries, 'aborted save must not execute any query'); + $this->assertFalse($result, 'aborted save returns false'); + } + + public function testRetrievedHandlerReceivesHydratedModel(): void + { + $GLOBALS['wpdb']->queueResult([(object) ['id' => 7]]); + + RetrieveUser::first(); + + $this->assertSame(7, RetrieveUser::$seenId, 'retrieved must fire after the model is filled'); + } +} diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php new file mode 100644 index 0000000..dc34f38 --- /dev/null +++ b/tests/Query/GrammarTest.php @@ -0,0 +1,127 @@ +assertSame( + 'SELECT `wp_users`.* FROM wp_users', + User::select('*')->toSql() + ); + } + + public function testSelectSpecificColumns(): void + { + $this->assertSame( + 'SELECT `wp_users`.`id`,`wp_users`.`user_login` FROM wp_users', + (new User())->select('id', 'user_login')->toSql() + ); + } + + public function testWhereEquals(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`id` = %d', + (new User())->where('id', 5)->toSql() + ); + } + + public function testWhereWithOperator(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`id` > %d', + (new User())->where('id', '>', 0)->toSql() + ); + } + + public function testOrWhere(): void + { + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`a` = %d OR `wp_users`.`b` = %d', + (new User())->where('a', 1)->orWhere('b', 2)->toSql() + ); + } + + public function testNestedClosureWhereProducesParenthesizedGroup(): void + { + $sql = User::where('a', 1)->where(function ($q) { + $q->where('b', 2)->orWhere('c', 3); + })->toSql(); + + $this->assertSame( + 'SELECT FROM wp_users WHERE `wp_users`.`a` = %d AND ' + . '( `wp_users`.`b` = %d OR `wp_users`.`c` = %d)', + $sql + ); + + $this->assertStringContainsString('( `wp_users`.`b` = %d OR `wp_users`.`c` = %d)', $sql); + } + + public function testJoin(): void + { + $this->assertSame( + 'SELECT FROM wp_users INNER JOIN wp_wp_posts ON `wp_users`.`user_id` = id', + (new User())->join('posts', 'user_id', '=', 'id')->toSql() + ); + } + + public function testGroupBy(): void + { + $this->assertSame( + 'SELECT FROM wp_users GROUP BY status', + (new User())->groupBy('status')->toSql() + ); + } + + public function testHaving(): void + { + $this->assertSame( + 'SELECT FROM wp_users GROUP BY status HAVING `wp_users`.`id` > %d', + (new User())->groupBy('status')->having('id', '>', 1)->toSql() + ); + } + + public function testOrderByDesc(): void + { + $this->assertSame( + 'SELECT FROM wp_users ORDER BY id DESC', + (new User())->orderBy('id')->desc()->toSql() + ); + } + + public function testLimitAndOffset(): void + { + $this->assertSame( + 'SELECT FROM wp_users LIMIT 10 OFFSET 20', + (new User())->take(10)->skip(20)->toSql() + ); + } + + public function testLimitWithoutOffsetOmitsOffsetClause(): void + { + $this->assertSame( + 'SELECT FROM wp_users LIMIT 5', + (new User())->take(5)->toSql() + ); + } + + public function testCompileSelectReturnsString(): void + { + $query = (new User())->select('*'); + + $this->assertIsString($query->grammar()->compileSelect($query)); + $this->assertInstanceOf(QueryBuilder::class, $query); + } +} diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php new file mode 100644 index 0000000..d66d6a8 --- /dev/null +++ b/tests/QueryFeaturesTest.php @@ -0,0 +1,160 @@ +join('posts', 'posts.user_id', '=', 'users.id')->toSql(); + + $this->assertStringContainsString('INNER JOIN', $sql); + $this->assertStringContainsString('posts.user_id', $sql); + $this->assertStringContainsString('users.id', $sql); + } + + public function testLeftJoinThenWhereKeepsBindingsInOrder(): void + { + $qb = (new User())->leftJoin('posts', 'posts.user_id', '=', 'users.id')->where('users.id', '>', 5); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('LEFT JOIN', $sql); + $this->assertStringContainsString('WHERE', $sql); + $this->assertSame([5], $qb->getBindings(), 'join carries no binding; only the where value'); + } + + // --- Sub-queries --------------------------------------------------------- + + public function testNestedClosureGroupsAndFlattensBindingsInOrder(): void + { + $qb = User::where('a', 1)->where(function ($q) { + $q->where('b', 2)->orWhere('c', 3); + }); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('(', $sql); + $this->assertStringContainsString('OR', $sql); + $this->assertSame([1, 2, 3], $qb->getBindings(), 'outer then nested bindings, in placeholder order'); + } + + public function testWhereHasEmitsCorrelatedExistsSubquery(): void + { + $sql = User::whereHas('posts')->toSql(); + + $this->assertStringContainsString('exists(', $sql); + $this->assertStringContainsString('FROM wp_posts', $sql); + $this->assertStringContainsString('`wp_users`.`id`=`wp_posts`.`user_id`', $sql); + } + + public function testWhereHasAppliesConstraintInsideSubquery(): void + { + $sql = User::whereHas('posts', function ($q) { + $q->where('published', 1); + })->toSql(); + + $this->assertStringContainsString('exists(', $sql); + $this->assertStringContainsString('published', $sql); + } + + public function testSelectRawBindingsPrecedeWhereBindings(): void + { + $qb = User::query()->selectRaw('%s as label', ['L'])->where('id', 5); + + $qb->toSql(); + + $this->assertSame(['L', 5], $qb->getBindings(), 'selectRaw bindings compile before where bindings'); + } + + // --- when() -------------------------------------------------------------- + + public function testWhenTrueAppliesCallback(): void + { + $qb = User::query()->when(true, function ($q) { + $q->where('active', 1); + }); + + $sql = $qb->toSql(); + + $this->assertStringContainsString('active', $sql); + $this->assertSame([1], $qb->getBindings()); + } + + public function testWhenFalseRunsDefaultBranch(): void + { + $qb = User::query()->when( + false, + function ($q) { + $q->where('active', 1); + }, + function ($q) { + $q->where('active', 0); + } + ); + + $qb->toSql(); + + $this->assertSame([0], $qb->getBindings(), 'default branch applied when value is falsey'); + } + + // --- Constrained eager loading (relation callable $query) ---------------- + + public function testWithCallableConstrainsTheEagerLoadQuery(): void + { + $builder = User::with('posts', function ($q) { + $q->where('status', 'published'); + }); + $relation = $builder->getModel()->getRelations()['posts']; + + $this->assertStringContainsString('status', $relation->toSql()); + $this->assertSame(['published'], $relation->getBindings()); + } + + // --- Update path --------------------------------------------------------- + + public function testBulkUpdateExecutesUpdateStatement(): void + { + User::where('id', 1)->update(['status' => 'archived']); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringStartsWith('UPDATE wp_users', $sql); + $this->assertMatchesRegularExpression('/SET\s+status\s*=/i', $sql); + $this->assertStringContainsString('WHERE', $sql); + } + + // --- LIKE operator (case-insensitive) ------------------------------------ + + public function testLikeOperatorBindsValue(): void + { + $qb = User::where('name', 'like', '%ada%'); + + $sql = $qb->toSql(); + + $this->assertMatchesRegularExpression('/like\s+%s/i', $sql); + $this->assertSame(['%ada%'], $qb->getBindings(), 'LIKE value must be bound, not concatenated'); + } +} diff --git a/tests/RelationAggregateColumnTest.php b/tests/RelationAggregateColumnTest.php new file mode 100644 index 0000000..91d1748 --- /dev/null +++ b/tests/RelationAggregateColumnTest.php @@ -0,0 +1,51 @@ +_`. + */ +final class RelationAggregateColumnTest extends TestCase +{ + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + /** @return array */ + public static function aggregateMethods(): array + { + return [ + 'withSum' => ['withSum', 'sum'], + 'withMin' => ['withMin', 'min'], + 'withMax' => ['withMax', 'max'], + 'withAvg' => ['withAvg', 'avg'], + ]; + } + + #[DataProvider('aggregateMethods')] + public function testAggregateEmitsFunctionOverRelatedColumn(string $method, string $function): void + { + $sql = User::$method('posts.amount')->toSql(); + + $this->assertStringContainsStringIgnoringCase($function . '(', $sql, "should emit a {$function}() aggregate"); + $this->assertStringContainsString('amount', $sql, 'should aggregate the related `amount` column, not *'); + $this->assertStringContainsString('posts_' . $function, $sql, "should alias the column posts_{$function}"); + } + + #[DataProvider('aggregateMethods')] + public function testAggregateDoesNotAggregateStar(string $method, string $function): void + { + $sql = User::$method('posts.amount')->toSql(); + + $this->assertStringNotContainsStringIgnoringCase($function . '(*)', $sql, "{$function}(*) means the column was ignored"); + } +} diff --git a/tests/RelationDefinitionRegressionTest.php b/tests/RelationDefinitionRegressionTest.php new file mode 100644 index 0000000..56a91e4 --- /dev/null +++ b/tests/RelationDefinitionRegressionTest.php @@ -0,0 +1,48 @@ +assertInstanceOf(QueryBuilder::class, (new User())->posts()); + } + + public function testStaticBuilderChainStillWorks(): void + { + $sql = User::where('id', '>', 0)->toSql(); + + $this->assertStringContainsString('FROM wp_users', $sql); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testGetReturnsCollectionForMultipleRows(): void + { + $GLOBALS['wpdb']->queueResult([ + (object) ['id' => 1], + (object) ['id' => 2], + ]); + + $result = User::where('id', '>', 0)->get(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertCount(2, $result); + } +} diff --git a/tests/RelationStaticAccessTest.php b/tests/RelationStaticAccessTest.php new file mode 100644 index 0000000..b017182 --- /dev/null +++ b/tests/RelationStaticAccessTest.php @@ -0,0 +1,121 @@ +with(...)`), and must + * produce identical SQL either way. Moving them onto QueryBuilder (forwarded + * from Model via __call/__callStatic) is what makes the static form legal — + * PHP only routes through __callStatic for methods that are not defined on the + * class itself. + */ +final class RelationStaticAccessTest extends TestCase +{ + /** Relation entry methods that take a single relation name. */ + public static function singleRelationMethods(): array + { + return [ + 'with' => ['with'], + 'withCount' => ['withCount'], + 'withMin' => ['withMin'], + 'withMax' => ['withMax'], + 'withAvg' => ['withAvg'], + 'withSum' => ['withSum'], + 'withExists' => ['withExists'], + 'whereHas' => ['whereHas'], + 'withWhereHas' => ['withWhereHas'], + ]; + } + + #[DataProvider('singleRelationMethods')] + public function testRelationMethodIsCallableStatically(string $method): void + { + $builder = User::$method('posts'); + + $this->assertInstanceOf( + QueryBuilder::class, + $builder, + "Model::{$method}() must be callable statically and return a QueryBuilder" + ); + } + + #[DataProvider('singleRelationMethods')] + public function testRelationMethodIsCallableOnInstance(string $method): void + { + $builder = (new User())->{$method}('posts'); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + } + + #[DataProvider('singleRelationMethods')] + public function testStaticAndInstanceProduceIdenticalSql(string $method): void + { + $static = User::$method('posts')->toSql(); + $instance = (new User())->{$method}('posts')->toSql(); + + $this->assertSame($instance, $static); + } + + public function testWithRegistersRelationOnModelStatically(): void + { + $builder = User::with('posts'); + + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } + + public function testWithCountStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT `wp_users`.*, (SELECT count(*) FROM wp_posts WHERE ' + . '`wp_users`.`id`=`wp_posts`.`user_id`) as `posts_count` FROM wp_users'; + + $this->assertSame($expected, User::withCount('posts')->toSql()); + } + + public function testWhereHasStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT FROM wp_users WHERE exists(SELECT `wp_posts`.* FROM ' + . 'wp_posts WHERE `wp_users`.`id`=`wp_posts`.`user_id`)'; + + $this->assertSame($expected, User::whereHas('posts')->toSql()); + } + + public function testWithExistsStaticSqlMatchesBaseline(): void + { + $expected = 'SELECT `wp_users`.*, exists(SELECT `wp_posts`.* FROM wp_posts ' + . 'WHERE `wp_users`.`id`=`wp_posts`.`user_id`) as `posts_exists` FROM wp_users'; + + $this->assertSame($expected, User::withExists('posts')->toSql()); + } + + public function testWithIsChainableAfterBuilderMethod(): void + { + $builder = User::where('id', '>', 0)->with('posts'); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } + + public function testWithAcceptsArrayOfRelations(): void + { + $relations = User::with(['posts'])->getModel()->getRelations(); + + $this->assertArrayHasKey('posts', $relations); + } + + public function testWithSupportsClosureConstraintStatically(): void + { + $builder = User::with('posts', function (QueryBuilder $query) { + $query->where('status', 'published'); + }); + + $this->assertArrayHasKey('posts', $builder->getModel()->getRelations()); + } +} diff --git a/tests/SoftDeleteTest.php b/tests/SoftDeleteTest.php new file mode 100644 index 0000000..ffb81c7 --- /dev/null +++ b/tests/SoftDeleteTest.php @@ -0,0 +1,44 @@ +` on the targeted rows and + * (b) honour the same no-WHERE guard as a hard delete, so a soft delete with no + * conditions cannot silently rewrite the whole table. + */ +final class SoftDeleteTest extends TestCase +{ + protected function setUp(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + protected function tearDown(): void + { + $GLOBALS['wpdb'] = new FakeWpdb(); + } + + public function testSoftDeleteSetsDeletedAtColumnOnMatchedRows(): void + { + SoftPost::where('id', 1)->delete(); + + $sql = $GLOBALS['wpdb']->last_query; + + $this->assertStringContainsString('UPDATE wp_soft_posts', $sql); + $this->assertMatchesRegularExpression('/SET\s+`?\w*\.?`?deleted_at`?\s*=/i', $sql, 'must assign the deleted_at column'); + $this->assertStringContainsString('WHERE', $sql); + } + + public function testSoftDeleteWithoutWhereIsGuarded(): void + { + $this->expectException(RuntimeException::class); + + SoftPost::query()->delete(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8c1236f --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,124 @@ +last_result = $rows; + } + + public function query($sql) + { + $this->last_query = $sql; + $this->queries[] = $sql; + + if (\is_callable($this->resolver)) { + $this->last_result = ($this->resolver)($sql); + } + + return $this->rows_affected; + } + + public function prepare($query, ...$args) + { + if (\count($args) === 1 && \is_array($args[0])) { + $args = $args[0]; + } + + $index = 0; + + return preg_replace_callback( + '/%[dsfF]/', + function ($match) use (&$index, $args) { + $value = $args[$index] ?? ''; + $index++; + + return is_numeric($value) ? (string) $value : "'" . $value . "'"; + }, + $query + ); + } + + public function get_results($query) + { + return $this->last_result; + } +} + +$GLOBALS['wpdb'] = new FakeWpdb(); + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/Fixtures/Post.php'; +require __DIR__ . '/Fixtures/User.php'; +require __DIR__ . '/Fixtures/SoftPost.php'; +require __DIR__ . '/Fixtures/EventUser.php'; +require __DIR__ . '/Fixtures/RetrieveUser.php'; +require __DIR__ . '/Fixtures/CastModel.php'; +require __DIR__ . '/Fixtures/AccessorModel.php';