diff --git a/README.md b/README.md
index c2e3f93..185f946 100644
--- a/README.md
+++ b/README.md
@@ -43,5 +43,7 @@ $active = Contact::where('is_active', 1)
- **[Usage guide](docs/usage.md)** — models, query builder, relationships,
casts, events, transactions, and more.
+- **[Relationships](docs/relations.md)** — `hasOne`/`belongsTo`/`hasMany`/`belongsToMany`,
+ eager & lazy loading, relation aggregates, and limitations.
- **[Schema builder](docs/schema.md)** — table creation, columns, indexes, and migrations.
- **[Breaking changes](docs/breaking-changes.md)** — upgrade notes.
diff --git a/composer.json b/composer.json
index 23eacd1..3dfd57f 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,7 @@
]
},
"require": {
- "php": "^7.4 || ^8.0"
+ "php": ">=8.2"
},
"autoload": {
"psr-4": {
@@ -37,14 +37,16 @@
}
},
"scripts": {
+ "test": "phpunit",
"lint": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php",
- "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 7.4-"
+ "compat": "./vendor/bin/phpcs -p ./src --standard=PHPCompatibilityWP --runtime-set testVersion 8.2-"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.10",
"sirbrillig/phpcs-variable-analysis": "*",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7",
- "phpcompatibility/phpcompatibility-wp": "*"
+ "phpcompatibility/phpcompatibility-wp": "*",
+ "phpunit/phpunit": "^11.5"
},
"extra": {
"branch-alias": {
@@ -52,6 +54,9 @@
}
},
"config": {
+ "platform": {
+ "php": "8.2.0"
+ },
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md
index 2391de2..b12df92 100644
--- a/docs/breaking-changes.md
+++ b/docs/breaking-changes.md
@@ -29,6 +29,8 @@ API and runtime behavior change, it is a **major** version bump.
| 10 | Relations | `addRelation()` signature `string` → `array`, void | Medium |
| 11 | Blueprint | `binary()` removed | Low |
| 12 | QueryBuilder | exception type/message changed in `exec()` | Low |
+| 13 | Model | soft-delete reads exclude trashed by default (opt out with `$soft_delete_scope = false`) | Medium |
+| 14 | Composer | minimum PHP raised to **8.2** (was 7.4) — package no longer installs on PHP < 8.2 | High |
---
@@ -71,6 +73,12 @@ $users->all(); // underlying plain array
$users->toArray(); // array of model arrays
```
+> **Silent-data-loss trap:** an `if (is_array($result)) { … }` "got rows?" guard
+> now **inverts** — a non-empty read is a `Collection` (`is_array` → false) while
+> a zero-row read is a real `[]` (`is_array` → true) — so the branch runs only
+> when there is *nothing* to process, silently dropping data whenever rows exist.
+> Replace such guards with `empty($result)` / `!empty($result)`.
+
---
### 2.2 `QueryBuilder::update()` is no longer chainable and executes immediately
@@ -93,6 +101,15 @@ attributes and executes.
**Migration:** set conditions **before** `update()`; drop trailing
`->save()`/`->exec()`.
+> **Don't chain after `update()`:** `update()` returns the *result*
+> (`Model|false` for an existing model, `int|false` for a fresh one), never the
+> builder, so a trailing builder call breaks. On an **existing** model
+> `$model->update([...])->save()` now works — the `->save()` lands on the returned
+> Model and no-ops (redundant) — but on a **fresh** model the `int` return still
+> fatals (`save()` on int). Drop the trailing `->save()`; `update()` already
+> persisted. (Before the `save()` 0-row fix in §3, the existing-model chain also
+> fataled on any no-op update.)
+
---
### 2.3 `QueryBuilder::save()` return value changed
@@ -128,16 +145,27 @@ which wraps the name as `` `table`.`column` `` unless it already contains a `.`.
->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.
+**Why it breaks:** a raw SQL expression or function call passed to `select()` is
+quoted as a single identifier.
-**Migration:** use `selectRaw()` for expressions; plain columns need no change:
+A plain `column AS alias` **is** handled — `prepareColumnName()` qualifies the
+column and keeps the alias separate, so `->select(['id', 'title AS t'])` emits
+`` `table`.`id`, `table`.`title` AS `t` ``. Only expressions/function calls
+(`COUNT(*)`, `SUM(amount) AS amt`, …) still need `selectRaw()`:
```php
-->selectRaw('COUNT(*) as total')
+->select(['title AS t']) // ✅ simple column alias
+->selectRaw('COUNT(*) as total') // expressions / functions
->selectRaw('SUM(amount) as amt', $bindings)
```
+> **Gotcha — an expression may "accidentally" survive:** `prepareColumnName()`
+> passes a column through untouched only when it already contains a `.`. So
+> `select(['CONCAT("https://example.com/…", col) as x'])` emits valid SQL *merely*
+> because the URL contains a dot — the identical code breaks on a dotless host
+> (`http://localhost/…`). Never rely on this; route any function/expression
+> through `selectRaw()`.
+
---
### 2.5 `delete()` with no `WHERE` clause no longer wipes the table
@@ -238,6 +266,11 @@ 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.
+> **Runtime fatal:** a leftover no-arg `->withCount()` — including one buried in
+> a relation method that is later eager-loaded via `with('rel')` (the relation
+> resolver invokes the method to validate it) — now throws `ArgumentCountError`;
+> the relation-name parameter is required.
+
---
### 2.10 `addRelation()` signature changed
@@ -281,6 +314,50 @@ specifically for this case, should be reviewed.
---
+### 2.13 Soft-delete reads now exclude trashed rows by default
+
+A model with `public $soft_deletes = true;` previously returned **all** rows —
+trashed and non-trashed alike — unless it also opted in with
+`$soft_delete_scope = true`. Reads now inject `deleted_at IS NULL` **by default**,
+so trashed rows no longer appear.
+
+```php
+class Post extends Model
+{
+ public $soft_deletes = true; // reads now hide trashed rows automatically
+}
+
+Post::all(); // excludes trashed rows
+Post::withTrashed()->get(); // include trashed
+Post::onlyTrashed()->get(); // only trashed
+```
+
+**Migration:** to keep the old unfiltered behavior, declare the opt-out flag:
+
+```php
+public $soft_delete_scope = false; // reads return every row, including trashed
+```
+
+`refresh()` reloads a row by its own primary key with `withTrashed()`, so
+re-hydrating a trashed model still reports `exists() === true`.
+
+---
+
+### 2.14 Minimum PHP is now 8.2
+
+`composer.json` `require.php` changed from `^7.4 || ^8.0` to `>=8.2`. The package
+no longer installs on PHP 7.4, 8.0, or 8.1.
+
+**Why it breaks:** a plugin whose own `require.php` still allows < 8.2 can no
+longer resolve this package version via Composer.
+
+**Migration:** raise the consuming plugin's minimum PHP to 8.2 (and its runtime)
+before upgrading. Stay on the previous release if you must support older PHP.
+The test suite runs on PHPUnit 11 (`composer test`); the compatibility gate now
+targets `8.2-`.
+
+---
+
## 3. Behavioral changes
Not signature breaks, but observable runtime differences.
@@ -295,6 +372,12 @@ Not signature breaks, but observable runtime differences.
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 `''`.
+- **`save()` decides success from the query result, not the affected/returned id.**
+ A successful UPDATE that changes no rows (an idempotent re-save where no value
+ differs) and a successful INSERT into a table with a manual/composite key (no
+ auto-increment id) both now return the Model instead of `false` — `exec()`
+ returns `false` only on a real DB error/cancel. The auto-increment id is still
+ assigned to the primary key when present. Genuine errors still return `false`.
- **`paginate()`** defaults `select` to `*` when empty and computes the count
before applying limit/offset; pagination with explicit `select` columns and
count no longer conflict.
@@ -303,6 +386,55 @@ Not signature breaks, but observable runtime differences.
- **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.
+- **`upsert()` now manages `updated_at`** for `$timestamps` models: it inserts both
+ `created_at` and `updated_at`, and on a duplicate key bumps
+ `updated_at = VALUES(updated_at)` while preserving `created_at` — replacing the
+ prior behavior that left `updated_at` untouched and mapped
+ `updated_at = VALUES(created_at)`. The generated SQL changes for upsert on
+ timestamped models.
+- **`belongsToMany()` positional args 2 and 3 changed meaning** — from
+ `(foreignKey, localKey)` to `(pivotTable, foreignPivotKey)` (see §4.6).
+ `belongsToMany($model)` with no extra args is **byte-identical** to before
+ (legacy null-pivot path). Any call passing positional arg 2+ now takes the
+ pivot path, treating arg 2 as the pivot table name. Zero known callers across
+ consumers; flagged for completeness.
+- **Bulk `insert()` and `upsert()` now JSON-encode array/object values** via
+ `wp_json_encode`, matching `save()`/`update()`. Previously a multi-row
+ `insert([[...]])` or `upsert()` bound an array as the literal `"Array"` and an
+ object threw — both repaired. Scalar values are unchanged.
+- **Invalid-SQL / crash repairs (output changes only for previously-broken
+ input; working inputs are byte-identical):** `whereIn('c', [])` / `where('c',
+ [])` now emit `0 = 1` (was invalid `IN ()`); `where('c', '=', null)` and other
+ operator+null forms emit `IS [NOT] NULL` (was a truncated, value-less clause);
+ a `where`/`whereIn` value that is an object or a nested array is `wp_json_encode`d
+ (was a fatal / binding mismatch); `aggregate(fn, '*')` emits `COUNT(*)` (was
+ invalid `COUNT(\`t\`.*)`); an empty `save()` (no changed columns) skips the
+ query and returns the model (was a malformed `UPDATE … SET`); `insert([])`,
+ empty bulk rows, and `upsert($v, [])` no longer emit malformed SQL; a single
+ `insert()` row whose first value is an array no longer crashes.
+- **`take()` / `skip()` cast their argument to `int`** — blocks `LIMIT`/`OFFSET`
+ injection; numeric input is byte-identical.
+- **`orderBy()` / `groupBy()` validate the column** as a plain identifier
+ (`^[A-Za-z0-9_.`]+$`) and throw `RuntimeException` otherwise — blocks
+ `ORDER BY`/`GROUP BY` injection. Plain/qualified identifiers emit byte-identical
+ SQL; pass raw expressions through `orderByRaw()`.
+- **Cast aliases `integer`/`float`/`double`/`json`/`datetime` now work** (map onto
+ the existing casters) — they were previously silent no-ops returning the raw value.
+- **Bulk `insert()` aligns ragged rows by column** — a row whose keys differ from
+ the first row no longer silently shifts values into the wrong columns (uniform
+ rows unchanged).
+- **Eager-loaded empty relations resolve to `[]` without a re-query.** A parent
+ with no related rows previously stored `null`, so accessing the relation fired a
+ fresh lazy query (an N+1) that returned an empty `Collection`. It now holds `[]`
+ directly — no extra query. The value is empty either way (`count()` 0, falsy);
+ the type for an empty eager relation is now a plain array, matching a non-empty
+ eager relation.
+- **`join()` table prefix corrected for custom-`$prefix` models.** Join (and
+ pivot) tables now carry the model's **full** table prefix via the new
+ `Model::getTablePrefix()` (`wp_` plus the plugin prefix), matching the model's
+ own table. Default-`$prefix` models are unchanged (`getTablePrefix()` equals
+ `getPrefix()` there); custom-`$prefix` models that previously lost `wp_` on
+ joins now match their own table.
---
@@ -397,13 +529,45 @@ User::query()->with('posts')->where('active', 1)->get();
`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.
+ `getPrefix()`, `getTablePrefix()` (full table prefix — `wp_` + plugin prefix —
+ for join/pivot table names), `withCast(array $casts)`, `bool`/`boolean` cast.
- **Connection:** `startTransaction()`, `commit()`, `rollback()`.
+- **Model (soft-delete):** `forceDelete()` (real `DELETE`, bypasses the soft
+ rewrite) and `restore()` (clears `deleted_at`). Both throw on a non-soft-delete
+ model.
- **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`).
+### 4.6 Real pivot-table many-to-many on `belongsToMany`
+
+`belongsToMany` now resolves a true many-to-many relation through a pivot
+(junction) table for **reads** — eager `with()` and lazy `$model->relation`:
+
+```php
+public function roles()
+{
+ return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
+}
+
+Member::with('roles')->get(); // eager
+$member->roles; // lazy
+```
+
+Full signature
+`belongsToMany($model, $pivotTable = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null)`.
+Omitted keys derive from the package FK convention (`members_id`, `roles_id`).
+`withPivot([...])` selects extra pivot columns, exposed flat on each related
+model as `pivot_
` attributes (the parent link is always exposed as
+`pivot_`). When `$pivotTable` is `null` the method keeps its
+**legacy** behaviour (resolves like `hasMany`), so existing `belongsToMany($model)`
+calls are unaffected.
+
+Out of scope (read-only): `attach`/`detach`/`sync`, and
+`withCount`/`whereHas`/aggregates over a pivot relation (these throw
+`RuntimeException`). See the usage doc's Limitations for the full list.
+
---
## 5. Deprecations
diff --git a/docs/relations.md b/docs/relations.md
new file mode 100644
index 0000000..8348c04
--- /dev/null
+++ b/docs/relations.md
@@ -0,0 +1,277 @@
+# wp-database — Relationships
+
+Full reference for defining and loading relationships. For the broader API see
+the [Usage guide](usage.md).
+
+Relationships are declared as **methods** on a model; each method returns a query
+for the related model. Load them eagerly with `with()` or lazily by accessing the
+method name as a property (`$model->relation`).
+
+- [Key convention](#key-convention)
+- [`hasOne` / `belongsTo` (one-to-one)](#hasone--belongsto-one-to-one)
+- [`hasMany` (one-to-many)](#hasmany-one-to-many)
+- [`belongsToMany` (many-to-many)](#belongstomany-many-to-many)
+- [Eager loading](#eager-loading)
+- [Lazy loading](#lazy-loading)
+- [Relation aggregates & existence](#relation-aggregates--existence)
+- [Limitations](#limitations)
+
+---
+
+## Key convention
+
+> **`$foreignKey` is the column on the _related_ model's table; `$localKey` is
+> the column on the _calling_ model's table.** This is the **reverse** of
+> Laravel's naming — always pass both arguments explicitly when your columns
+> differ from the ORM default (`{callerTable}_id` / `id`) to avoid confusion.
+
+Every relation method takes the same first three arguments:
+
+```php
+relation($model, $foreignKey = null, $localKey = null)
+```
+
+Omitted keys derive from the package's foreign-key convention: `$foreignKey`
+defaults to the caller's `getForeignKey()` (`{tableWithoutPrefix}_{primaryKey}`,
+e.g. `contacts_id` — note: plural, unlike Laravel's singular default) and
+`$localKey` defaults to the caller's primary key.
+
+---
+
+## `hasOne` / `belongsTo` (one-to-one)
+
+**`hasOne()` is a direct alias of `belongsTo()`.** Both set the same `oneToOne`
+relation type and return a single related model. There is no separate
+reverse-direction implementation — direction is determined entirely by the keys
+you pass and which model calls the method.
+
+```php
+class Contact extends Model
+{
+ public function profile()
+ {
+ // foreignKey='contact_id' on the profiles (related) table
+ // localKey='id' on the contacts (this) table
+ // Predicate: WHERE profiles.contact_id IN (SELECT id FROM contacts)
+ return $this->hasOne(Profile::class, 'contact_id', 'id');
+ }
+}
+
+class Deal extends Model
+{
+ public function contact()
+ {
+ // Deal.contact_id references Contact.id
+ // foreignKey='id' on the contacts (related) table
+ // localKey='contact_id' on the deals (this) table
+ // Predicate: WHERE contacts.id IN (SELECT contact_id FROM deals)
+ return $this->belongsTo(Contact::class, 'id', 'contact_id');
+ }
+}
+```
+
+A `oneToOne` relation resolves to a single `Model` (or `[]` when there is no
+match), not a `Collection`.
+
+---
+
+## `hasMany` (one-to-many)
+
+Same key convention; resolves to a `Collection` of related models.
+
+```php
+class Contact extends Model
+{
+ public function deals()
+ {
+ // foreignKey='contact_id' lives on the deals table
+ // localKey='id' lives on the contacts table
+ // Predicate: WHERE deals.contact_id IN (SELECT id FROM contacts)
+ return $this->hasMany(Deal::class, 'contact_id', 'id');
+ }
+}
+```
+
+---
+
+## `belongsToMany` (many-to-many)
+
+Resolves a many-to-many relation through a pivot (junction) table. Signature:
+
+```php
+belongsToMany(
+ $model,
+ $pivotTable = null, // unprefixed pivot/junction table name
+ $foreignPivotKey = null, // parent's key column ON the pivot table
+ $relatedPivotKey = null, // related's key column ON the pivot table
+ $parentKey = null, // local key column on the parent table
+ $relatedKey = null // key column on the related table
+)
+```
+
+When `$pivotTable` is `null` the method keeps its **legacy** behaviour (resolves
+exactly like `hasMany` — the related table must carry the parent FK). Pass the
+pivot table name (unprefixed; the package prefixes it like `join()` does) to get
+real pivot behaviour.
+
+Omitted keys derive from the package's foreign-key convention:
+
+| Argument | Default | Member (`members`) ↔ Role (`roles`) |
+|---|---|---|
+| `$foreignPivotKey` | parent `getForeignKey()` | `members_id` |
+| `$relatedPivotKey` | related `getForeignKey()` | `roles_id` |
+| `$parentKey` | parent `getPrimaryKey()` | `id` |
+| `$relatedKey` | related `getPrimaryKey()` | `id` |
+
+```php
+class Member extends Model
+{
+ protected $table = 'members';
+
+ public function roles()
+ {
+ // pivot table role_user(member_id, role_id)
+ return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
+ }
+
+ // Carry extra pivot columns; they surface flat as `pivot_` attributes
+ public function rolesWithAssignment()
+ {
+ return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id')
+ ->withPivot(['assigned_at']);
+ }
+}
+```
+
+The link column rides along on every related model as the reserved attribute
+`pivot_` (e.g. `pivot_member_id`), and each `withPivot()` column
+as `pivot_` (e.g. `pivot_assigned_at`). These flat `pivot_*` attributes
+appear in `toArray()`.
+
+```php
+// Eager
+foreach (Member::with('roles')->get() as $member) {
+ foreach ($member->roles as $role) {
+ echo $role->pivot_member_id; // the parent link
+ }
+}
+
+// Lazy
+$member = Member::query()->findOne(['id' => 1]);
+foreach ($member->roles as $role) { /* ... */ }
+```
+
+Pivot relations are **read-only**: `attach`/`detach`/`sync` and
+`withCount`/`whereHas`/aggregates over a pivot relation are **not** supported
+(the aggregates throw a `RuntimeException`). See [Limitations](#limitations).
+
+---
+
+## Eager loading
+
+Load a relation for every parent in one extra query (avoids the N+1 problem).
+
+```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) { /* ... */ }
+}
+```
+
+A parent with no related rows resolves to an empty result **without** triggering
+a fresh lazy query when the relation is later accessed — the eager load already
+resolved it to empty.
+
+---
+
+## Lazy loading
+
+Access the relation method name as a property to load it on demand:
+
+```php
+$contact = Contact::query()->findOne(['id' => 1]);
+
+$contact->deals; // Collection, queried on first access
+$contact->profile; // single Model or []
+```
+
+---
+
+## 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'` for a custom 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 given as `'relation.column'`; passing just the relation name defaults to `*`,
+which is meaningful only for `withCount` and `withExists`.
+
+---
+
+## Limitations
+
+- **Relation names must not be untrusted input.** `with()`, `whereHas()`,
+ `withCount()` (and the other `with*` aggregates) resolve a relation by calling
+ the model method of that name. A name that is not a relation is rejected with a
+ `RuntimeException` ("Relation [x] is not defined on [Class]."), and framework
+ `Model` methods are rejected **without** being called — but a consumer model's
+ own no-arg method is invoked once before its non-relation return is discarded.
+ Pass only trusted, code-defined relation names (same contract as Eloquent),
+ never a raw request value.
+
+- **`belongsTo` and `hasOne` are the same alias; key naming is reversed from
+ Laravel.** Both set the `oneToOne` relation. `$foreignKey` is the column on the
+ **related** table and `$localKey` is the column on the **calling** model's
+ table — the opposite of Laravel. Supply both arguments explicitly.
+
+- **`belongsToMany` pivot relations are read-only and single-key.** Real
+ pivot-table many-to-many is supported for reads (eager `with()` + lazy
+ `$model->relation`), with these gaps:
+ - No `withCount`/`whereHas`/aggregates on a pivot relation — they **throw**
+ `RuntimeException` (the pivot metadata has no single `foreignKey`/`localKey`).
+ - No `attach`/`detach`/`sync` (write side is out of scope).
+ - Single-column pivot/parent/related keys only — no composite keys.
+ - Duplicate pivot rows yield duplicate related models (no `DISTINCT`).
+ - Eager constraint closures may add `where`/`orderBy`/`limit` but **cannot**
+ narrow the selected columns — the pivot path always selects `related.*` so
+ the aliased pivot column can ride along.
+ - Pivot values surface as flat **reserved** `pivot_*` attributes (including the
+ link key `pivot_`) and appear in `toArray()`. A related
+ column literally named `pivot_*` would be overwritten. These attributes are
+ excluded from dirty-tracking on UPDATE, so re-saving a hydrated related model
+ is safe; a forced re-INSERT would attempt to write the non-existent columns.
+ - Null parent key: the eager path buckets a null parent key under null
+ (relation resolves to `null`); the lazy path renders `… IS NULL` and returns
+ pivot rows whose link column is NULL — a minor divergence.
diff --git a/docs/schema.md b/docs/schema.md
index 3708a4b..0c4b801 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -212,8 +212,11 @@ Adds a single nullable `TIMESTAMP` column named `deleted_at`, defaulting to `NUL
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.
+rather than removing the row. Reads return **all rows by default** — including trashed
+ones. To enable automatic filtering, also declare `public $soft_delete_scope = true;` on
+the model: reads then exclude trashed rows automatically. Use `->withTrashed()` to
+include them, or `->onlyTrashed()` to return only trashed rows. See
+[Limitations](usage.md#limitations--known-issues) for the `refresh()` edge case.
---
@@ -256,28 +259,17 @@ Schema::edit('orders', function ($table) {
### Modifying an existing column
-Chain `change()` on a column definition inside `Schema::edit` to emit `CHANGE COLUMN`
+Chain `change()` on a column definition inside `Schema::edit` to emit `MODIFY COLUMN`
instead of `ADD COLUMN`:
```php
Schema::edit('orders', function ($table) {
- $table->varchar('reference', 128)->change(); // CHANGE COLUMN — widen the length
+ $table->varchar('reference', 128)->change(); // MODIFY 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` |
@@ -315,33 +307,32 @@ on `(new Schema())->create(...)` — both are equivalent thanks to `__callStatic
| `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. |
+| `Schema::withWpPrefix()` | Sets the prefix to `Connection::getPrefix()` (WordPress prefix + plugin prefix), so the table matches what `Model`s use. 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)
+// Literal prefix — table resolves as "custom_orders"
+// (without withPrefix/withWpPrefix, the bare name "orders" is used — no prefix is applied)
Schema::withPrefix('custom_')->create('orders', function ($table) {
$table->id();
$table->string('reference');
$table->timestamps();
});
+
+// Match the Model prefix (e.g. wp_ or wp_myplugin_) — table resolves as "wp_orders"
+Schema::withWpPrefix()->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.
+- **Schema builder uses the literal table name by design.** `Schema::create()` passes the
+ name through as-is — no WordPress prefix is added automatically, so existing tables are
+ never relocated. Use `Schema::withPrefix('your_prefix_')` for a literal prefix, or
+ `Schema::withWpPrefix()` to match the prefix `Model`s use (`Connection::getPrefix()`).
+ The `$prefix` property intentionally defaults to `null` (not `''`) to preserve
+ bare-table behaviour for plugins that rely on it.
diff --git a/docs/usage.md b/docs/usage.md
index 8a8b408..141884b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -110,7 +110,7 @@ class Contact extends Model
];
// 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.
+ // Reads exclude trashed rows by default; opt out with $soft_delete_scope = false.
public $soft_deletes = true;
}
```
@@ -123,7 +123,7 @@ class Contact extends Model
| `$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). |
+| `$soft_deletes` | Must be declared `true` on the model. `delete()` then sets `deleted_at` instead of removing the row. Reads **exclude trashed rows by default**. Use `->withTrashed()` to include them, or `->onlyTrashed()` to return only trashed rows. Declare `public $soft_delete_scope = false;` to opt out of the filter (reads return every row, including trashed). See [Limitations](#limitations--known-issues) and [`softDeletes()` in `docs/schema.md`](schema.md#softdeletes). |
---
@@ -142,7 +142,9 @@ $contact = Contact::insert([
'email' => 'ada@example.com',
]);
-// Bulk insert (array of rows) — returns a Collection of created models
+// Bulk insert (array of rows) — always returns a Collection.
+// Element type: Model on success (re-query hydrated); int (inserted ID) on
+// the rare fallback where the post-insert re-query yields no rows.
$created = Contact::insert([
['first_name' => 'Ada', 'email' => 'ada@x.com'],
['first_name' => 'Grace','email' => 'grace@x.com'],
@@ -152,6 +154,10 @@ $created = Contact::insert([
`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`.
+Array and object values are JSON-encoded (`wp_json_encode`) automatically across
+`save()`, `update()`, single-row and bulk `insert()`, and `upsert()` — pass the
+raw array/object, not a pre-encoded string.
+
---
## Reading records
@@ -192,13 +198,16 @@ inspect the SQL without executing.
```php
Contact::select('id', 'email')->get();
Contact::select(['id', 'email'])->get();
+Contact::select(['id', 'title AS t'])->get(); // column alias — qualifies `title`, keeps AS `t`
Contact::addSelect('phone')->get(); // add to existing select
+Contact::select('id', 'email')->distinct()->get(); // SELECT DISTINCT
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.
+> `select()` handles plain columns and `column AS alias`, back-tick-quoting them
+> as identifiers. Pass raw SQL expressions or function calls (`COUNT(*)`, …) to
+> `selectRaw()` instead.
### Where
@@ -266,11 +275,13 @@ Contact::query()
// 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).
+Pass **unprefixed** table names — `join()` prepends the model's full table prefix
+(the same one the model's own table uses, including `wp_` for models with a custom
+`$prefix`). Qualify columns as `table.column` using the **unprefixed** table name:
+the builder resolves a qualifier that matches the model's own table or a joined
+table to its physical, prefixed name in `select`, `where`/`having`, `ON`, `groupBy`
+and `orderBy`. Already-prefixed names, table aliases, and unknown tables are left
+untouched.
### Limit / offset / pagination
@@ -295,8 +306,15 @@ $page = Contact::where('is_active', 1)->paginate($pageNo = 1, $perPage = 20);
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
+Contact::avg('score'); // mixed|null
+Contact::sum('score'); // mixed|null
+Contact::aggregate('GROUP_CONCAT', 'tag'); // any bare-identifier function
```
+> `aggregate()` accepts any SQL function whose name is a bare identifier
+> (letters, digits, underscore); other input is rejected. It does not emit
+> `COUNT(DISTINCT …)` — `distinct()` is ignored by `count()`/`paginate()`.
+
### Inspecting the SQL
```php
@@ -365,8 +383,19 @@ Contact::destroy([1, 2, 3]); // delete by primary keys
`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).
+ the row, and reads **exclude trashed rows by default**. Use `->withTrashed()`
+ to include them, or `->onlyTrashed()` to return only trashed rows. To opt out
+ of the automatic filter (reads return every row, including trashed), declare
+ `public $soft_delete_scope = false;`. See
+ [Limitations](#limitations--known-issues).
+- On a soft-delete model, `forceDelete()` emits a real `DELETE` (bypassing the
+ soft rewrite) and `restore()` clears `deleted_at`. Both throw on a model
+ without `$soft_deletes`.
+
+```php
+Contact::where('id', 1)->forceDelete(); // real DELETE, even for soft-delete models
+Contact::onlyTrashed()->where('id', 1)->restore(); // deleted_at = NULL
+```
---
@@ -389,13 +418,11 @@ Contact::query()->upsert([
```
> **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).
+> work on other databases. See [Limitations](#limitations--known-issues).
+
+On a model with `$timestamps = true`, `upsert()` sets both `created_at` and
+`updated_at` on insert, and on a duplicate key it bumps `updated_at`
+(`updated_at = VALUES(updated_at)`) while preserving the original `created_at`.
---
@@ -431,6 +458,10 @@ Contact::query()->withCast(['is_active' => 'bool'])->get();
## Relationships
+> **Full reference:** [docs/relations.md](relations.md) — every relation type,
+> eager/lazy loading, aggregates, and relation-specific limitations in one place.
+> This section is a quick overview.
+
Define relationships as methods on the model. The relation method returns a
query for the related model.
@@ -476,13 +507,34 @@ class Deal extends Model
> 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)`.
+Available: `hasOne()`, `hasMany()`, `belongsTo()` — each takes
+`($model, $foreignKey = null, $localKey = null)`.
+
+### Many-to-many (`belongsToMany`)
+
+Resolves a many-to-many relation through a pivot (junction) table. Pass the
+unprefixed pivot table name plus the parent/related key columns on it:
-> **`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).
+```php
+class Member extends Model
+{
+ protected $table = 'members';
+
+ public function roles()
+ {
+ // pivot table role_user(member_id, role_id)
+ return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
+ }
+}
+```
+
+With no `$pivotTable` the call keeps its **legacy** `hasMany`-style behaviour.
+Pivot values surface as flat reserved `pivot_*` attributes; add extra ones with
+`->withPivot([...])`. Pivot relations are **read-only** — `attach`/`detach`/`sync`
+and aggregates over a pivot relation are not supported (the latter throw).
+
+See [docs/relations.md](relations.md#belongstomany-many-to-many) for the full
+signature, key-defaults table, `withPivot`, eager/lazy access, and limitations.
### Eager loading
@@ -565,17 +617,13 @@ class Contact extends Model
```
**Subscribable events** (registered via Closure using the methods below):
-`retrieved`, `saving`, `saved`, `updating`, `updated`, `deleting`, `deleted`.
+`retrieved`, `creating`, `created`, `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`.
@@ -619,8 +667,9 @@ 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.
+design the table name is used as-is — call `Schema::withPrefix($prefix)` to
+apply a literal prefix, or `Schema::withWpPrefix()` to use the same prefix
+`Model`s use (`Connection::getPrefix()`).
```php
use BitApps\WPDatabase\Schema;
@@ -670,54 +719,30 @@ 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.
+- **Relation limitations** — `belongsToMany` pivot relations are read-only and
+ single-key (no `attach`/`detach`/`sync`, no aggregates over a pivot relation),
+ and `belongsTo`/`hasOne` share one `oneToOne` alias whose key naming is
+ **reversed from Laravel**. Full detail:
+ [docs/relations.md](relations.md#limitations).
+
+- **Soft delete reads exclude trashed rows by default.** `softDeletes()` adds a
+ `deleted_at` column and `delete()` sets it. Reads automatically filter out
+ trashed rows (`deleted_at IS NULL`); `->withTrashed()` includes them and
+ `->onlyTrashed()` returns only trashed rows. Declare
+ `public $soft_delete_scope = false;` on the model to opt out of the filter and
+ read every row, including trashed ones. `refresh()` reloads a row by its own
+ primary key with `withTrashed()`, so re-hydrating a trashed model still reports
+ `exists() === true` and a following `save()` correctly UPDATEs.
- **`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.
+ which is not portable to other databases. Workaround: use separate `insert` +
+ `update` calls where cross-database portability is required.
+
+- **Schema builder uses the literal table name by design.** `Schema::create()` passes
+ the name through as-is — no WordPress prefix is added automatically, so existing
+ tables are never relocated. Use `Schema::withPrefix('your_prefix_')` for a literal
+ prefix, or `Schema::withWpPrefix()` to match the prefix `Model`s use
+ (`Connection::getPrefix()`). The `$prefix` property intentionally defaults to `null`
+ (not `''`) to preserve bare-table behaviour for plugins that rely on it.
+ See [Schema builder reference](schema.md).
+
diff --git a/src/Blueprint.php b/src/Blueprint.php
index 1be96bf..4a93645 100644
--- a/src/Blueprint.php
+++ b/src/Blueprint.php
@@ -43,8 +43,8 @@
* @method Blueprint float($name, $length = null)
* @method Blueprint double($name, $length = null)
* @method Blueprint double_precision($name, $length = null)
- * @method Blueprint decimal($name, $length = null)
- * @method Blueprint dec($name, $length = null)
+ * @method Blueprint decimal($name, $precision = null, $scale = null)
+ * @method Blueprint dec($name, $precision = null, $scale = null)
* @method Blueprint date($name)
* @method Blueprint datetime($name)
* @method Blueprint timestamp($name)
@@ -99,7 +99,7 @@ class Blueprint
* @param string $prefix Table prefix
* @param null|Closure $callback Closure to build the blueprint
*/
- public function __construct($table, $method, $prefix = '', Closure $callback = null)
+ public function __construct($table, $method, $prefix = '', ?Closure $callback = null)
{
$this->_prefix = $prefix;
$this->table = "{$prefix}{$table}";
@@ -116,7 +116,9 @@ public function __call($method, $parameters)
{
$formattedMethodName = strtoupper(str_replace('_', ' ', $method));
if ($this->isValidType($formattedMethodName)) {
- if (\count($parameters) > 2) {
+ // Only decimal/dec take a scale (name, precision, scale); others are name + length.
+ $maxParams = \in_array($method, ['decimal', 'dec'], true) ? 3 : 2;
+ if (\count($parameters) > $maxParams) {
throw new Exception('Too many parameters');
}
@@ -204,6 +206,7 @@ public function edit()
{
$queryToAdd[] = $this->addColumnQuery();
$queryToAdd[] = $this->dropColumnQuery();
+ $queryToAdd[] = $this->renameColumnQuery();
$queryToAdd = $queryToAdd + $this->_edit;
$queryToAdd[] = $this->addPrimaryKeyQuery();
$queryToAdd[] = $this->addUniqueIndexQuery();
@@ -237,11 +240,13 @@ public function rename($newName)
return $this;
}
- public function addColumn($name, $type, $length = null)
+ public function addColumn($name, $type, $length = null, $scale = null)
{
if ($this->method === 'addColumn') {
$this->_sql = "ALTER TABLE {$this->table} ADD {$name} {$type}";
- if ($length) {
+ if (!\is_null($scale)) {
+ $this->_sql .= "({$length}, {$scale})";
+ } elseif ($length) {
$this->_sql .= "({$length})";
}
} else {
@@ -250,7 +255,10 @@ public function addColumn($name, $type, $length = null)
'name' => $name,
'type' => $type,
];
- if (!\is_null($length)) {
+ if (!\is_null($scale)) {
+ $this->columns[$this->columnIndex]['precision'] = $length;
+ $this->columns[$this->columnIndex]['scale'] = $scale;
+ } elseif (!\is_null($length)) {
$this->length($length);
}
}
@@ -272,7 +280,7 @@ public function dropColumn($column)
public function renameColumn($column, $newName)
{
if ($this->method === 'renameColumn') {
- $this->_sql = "ALTER TABLE {$this->table} CHANGE {$column} {$newName}";
+ $this->_sql = "ALTER TABLE {$this->table} RENAME COLUMN {$column} TO {$newName}";
} else {
$this->columnsToRename[] = [
'column' => $column,
@@ -292,7 +300,7 @@ public function renameColumnQuery()
$query .= "\n, ";
}
- $query .= "CHANGE {$column['column']} {$column['new_name']}";
+ $query .= "RENAME COLUMN {$column['column']} TO {$column['new_name']}";
}
}
@@ -499,83 +507,35 @@ public function cascade()
public function dropForeign($keys)
{
- if ($this->method === 'dropForeign') {
- $this->_sql = "ALTER TABLE `{$this->table}`";
- } else {
- $sql = '';
- $idCount = \count($keys) - 1;
- $i = 0;
- if (\is_array($keys)) {
- foreach ($keys as $key) {
- if ($i == $idCount) {
- $sql .= " DROP FOREIGN KEY `{$key}`";
- } else {
- $sql .= " DROP FOREIGN KEY `{$key}`,";
- }
-
- $i++;
- }
- } else {
- $sql .= " DROP FOREIGN KEY `{$keys}`";
- }
-
- $this->_edit['dropForeign'] = $sql;
- }
+ $this->applyDropClause('dropForeign', 'dropForeign', $this->buildDropForeignClause($keys));
return $this;
}
public function dropIndex($indexes)
{
- if ($this->method === 'dropIndex') {
- $this->_sql = "ALTER TABLE `{$this->table}`";
- } else {
- $sql = '';
- $idCount = \count($indexes) - 1;
- $i = 0;
- if (\is_array($indexes)) {
- foreach ($indexes as $index) {
- if ($i == $idCount) {
- $sql .= " DROP INDEX `{$index}`";
- } else {
- $sql .= " DROP INDEX `{$index}`,";
- }
-
- $i++;
- }
- } else {
- $sql .= " DROP INDEX `{$indexes}`";
- }
-
- $this->_edit['dropIndex'] = $sql;
- }
+ $this->applyDropClause('dropIndex', 'dropIndex', $this->buildDropIndexClause($indexes));
return $this;
}
public function dropPrimary()
{
- if ($this->method === 'dropPrimary') {
- $this->_sql = "ALTER TABLE `{$this->table}`";
- } else {
- $this->_edit['dropPrimary'] = ' DROP PRIMARY KEY';
- }
+ $this->applyDropClause('dropPrimary', 'dropPrimary', ' DROP PRIMARY KEY');
return $this;
}
public function dropUnique($indexes)
{
- return $this->dropIndex($indexes);
+ $this->applyDropClause('dropUnique', 'dropIndex', $this->buildDropIndexClause($indexes));
+
+ return $this;
}
public function dropTimestamps()
{
- if ($this->method === 'dropTimestamps') {
- $this->_sql = "ALTER TABLE `{$this->table}`";
- } else {
- $this->_edit['dropTimestamps'] = ' DROP COLUMN created_at, DROP COLUMN updated_at';
- }
+ $this->applyDropClause('dropTimestamps', 'dropTimestamps', ' DROP COLUMN created_at, DROP COLUMN updated_at');
return $this;
}
@@ -596,6 +556,43 @@ public function length($length)
return $this;
}
+ private function buildDropIndexClause($indexes)
+ {
+ if (\is_array($indexes)) {
+ $clauses = [];
+ foreach ($indexes as $index) {
+ $clauses[] = " DROP INDEX `{$index}`";
+ }
+
+ return implode(',', $clauses);
+ }
+
+ return " DROP INDEX `{$indexes}`";
+ }
+
+ private function buildDropForeignClause($keys)
+ {
+ if (\is_array($keys)) {
+ $clauses = [];
+ foreach ($keys as $key) {
+ $clauses[] = " DROP FOREIGN KEY `{$key}`";
+ }
+
+ return implode(',', $clauses);
+ }
+
+ return " DROP FOREIGN KEY `{$keys}`";
+ }
+
+ private function applyDropClause($directMethod, $editKey, $clause)
+ {
+ if ($this->method === $directMethod) {
+ $this->_sql = "ALTER TABLE `{$this->table}`" . $clause;
+ } else {
+ $this->_edit[$editKey] = $clause;
+ }
+ }
+
private function getCollation()
{
$collate = null;
@@ -639,18 +636,16 @@ private function addColumnQuery()
$query .= "\n, ";
}
- if ($this->method === 'edit') {
- $query .= 'ADD COLUMN ';
- }
-
if (isset($column['change'])) {
- $query .= 'CHANGE COLUMN ';
+ $query .= 'MODIFY COLUMN ';
+ } elseif ($this->method === 'edit') {
+ $query .= 'ADD COLUMN ';
}
$query .= $column['name'] . ' ' . $column['type'];
if (!empty($column['length'])) {
$query .= '(' . $column['length'] . ')';
- } elseif (!empty($column['precision']) && !empty($column['scale'])) {
+ } elseif (isset($column['precision'], $column['scale'])) {
$query .= '(' . $column['precision'] . ', ' . $column['scale'] . ')';
} else {
$query .= ' ';
@@ -748,12 +743,13 @@ private function addUniqueIndexQuery()
return '';
}
- $query = '';
+ $addPrefix = $this->method === 'edit' ? ' ADD ' : '';
+ $query = '';
foreach ($this->uniqueIndex as $key => $uniqueColumn) {
if (\is_array($uniqueColumn)) {
- $query .= "\nUNIQUE INDEX " . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),';
+ $query .= "\n" . $addPrefix . 'UNIQUE INDEX ' . implode('_', $uniqueColumn) . '_UNIQUE (' . implode(',', $uniqueColumn) . '),';
} else {
- $query .= "\nUNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),";
+ $query .= "\n" . $addPrefix . "UNIQUE INDEX {$uniqueColumn}_UNIQUE ({$uniqueColumn} ASC),";
}
}
diff --git a/src/Concerns/HasEvents.php b/src/Concerns/HasEvents.php
index 80cdf52..4594ae9 100644
--- a/src/Concerns/HasEvents.php
+++ b/src/Concerns/HasEvents.php
@@ -50,6 +50,16 @@ protected static function retrieved(Closure $callback)
static::registerEvent('retrieved', $callback);
}
+ protected static function creating(Closure $callback)
+ {
+ static::registerEvent('creating', $callback);
+ }
+
+ protected static function created(Closure $callback)
+ {
+ static::registerEvent('created', $callback);
+ }
+
protected static function saving(Closure $callback)
{
static::registerEvent('saving', $callback);
diff --git a/src/Concerns/QueriesRelationships.php b/src/Concerns/QueriesRelationships.php
index 5e6366a..3cce9cd 100644
--- a/src/Concerns/QueriesRelationships.php
+++ b/src/Concerns/QueriesRelationships.php
@@ -7,8 +7,10 @@
namespace BitApps\WPDatabase\Concerns;
+use BitApps\WPDatabase\Model;
use BitApps\WPDatabase\QueryBuilder;
use Closure;
+use RuntimeException;
if (!\defined('ABSPATH')) {
exit;
@@ -37,6 +39,21 @@ public function with($relation, $callback = null)
return $this;
}
+ /**
+ * Selects extra pivot-table columns for a pivot belongsToMany relation.
+ * They surface flat on each related model as `pivot_` attributes.
+ *
+ * @param array|string $columns
+ *
+ * @return $this
+ */
+ public function withPivot($columns)
+ {
+ $this->_model->addPivotColumns(\is_array($columns) ? $columns : \func_get_args());
+
+ return $this;
+ }
+
/**
* Adds a relation count sub query to this query.
*
@@ -127,6 +144,8 @@ public function withAggregate($relation, $column, $function)
return $this;
}
+ $this->assertSafeAggregateFunction($function);
+
if (empty($this->select)) {
$this->select = ["`{$this->_model->getTable()}`.*"];
}
@@ -284,6 +303,10 @@ private function resolveRelations($relation, $callback)
*/
private function correlate(QueryBuilder $relationalQuery)
{
+ if ($relationalQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) {
+ throw new RuntimeException('Relation aggregates and whereHas are not supported for pivot belongsToMany relations.');
+ }
+
$relationKey = $relationalQuery->getModel()->getActiveRelationKey();
$relationalQuery->whereRaw(
diff --git a/src/Concerns/Relations.php b/src/Concerns/Relations.php
index a41f1d0..305d6a2 100644
--- a/src/Concerns/Relations.php
+++ b/src/Concerns/Relations.php
@@ -9,6 +9,7 @@
use BitApps\WPDatabase\Model;
use BitApps\WPDatabase\QueryBuilder;
use Closure;
+use RuntimeException;
if (!\defined('ABSPATH')) {
exit;
@@ -22,6 +23,9 @@ trait Relations
private $_relationKeys = [];
+ /** Memoized framework-vs-consumer verdict per "class::method". */
+ private static $relationMethodCache = [];
+
/**
* Undocumented function.
*
@@ -81,13 +85,28 @@ public function newHasMany($model, $foreignKey = null, $localKey = null)
return $model->newQuery();
}
- public function belongsToMany($model, $foreignKey = null, $localKey = null)
- {
- $relationKeys = $this->getRelationKeys($foreignKey, $localKey);
- $foreignKey = $relationKeys[0];
- $localKey = $relationKeys[1];
+ public function belongsToMany(
+ $model,
+ $pivotTable = null,
+ $foreignPivotKey = null,
+ $relatedPivotKey = null,
+ $parentKey = null,
+ $relatedKey = null
+ ) {
+ if ($pivotTable === null) {
+ $relationKeys = $this->getRelationKeys($foreignPivotKey, $relatedPivotKey);
+
+ return $this->newBelongsToMany($model, $relationKeys[0], $relationKeys[1]);
+ }
- return $this->newBelongsToMany($model, $foreignKey, $localKey);
+ return $this->newBelongsToManyPivot(
+ $model,
+ $pivotTable,
+ $foreignPivotKey,
+ $relatedPivotKey,
+ $parentKey,
+ $relatedKey
+ );
}
public function newBelongsToMany($model, $foreignKey = null, $localKey = null)
@@ -102,6 +121,46 @@ public function newBelongsToMany($model, $foreignKey = null, $localKey = null)
return $model->newQuery();
}
+ public function newBelongsToManyPivot(
+ $model,
+ $pivotTable,
+ $foreignPivotKey = null,
+ $relatedPivotKey = null,
+ $parentKey = null,
+ $relatedKey = null
+ ) {
+ $related = new $model();
+
+ $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey();
+ $relatedPivotKey = $relatedPivotKey ?: $related->getForeignKey();
+ $parentKey = $parentKey ?: $this->getPrimaryKey();
+ $relatedKey = $relatedKey ?: $related->getPrimaryKey();
+
+ $related->setRelateAs(Model::RELATE_AS_PIVOT);
+ $related->_relationKeys[Model::RELATE_AS_PIVOT] = [
+ 'pivotTable' => $pivotTable,
+ 'foreignPivotKey' => $foreignPivotKey,
+ 'relatedPivotKey' => $relatedPivotKey,
+ 'parentKey' => $parentKey,
+ 'relatedKey' => $relatedKey,
+ 'pivotColumns' => [],
+ ];
+
+ return $related->newQuery();
+ }
+
+ public function addPivotColumns(array $columns)
+ {
+ if (!isset($this->_relationKeys[Model::RELATE_AS_PIVOT])) {
+ throw new RuntimeException('withPivot() is only valid on a pivot belongsToMany relation.');
+ }
+
+ $this->_relationKeys[Model::RELATE_AS_PIVOT]['pivotColumns'] = array_merge(
+ $this->_relationKeys[Model::RELATE_AS_PIVOT]['pivotColumns'],
+ $columns
+ );
+ }
+
/**
* Returns list of query of relations
*
@@ -129,7 +188,15 @@ public function getRelationalKeys()
*/
public function getActiveRelationKey()
{
- return $this->getRelationalKeys()[$this->getRelateAs()];
+ $relateAs = $this->getRelateAs();
+ $relationalKeys = $this->getRelationalKeys();
+
+ // Short-circuit a null tag: isset($array[null]) is a deprecated null offset.
+ if ($relateAs === null || !isset($relationalKeys[$relateAs])) {
+ throw new RuntimeException('No relation keys for relation tag [' . $relateAs . '].');
+ }
+
+ return $relationalKeys[$relateAs];
}
/**
@@ -144,14 +211,14 @@ 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}();
+ $preparedRelation[$value] = $this->resolveRelationQuery($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}();
+ $preparedRelation[$key] = $this->resolveRelationQuery($method);
if ($value instanceof Closure) {
$value($preparedRelation[$key]);
}
@@ -161,6 +228,84 @@ public function prepareRelation(array $relations): array
return $preparedRelation;
}
+ /**
+ * Resolves an existing method name to its relation query, or fails loudly.
+ *
+ * The name has already passed method_exists() — a missing name is silently
+ * skipped by the caller (zero-BC for optional/typo'd relation lists). A method
+ * declared on the framework Model is rejected WITHOUT being called, so a
+ * side-effecting method (e.g. refresh()) can never run through a relation
+ * name; a consumer-declared method is called and its return validated.
+ *
+ * @param string $method
+ *
+ * @return QueryBuilder
+ */
+ private function resolveRelationQuery($method)
+ {
+ if ($this->isFrameworkModelMethod($method)) {
+ throw new RuntimeException($this->undefinedRelationMessage($method));
+ }
+
+ $result = $this->{$method}();
+
+ if (!$this->isRelationQuery($result)) {
+ throw new RuntimeException($this->undefinedRelationMessage($method));
+ }
+
+ return $result;
+ }
+
+ /**
+ * True when $method is declared on the framework Model itself (traits flatten
+ * into the using class, so relation/write helpers all report Model as their
+ * declaring class) rather than on a consumer subclass. Compares against
+ * Model::class so the check survives php-scoper's namespace prefixing.
+ *
+ * @param string $method
+ *
+ * @return bool
+ */
+ private function isFrameworkModelMethod($method)
+ {
+ $cacheKey = \get_class($this) . '::' . $method;
+ if (isset(self::$relationMethodCache[$cacheKey])) {
+ return self::$relationMethodCache[$cacheKey];
+ }
+
+ $declaringClass = (new \ReflectionMethod($this, $method))->getDeclaringClass()->getName();
+
+ return self::$relationMethodCache[$cacheKey] = $declaringClass === Model::class;
+ }
+
+ /**
+ * True when $result is a relation query: a QueryBuilder whose model carries
+ * an active relation-key entry.
+ *
+ * @param mixed $result
+ *
+ * @return bool
+ */
+ private function isRelationQuery($result)
+ {
+ if (!$result instanceof QueryBuilder) {
+ return false;
+ }
+
+ try {
+ $result->getModel()->getActiveRelationKey();
+ } catch (RuntimeException $e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function undefinedRelationMessage($method)
+ {
+ return 'Relation [' . $method . '] is not defined on [' . \get_class($this) . '].';
+ }
+
public function prepareRelationName(string $relationName): array
{
$name = $relationName;
@@ -193,13 +338,18 @@ private function retrieveRelateData(QueryBuilder $query)
if (\count($relations) > 0) {
foreach ($relations as $relationName => $relationQuery) {
- $parentQuery = clone $query;
+ if ($relationQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) {
+ $this->retrievePivotRelateData($relationName, $relationQuery, $query);
+
+ continue;
+ }
+
$relationKey = $relationQuery->getModel()->getActiveRelationKey();
$relationQuery->whereRaw(
$relationKey['foreignKey']
. ' IN ( SELECT * FROM ('
- . $parentQuery->select($relationKey['localKey'])->prepare()
+ . $query->prepareKeySubquery($relationKey['localKey'])
. ') AS subquery )'
);
@@ -215,16 +365,77 @@ private function retrieveRelateData(QueryBuilder $query)
}
}
+ private function retrievePivotRelateData($relationName, QueryBuilder $relationQuery, QueryBuilder $query)
+ {
+ [$pivot, $pivotRef, $bucketAlias] = $this->applyPivotSelectAndJoin($relationQuery);
+
+ $relationQuery->whereRaw(
+ $pivotRef . '.' . $pivot['foreignPivotKey']
+ . ' IN ( SELECT * FROM ('
+ . $query->prepareKeySubquery($pivot['parentKey'])
+ . ') AS subquery )'
+ );
+
+ $relatedModels = $relationQuery->get();
+
+ if ($relatedModels) {
+ foreach ($relatedModels as $relatedModel) {
+ $this->_relatedData[$relationName][$relatedModel->getAttribute($bucketAlias)][] = $relatedModel;
+ }
+ }
+ }
+
+ /**
+ * Selects related.* plus the aliased pivot link column, joins the pivot
+ * table on the related key, and appends any withPivot() columns. Returns
+ * [pivot metadata, prefixed pivot-table reference, bucket alias] so callers
+ * build their predicate without recomputing them.
+ *
+ * @return array
+ */
+ private function applyPivotSelectAndJoin(QueryBuilder $relationQuery)
+ {
+ $model = $relationQuery->getModel();
+ $pivot = $model->getActiveRelationKey();
+ $pivotRef = $model->getTablePrefix() . $pivot['pivotTable'];
+ $alias = Model::PIVOT_ATTRIBUTE_PREFIX . $pivot['foreignPivotKey'];
+
+ $relationQuery->select(['*']);
+ $relationQuery->join(
+ $pivot['pivotTable'],
+ $pivotRef . '.' . $pivot['relatedPivotKey'],
+ '=',
+ $relationQuery->getTable() . '.' . $pivot['relatedKey']
+ );
+ $relationQuery->selectRaw($pivotRef . '.' . $pivot['foreignPivotKey'] . ' as `' . $alias . '`');
+
+ foreach ($pivot['pivotColumns'] as $column) {
+ $relationQuery->selectRaw(
+ $pivotRef . '.' . $column . ' as `' . Model::PIVOT_ATTRIBUTE_PREFIX . $column . '`'
+ );
+ }
+
+ return [$pivot, $pivotRef, $alias];
+ }
+
private function setRelatedData(Model $model)
{
$relations = $this->getRelations();
if (\count($relations) > 0) {
foreach ($relations as $relationName => $relationQuery) {
+ if ($relationQuery->getModel()->getRelateAs() === Model::RELATE_AS_PIVOT) {
+ $this->setPivotRelatedData($model, $relationName, $relationQuery->getModel());
+
+ continue;
+ }
+
$relationKey = $relationQuery->getModel()->getActiveRelationKey();
+ // empty relations store [] (not null) so accessing them returns the
+ // resolved-empty result instead of falling through to a lazy re-query (N+1).
$data = isset(
$this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])]
- ) ? $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] : null;
+ ) ? $this->_relatedData[$relationName][$model->getAttribute($relationKey['localKey'])] : [];
if ($relationQuery->getModel()->getRelateAs() === 'oneToOne' && is_countable($data) && \count($data)) {
$data = $data[0];
@@ -236,4 +447,16 @@ private function setRelatedData(Model $model)
}
}
}
+
+ private function setPivotRelatedData(Model $model, $relationName, Model $relatedModel)
+ {
+ $pivot = $relatedModel->getActiveRelationKey();
+ $key = $model->getAttribute($pivot['parentKey']);
+ $data = isset($this->_relatedData[$relationName][$key])
+ ? $this->_relatedData[$relationName][$key]
+ : [];
+
+ [$name, $alias] = $this->prepareRelationName($relationName);
+ $model->setAttribute(\is_null($alias) ? $name : $alias, $data);
+ }
}
diff --git a/src/Model.php b/src/Model.php
index 012e1da..d409439 100644
--- a/src/Model.php
+++ b/src/Model.php
@@ -73,6 +73,7 @@
* @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 withPivot($columns)
* @method static QueryBuilder withCount(string|array $relation)
* @method static QueryBuilder withMin(string|array $relation)
* @method static QueryBuilder withMax(string|array $relation)
@@ -84,9 +85,15 @@
* @method static int count()
* @method static mixed max($column)
* @method static mixed min($column)
+ * @method static mixed avg($column)
+ * @method static mixed sum($column)
* @method static bool|string delete()
+ * @method static string|bool forceDelete()
+ * @method static string|bool|Model restore()
* @method static string toSql()
* @method static string prepare($sql = null)
+ * @method static QueryBuilder withTrashed()
+ * @method static QueryBuilder onlyTrashed()
*
* @mixin \BitApps\WPDatabase\QueryBuilder
*/
@@ -94,6 +101,10 @@ abstract class Model implements ArrayAccess, JsonSerializable
{
use Relations, HasEvents;
+ public const RELATE_AS_PIVOT = 'belongsToManyPivot';
+
+ public const PIVOT_ATTRIBUTE_PREFIX = 'pivot_';
+
public $timestamps = true;
protected $table;
@@ -151,13 +162,7 @@ public function __construct($attributes = [])
$this->_tableWithoutPrefix = $this->table;
}
- $dbPrefix = Connection::wpPrefix();
-
- if ($this->prefix === '') {
- $dbPrefix = Connection::getPrefix();
- }
-
- $this->table = $dbPrefix . $this->prefix . $this->_tableWithoutPrefix;
+ $this->table = $this->getTablePrefix() . $this->_tableWithoutPrefix;
if (!isset($this->primaryKey)) {
$this->primaryKey = 'id';
@@ -235,7 +240,10 @@ public function refresh()
return false;
}
- $result = $this->newQuery()->findOne([$this->primaryKey => $this->attributes[$this->primaryKey]]);
+ // withTrashed(): refresh reloads this row by its own PK, so it must find
+ // the row even when trashed — otherwise a hydrated soft-deleted model
+ // reports exists() === false and the next save() re-INSERTs a duplicate.
+ $result = $this->newQuery()->withTrashed()->findOne([$this->primaryKey => $this->attributes[$this->primaryKey]]);
if (!$result) {
$this->_isExists = false;
@@ -295,11 +303,25 @@ public function getTable()
return $this->table;
}
+ /** Query/schema default prefix; NOT the full table prefix — use getTablePrefix() for join/pivot table names (it keeps wp_ for custom $prefix). */
public function getPrefix()
{
return $this->prefix === '' ? Connection::getPrefix() : $this->prefix;
}
+ /**
+ * Full prefix prepended to this model's table: wp_ plus the plugin prefix.
+ * Mirrors the table built in the constructor so joins and pivot tables
+ * resolve to the same physical table the model itself targets. Unlike
+ * getPrefix(), this never drops wp_ for custom-$prefix models.
+ *
+ * @return string
+ */
+ public function getTablePrefix()
+ {
+ return $this->prefix === '' ? Connection::getPrefix() : Connection::wpPrefix() . $this->prefix;
+ }
+
public function getTableWithoutPrefix()
{
return $this->_tableWithoutPrefix;
@@ -368,7 +390,33 @@ public function isDirty()
public function getDirtyAttributes()
{
- return $this->dirty;
+ if (!\is_array($this->dirty)) {
+ return $this->dirty;
+ }
+
+ return array_filter($this->dirty, function ($value) {
+ return !$this->isRelationValue($value);
+ });
+ }
+
+ /**
+ * True when a value is a loaded relation (a Collection, a related Model, or
+ * a list whose first element is one) rather than a persistable column value.
+ * A plain JSON array of scalars is NOT a relation and is still written.
+ *
+ * @param mixed $value
+ *
+ * @return bool
+ */
+ public function isRelationValue($value)
+ {
+ if ($value instanceof Collection || $value instanceof Model) {
+ return true;
+ }
+
+ return \is_array($value)
+ && isset($value[0])
+ && ($value[0] instanceof Model || $value[0] instanceof Collection);
}
public function getOriginal()
@@ -530,15 +578,37 @@ private function castTo($column, $value)
return $value;
}
- if (
- !isset($this->casts)
- || (isset($this->casts) && !isset($this->casts[$column]))
- || !method_exists($this, 'castTo' . ucfirst($this->casts[$column]))
- ) {
+ if (!isset($this->casts) || !isset($this->casts[$column])) {
+ return $value;
+ }
+
+ $caster = $this->resolveCastMethod($this->casts[$column]);
+ if (!method_exists($this, $caster)) {
return $value;
}
- return \call_user_func([$this, 'castTo' . ucfirst($this->casts[$column])], $value);
+ return \call_user_func([$this, $caster], $value);
+ }
+
+ /**
+ * Resolves a cast name to its caster method, mapping the documented aliases
+ * (integer/float/double/json/datetime) onto the existing casters.
+ *
+ * @param string $cast
+ *
+ * @return string
+ */
+ private function resolveCastMethod($cast)
+ {
+ $aliases = [
+ 'integer' => 'castToInt',
+ 'float' => 'castToFloat',
+ 'double' => 'castToFloat',
+ 'json' => 'castToArray',
+ 'datetime' => 'castToDate',
+ ];
+
+ return isset($aliases[$cast]) ? $aliases[$cast] : 'castTo' . ucfirst($cast);
}
private function castToObject($value)
@@ -564,6 +634,11 @@ private function castToInt($value)
return (int) $value;
}
+ private function castToFloat($value)
+ {
+ return (float) $value;
+ }
+
private function castToString($value)
{
return (string) $value;
@@ -586,7 +661,18 @@ private function castToDate($value)
private function processRelatedAttribute(QueryBuilder $attribute)
{
- $relation = $attribute->getModel()->getRelateAs();
+ $relation = $attribute->getModel()->getRelateAs();
+
+ if ($relation === self::RELATE_AS_PIVOT) {
+ [$pivot, $pivotRef] = $this->applyPivotSelectAndJoin($attribute);
+ $attribute->where(
+ $pivotRef . '.' . $pivot['foreignPivotKey'],
+ $this->getAttribute($pivot['parentKey'])
+ );
+
+ return $attribute->get();
+ }
+
$relationKey = $attribute->getModel()->getRelationalKeys()[$relation];
$attribute->where($relationKey['foreignKey'], $this->getAttribute($relationKey['localKey']));
if ($relation == 'oneToOne') {
diff --git a/src/Query/Grammar.php b/src/Query/Grammar.php
index 2fbbe0a..ea1441b 100644
--- a/src/Query/Grammar.php
+++ b/src/Query/Grammar.php
@@ -28,7 +28,8 @@ public function compileSelect(QueryBuilder $query): string
{
$query->resetBindings();
- $sql = 'SELECT ' . implode(',', $query->select);
+ $columns = array_map([$query, 'resolveQualifier'], $query->select);
+ $sql = 'SELECT ' . ($query->isDistinct() ? 'DISTINCT ' : '') . implode(',', $columns);
$sql .= $this->prepareRawSelect($query);
$sql .= ' FROM ' . $query->getTable();
$sql .= $this->getFrom($query);
@@ -148,7 +149,7 @@ private function getGroupBy(QueryBuilder $query)
return '';
}
- return ' GROUP BY ' . implode(',', $groupBy);
+ return ' GROUP BY ' . implode(',', array_map([$query, 'resolveQualifier'], $groupBy));
}
/**
@@ -184,7 +185,7 @@ private function getOrderBy(QueryBuilder $query)
$sql .= $order['raw'] . ', ';
$query->addBindings($order['bindings']);
} elseif (isset($order['column'])) {
- $sql .= $order['column'] . ' ' . $order['direction'] . ', ';
+ $sql .= $query->resolveQualifier($order['column']) . ' ' . $order['direction'] . ', ';
}
}
@@ -258,7 +259,7 @@ private function prepareRawSelect(QueryBuilder $query)
private function prepareColumnForWhere(QueryBuilder $query, $clause)
{
if (isset($clause['column'])) {
- return ' ' . $query->prepareColumnName($clause['column']);
+ return ' ' . $query->resolveQualifier($query->prepareColumnName($clause['column']));
}
}
@@ -300,7 +301,7 @@ private function prepareValueForWhere(QueryBuilder $query, $clause)
{
$sql = '';
if (isset($clause['secondColumn'])) {
- return ' ' . $clause['secondColumn'];
+ return ' ' . $query->resolveQualifier($clause['secondColumn']);
}
if (!isset($clause['value'])) {
diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php
index 43fde14..1155dce 100644
--- a/src/QueryBuilder.php
+++ b/src/QueryBuilder.php
@@ -77,6 +77,12 @@ class QueryBuilder
private $_grammar;
+ private $_withTrashed = false;
+
+ private $_onlyTrashed = false;
+
+ private $_forceDelete = false;
+
/**
* Constructs QueryBuilder
*
@@ -240,13 +246,47 @@ public function getTable()
/**
* Returns the clause list (where/having) for the given type.
*
+ * When the type is 'where' and the model opts into soft-delete scope,
+ * a deleted_at IS NULL (or IS NOT NULL for onlyTrashed) clause is injected
+ * on SELECT queries without mutating $this->where.
+ *
* @param string $type
*
* @return array
*/
public function getClauseList($type)
{
- return $type === 'having' ? $this->having : $this->where;
+ if ($type === 'having') {
+ return $this->having;
+ }
+
+ if (!$this->isSoftDeleteModel() || $this->_method !== self::SELECT) {
+ return $this->where;
+ }
+
+ $scopeClause = null;
+ if ($this->_onlyTrashed) {
+ $scopeClause = ['column' => 'deleted_at', 'operator' => 'IS NOT NULL'];
+ } elseif ($this->autoScopeEnabled() && !$this->_withTrashed) {
+ $scopeClause = ['column' => 'deleted_at', 'operator' => 'IS NULL'];
+ }
+
+ if ($scopeClause === null) {
+ return $this->where;
+ }
+
+ if (empty($this->where)) {
+ return [$scopeClause];
+ }
+
+ // Wrap user conditions to prevent AND/OR precedence issues with the injected scope
+ $nestedQuery = $this->newNestedQuery();
+ $nestedQuery->where = $this->where;
+
+ return [
+ ['query' => $nestedQuery],
+ $scopeClause,
+ ];
}
/**
@@ -323,6 +363,10 @@ public function all($columns = ['*'])
public function prepareColumnName(string $column)
{
+ if (preg_match('/^(.+?)\s+as\s+(.+)$/i', $column, $matches)) {
+ return $this->prepareColumnName(trim($matches[1])) . ' AS `' . trim($matches[2], " `") . '`';
+ }
+
if (strpos($column, '.') !== false) {
return $column;
}
@@ -354,6 +398,30 @@ public function select($columns = ['*'])
return $this;
}
+ /**
+ * Compiles this query as a single-column key subquery for a relation's
+ * IN (...) constraint: only $keyColumn is projected. Strips selectRaw (extra
+ * columns would break the operand count) and, unless a LIMIT pins the set,
+ * ORDER BY (meaningless for set membership and may reference a stripped raw
+ * select alias).
+ *
+ * @param string $keyColumn
+ *
+ * @return string
+ */
+ public function prepareKeySubquery($keyColumn): string
+ {
+ $clone = clone $this;
+ $clone->selectRaw = ['columns' => [], 'bindings' => []];
+ $clone->groupBy = [];
+ $clone->having = [];
+ if (!isset($clone->limit)) {
+ $clone->orderBy = [];
+ }
+
+ return $clone->select($keyColumn)->prepare();
+ }
+
/**
* Adds column to select list
*
@@ -393,6 +461,29 @@ public function selectRaw($column, array $bindings = [])
return $this;
}
+ /**
+ * Adds DISTINCT to the SELECT. Note: this does not emit COUNT(DISTINCT ...)
+ * and is not pagination-aware (count()/paginate() ignore it).
+ *
+ * @return $this
+ */
+ public function distinct()
+ {
+ $this->distinct = true;
+
+ return $this;
+ }
+
+ /**
+ * Whether DISTINCT was requested for this SELECT.
+ *
+ * @return bool
+ */
+ public function isDistinct()
+ {
+ return $this->distinct;
+ }
+
/**
* Prepare the query and execute.
*
@@ -536,6 +627,14 @@ public function orWhereRaw($sql, $bindings = [])
*/
public function whereIn($column, $value)
{
+ if (\is_array($value)) {
+ if ($value === []) {
+ return $this->whereRaw('0 = 1');
+ }
+
+ $value = $this->sanitizeInValues($value);
+ }
+
$this->where[] = [
'column' => $column,
'value' => $value,
@@ -579,6 +678,30 @@ public function whereNotNull($column)
return $this;
}
+ /**
+ * Include soft-deleted rows in the result set.
+ *
+ * @return $this
+ */
+ public function withTrashed()
+ {
+ $this->_withTrashed = true;
+
+ return $this;
+ }
+
+ /**
+ * Restrict results to only soft-deleted rows.
+ *
+ * @return $this
+ */
+ public function onlyTrashed()
+ {
+ $this->_onlyTrashed = true;
+
+ return $this;
+ }
+
/**
* Set where clause with between condition
*
@@ -662,7 +785,11 @@ public function paginate($pageNo = 0, $perPage = 10)
*/
public function groupBy($columns)
{
- $columns = \is_array($columns) ? $columns : \func_get_args();
+ $columns = \is_array($columns) ? $columns : \func_get_args();
+ foreach ($columns as $column) {
+ $this->assertSafeIdentifier($column);
+ }
+
$this->groupBy = array_merge($this->groupBy, $columns);
return $this;
@@ -705,25 +832,119 @@ public function orHaving(...$params)
*/
public function join($table, $firstColumn, $operator = null, $secondColumn = null, $type = 'INNER')
{
- $table = Connection::wpPrefix() . $this->_model->getPrefix() . $table;
- $hasAlias = preg_split('/ as /i', $table);
- if ($hasAlias && isset($hasAlias[1])) {
- $table = $hasAlias[0];
- $alias = $hasAlias[1];
- } else {
- $alias = $table;
- }
+ $parts = preg_split('/ as /i', $table);
+ $rawTable = $parts[0];
+ $alias = isset($parts[1]) ? $parts[1] : null;
+ $prefixedTable = $this->_model->getTablePrefix() . $rawTable;
+ $reference = $alias !== null ? $alias : $prefixedTable;
+ $tableSql = $alias !== null ? $prefixedTable . ' as ' . $alias : $prefixedTable;
- $on[] = $this->prepareOn($alias, $firstColumn, $operator, $secondColumn, 'AND');
+ $on[] = $this->prepareOn($reference, $firstColumn, $operator, $secondColumn, 'AND');
$this->joins[] = [
- 'table' => $table,
- 'on' => $on,
- 'type' => $type,
+ 'table' => $tableSql,
+ 'alias' => $reference,
+ 'on' => $on,
+ 'type' => $type,
+ 'raw' => $rawTable,
+ 'prefixed' => $prefixedTable,
+ 'userAlias' => $alias,
];
return $this;
}
+ /**
+ * Maps each unprefixed table name this query knows about (the model's own
+ * table plus every non-aliased join) to its physical, prefixed name.
+ * Aliased joins are excluded: they are referenced by their alias, not the
+ * physical table, so their columns must not be rewritten.
+ *
+ * @return array
+ */
+ public function getTableMap()
+ {
+ $map = [$this->_model->getTableWithoutPrefix() => $this->table];
+ foreach ($this->joins as $join) {
+ if ($join['userAlias'] === null) {
+ $map[$join['raw']] = $join['prefixed'];
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * Table aliases in scope (from() alias + every join alias). A qualifier that
+ * matches an alias is never rewritten to a physical table name.
+ *
+ * @return array
+ */
+ public function getTableAliases()
+ {
+ $aliases = [];
+ if (!\is_null($this->_from)) {
+ $aliases[] = $this->_from;
+ }
+ foreach ($this->joins as $join) {
+ if ($join['userAlias'] !== null) {
+ $aliases[] = $join['userAlias'];
+ }
+ }
+
+ return $aliases;
+ }
+
+ /**
+ * Rewrites a qualified column whose table part is an unprefixed name this
+ * query owns (`users.id` -> `` `wp_users`.id ``). Aliases win over the map,
+ * and unknown / already-physical qualifiers pass through unchanged.
+ * Idempotent: a physical qualifier is never a map key.
+ *
+ * @param mixed $column
+ *
+ * @return mixed
+ */
+ public function resolveQualifier($column)
+ {
+ if (!\is_string($column)) {
+ return $column;
+ }
+
+ $dot = strpos($column, '.');
+ if ($dot === false) {
+ return $column;
+ }
+
+ $left = trim(substr($column, 0, $dot), '`');
+ $right = substr($column, $dot + 1);
+
+ if (\in_array($left, $this->getTableAliases(), true)) {
+ return $column;
+ }
+
+ $map = $this->getTableMap();
+
+ return isset($map[$left]) ? '`' . $map[$left] . '`.' . $right : $column;
+ }
+
+ /**
+ * Creates a nested builder that compiles its conditions inside this query's
+ * table context: it shares the model (via newQuery()) plus this query's
+ * joins and from() alias, so resolveQualifier() inside a nested where group
+ * resolves joined-table qualifiers exactly as at top level. The joins stay
+ * inert — a nested builder compiles only its conditions, never JOIN SQL.
+ *
+ * @return QueryBuilder
+ */
+ private function newNestedQuery()
+ {
+ $query = $this->newQuery();
+ $query->joins = $this->joins;
+ $query->_from = $this->_from;
+
+ return $query;
+ }
+
/**
* Sets left join
*
@@ -801,7 +1022,7 @@ public function on($firstColumn, $operator = null, $secondColumn = null, $bool =
$joinIndex = 0;
}
- $table = $this->joins[$joinIndex]['table'];
+ $table = $this->joins[$joinIndex]['alias'];
$this->joins[$joinIndex]['on'][] = $this->prepareOn($table, $firstColumn, $operator, $secondColumn, $bool);
return $this;
@@ -830,6 +1051,8 @@ public function orOn($firstColumn, $operator = null, $secondColumn = null)
*/
public function orderBy($column)
{
+ $this->assertSafeIdentifier($column);
+
$this->orderBy[] = [
'column' => $column,
'direction' => 'ASC',
@@ -936,7 +1159,7 @@ public function prepareRaw()
*/
public function take($count)
{
- $this->limit = $count;
+ $this->limit = (int) $count;
return $this;
}
@@ -950,7 +1173,7 @@ public function take($count)
*/
public function skip($count)
{
- $this->offset = $count;
+ $this->offset = (int) $count;
return $this;
}
@@ -964,7 +1187,11 @@ public function skip($count)
*/
public function insert($attributes = [])
{
- if (\is_array(reset($attributes))) {
+ if (empty($attributes)) {
+ return false;
+ }
+
+ if ($this->isListOfRows($attributes)) {
return $this->bulkInsert($attributes);
}
@@ -1024,6 +1251,12 @@ public function save()
$columns = $this->prepareAttributeForSaveOrUpdate($this->_model->exists());
$pk = $this->_model->getPrimaryKey();
if ($this->_model->exists()) {
+ if (empty($columns)) {
+ $this->_model->fireEvent('saved');
+
+ return $this->_model;
+ }
+
$isPkExistsInWhere = false;
$pkValue = $this->_model->getAttribute($pk);
@@ -1039,7 +1272,8 @@ public function save()
$this->update = $columns;
- if ($this->exec()) {
+ // 0-row (no-op) UPDATE is success; exec() is false only on error/cancel.
+ if ($this->exec() !== false) {
$this->_model->fireEvent('saved');
return $this->_model;
@@ -1049,15 +1283,19 @@ public function save()
}
$this->insert = $columns;
- $this->exec();
+ if ($this->exec() === false) {
+ return false;
+ }
+
+ // Set the PK from the auto-increment id when there is one; a table with a
+ // manual/composite key returns insert_id 0 yet the insert still succeeded.
if ($insertId = $this->lastInsertId()) {
$this->_model->setAttribute($pk, $insertId);
- $this->_model->fireEvent('saved');
-
- return $this->_model;
}
- return false;
+ $this->_model->fireEvent('saved');
+
+ return $this->_model;
}
/**
@@ -1080,16 +1318,47 @@ public function min($column)
return $this->aggregate('MIN', $column);
}
+ public function avg($column)
+ {
+ return $this->aggregate('AVG', $column);
+ }
+
+ public function sum($column)
+ {
+ return $this->aggregate('SUM', $column);
+ }
+
public function aggregate($function, $column)
{
+ $this->assertSafeAggregateFunction($function);
+
$query = $this->clone();
$query->select = [];
$query->selectRaw = ['columns' => [], 'bindings' => []];
- $result = $query->selectRaw($function . '(' . $query->prepareColumnName($column) . ') as ' . $function)->exec();
+ $query->distinct = false;
+ $preparedColumn = $column === '*' ? '*' : $query->prepareColumnName($column);
+ $result = $query->selectRaw($function . '(' . $preparedColumn . ') as ' . $function)->exec();
return \is_array($result) && isset($result[0]->{$function}) ? $result[0]->{$function} : null;
}
+ /**
+ * Guards an aggregate function name that is interpolated straight into SQL:
+ * only a bare identifier is allowed, so parens/spaces/semicolons cannot
+ * smuggle in a payload. Case is preserved (SQL function names are
+ * case-insensitive).
+ *
+ * @param mixed $function
+ *
+ * @return void
+ */
+ private function assertSafeAggregateFunction($function)
+ {
+ if (!\is_string($function) || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $function)) {
+ throw new RuntimeException('Invalid aggregate function name.');
+ }
+ }
+
public function delete()
{
$this->_method = self::DELETE;
@@ -1100,6 +1369,37 @@ public function delete()
return $this->exec();
}
+ /**
+ * Permanently deletes the targeted rows of a soft-delete model, bypassing
+ * the soft-delete rewrite (emits a real DELETE).
+ *
+ * @return string|bool
+ */
+ public function forceDelete()
+ {
+ if (!$this->isSoftDeleteModel()) {
+ throw new RuntimeException('forceDelete() is only available on soft-delete models.');
+ }
+
+ $this->_forceDelete = true;
+
+ return $this->delete();
+ }
+
+ /**
+ * Restores soft-deleted rows by nulling deleted_at and persisting.
+ *
+ * @return string|bool|Model
+ */
+ public function restore()
+ {
+ if (!$this->isSoftDeleteModel()) {
+ throw new RuntimeException('restore() is only available on soft-delete models.');
+ }
+
+ return $this->update(['deleted_at' => null]);
+ }
+
/**
* Starts transaction
*
@@ -1212,17 +1512,22 @@ public function upsert(array $values, ?array $update = null)
$values = [$values];
}
- if (\is_null($update)) {
+ if (empty($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) {
+ $manageTimestamps = property_exists($this->_model, 'timestamps') && $this->_model->timestamps;
+ $addCreatedAt = $manageTimestamps && !\in_array('created_at', $columns, true);
+ $addUpdatedAt = $manageTimestamps && !\in_array('updated_at', $columns, true);
+ if ($addCreatedAt) {
$columns[] = 'created_at';
}
+ if ($addUpdatedAt) {
+ $columns[] = 'updated_at';
+ }
$sql = 'INSERT INTO ' . $this->table;
$sql .= ' (' . implode(', ', $columns) . ')';
@@ -1230,8 +1535,14 @@ public function upsert(array $values, ?array $update = null)
$insertAbleValues = [];
foreach ($values as $row) {
ksort($row);
- if ($createdAt) {
- $row['created_at'] = $this->currentTimestamp();
+ if ($addCreatedAt || $addUpdatedAt) {
+ $now = $this->currentTimestamp();
+ if ($addCreatedAt) {
+ $row['created_at'] = $now;
+ }
+ if ($addUpdatedAt) {
+ $row['updated_at'] = $now;
+ }
}
$rowValues = array_values($row);
@@ -1244,6 +1555,10 @@ function ($value) {
return 'NULL';
}
+ if (\is_array($value) || \is_object($value)) {
+ $value = wp_json_encode($value);
+ }
+
$this->bindings[] = $value;
return $this->getValueType($value);
@@ -1255,15 +1570,14 @@ function ($value) {
$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';
+ if ($manageTimestamps) {
+ // Never overwrite the original creation time on update; always bump updated_at.
+ $update = array_diff($update, ['created_at']);
+ if (!\in_array('updated_at', $update, true)) {
+ $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);
@@ -1321,7 +1635,7 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where')
$conditions['bool'] = $bool;
if ($params[0] instanceof Closure) {
- $nestedQuery = $this->newQuery()->queryFor($type);
+ $nestedQuery = $this->newNestedQuery()->queryFor($type);
\call_user_func($params[0], $nestedQuery);
$conditions['query'] = $nestedQuery;
if (isset($params[1])) {
@@ -1341,7 +1655,7 @@ protected function prepareConditional($params, $bool = 'AND', $type = 'where')
$conditions['bool'] = $params[3];
}
- return $conditions;
+ return $this->normalizeConditions($conditions, $type);
}
/**
@@ -1393,11 +1707,14 @@ protected function prepareHaving($params, $bool = 'AND')
protected function prepareOn($table, $column, $operator, $secondColumn, $bool = 'AND')
{
if (\is_null($operator) && \is_null($secondColumn)) {
- $column = $this->_model->getTable() . '.' . $column;
- $secondColumn = $table . '.' . $column;
+ $secondColumn = $column;
$operator = '=';
}
+ if (!\is_null($secondColumn) && strpos($secondColumn, '.') === false) {
+ $secondColumn = $table . '.' . $secondColumn;
+ }
+
return compact('column', 'operator', 'secondColumn', 'bool');
}
@@ -1436,6 +1753,162 @@ protected function getTimeZone()
return $timezoneString;
}
+ /**
+ * Coerces each IN-list element to a scalar so it maps to exactly one
+ * placeholder and one binding (a nested array/object is JSON-encoded, never
+ * flattened into multiple bindings).
+ *
+ * @param array $values
+ *
+ * @return array
+ */
+ private function sanitizeInValues(array $values)
+ {
+ return array_map(
+ function ($value) {
+ if (\is_array($value) || \is_object($value)) {
+ return wp_json_encode($value);
+ }
+
+ return $value;
+ },
+ $values
+ );
+ }
+
+ /**
+ * True only when $attributes is a non-empty positional list whose every
+ * element is itself an array (a list of rows for bulk insert). An assoc row
+ * whose first value happens to be an array must take the single-row path.
+ *
+ * @param mixed $attributes
+ *
+ * @return bool
+ */
+ private function isListOfRows($attributes)
+ {
+ if (!\is_array($attributes) || $attributes === []) {
+ return false;
+ }
+
+ if (array_keys($attributes) !== range(0, \count($attributes) - 1)) {
+ return false;
+ }
+
+ foreach ($attributes as $row) {
+ if (!\is_array($row)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Repairs where-clause values that would otherwise emit invalid SQL or fatal
+ * on prepare, independent of arity: an empty array becomes the false
+ * constant `0 = 1`; a null value with an explicit operator becomes
+ * IS [NOT] NULL; a non-empty array has its elements coerced to scalars; an
+ * object value is JSON-encoded. Having clauses are left untouched.
+ *
+ * @param array $conditions
+ * @param string $type
+ *
+ * @return array
+ */
+ private function normalizeConditions(array $conditions, $type)
+ {
+ if ($type !== 'where' || !\array_key_exists('value', $conditions)) {
+ return $conditions;
+ }
+
+ $value = $conditions['value'];
+ $bool = isset($conditions['bool']) ? $conditions['bool'] : 'AND';
+
+ if (\is_array($value)) {
+ if ($value === []) {
+ return ['bool' => $bool, 'raw' => '0 = 1', 'bindings' => []];
+ }
+
+ $conditions['value'] = $this->sanitizeInValues($value);
+
+ return $conditions;
+ }
+
+ if (\is_null($value)) {
+ if (isset($conditions['operator'])) {
+ unset($conditions['value']);
+ $conditions['operator'] = $this->nullOperator($conditions['operator']);
+ }
+
+ return $conditions;
+ }
+
+ if (\is_object($value)) {
+ $conditions['value'] = wp_json_encode($value);
+ }
+
+ return $conditions;
+ }
+
+ /**
+ * Maps a comparison operator to its null-safe form for a null value.
+ *
+ * @param string $operator
+ *
+ * @return string
+ */
+ private function nullOperator($operator)
+ {
+ $negations = ['!=', '<>', 'NOT', 'IS NOT', 'IS NOT NULL'];
+
+ return \in_array($operator, $negations, true) ? 'IS NOT NULL' : 'IS NULL';
+ }
+
+ /**
+ * Guards an ORDER BY / GROUP BY column against injection: only a plain,
+ * qualified (table.column) or back-ticked identifier is accepted. Raw
+ * expressions must go through orderByRaw(). Valid identifiers are not
+ * re-rendered, so the emitted SQL stays byte-identical.
+ *
+ * @param mixed $column
+ *
+ * @return void
+ */
+ private function assertSafeIdentifier($column)
+ {
+ if (!\is_string($column) || !preg_match('/^[A-Za-z0-9_.`]+$/', $column)) {
+ throw new RuntimeException('Unsafe column passed to order/group by clause.');
+ }
+ }
+
+ /**
+ * Returns true when the model declares soft-delete support.
+ *
+ * @return bool
+ */
+ private function isSoftDeleteModel()
+ {
+ return property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes;
+ }
+
+ /**
+ * Returns true when soft-delete read scope is active for the model.
+ *
+ * Soft-delete models exclude trashed rows by default; a model opts out by
+ * declaring public $soft_delete_scope = false.
+ *
+ * @return bool
+ */
+ private function autoScopeEnabled()
+ {
+ if (property_exists($this->_model, 'soft_delete_scope')) {
+ return (bool) $this->_model->soft_delete_scope;
+ }
+
+ return true;
+ }
+
/**
* Run bulk insert query
*
@@ -1446,6 +1919,10 @@ protected function getTimeZone()
private function bulkInsert($attributes)
{
$firstRow = reset($attributes);
+ if (empty($firstRow)) {
+ return new Collection([]);
+ }
+
ksort($firstRow);
$columns = array_keys($firstRow);
$createdAt = property_exists($this->_model, 'timestamps') && $this->_model->timestamps;
@@ -1460,13 +1937,18 @@ private function bulkInsert($attributes)
$sql .= ' VALUES ';
$values = [];
foreach ($attributes as $row) {
- ksort($row);
if ($createdAt) {
$row['created_at'] = $this->currentTimestamp();
}
- $rowValues = array_values($row);
- $values[] = ' ('
+ // Align each row to the header columns by key so rows with differing
+ // keys are not positionally misaligned (absent column => NULL).
+ $rowValues = [];
+ foreach ($columns as $column) {
+ $rowValues[] = isset($row[$column]) ? $row[$column] : null;
+ }
+
+ $values[] = ' ('
. implode(
', ',
array_map(
@@ -1475,6 +1957,10 @@ function ($value) {
return 'NULL';
}
+ if (\is_array($value) || \is_object($value)) {
+ $value = wp_json_encode($value);
+ }
+
$this->bindings[] = $value;
return $this->getValueType($value);
@@ -1505,7 +1991,7 @@ function ($value) {
return $allRows;
}
- return $ids;
+ return new Collection($ids);
}
return false;
@@ -1530,6 +2016,8 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false)
$columnsToPrepare = array_keys($this->_model->getAttributes());
}
+ $columnsToPrepare = $this->withoutRelationColumns($columnsToPrepare);
+
if (property_exists($this->_model, 'timestamps') && $this->_model->timestamps) {
if (!$isUpdate) {
$this->_model->setAttribute('created_at', $this->currentTimestamp());
@@ -1558,6 +2046,25 @@ private function prepareAttributeForSaveOrUpdate($isUpdate = false)
return $columnsToPrepare;
}
+ /**
+ * Drops columns whose current value is a loaded relation (Collection/Model)
+ * from a save/update write set, so lazily-read relations are never persisted
+ * to a non-existent column. Re-indexes to keep bindings positionally aligned.
+ *
+ * @param array $columns
+ *
+ * @return array
+ */
+ private function withoutRelationColumns(array $columns)
+ {
+ $attributes = $this->_model->getAttributes();
+
+ return array_values(array_filter($columns, function ($column) use ($attributes) {
+ return !\array_key_exists($column, $attributes)
+ || !$this->_model->isRelationValue($attributes[$column]);
+ }));
+ }
+
/**
* Prepares insert statement
*
@@ -1630,7 +2137,7 @@ private function prepareDelete()
return '';
}
- if (property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) {
+ if (!$this->_forceDelete && property_exists($this->_model, 'soft_deletes') && $this->_model->soft_deletes) {
$timestamp = $this->currentTimestamp();
array_unshift($this->bindings, $timestamp);
diff --git a/src/Schema.php b/src/Schema.php
index 5e5aff0..6cf2073 100644
--- a/src/Schema.php
+++ b/src/Schema.php
@@ -46,7 +46,15 @@ public function __call($method, $parameters)
return $this->build($blueprint);
}
- public function createBlueprint($schema, $method, Closure $callback = null)
+ public static function withWpPrefix()
+ {
+ $schema = new self();
+ $schema->prefix = Connection::getPrefix();
+
+ return $schema;
+ }
+
+ public function createBlueprint($schema, $method, ?Closure $callback = null)
{
return new Blueprint(
$schema,
diff --git a/tests/AggregateMethodsTest.php b/tests/AggregateMethodsTest.php
new file mode 100644
index 0000000..38462f6
--- /dev/null
+++ b/tests/AggregateMethodsTest.php
@@ -0,0 +1,91 @@
+resolver = function () {
+ return [(object) ['AVG' => '42.5']];
+ };
+
+ $this->assertSame('42.5', User::query()->avg('score'));
+ $this->assertStringContainsString('AVG(', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testSumReturnsValue(): void
+ {
+ $GLOBALS['wpdb']->resolver = function () {
+ return [(object) ['SUM' => '100']];
+ };
+
+ $this->assertSame('100', User::query()->sum('score'));
+ $this->assertStringContainsString('SUM(', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testAggregateRejectsNonIdentifierFunctionName(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ User::query()->aggregate('COUNT(*); DROP TABLE users; --', 'id');
+ }
+
+ public function testAggregateAllowsNonAllowlistedAggregate(): void
+ {
+ $GLOBALS['wpdb']->resolver = function () {
+ return [(object) ['GROUP_CONCAT' => 'a,b']];
+ };
+
+ $this->assertSame('a,b', User::query()->aggregate('GROUP_CONCAT', 'score'));
+ }
+
+ public function testAggregatePreservesFunctionNameCase(): void
+ {
+ $GLOBALS['wpdb']->resolver = function () {
+ return [(object) ['avg' => '3']];
+ };
+
+ User::query()->aggregate('avg', 'score');
+
+ $this->assertStringContainsString('avg(', $GLOBALS['wpdb']->last_query);
+ $this->assertStringNotContainsString('AVG(', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testDistinctDoesNotLeakIntoAggregate(): void
+ {
+ $GLOBALS['wpdb']->resolver = function () {
+ return [(object) ['COUNT' => '5']];
+ };
+
+ User::query()->distinct()->count();
+
+ $this->assertStringNotContainsString('DISTINCT', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testWithAggregateRejectsNonIdentifierFunctionName(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ User::query()->withAggregate('posts', 'id', 'SUM(x); DROP TABLE users; --');
+ }
+}
diff --git a/tests/AttributeCastTest.php b/tests/AttributeCastTest.php
new file mode 100644
index 0000000..fdfa01d
--- /dev/null
+++ b/tests/AttributeCastTest.php
@@ -0,0 +1,44 @@
+ '1']);
+
+ $this->assertSame(true, $model->flag);
+ }
+
+ /**
+ * 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/BelongsToManyPivotTest.php b/tests/BelongsToManyPivotTest.php
new file mode 100644
index 0000000..b762189
--- /dev/null
+++ b/tests/BelongsToManyPivotTest.php
@@ -0,0 +1,200 @@
+ 100, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-01-01'],
+ (object) ['id' => 101, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-02-01'],
+ (object) ['id' => 102, 'pivot_member_id' => 2, 'pivot_assigned_at' => '2024-03-01'],
+ ];
+ }
+
+ return [(object) ['id' => 1], (object) ['id' => 2]];
+ };
+ }
+
+ public function testEagerLoadGroupsRelatedRowsByParent(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->pivotResolver();
+
+ $members = Member::with('roles')->get();
+
+ $this->assertInstanceOf(Collection::class, $members);
+ $this->assertCount(2, $members);
+ $this->assertCount(2, $members[0]->roles, 'member 1 should have 2 roles');
+ $this->assertCount(1, $members[1]->roles, 'member 2 should have 1 role');
+ $this->assertInstanceOf(Role::class, $members[0]->roles[0]);
+ $this->assertSame(100, $members[0]->roles[0]->id);
+ $this->assertSame(101, $members[0]->roles[1]->id);
+ $this->assertSame(102, $members[1]->roles[0]->id);
+ }
+
+ public function testEagerSqlShape(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->pivotResolver();
+
+ Member::with('roles')->get();
+ $sql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('SELECT `wp_roles`.*', $sql);
+ $this->assertStringContainsString('wp_role_user.member_id as `pivot_member_id`', $sql);
+ $this->assertStringContainsString('INNER JOIN wp_role_user', $sql);
+ $this->assertStringContainsString('wp_role_user.role_id = wp_roles.id', $sql);
+ // Inner fragment without a leading `WHERE ` boundary: the grammar emits `WHERE ` (double space).
+ $this->assertStringContainsString(
+ 'wp_role_user.member_id IN ( SELECT * FROM (SELECT `wp_members`.`id` FROM wp_members) AS subquery )',
+ $sql
+ );
+
+ // Exact pin (absorbs the double-space WHERE/ON grammar artifacts).
+ $expected = 'SELECT `wp_roles`.*, wp_role_user.member_id as `pivot_member_id`'
+ . ' FROM wp_roles INNER JOIN wp_role_user ON wp_role_user.role_id = wp_roles.id'
+ . ' WHERE wp_role_user.member_id IN ( SELECT * FROM (SELECT `wp_members`.`id` FROM wp_members) AS subquery )';
+ $this->assertSame($expected, $sql);
+ }
+
+ public function testLazyAccessEmitsSingleValuePredicate(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->pivotResolver();
+
+ $member = new Member(['id' => 1]);
+ $roles = $member->roles;
+
+ $this->assertInstanceOf(Collection::class, $roles);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringNotContainsString('subquery', $sql, 'lazy access must not use the IN ( SELECT ) subquery form');
+
+ // Exact pin: single-value predicate, double-space WHERE/ON/`=` grammar artifacts.
+ $expected = 'SELECT `wp_roles`.*, wp_role_user.member_id as `pivot_member_id`'
+ . ' FROM wp_roles INNER JOIN wp_role_user ON wp_role_user.role_id = wp_roles.id'
+ . ' WHERE wp_role_user.member_id = 1';
+ $this->assertSame($expected, $sql);
+ }
+
+ public function testDefaultKeyDerivationUsesForeignKeyConvention(): void
+ {
+ $GLOBALS['wpdb']->resolver = function ($sql) {
+ if (strpos($sql, 'wp_roles') !== false) {
+ return [
+ (object) ['id' => 100, 'pivot_members_id' => 1],
+ (object) ['id' => 101, 'pivot_members_id' => 1],
+ (object) ['id' => 102, 'pivot_members_id' => 2],
+ ];
+ }
+
+ return [(object) ['id' => 1], (object) ['id' => 2]];
+ };
+
+ $members = Member::with('rolesDefaultKeys')->get();
+ $sql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('wp_role_user.members_id as `pivot_members_id`', $sql);
+ $this->assertStringContainsString('wp_role_user.roles_id = wp_roles.id', $sql);
+ $this->assertCount(2, $members[0]->rolesDefaultKeys);
+ $this->assertCount(1, $members[1]->rolesDefaultKeys);
+ }
+
+ public function testWithPivotSelectsAndExposesExtraColumn(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->pivotResolver();
+
+ $members = Member::with('rolesWithPivot')->get();
+ $sql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('wp_role_user.assigned_at as `pivot_assigned_at`', $sql);
+ $this->assertSame('2024-01-01', $members[0]->rolesWithPivot[0]->pivot_assigned_at);
+ }
+
+ public function testLegacyNullPivotPathIsUnchanged(): void
+ {
+ $query = (new Member())->legacyRoles();
+
+ $this->assertInstanceOf(QueryBuilder::class, $query);
+ $this->assertSame('belongsToMany', $query->getModel()->getRelateAs());
+ $this->assertSame(
+ ['foreignKey' => 'members_id', 'localKey' => 'id'],
+ $query->getModel()->getActiveRelationKey()
+ );
+ }
+
+ public function testAggregatesOnPivotRelationThrow(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('pivot belongsToMany');
+
+ Member::withCount('roles')->get();
+ }
+
+ public function testBucketAliasLeaksAsReservedAttribute(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->pivotResolver();
+
+ $members = Member::with('roles')->get();
+
+ $this->assertSame(1, $members[0]->roles[0]->pivot_member_id);
+ $this->assertSame(2, $members[1]->roles[0]->pivot_member_id);
+ }
+
+ public function testMultiplePivotRelationsResolveIndependently(): void
+ {
+ $GLOBALS['wpdb']->resolver = function ($sql) {
+ if (strpos($sql, 'assigned_at') !== false) {
+ return [
+ (object) ['id' => 200, 'pivot_member_id' => 1, 'pivot_assigned_at' => '2024-01-01'],
+ (object) ['id' => 201, 'pivot_member_id' => 2, 'pivot_assigned_at' => '2024-02-01'],
+ ];
+ }
+
+ if (strpos($sql, 'wp_roles') !== false) {
+ return [
+ (object) ['id' => 100, 'pivot_member_id' => 1],
+ (object) ['id' => 102, 'pivot_member_id' => 2],
+ ];
+ }
+
+ return [(object) ['id' => 1], (object) ['id' => 2]];
+ };
+
+ $members = Member::with(['roles', 'rolesWithPivot'])->get();
+
+ $this->assertCount(1, $members[0]->roles);
+ $this->assertCount(1, $members[0]->rolesWithPivot);
+ $this->assertSame(100, $members[0]->roles[0]->id);
+ $this->assertSame(200, $members[0]->rolesWithPivot[0]->id);
+ $this->assertSame('2024-01-01', $members[0]->rolesWithPivot[0]->pivot_assigned_at);
+ $this->assertNull($members[0]->roles[0]->pivot_assigned_at, 'roles must not pick up the withPivot column');
+ }
+
+ public function testPivotConstantsAreExposedOnModel(): void
+ {
+ $this->assertSame('belongsToManyPivot', Model::RELATE_AS_PIVOT);
+ $this->assertSame('pivot_', Model::PIVOT_ATTRIBUTE_PREFIX);
+ }
+}
diff --git a/tests/BulkInsertReturnTest.php b/tests/BulkInsertReturnTest.php
new file mode 100644
index 0000000..359ff6c
--- /dev/null
+++ b/tests/BulkInsertReturnTest.php
@@ -0,0 +1,74 @@
+rows_affected = 2;
+ $GLOBALS['wpdb']->insert_id = 10;
+ // last_result defaults to [] — get() returns [], triggering the fallback path.
+
+ $result = User::query()->insert([['name' => 'a'], ['name' => 'b']]);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ }
+
+ public function testBulkInsertHappyPathReturnsCollection(): void
+ {
+ // Happy path: INSERT succeeds and re-query hydrates the rows into Models.
+ $GLOBALS['wpdb']->rows_affected = 2;
+ $GLOBALS['wpdb']->insert_id = 10;
+ $GLOBALS['wpdb']->queueResult([
+ (object) ['id' => 10, 'name' => 'a'],
+ (object) ['id' => 11, 'name' => 'b'],
+ ]);
+
+ $result = User::query()->insert([['name' => 'a'], ['name' => 'b']]);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ }
+
+ /**
+ * Array/object values must be JSON-encoded in bulk insert, matching save()/
+ * update() — so callers don't have to wp_json_encode() them manually.
+ */
+ public function testBulkInsertEncodesArrayAndObjectValuesAsJson(): void
+ {
+ // rows_affected = 0 keeps the INSERT as last_query (no post-insert re-query).
+ $GLOBALS['wpdb']->rows_affected = 0;
+
+ User::query()->insert([
+ ['name' => 'a', 'meta' => ['x' => 1]],
+ ['name' => 'b', 'meta' => (object) ['y' => 2]],
+ ]);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('{"x":1}', $sql);
+ $this->assertStringContainsString('{"y":2}', $sql);
+ $this->assertStringNotContainsString('Array', $sql);
+ }
+}
diff --git a/tests/CastAliasFixTest.php b/tests/CastAliasFixTest.php
new file mode 100644
index 0000000..d609fba
--- /dev/null
+++ b/tests/CastAliasFixTest.php
@@ -0,0 +1,60 @@
+ '42']);
+
+ $this->assertSame(42, $model->n);
+ }
+
+ public function testFloatAliasCastsToFloat(): void
+ {
+ $model = new CastAliasModel(['f' => '4.5']);
+
+ $this->assertSame(4.5, $model->f);
+ }
+
+ public function testDoubleAliasCastsToFloat(): void
+ {
+ $model = new CastAliasModel(['d' => '2.5']);
+
+ $this->assertSame(2.5, $model->d);
+ }
+
+ public function testJsonAliasDecodesToArray(): void
+ {
+ $model = new CastAliasModel(['data' => '{"x":1}']);
+
+ $this->assertSame(['x' => 1], $model->data);
+ }
+
+ public function testDatetimeAliasCastsToDate(): void
+ {
+ $model = new CastAliasModel(['at' => '2024-01-02 03:04:05']);
+
+ $this->assertInstanceOf(DateTime::class, $model->at);
+ }
+}
diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php
new file mode 100644
index 0000000..4228e2a
--- /dev/null
+++ b/tests/CollectionTest.php
@@ -0,0 +1,35 @@
+ 1])]);
+
+ $this->assertSame(['L'], $collection->pluck('label')->all());
+ }
+}
diff --git a/tests/CreatingCreatedEventTest.php b/tests/CreatingCreatedEventTest.php
new file mode 100644
index 0000000..a3dd325
--- /dev/null
+++ b/tests/CreatingCreatedEventTest.php
@@ -0,0 +1,43 @@
+insert_id = 1;
+
+ CreatingUser::insert(['name' => 'Ada']);
+
+ $this->assertTrue(CreatingUser::$creatingCalled, 'creating handler should have run');
+ $this->assertTrue(CreatingUser::$createdCalled, 'created handler should have run');
+ }
+
+ public function testCreatingReturningFalseAbortsInsert(): void
+ {
+ CreatingUser::$abortCreating = true;
+
+ $result = CreatingUser::insert(['name' => 'Ada']);
+
+ $this->assertSame([], $GLOBALS['wpdb']->queries, 'aborted insert must not execute any query');
+ $this->assertFalse($result, 'aborted insert returns false');
+ }
+}
diff --git a/tests/EagerKeySubqueryScopeFixTest.php b/tests/EagerKeySubqueryScopeFixTest.php
new file mode 100644
index 0000000..45b54be
--- /dev/null
+++ b/tests/EagerKeySubqueryScopeFixTest.php
@@ -0,0 +1,43 @@
+resolver = static function ($sql) {
+ if (strpos($sql, 'wp_posts') !== false) {
+ return [(object) ['id' => 10, 'user_id' => 1]];
+ }
+
+ return [(object) ['id' => 1]];
+ };
+ }
+
+ protected function tearDown(): void
+ {
+ $GLOBALS['wpdb'] = new FakeWpdb();
+ }
+
+ public function testEagerKeySubqueryDropsParentGroupByAndHaving(): void
+ {
+ User::groupBy('status')->having('cnt', '>', 1)->with('posts')->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringNotContainsString('GROUP BY', $postsSql);
+ $this->assertStringNotContainsString('HAVING', $postsSql);
+ $this->assertStringNotContainsString('cnt', $postsSql);
+ }
+}
diff --git a/tests/EagerLoadIntegrationTest.php b/tests/EagerLoadIntegrationTest.php
index 16a0828..ac99069 100644
--- a/tests/EagerLoadIntegrationTest.php
+++ b/tests/EagerLoadIntegrationTest.php
@@ -52,4 +52,81 @@ public function testStaticWithEagerLoadsAndGroupsRelatedRows(): void
$this->assertCount(1, $second->posts, 'user 2 should have 1 post');
$this->assertInstanceOf(Post::class, $first->posts[0]);
}
+
+ /**
+ * A parent with NO related rows must not trigger a fresh lazy query when the
+ * relation is accessed (the eager load already resolved it to empty) — the
+ * N+1 the eager load exists to prevent.
+ */
+ public function testEmptyEagerRelationDoesNotReQueryOnAccess(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function ($sql) {
+ if (strpos($sql, 'wp_posts') !== false) {
+ return [(object) ['id' => 10, 'user_id' => 1]];
+ }
+
+ return [(object) ['id' => 1], (object) ['id' => 2]];
+ };
+
+ $users = User::with('posts')->get();
+ $second = null;
+ foreach ($users as $u) {
+ if ((int) $u->id === 2) {
+ $second = $u;
+ }
+ }
+
+ $GLOBALS['wpdb']->queries = [];
+ $posts = $second->posts; // user 2 has no posts
+
+ $this->assertCount(0, $posts, 'empty eager relation resolves to empty');
+ $this->assertCount(0, $GLOBALS['wpdb']->queries, 'no re-query (N+1) on accessing an empty eager relation');
+ }
+
+ /**
+ * The parent's selectRaw must NOT leak into the eager key subquery — that
+ * subquery feeds an IN (...) and must return exactly one column (the localKey),
+ * else MySQL raises "Operand should contain 1 column(s)".
+ */
+ public function testEagerLoadKeySubqueryIgnoresParentSelectRaw(): void
+ {
+ User::select(['id'])->selectRaw('CONCAT("X", id) as cx')->with('posts')->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringNotContainsString('CONCAT', $postsSql);
+ $this->assertStringContainsString(
+ 'IN ( SELECT * FROM (SELECT `wp_users`.`id` FROM wp_users',
+ $postsSql
+ );
+ }
+
+ /**
+ * ORDER BY is meaningless in a value-list IN ( SELECT key ... ) and may
+ * reference a stripped selectRaw alias — so it is dropped from the key
+ * subquery when no LIMIT pins the set.
+ */
+ public function testEagerLoadKeySubqueryDropsParentOrderBy(): void
+ {
+ User::select(['id'])->selectRaw('CONCAT("X", id) as cx')->orderBy('cx', 'DESC')->with('posts')->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringNotContainsString('ORDER BY', $postsSql);
+ $this->assertStringNotContainsString('cx', $postsSql);
+ }
+
+ /**
+ * When a LIMIT pins which parent rows the set comes from, ORDER BY is kept
+ * so the limited set stays deterministic.
+ */
+ public function testEagerLoadKeySubqueryKeepsOrderByWhenLimited(): void
+ {
+ User::orderBy('id', 'DESC')->take(5)->with('posts')->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('ORDER BY', $postsSql);
+ $this->assertStringContainsString('LIMIT 5', $postsSql);
+ }
}
diff --git a/tests/Fixtures/CastAliasModel.php b/tests/Fixtures/CastAliasModel.php
new file mode 100644
index 0000000..b709f92
--- /dev/null
+++ b/tests/Fixtures/CastAliasModel.php
@@ -0,0 +1,22 @@
+ 'integer',
+ 'f' => 'float',
+ 'd' => 'double',
+ 'data' => 'json',
+ 'at' => 'datetime',
+ ];
+}
diff --git a/tests/Fixtures/CreatingUser.php b/tests/Fixtures/CreatingUser.php
new file mode 100644
index 0000000..b8ed830
--- /dev/null
+++ b/tests/Fixtures/CreatingUser.php
@@ -0,0 +1,37 @@
+belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
+ }
+
+ public function rolesDefaultKeys()
+ {
+ return $this->belongsToMany(Role::class, 'role_user');
+ }
+
+ public function rolesWithPivot()
+ {
+ return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id')->withPivot(['assigned_at']);
+ }
+
+ public function legacyRoles()
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
diff --git a/tests/Fixtures/PrefixedModel.php b/tests/Fixtures/PrefixedModel.php
new file mode 100644
index 0000000..056211f
--- /dev/null
+++ b/tests/Fixtures/PrefixedModel.php
@@ -0,0 +1,18 @@
+.
+ */
+class PrefixedModel extends Model
+{
+ public $timestamps = false;
+
+ protected $table = 'widgets';
+
+ protected $prefix = 'crm_';
+}
diff --git a/tests/Fixtures/RelationBaseModel.php b/tests/Fixtures/RelationBaseModel.php
new file mode 100644
index 0000000..99b1b87
--- /dev/null
+++ b/tests/Fixtures/RelationBaseModel.php
@@ -0,0 +1,22 @@
+hasMany(Post::class, 'base_id', 'id');
+ }
+}
diff --git a/tests/Fixtures/RelationLeafModel.php b/tests/Fixtures/RelationLeafModel.php
new file mode 100644
index 0000000..28d94d2
--- /dev/null
+++ b/tests/Fixtures/RelationLeafModel.php
@@ -0,0 +1,11 @@
+hasMany(Post::class, 'sentinel_id', 'id');
+ }
+
+ /** NOT a relation — must be rejected, never used as one. */
+ public function destroyTheWorld()
+ {
+ return 'not a query builder';
+ }
+
+ /** Returns a QueryBuilder, but NOT a relation query (no active relation key). */
+ public function plainQuery()
+ {
+ return self::query();
+ }
+}
diff --git a/tests/Fixtures/Role.php b/tests/Fixtures/Role.php
new file mode 100644
index 0000000..88ecdfb
--- /dev/null
+++ b/tests/Fixtures/Role.php
@@ -0,0 +1,14 @@
+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/OrderGroupInjectionFixTest.php b/tests/OrderGroupInjectionFixTest.php
new file mode 100644
index 0000000..9d34158
--- /dev/null
+++ b/tests/OrderGroupInjectionFixTest.php
@@ -0,0 +1,67 @@
+expectException(RuntimeException::class);
+
+ User::query()->orderBy('id; DROP TABLE x');
+ }
+
+ public function testGroupByRejectsInjectionPayload(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ User::query()->groupBy('a); DROP');
+ }
+
+ public function testOrderByPlainColumnUnchanged(): void
+ {
+ $sql = User::query()->orderBy('id', 'DESC')->toSql();
+
+ $this->assertStringContainsString('ORDER BY id ASC', $sql);
+ }
+
+ public function testOrderByQualifiedColumnUnchanged(): void
+ {
+ $sql = User::query()->orderBy('t.col')->toSql();
+
+ $this->assertStringContainsString('ORDER BY t.col ASC', $sql);
+ }
+
+ public function testGroupByPlainColumnUnchanged(): void
+ {
+ $sql = User::query()->groupBy('contact_id')->toSql();
+
+ $this->assertStringContainsString('GROUP BY contact_id', $sql);
+ }
+
+ public function testGroupByQualifiedColumnUnchanged(): void
+ {
+ $sql = User::query()->groupBy('wp_x.module')->toSql();
+
+ $this->assertStringContainsString('GROUP BY wp_x.module', $sql);
+ }
+}
diff --git a/tests/QualifiedColumnPrefixTest.php b/tests/QualifiedColumnPrefixTest.php
new file mode 100644
index 0000000..1c92e94
--- /dev/null
+++ b/tests/QualifiedColumnPrefixTest.php
@@ -0,0 +1,114 @@
+where('users.status', 1)->toSql();
+ $this->assertStringContainsString('`wp_users`.status', $sql);
+ }
+
+ public function testSelectResolvesModelAndJoinedTable(): void
+ {
+ $sql = (new User())->join('posts', 'user_id', '=', 'id')
+ ->select('users.id', 'posts.title')->toSql();
+
+ $this->assertStringContainsString('`wp_users`.id', $sql);
+ $this->assertStringContainsString('`wp_posts`.title', $sql);
+ }
+
+ public function testSelectResolvesRegardlessOfJoinOrder(): void
+ {
+ $sql = (new User())->select('posts.title')
+ ->join('posts', 'user_id', '=', 'id')->toSql();
+
+ $this->assertStringContainsString('`wp_posts`.title', $sql);
+ }
+
+ public function testJoinOnResolvesBothSides(): void
+ {
+ $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql();
+
+ $this->assertStringContainsString('`wp_posts`.user_id', $sql);
+ $this->assertStringContainsString('`wp_users`.id', $sql);
+ $this->assertStringNotContainsString(' users.id', $sql);
+ }
+
+ public function testAlreadyPhysicalNameUntouched(): void
+ {
+ $sql = (new User())->where('wp_users.id', 5)->toSql();
+
+ $this->assertStringContainsString('wp_users.id', $sql);
+ $this->assertStringNotContainsString('wp_wp_users', $sql);
+ $this->assertStringNotContainsString('`wp_users`.id', $sql);
+ }
+
+ public function testAliasShadowingModelNameWins(): void
+ {
+ $sql = (new User())->from('users')->where('users.id', 5)->toSql();
+
+ $this->assertStringContainsString(' users.id', $sql);
+ $this->assertStringNotContainsString('`wp_users`.id', $sql);
+ }
+
+ public function testGroupByResolvesJoinedTable(): void
+ {
+ $sql = (new User())->join('posts', 'user_id', '=', 'id')
+ ->groupBy('posts.status')->toSql();
+
+ $this->assertStringContainsString('GROUP BY `wp_posts`.status', $sql);
+ }
+
+ public function testOrderByResolvesJoinedTable(): void
+ {
+ $sql = (new User())->join('posts', 'user_id', '=', 'id')
+ ->orderBy('posts.title')->toSql();
+
+ $this->assertStringContainsString('ORDER BY `wp_posts`.title', $sql);
+ }
+
+ public function testResolveQualifierIsIdempotent(): void
+ {
+ $qb = User::query();
+ $once = $qb->resolveQualifier('users.id');
+ $twice = $qb->resolveQualifier($once);
+
+ $this->assertSame('`wp_users`.id', $once);
+ $this->assertSame($once, $twice);
+ }
+
+ public function testResolveQualifierLeavesUnqualifiedColumnAlone(): void
+ {
+ $this->assertSame('id', User::query()->resolveQualifier('id'));
+ }
+
+ public function testJoinedTableResolvesInsideNestedClosure(): void
+ {
+ $sql = (new User())->join('posts', 'user_id', '=', 'id')
+ ->where(function ($q) {
+ $q->where('posts.status', 1);
+ })->toSql();
+
+ $this->assertStringContainsString('`wp_posts`.status', $sql);
+ }
+
+ public function testJoinedTableResolvesUnderSoftDeleteScope(): void
+ {
+ $sql = (new SoftPost())->join('users', 'post_id', '=', 'id')
+ ->where('users.role', 'admin')->toSql();
+
+ $this->assertStringContainsString('`wp_users`.role', $sql);
+ }
+}
diff --git a/tests/Query/GrammarTest.php b/tests/Query/GrammarTest.php
index dc34f38..68cf0f3 100644
--- a/tests/Query/GrammarTest.php
+++ b/tests/Query/GrammarTest.php
@@ -72,11 +72,25 @@ public function testNestedClosureWhereProducesParenthesizedGroup(): void
public function testJoin(): void
{
$this->assertSame(
- 'SELECT FROM wp_users INNER JOIN wp_wp_posts ON `wp_users`.`user_id` = id',
+ 'SELECT FROM wp_users INNER JOIN wp_posts ON `wp_users`.`user_id` = wp_posts.id',
(new User())->join('posts', 'user_id', '=', 'id')->toSql()
);
}
+ public function testJoinWithAlias(): void
+ {
+ $sql = (new User())->join('posts as p', 'user_id', '=', 'id')->toSql();
+ $this->assertStringContainsString('INNER JOIN wp_posts as p ON', $sql);
+ $this->assertStringContainsString('= p.id', $sql);
+ }
+
+ public function testJoinWithDottedColumnsResolvesKnownTables(): void
+ {
+ // Unprefixed model/join table names in ON columns resolve to physical.
+ $sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql();
+ $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id', $sql);
+ }
+
public function testGroupBy(): void
{
$this->assertSame(
@@ -124,4 +138,20 @@ public function testCompileSelectReturnsString(): void
$this->assertIsString($query->grammar()->compileSelect($query));
$this->assertInstanceOf(QueryBuilder::class, $query);
}
+
+ public function testDistinctEmitsSelectDistinct(): void
+ {
+ $this->assertSame(
+ 'SELECT DISTINCT `wp_users`.`id` FROM wp_users',
+ (new User())->select('id')->distinct()->toSql()
+ );
+ }
+
+ public function testWithoutDistinctSelectIsUnchanged(): void
+ {
+ $this->assertSame(
+ 'SELECT `wp_users`.`id` FROM wp_users',
+ (new User())->select('id')->toSql()
+ );
+ }
}
diff --git a/tests/QueryClauseEdgeFixTest.php b/tests/QueryClauseEdgeFixTest.php
new file mode 100644
index 0000000..e475a79
--- /dev/null
+++ b/tests/QueryClauseEdgeFixTest.php
@@ -0,0 +1,110 @@
+where('id', '=', null);
+
+ $this->assertStringContainsString('`wp_users`.`id` IS NULL', $qb->toSql());
+ $qb->toSql();
+ $this->assertSame([], $qb->getBindings());
+ }
+
+ public function testWhereWithNotEqualNullEmitsIsNotNull(): void
+ {
+ $this->assertStringContainsString(
+ '`wp_users`.`id` IS NOT NULL',
+ (new User())->where('id', '!=', null)->toSql()
+ );
+ }
+
+ // A3 ---------------------------------------------------------------------
+
+ public function testWhereInEmptyArrayEmitsFalseConstant(): void
+ {
+ $qb = (new User())->whereIn('id', []);
+
+ $sql = $qb->toSql();
+ $this->assertStringContainsString('0 = 1', $sql);
+ $this->assertStringNotContainsString('IN ()', $sql);
+ $this->assertSame([], $qb->getBindings());
+ }
+
+ public function testWhereWithEmptyArrayValueEmitsFalseConstant(): void
+ {
+ $sql = (new User())->where('status', [])->toSql();
+
+ $this->assertStringContainsString('0 = 1', $sql);
+ $this->assertStringNotContainsString('IN ()', $sql);
+ }
+
+ // A8 ---------------------------------------------------------------------
+
+ public function testWhereInNestedArrayElementGetsOnePlaceholderEach(): void
+ {
+ $qb = (new User())->whereIn('id', [[1, 2], 3]);
+
+ $this->assertStringContainsString('IN (%s,%d)', $qb->toSql());
+ $this->assertSame(['[1,2]', 3], $qb->getBindings());
+ }
+
+ // B3 ---------------------------------------------------------------------
+
+ public function testWhereWithObjectValueBindsJsonString(): void
+ {
+ $qb = (new User())->where('meta', (object) ['k' => 'v']);
+
+ $this->assertStringContainsString('`wp_users`.`meta` = %s', $qb->toSql());
+ $qb->toSql();
+ $this->assertSame(['{"k":"v"}'], $qb->getBindings());
+ }
+
+ // E1 ---------------------------------------------------------------------
+
+ public function testTakeCastsArgumentToIntBlockingInjection(): void
+ {
+ $sql = (new User())->take('5; DROP TABLE wp_users')->toSql();
+
+ $this->assertStringContainsString('LIMIT 5', $sql);
+ $this->assertStringNotContainsString('DROP', $sql);
+ }
+
+ public function testTakeNumericIsByteIdentical(): void
+ {
+ $this->assertStringContainsString('LIMIT 10', (new User())->take(10)->toSql());
+ }
+
+ public function testSkipCastsArgumentToInt(): void
+ {
+ $sql = (new User())->take(10)->skip('20; DROP')->toSql();
+
+ $this->assertStringContainsString('OFFSET 20', $sql);
+ $this->assertStringNotContainsString('DROP', $sql);
+ }
+}
diff --git a/tests/QueryFeaturesTest.php b/tests/QueryFeaturesTest.php
index d66d6a8..56ffc97 100644
--- a/tests/QueryFeaturesTest.php
+++ b/tests/QueryFeaturesTest.php
@@ -24,6 +24,18 @@ protected function tearDown(): void
$GLOBALS['wpdb'] = new FakeWpdb();
}
+ // --- Select --------------------------------------------------------------
+
+ public function testSelectColumnAliasQualifiesColumnNotWholeExpression(): void
+ {
+ $sql = (new User())->select(['id', 'name AS n'])->toSql();
+
+ // the column is qualified/back-ticked, the alias kept separate
+ $this->assertStringContainsString('`name` AS `n`', $sql);
+ // the whole "name AS n" must NOT be treated as one column name
+ $this->assertStringNotContainsString('`name AS n`', $sql);
+ }
+
// --- Joins ---------------------------------------------------------------
public function testInnerJoinCompilesWithOnClause(): void
@@ -31,8 +43,9 @@ public function testInnerJoinCompilesWithOnClause(): void
$sql = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->toSql();
$this->assertStringContainsString('INNER JOIN', $sql);
- $this->assertStringContainsString('posts.user_id', $sql);
- $this->assertStringContainsString('users.id', $sql);
+ // Unprefixed model/join table names in ON columns resolve to physical.
+ $this->assertStringContainsString('`wp_posts`.user_id', $sql);
+ $this->assertStringContainsString('`wp_users`.id', $sql);
}
public function testLeftJoinThenWhereKeepsBindingsInOrder(): void
diff --git a/tests/RaggedBulkInsertFixTest.php b/tests/RaggedBulkInsertFixTest.php
new file mode 100644
index 0000000..421f471
--- /dev/null
+++ b/tests/RaggedBulkInsertFixTest.php
@@ -0,0 +1,43 @@
+rows_affected = 0;
+
+ User::query()->insert([
+ ['a' => 'x', 'b' => 'y'],
+ ['b' => 'z', 'c' => 'w'],
+ ]);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('(a, b)', $sql);
+ $this->assertStringContainsString("('x', 'y')", $sql);
+ $this->assertStringContainsString("(NULL, 'z')", $sql);
+ $this->assertStringNotContainsString("('z'", $sql);
+ }
+}
diff --git a/tests/RelationDirtyFixTest.php b/tests/RelationDirtyFixTest.php
new file mode 100644
index 0000000..b34dbfe
--- /dev/null
+++ b/tests/RelationDirtyFixTest.php
@@ -0,0 +1,74 @@
+resolver = static function ($sql) {
+ if (strpos($sql, 'wp_posts') !== false) {
+ return [(object) ['id' => 5, 'user_id' => 1, 'title' => 'p']];
+ }
+
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ $user = User::query()->where('id', 1)->first();
+ $posts = $user->posts;
+
+ $this->assertInstanceOf(Collection::class, $posts);
+ $this->assertArrayNotHasKey('posts', $user->getDirtyAttributes());
+
+ $user->name = 'Grace';
+
+ $GLOBALS['wpdb']->queries = [];
+ $GLOBALS['wpdb']->last_query = '';
+
+ $user->save();
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('UPDATE wp_users', $sql);
+ $this->assertStringContainsString('name =', $sql);
+ $this->assertStringNotContainsString('posts', $sql);
+ }
+
+ public function testRealArrayColumnIsStillWritten(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ $user = User::query()->where('id', 1)->first();
+ $user->tags = ['a', 'b'];
+
+ $GLOBALS['wpdb']->queries = [];
+ $GLOBALS['wpdb']->last_query = '';
+
+ $user->save();
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('tags =', $sql);
+ $this->assertStringContainsString('["a","b"]', $sql);
+ }
+}
diff --git a/tests/RelationEagerEdgeTest.php b/tests/RelationEagerEdgeTest.php
new file mode 100644
index 0000000..0e22a76
--- /dev/null
+++ b/tests/RelationEagerEdgeTest.php
@@ -0,0 +1,135 @@
+ 10, 'user_id' => 1],
+ (object) ['id' => 11, 'user_id' => 1],
+ (object) ['id' => 12, 'user_id' => 2],
+ ];
+ }
+
+ return [(object) ['id' => 1], (object) ['id' => 2]];
+ };
+ }
+
+ public function testWhereHasClosureConstrainsExistsSubquery(): void
+ {
+ $sql = User::whereHas('posts', static function ($q) {
+ $q->where('status', 'published');
+ })->toSql();
+
+ $this->assertStringContainsString('exists(', $sql);
+ $this->assertStringContainsString('`wp_posts`.`status`', $sql);
+ $this->assertStringContainsString('`wp_users`.`id`=`wp_posts`.`user_id`', $sql);
+ }
+
+ public function testMultipleWhereHasAreAnded(): void
+ {
+ $sql = User::whereHas('posts')->whereHas('posts')->toSql();
+
+ $this->assertSame(2, substr_count($sql, 'exists('));
+ }
+
+ public function testWithCountClosureNarrowsCountSubquery(): void
+ {
+ $sql = User::withCount(['posts' => static function ($q) {
+ $q->where('status', 'published');
+ }])->toSql();
+
+ $this->assertStringContainsString('count(*)', $sql);
+ $this->assertStringContainsString('`wp_posts`.`status`', $sql);
+ $this->assertStringContainsString('as `posts_count`', $sql);
+ }
+
+ public function testWithCountAliasNamesTheColumn(): void
+ {
+ $this->assertStringContainsString('as `c`', User::withCount('posts as c')->toSql());
+ }
+
+ public function testSelectThenWithCountDoesNotDuplicateBaseSelect(): void
+ {
+ $sql = User::select(['id'])->withCount('posts')->toSql();
+
+ $this->assertStringContainsString('`wp_users`.`id`, (SELECT count(*)', $sql);
+ $this->assertStringNotContainsString('`wp_users`.*', $sql);
+ }
+
+ public function testEagerClosureConstraintFiltersTheRelationQuery(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->eagerResolver();
+
+ User::with(['posts' => static function ($q) {
+ $q->where('status', 'published');
+ }])->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('`wp_posts`.`status`', $postsSql);
+ $this->assertStringContainsString('IN ( SELECT * FROM (', $postsSql);
+ }
+
+ public function testParentWherePropagatesIntoEagerSubquery(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->eagerResolver();
+
+ User::where('status', 'active')->with('posts')->get();
+
+ $postsSql = $GLOBALS['wpdb']->queries[1];
+
+ $this->assertStringContainsString('`wp_users`.`status`', $postsSql);
+ $this->assertStringContainsString('AS subquery', $postsSql);
+ }
+
+ public function testEagerRelationAliasAttachesUnderAlias(): void
+ {
+ $GLOBALS['wpdb']->resolver = $this->eagerResolver();
+
+ $users = User::with('posts as recent')->get();
+
+ // eager-attached relations are plain arrays (lazy access returns a Collection).
+ $this->assertCount(2, $users[0]->recent);
+ $this->assertSame(10, $users[0]->recent[0]->id);
+ }
+
+ public function testEagerLoadOnFirstAttachesRelation(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function ($sql) {
+ if (strpos($sql, 'wp_posts') !== false) {
+ return [(object) ['id' => 10, 'user_id' => 1]];
+ }
+
+ return [(object) ['id' => 1]];
+ };
+
+ $user = User::with('posts')->where('id', 1)->first();
+
+ $this->assertCount(1, $user->posts);
+ $this->assertSame(10, $user->posts[0]->id);
+ }
+}
diff --git a/tests/RelationResolutionSafetyTest.php b/tests/RelationResolutionSafetyTest.php
new file mode 100644
index 0000000..dbc81ff
--- /dev/null
+++ b/tests/RelationResolutionSafetyTest.php
@@ -0,0 +1,171 @@
+assertInstanceOf(
+ RuntimeException::class,
+ $caught,
+ 'a non-relation method name must be rejected at resolution with a RuntimeException'
+ );
+ }
+
+ public function testWithRejectsNonRelationMethod(): void
+ {
+ // must be rejected at resolution (the with() call), not blow up later.
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::with('destroyTheWorld');
+ });
+ }
+
+ public function testWithCountRejectsNonRelationMethod(): void
+ {
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::withCount('destroyTheWorld');
+ });
+ }
+
+ public function testWhereHasRejectsNonRelationMethod(): void
+ {
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::whereHas('destroyTheWorld');
+ });
+ }
+
+ public function testGenuineRelationStillResolves(): void
+ {
+ // zero-BC guard: a real relation method must keep resolving, never rejected.
+ try {
+ RelationSentinel::with('posts')->get();
+ } catch (RuntimeException $e) {
+ $this->fail('a genuine relation must not be rejected: ' . $e->getMessage());
+ }
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testGetActiveRelationKeyFailsLoudlyOnUnknownTag(): void
+ {
+ $model = new RelationSentinel();
+ $model->setRelateAs('not_a_real_tag');
+
+ $threw = false;
+
+ try {
+ @$model->getActiveRelationKey();
+ } catch (Throwable $e) {
+ $threw = true;
+ }
+
+ $this->assertTrue(
+ $threw,
+ 'getActiveRelationKey() must fail loudly on an unknown relation tag, not return a silent null'
+ );
+ }
+
+ // A method that DOES return a QueryBuilder, but one whose model has no active
+ // relation key, must still be rejected (the second isRelationQuery() branch).
+ public function testWithRejectsMethodReturningNonRelationQueryBuilder(): void
+ {
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::with('plainQuery');
+ });
+ }
+
+ // A framework Model method (refresh() runs a SELECT) must be rejected — and,
+ // per option-α, never invoked.
+ public function testFrameworkModelMethodIsRejected(): void
+ {
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::with('refresh');
+ });
+ }
+
+ // withWhereHas() is the 4th resolution entry point and must reject too.
+ public function testWithWhereHasRejectsNonRelationMethod(): void
+ {
+ $this->assertRejectedAsRelation(static function () {
+ RelationSentinel::withWhereHas('destroyTheWorld');
+ });
+ }
+
+ // A relation declared on an intermediate base class (leaf -> base -> Model)
+ // must resolve — its declaring class is the base, not the framework Model.
+ public function testRelationOnIntermediateBaseClassIsAllowed(): void
+ {
+ $threw = false;
+
+ try {
+ RelationLeafModel::with('widgets');
+ } catch (Throwable $e) {
+ $threw = true;
+ }
+
+ $this->assertFalse($threw, 'a relation declared on an intermediate base class must resolve');
+ }
+
+ // The framework-vs-consumer verdict is memoized per "class::method"; the
+ // cache must hold the correct boolean for each and not collide across methods.
+ public function testFrameworkVerdictIsMemoizedPerClassMethod(): void
+ {
+ $cacheProp = new ReflectionProperty(Model::class, 'relationMethodCache');
+ if (\PHP_VERSION_ID < 80100) {
+ $cacheProp->setAccessible(true); // required on 7.4; a deprecated no-op on 8.1+
+ }
+ $cacheProp->setValue(null, []);
+
+ try {
+ RelationSentinel::with('refresh'); // framework method -> rejected
+ } catch (RuntimeException $e) {
+ // expected
+ }
+ RelationSentinel::with('posts'); // consumer relation -> resolves
+
+ $cache = $cacheProp->getValue();
+ $prefix = RelationSentinel::class;
+
+ $this->assertArrayHasKey($prefix . '::refresh', $cache);
+ $this->assertTrue($cache[$prefix . '::refresh'], 'refresh memoized as a framework method');
+ $this->assertArrayHasKey($prefix . '::posts', $cache);
+ $this->assertFalse($cache[$prefix . '::posts'], 'posts memoized as a consumer method');
+ }
+}
diff --git a/tests/SaveEventFiresTest.php b/tests/SaveEventFiresTest.php
new file mode 100644
index 0000000..14401ce
--- /dev/null
+++ b/tests/SaveEventFiresTest.php
@@ -0,0 +1,98 @@
+resolver = static function () {
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ $user = SavedEventUser::query()->where('id', 1)->first(); // existing
+ $user->name = 'Grace';
+
+ $GLOBALS['wpdb']->rows_affected = 0; // no-op UPDATE
+ SavedEventUser::resetCounters(); // ignore anything fired during load
+
+ $user->save();
+
+ $this->assertSame(1, SavedEventUser::$savedCount, 'saved must fire on a 0-row UPDATE');
+ }
+
+ public function testUpdatedFiresOnZeroRowUpdate(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ $user = SavedEventUser::query()->where('id', 1)->first();
+ $user->name = 'Grace';
+
+ $GLOBALS['wpdb']->rows_affected = 0;
+ SavedEventUser::resetCounters();
+
+ $user->save();
+
+ $this->assertSame(1, SavedEventUser::$updatedCount, 'updated must fire even on a 0-row UPDATE');
+ }
+
+ public function testCreatedFiresOnInsert(): void
+ {
+ $GLOBALS['wpdb']->insert_id = 5;
+ $GLOBALS['wpdb']->rows_affected = 1;
+
+ $user = new SavedEventUser();
+ $user->name = 'Ada';
+ $user->save();
+
+ $this->assertSame(1, SavedEventUser::$createdCount, 'created must fire on a successful INSERT');
+ }
+
+ public function testSavedFiresOnManualPkInsert(): void
+ {
+ $GLOBALS['wpdb']->insert_id = 0; // no auto-increment id
+ $GLOBALS['wpdb']->rows_affected = 1; // insert succeeded
+ $GLOBALS['wpdb']->last_error = '';
+
+ $user = new SavedEventUser();
+ $user->name = 'Ada';
+ $user->save();
+
+ $this->assertSame(1, SavedEventUser::$savedCount, 'saved must fire on a successful manual-PK INSERT');
+ }
+
+ public function testSavedDoesNotFireOnFailedWrite(): void
+ {
+ $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // exec() -> false
+
+ $user = new SavedEventUser();
+ $user->name = 'Ada';
+ $result = $user->save();
+
+ $this->assertFalse($result);
+ $this->assertSame(0, SavedEventUser::$savedCount, 'saved must NOT fire when the write fails');
+ }
+}
diff --git a/tests/SaveInsertReturnFixTest.php b/tests/SaveInsertReturnFixTest.php
new file mode 100644
index 0000000..82f0e41
--- /dev/null
+++ b/tests/SaveInsertReturnFixTest.php
@@ -0,0 +1,77 @@
+insert_id = 7;
+ $GLOBALS['wpdb']->rows_affected = 1;
+
+ $user = new User();
+ $user->name = 'Ada';
+ $result = $user->save();
+
+ $this->assertSame($user, $result);
+ $this->assertEquals(7, $user->getAttribute('id'), 'auto-increment id must be set from lastInsertId()');
+ }
+
+ public function testManualPkInsertReturnsModelWhenNoAutoIncrementId(): void
+ {
+ $GLOBALS['wpdb']->insert_id = 0; // manual/composite key -> no auto id
+ $GLOBALS['wpdb']->rows_affected = 1; // insert succeeded
+ $GLOBALS['wpdb']->last_error = '';
+
+ $user = new User();
+ $user->name = 'Ada';
+ $result = $user->save();
+
+ $this->assertSame($user, $result, 'successful insert with no auto-increment id must return the Model');
+ }
+
+ public function testInsertErrorStillReturnsFalse(): void
+ {
+ $GLOBALS['wpdb']->insert_id = 0;
+ $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // exec() -> false
+
+ $user = new User();
+ $user->name = 'Ada';
+
+ $this->assertFalse($user->save(), 'a failed insert must return false');
+ }
+
+ public function testFailedInsertWithStaleInsertIdReturnsFalseAndDoesNotSetPk(): void
+ {
+ $GLOBALS['wpdb']->insert_id = 99; // stale id from an earlier insert
+ $GLOBALS['wpdb']->last_error = 'ER_DUP_ENTRY'; // this insert failed -> exec() false
+
+ $user = new User();
+ $user->name = 'Ada';
+ $result = $user->save();
+
+ $this->assertFalse($result, 'a failed insert must return false even with a stale insert_id');
+ $this->assertNotEquals(99, $user->getAttribute('id'), 'a failed insert must not set the PK from a stale insert_id');
+ }
+}
diff --git a/tests/SaveZeroRowUpdateFixTest.php b/tests/SaveZeroRowUpdateFixTest.php
new file mode 100644
index 0000000..1f5fc5f
--- /dev/null
+++ b/tests/SaveZeroRowUpdateFixTest.php
@@ -0,0 +1,73 @@
+save()` fatals on `false->save()` and `if (!$model->save())`
+ * falsely reports an error. A genuine error must still return false.
+ */
+final class SaveZeroRowUpdateFixTest extends TestCase
+{
+ protected function setUp(): void
+ {
+ $GLOBALS['wpdb'] = new FakeWpdb();
+ }
+
+ protected function tearDown(): void
+ {
+ $GLOBALS['wpdb'] = new FakeWpdb();
+ }
+
+ private function existingUser(): User
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ return User::query()->where('id', 1)->first();
+ }
+
+ public function testZeroRowUpdateReturnsModelNotFalse(): void
+ {
+ $user = $this->existingUser();
+ $user->name = 'Grace'; // dirty -> a real UPDATE is built
+
+ $GLOBALS['wpdb']->rows_affected = 0; // matched the row but changed nothing
+ $GLOBALS['wpdb']->last_error = '';
+
+ $result = $user->save();
+
+ $this->assertSame($user, $result, 'a 0-row (no-op) UPDATE is success, must return the Model');
+ }
+
+ public function testChainedUpdateSaveDoesNotFatalOnZeroRowUpdate(): void
+ {
+ $user = $this->existingUser();
+
+ $GLOBALS['wpdb']->rows_affected = 0;
+
+ // update() delegates to save() on an existing model; the trailing save()
+ // must land on a Model, not false.
+ $result = $user->update(['name' => 'Grace'])->save();
+
+ $this->assertSame($user, $result);
+ }
+
+ public function testRealErrorStillReturnsFalse(): void
+ {
+ $user = $this->existingUser();
+ $user->name = 'Grace';
+
+ $GLOBALS['wpdb']->rows_affected = 0;
+ $GLOBALS['wpdb']->last_error = 'ER_SOMETHING'; // exec() -> false
+
+ $this->assertFalse($user->save(), 'a real DB error must still return false');
+ }
+}
diff --git a/tests/SchemaDdlFixTest.php b/tests/SchemaDdlFixTest.php
new file mode 100644
index 0000000..711f0f3
--- /dev/null
+++ b/tests/SchemaDdlFixTest.php
@@ -0,0 +1,55 @@
+string('email')->unique();
+ });
+
+ $this->assertStringContainsString('ADD UNIQUE INDEX', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testEditRenameColumnIsEmitted(): void
+ {
+ Schema::edit('orders', function ($table) {
+ $table->renameColumn('old_name', 'new_name');
+ });
+
+ $this->assertStringContainsString(
+ 'RENAME COLUMN old_name TO new_name',
+ $GLOBALS['wpdb']->last_query
+ );
+ }
+
+ public function testDecimalEmitsPrecisionAndScale(): void
+ {
+ Schema::create('invoices', function ($table) {
+ $table->decimal('amount', 8, 2);
+ });
+
+ $this->assertStringContainsString('DECIMAL(8, 2)', $GLOBALS['wpdb']->last_query);
+ }
+}
diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php
new file mode 100644
index 0000000..af4756a
--- /dev/null
+++ b/tests/SchemaTest.php
@@ -0,0 +1,156 @@
+varchar('reference', 128)->change();
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('MODIFY COLUMN reference VARCHAR(128)', $sql);
+ $this->assertStringNotContainsString('ADD COLUMN', $sql);
+ }
+
+ public function testNonChangedEditEmitsAddColumn(): void
+ {
+ Schema::edit('orders', function ($table) {
+ $table->varchar('note', 64);
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ADD COLUMN note VARCHAR(64)', $sql);
+ }
+
+ public function testDropPrimaryDirectCallEmitsFullSql(): void
+ {
+ Schema::dropPrimary('orders');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ALTER TABLE `orders`', $sql);
+ $this->assertStringContainsString('DROP PRIMARY KEY', $sql);
+ }
+
+ public function testDropTimestampsDirectCallEmitsFullSql(): void
+ {
+ Schema::dropTimestamps('orders');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ALTER TABLE `orders`', $sql);
+ $this->assertStringContainsString('DROP COLUMN created_at, DROP COLUMN updated_at', $sql);
+ }
+
+ public function testDropIndexDirectCallSingleNameEmitsFullSql(): void
+ {
+ Schema::dropIndex('orders', 'email_INDEX');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ALTER TABLE `orders`', $sql);
+ $this->assertStringContainsString('DROP INDEX `email_INDEX`', $sql);
+ }
+
+ public function testDropIndexDirectCallArrayNamesEmitsFullSql(): void
+ {
+ Schema::dropIndex('orders', ['a', 'b']);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('DROP INDEX `a`', $sql);
+ $this->assertStringContainsString('DROP INDEX `b`', $sql);
+ }
+
+ public function testDropUniqueDirectCallEmitsDropIndexSql(): void
+ {
+ Schema::dropUnique('orders', 'email_UNIQUE');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ALTER TABLE `orders`', $sql);
+ $this->assertStringContainsString('DROP INDEX `email_UNIQUE`', $sql);
+ }
+
+ public function testDropForeignDirectCallEmitsFullSql(): void
+ {
+ Schema::dropForeign('orders', 'fk_user');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('ALTER TABLE `orders`', $sql);
+ $this->assertStringContainsString('DROP FOREIGN KEY `fk_user`', $sql);
+ }
+
+ public function testDropPrimaryEditModeStillWorks(): void
+ {
+ Schema::edit('orders', function ($table) {
+ $table->dropPrimary();
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('DROP PRIMARY KEY', $sql);
+ }
+
+ // --- B1: prefix behaviour ---
+
+ public function testBareDefaultNeverPrefixedWithWp(): void
+ {
+ Schema::create('orders', function ($table) {
+ $table->id();
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS orders', $sql);
+ $this->assertStringNotContainsString('wp_orders', $sql);
+ }
+
+ public function testWithWpPrefixPrependsWpPrefix(): void
+ {
+ Schema::withWpPrefix()->create('orders', function ($table) {
+ $table->id();
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS wp_orders', $sql);
+ }
+
+ public function testWithPrefixRegressionCustomPrefix(): void
+ {
+ Schema::withPrefix('custom_')->create('orders', function ($table) {
+ $table->id();
+ });
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS custom_orders', $sql);
+ }
+}
diff --git a/tests/SelectJoinAggregateEdgeTest.php b/tests/SelectJoinAggregateEdgeTest.php
new file mode 100644
index 0000000..7f46c2d
--- /dev/null
+++ b/tests/SelectJoinAggregateEdgeTest.php
@@ -0,0 +1,153 @@
+assertStringContainsString('t.col AS `a`', (new User())->select(['t.col AS a'])->toSql());
+ }
+
+ public function testLowercaseAsNormalisesToUppercase(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`col` AS `a`', (new User())->select(['col as a'])->toSql());
+ }
+
+ public function testAlreadyBacktickedAliasIsNotDoubleQuoted(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`col` AS `a`', (new User())->select(['col AS `a`'])->toSql());
+ }
+
+ public function testAliasWithSpacesIsBacktickedWhole(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`col` AS `the alias`', (new User())->select(['col AS the alias'])->toSql());
+ }
+
+ public function testMultipleAsSplitsOnFirst(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`a` AS `b as c`', (new User())->select(['a as b as c'])->toSql());
+ }
+
+ public function testSelectThenSelectRawJoinedByComma(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`id`, COUNT(*) as c', (new User())->select(['id'])->selectRaw('COUNT(*) as c')->toSql());
+ }
+
+ public function testEmptySelectEmitsQualifiedNothing(): void
+ {
+ $this->assertStringContainsString('SELECT FROM wp_users', (new User())->toSql());
+ }
+
+ public function testPrepareColumnNameStarStaysQualifiedStar(): void
+ {
+ $this->assertSame('`wp_users`.*', (new User())->prepareColumnName('*'));
+ }
+
+ // --- Joins ---------------------------------------------------------------
+
+ public function testRightFullCrossJoinKeywords(): void
+ {
+ $this->assertStringContainsString('RIGHT JOIN wp_posts', (new User())->rightJoin('posts', 'posts.user_id', '=', 'users.id')->toSql());
+ $this->assertStringContainsString('FULL JOIN wp_posts', (new User())->fullJoin('posts', 'posts.user_id', '=', 'users.id')->toSql());
+ $this->assertStringContainsString('CROSS JOIN wp_posts', (new User())->crossJoin('posts', 'posts.user_id', '=', 'users.id')->toSql());
+ }
+
+ public function testOnAndOrOnAppendToSameJoin(): void
+ {
+ // Unprefixed model/join table names in ON columns resolve to physical.
+ $and = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->on('posts.status', '=', 'users.state')->toSql();
+ $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id AND `wp_posts`.status = `wp_users`.state', $and);
+
+ $or = (new User())->join('posts', 'posts.user_id', '=', 'users.id')->orOn('posts.status', '=', 'users.state')->toSql();
+ $this->assertStringContainsString('`wp_posts`.user_id = `wp_users`.id OR `wp_posts`.status = `wp_users`.state', $or);
+ }
+
+ public function testJoinOnCustomPrefixModelQualifiesBaseColumnWithFullPrefix(): void
+ {
+ $sql = (new PrefixedModel())->join('gadgets', 'gid', '=', 'wid')->toSql();
+
+ $this->assertStringContainsString('INNER JOIN wp_crm_gadgets', $sql);
+ $this->assertStringContainsString('`wp_crm_widgets`.`gid` = wp_crm_gadgets.wid', $sql);
+ }
+
+ // --- Aggregates / ordering ----------------------------------------------
+
+ public function testCountCompilesCountOfQualifiedPrimaryKey(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['COUNT' => '7']];
+ };
+
+ $result = (new User())->count();
+
+ $this->assertSame(7, $result);
+ $this->assertStringContainsString('COUNT(`wp_users`.`id`) as COUNT', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testMaxAndMinReturnNullOnEmptyResultSet(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [];
+ };
+
+ $this->assertNull((new User())->max('score'));
+ $this->assertNull((new User())->min('score'));
+ }
+
+ public function testAggregateRunsOnCloneWithoutMutatingSelect(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['MAX' => 9]];
+ };
+
+ $query = (new User())->select(['id', 'name']);
+ $before = $query->toSql();
+ $query->max('score');
+
+ $this->assertSame($before, $query->toSql());
+ }
+
+ public function testAggregateCarriesWhereClause(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['COUNT' => '3']];
+ };
+
+ (new User())->where('active', 1)->count();
+
+ $this->assertStringContainsString('WHERE', $GLOBALS['wpdb']->last_query);
+ $this->assertStringContainsString('`wp_users`.`active`', $GLOBALS['wpdb']->last_query);
+ }
+
+ public function testAscFallsBackToPrimaryKey(): void
+ {
+ $this->assertStringContainsString('ORDER BY id ASC', (new User())->asc()->toSql());
+ }
+
+ public function testSkipWithoutTakeOmitsOffset(): void
+ {
+ $this->assertStringNotContainsString('OFFSET', (new User())->skip(20)->toSql());
+ }
+}
diff --git a/tests/SoftDeleteRestoreTest.php b/tests/SoftDeleteRestoreTest.php
new file mode 100644
index 0000000..2277be8
--- /dev/null
+++ b/tests/SoftDeleteRestoreTest.php
@@ -0,0 +1,51 @@
+forceDelete();
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('DELETE FROM wp_soft_posts', $sql);
+ $this->assertStringNotContainsString('deleted_at', $sql);
+ }
+
+ public function testRestoreNullsDeletedAt(): void
+ {
+ SoftPost::where('id', 1)->restore();
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertMatchesRegularExpression('/UPDATE\s+wp_soft_posts\s+SET\s+deleted_at\s*=\s*NULL/i', $sql);
+ }
+
+ public function testForceDeleteRejectsNonSoftDeleteModel(): void
+ {
+ $this->expectException(RuntimeException::class);
+
+ User::where('id', 1)->forceDelete();
+ }
+}
diff --git a/tests/SoftDeleteScopeTest.php b/tests/SoftDeleteScopeTest.php
new file mode 100644
index 0000000..8a956e0
--- /dev/null
+++ b/tests/SoftDeleteScopeTest.php
@@ -0,0 +1,134 @@
+toSql();
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NULL', $sql);
+ }
+
+ // Opt-out: $soft_delete_scope = false restores the unfiltered read
+ public function testSoftDeleteScopeFalseOptsOutOfFilter(): void
+ {
+ $sql = UnscopedSoftPost::query()->toSql();
+ $this->assertStringNotContainsString('deleted_at', $sql);
+ }
+
+ // Non-soft-delete model untouched
+ public function testNonSoftDeleteModelIsNotFiltered(): void
+ {
+ $sql = User::query()->toSql();
+ $this->assertStringNotContainsString('deleted_at', $sql);
+ }
+
+ // Opt-in model: default query filters out trashed rows
+ public function testScopedModelDefaultQueryFiltersDeletedAt(): void
+ {
+ $sql = ScopedSoftPost::query()->toSql();
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NULL', $sql);
+ }
+
+ // withTrashed() removes the filter
+ public function testWithTrashedRemovesDeletedAtFilter(): void
+ {
+ $sql = ScopedSoftPost::query()->withTrashed()->toSql();
+ $this->assertStringNotContainsString('deleted_at', $sql);
+ }
+
+ // onlyTrashed() returns only trashed rows
+ public function testOnlyTrashedFiltersToTrashedRows(): void
+ {
+ $sql = ScopedSoftPost::query()->onlyTrashed()->toSql();
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NOT NULL', $sql);
+ }
+
+ // onlyTrashed() works on a non-scoped $soft_deletes model too
+ public function testOnlyTrashedWorksWithoutScopeFlag(): void
+ {
+ $sql = SoftPost::query()->onlyTrashed()->toSql();
+ $this->assertStringContainsString('IS NOT NULL', $sql);
+ }
+
+ // Aggregate carries the scope
+ public function testAggregateCarriesSoftDeleteScope(): void
+ {
+ ScopedSoftPost::query()->count();
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NULL', $sql);
+ }
+
+ // scope + orWhere: user conditions must be parenthesized to prevent precedence leak
+ public function testScopedModelOrWhereGroupsUserConditions(): void
+ {
+ $sql = ScopedSoftPost::query()
+ ->where('status', 'active')
+ ->orWhere('status', 'pending')
+ ->toSql();
+
+ // scope clause must be outside the OR group
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NULL', $sql);
+ // user conditions must be parenthesized
+ $this->assertMatchesRegularExpression('/\(.*status.*OR.*status.*\)/s', $sql);
+ // scope must appear after the closing parenthesis (column may be backtick-quoted)
+ $this->assertMatchesRegularExpression('/\).*deleted_at.*IS\s+NULL/s', $sql);
+ }
+
+ // onlyTrashed + orWhere: same grouping requirement
+ public function testOnlyTrashedOrWhereGroupsUserConditions(): void
+ {
+ $sql = ScopedSoftPost::query()
+ ->where('status', 'active')
+ ->orWhere('status', 'pending')
+ ->onlyTrashed()
+ ->toSql();
+
+ $this->assertStringContainsString('deleted_at', $sql);
+ $this->assertStringContainsString('IS NOT NULL', $sql);
+ $this->assertMatchesRegularExpression('/\(.*status.*OR.*status.*\)/s', $sql);
+ $this->assertMatchesRegularExpression('/\).*deleted_at.*IS\s+NOT\s+NULL/s', $sql);
+ }
+
+ // refresh() reloads a row by PK with withTrashed(), so a trashed row is still
+ // found and exists() stays true (no re-INSERT on the next save()).
+ public function testRefreshReloadsTrashedRowWithoutScopeFilter(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['id' => 1, 'deleted_at' => '2026-01-01 00:00:00']];
+ };
+
+ $post = new SoftPost(1); // constructor triggers refresh()
+
+ $this->assertTrue($post->exists(), 'refresh() must find the row even when trashed');
+ $this->assertStringNotContainsString(
+ 'deleted_at',
+ $GLOBALS['wpdb']->last_query,
+ 'refresh() must not scope out trashed rows (withTrashed)'
+ );
+ }
+}
diff --git a/tests/TablePrefixTest.php b/tests/TablePrefixTest.php
new file mode 100644
index 0000000..40dfb8f
--- /dev/null
+++ b/tests/TablePrefixTest.php
@@ -0,0 +1,39 @@
+...,
+ * not just ... — regression guard for the join double-prefix fix.
+ */
+final class TablePrefixTest extends TestCase
+{
+ protected function setUp(): void
+ {
+ $GLOBALS['wpdb'] = new FakeWpdb();
+ }
+
+ protected function tearDown(): void
+ {
+ $GLOBALS['wpdb'] = new FakeWpdb();
+ }
+
+ public function testCustomPrefixModelTableCarriesWpPrefix(): void
+ {
+ $this->assertSame('wp_crm_widgets', (new PrefixedModel())->getTable());
+ }
+
+ public function testJoinOnCustomPrefixModelKeepsWpPrefix(): void
+ {
+ $sql = (new PrefixedModel())
+ ->join('gadgets', 'gadgets.widget_id', '=', 'widgets.id')
+ ->toSql();
+
+ $this->assertStringContainsString('JOIN wp_crm_gadgets', $sql);
+ }
+}
diff --git a/tests/UpsertTest.php b/tests/UpsertTest.php
new file mode 100644
index 0000000..c019a15
--- /dev/null
+++ b/tests/UpsertTest.php
@@ -0,0 +1,88 @@
+upsert(['first_name' => 'Ada', 'email' => 'a@x.com']);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('(email, first_name)', $sql);
+ $this->assertStringContainsString("('a@x.com', 'Ada')", $sql);
+ }
+
+ /**
+ * With timestamps enabled, upsert inserts both created_at and updated_at, and
+ * on duplicate bumps updated_at (VALUES(updated_at)) while preserving created_at
+ * (created_at is excluded from the ON DUPLICATE KEY UPDATE set).
+ */
+ public function testUpsertManagesTimestampsOnDuplicate(): void
+ {
+ TimestampedRow::query()->upsert(['email' => 'a@x.com', 'name' => 'Ada']);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ // both timestamp columns are inserted
+ $this->assertStringContainsString('created_at', $sql);
+ $this->assertStringContainsString('updated_at', $sql);
+ // updated_at is bumped from its own inserted value, not created_at
+ $this->assertStringContainsString('updated_at = VALUES(updated_at)', $sql);
+ $this->assertStringNotContainsString('VALUES(created_at)', $sql);
+ // created_at is preserved on update (never in the update set)
+ $this->assertStringNotContainsString('created_at = VALUES(created_at)', $sql);
+ }
+
+ /**
+ * With timestamps disabled, upsert applies no timestamp magic — columns map verbatim.
+ */
+ public function testUpsertWithoutTimestampsHasNoMagic(): 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('created_at = VALUES(created_at)', $sql);
+ $this->assertStringNotContainsString('updated_at', $sql);
+ }
+
+ /**
+ * Array/object values are JSON-encoded, matching bulk insert and save().
+ */
+ public function testUpsertEncodesArrayValuesAsJson(): void
+ {
+ User::query()->upsert(['email' => 'a@x.com', 'meta' => ['x' => 1]]);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+
+ $this->assertStringContainsString('{"x":1}', $sql);
+ $this->assertStringNotContainsString('Array', $sql);
+ }
+}
diff --git a/tests/WhereClauseEdgeTest.php b/tests/WhereClauseEdgeTest.php
new file mode 100644
index 0000000..9a7cdab
--- /dev/null
+++ b/tests/WhereClauseEdgeTest.php
@@ -0,0 +1,166 @@
+toSql();
+
+ return $qb->getBindings();
+ }
+
+ public function testWhereInTypesPlaceholdersPerElement(): void
+ {
+ $qb = (new User())->whereIn('id', [1, 'a', 2.5]);
+
+ $this->assertStringContainsString('`wp_users`.`id` IN (%d,%s,%f)', $qb->toSql());
+ $this->assertSame([1, 'a', 2.5], $qb->getBindings());
+ }
+
+ public function testWhereInSingleElement(): void
+ {
+ $qb = (new User())->whereIn('id', [5]);
+
+ $this->assertStringContainsString('`wp_users`.`id` IN (%d)', $qb->toSql());
+ $this->assertSame([5], $qb->getBindings());
+ }
+
+ public function testWhereInAssocDropsKeys(): void
+ {
+ $qb = (new User())->whereIn('id', ['x' => 1, 'y' => 2]);
+
+ $this->assertStringContainsString('IN (%d,%d)', $qb->toSql());
+ $this->assertSame([1, 2], $qb->getBindings());
+ }
+
+ public function testWhereWithArrayValueIsImplicitIn(): void
+ {
+ $qb = (new User())->where('id', [1, 2, 3]);
+
+ // the 2-arg array path produces "IN (" (two spaces) — distinct from whereIn's "IN (".
+ $this->assertStringContainsString('`wp_users`.`id` IN (%d,%d,%d)', $qb->toSql());
+ $this->assertSame([1, 2, 3], $qb->getBindings());
+ }
+
+ public function testWhereNullEmitsIsNull(): void
+ {
+ $qb = (new User())->where('id', null);
+
+ $this->assertStringContainsString('`wp_users`.`id` IS NULL', $qb->toSql());
+ $this->assertSame([], $qb->getBindings());
+ }
+
+ public function testWhereNullAndNotNullHelpers(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`deleted_at` IS NULL', (new User())->whereNull('deleted_at')->toSql());
+ $this->assertStringContainsString('`wp_users`.`deleted_at` IS NOT NULL', (new User())->whereNotNull('deleted_at')->toSql());
+ }
+
+ public function testFalsyValuesAreNotDropped(): void
+ {
+ $this->assertSame([0], $this->bindings((new User())->where('id', 0)));
+ $this->assertSame([false], $this->bindings((new User())->where('active', false)));
+ $this->assertSame(['0'], $this->bindings((new User())->where('id', '0')));
+ $this->assertSame([''], $this->bindings((new User())->where('id', '')));
+ }
+
+ public function testOperatorVariantsPassThrough(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`id` != %d', (new User())->where('id', '!=', 5)->toSql());
+ $this->assertStringContainsString('`wp_users`.`id` <> %d', (new User())->where('id', '<>', 5)->toSql());
+ $this->assertStringContainsString('`wp_users`.`id` >= %d', (new User())->where('id', '>=', 5)->toSql());
+ }
+
+ public function testLikeUppercaseAndNotLike(): void
+ {
+ $this->assertStringContainsString('`wp_users`.`name` LIKE %s', (new User())->where('name', 'LIKE', '%a%')->toSql());
+ $this->assertStringContainsString('`wp_users`.`name` NOT LIKE %s', (new User())->where('name', 'NOT LIKE', '%a%')->toSql());
+ }
+
+ public function testWhereBetweenAndOrWhereBetween(): void
+ {
+ $qb = (new User())->whereBetween('age', 18, 65);
+ $this->assertStringContainsString('(age BETWEEN %d AND %d)', $qb->toSql());
+ $this->assertSame([18, 65], $qb->getBindings());
+
+ $qb2 = (new User())->where('id', 1)->orWhereBetween('age', 18, 65);
+ $this->assertStringContainsString('OR (age BETWEEN %d AND %d)', $qb2->toSql());
+ $this->assertSame([1, 18, 65], $qb2->getBindings());
+ }
+
+ public function testNestedClosureFirstReordersBindingsToPlaceholderOrder(): void
+ {
+ $qb = (new User())
+ ->where(static function ($q) {
+ $q->where('b', 2)->orWhere('c', 3);
+ })
+ ->where('a', 1);
+
+ $this->assertStringContainsString('( `wp_users`.`b` = %d OR `wp_users`.`c` = %d) AND `wp_users`.`a` = %d', $qb->toSql());
+ $this->assertSame([2, 3, 1], $qb->getBindings());
+ }
+
+ public function testNestedClosureWithExplicitOrBool(): void
+ {
+ $qb = (new User())
+ ->where('a', 1)
+ ->where(static function ($q) {
+ $q->where('b', 2);
+ }, 'OR');
+
+ $this->assertStringContainsString('`wp_users`.`a` = %d OR ( `wp_users`.`b` = %d)', $qb->toSql());
+ $this->assertSame([1, 2], $qb->getBindings());
+ }
+
+ public function testWhereRawBindingOrder(): void
+ {
+ $this->assertSame([99, 5], $this->bindings((new User())->whereRaw('x = %d', [99])->where('id', 5)));
+ $this->assertSame([5, 99], $this->bindings((new User())->where('id', 5)->whereRaw('x = %d', [99])));
+ $this->assertSame([5, 99], $this->bindings((new User())->where('id', 5)->orWhereRaw('x = %d', [99])));
+ }
+
+ public function testSelectRawThenWhereThenHavingBindingOrder(): void
+ {
+ $qb = (new User())
+ ->selectRaw('%s as label', ['L'])
+ ->where('id', 5)
+ ->groupBy('status')
+ ->having('cnt', '>', 3);
+
+ $this->assertSame(['L', 5, 3], $this->bindings($qb));
+ }
+
+ public function testSoftDeleteScopeKeepsUserBindingOrderThroughParenWrap(): void
+ {
+ $qb = ScopedSoftPost::query()->where('a', 1)->orWhere('b', 2);
+
+ $sql = $qb->toSql();
+ $this->assertStringContainsString('( `wp_scoped_soft_posts`.`a` = %d OR `wp_scoped_soft_posts`.`b` = %d)', $sql);
+ $this->assertStringContainsString('`deleted_at` IS NULL', $sql);
+ $this->assertSame([1, 2], $qb->getBindings());
+ }
+}
diff --git a/tests/WriteEdgeFixTest.php b/tests/WriteEdgeFixTest.php
new file mode 100644
index 0000000..0ecdc10
--- /dev/null
+++ b/tests/WriteEdgeFixTest.php
@@ -0,0 +1,111 @@
+insert_id = 1;
+
+ $result = User::query()->insert(['tags' => ['a'], 'name' => 'x']);
+
+ $this->assertInstanceOf(User::class, $result);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringStartsWith('INSERT INTO wp_users', $sql);
+ $this->assertStringContainsString('["a"]', $sql, 'tags must be JSON-encoded');
+ $this->assertStringNotContainsString('),', $sql, 'must be a single VALUES tuple, not bulk');
+ }
+
+ // A5 ---------------------------------------------------------------------
+
+ public function testEmptySaveSkipsMalformedUpdateAndReturnsModel(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['id' => 1, 'name' => 'Ada']];
+ };
+
+ $user = User::query()->where('id', 1)->first();
+
+ $GLOBALS['wpdb']->queries = [];
+ $GLOBALS['wpdb']->last_query = '';
+
+ $result = $user->save();
+
+ $this->assertSame($user, $result);
+ $this->assertSame([], $GLOBALS['wpdb']->queries, 'no UPDATE … SET query may be emitted');
+ }
+
+ // A6 ---------------------------------------------------------------------
+
+ public function testInsertEmptyArrayReturnsFalseWithoutQuery(): void
+ {
+ $result = User::query()->insert([]);
+
+ $this->assertFalse($result);
+ $this->assertSame([], $GLOBALS['wpdb']->queries);
+ }
+
+ public function testBulkInsertEmptyRowsReturnsEmptyCollection(): void
+ {
+ $result = User::query()->insert([[], []]);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ $this->assertCount(0, $result);
+ $this->assertSame([], $GLOBALS['wpdb']->queries);
+ }
+
+ public function testUpsertEmptyUpdateListDefaultsToAllColumns(): void
+ {
+ User::query()->upsert(['email' => 'a@x.com'], []);
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('email = VALUES(email)', $sql);
+ $this->assertStringNotContainsString('UPDATE ;', $sql);
+ }
+
+ // A7 ---------------------------------------------------------------------
+
+ public function testAggregateStarUsesBareStar(): void
+ {
+ (new User())->aggregate('COUNT', '*');
+
+ $sql = $GLOBALS['wpdb']->last_query;
+ $this->assertStringContainsString('COUNT(*)', $sql);
+ $this->assertStringNotContainsString('COUNT(`wp_users`.*)', $sql);
+ }
+
+ public function testCountAggregateStillQualifiesPrimaryKey(): void
+ {
+ $GLOBALS['wpdb']->resolver = static function () {
+ return [(object) ['COUNT' => '3']];
+ };
+
+ (new User())->count();
+
+ $this->assertStringContainsString('COUNT(`wp_users`.`id`)', $GLOBALS['wpdb']->last_query);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 8c1236f..a84c415 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -8,35 +8,34 @@
* so `Connection` can resolve a prefix and capture executed SQL without a real
* database.
*/
-
error_reporting(E_ALL & ~E_DEPRECATED);
-if (!\defined('ABSPATH')) {
- \define('ABSPATH', __DIR__ . '/');
+if (!defined('ABSPATH')) {
+ define('ABSPATH', __DIR__ . '/');
}
-if (!\function_exists('esc_html')) {
+if (!function_exists('esc_html')) {
function esc_html($text)
{
return $text;
}
}
-if (!\function_exists('wp_json_encode')) {
+if (!function_exists('wp_json_encode')) {
function wp_json_encode($data, $options = 0, $depth = 512)
{
return json_encode($data, $options, $depth);
}
}
-if (!\function_exists('get_option')) {
+if (!function_exists('get_option')) {
function get_option($name, $default = false)
{
return $default;
}
}
-if (!\function_exists('wp_timezone_string')) {
+if (!function_exists('wp_timezone_string')) {
function wp_timezone_string()
{
return 'UTC';
@@ -63,10 +62,14 @@ class FakeWpdb
public $suppress_errors = false;
- /** @var string[] */
+ /**
+ * @var string[]
+ */
public $queries = [];
- /** @var null|callable resolves a result set from the SQL string */
+ /**
+ * @var null|callable resolves a result set from the SQL string
+ */
public $resolver;
public function queueResult(array $rows)
@@ -79,7 +82,7 @@ public function query($sql)
$this->last_query = $sql;
$this->queries[] = $sql;
- if (\is_callable($this->resolver)) {
+ if (is_callable($this->resolver)) {
$this->last_result = ($this->resolver)($sql);
}
@@ -88,7 +91,7 @@ public function query($sql)
public function prepare($query, ...$args)
{
- if (\count($args) === 1 && \is_array($args[0])) {
+ if (count($args) === 1 && is_array($args[0])) {
$args = $args[0];
}
@@ -110,6 +113,11 @@ public function get_results($query)
{
return $this->last_result;
}
+
+ public function has_cap($cap)
+ {
+ return false;
+ }
}
$GLOBALS['wpdb'] = new FakeWpdb();
@@ -119,6 +127,18 @@ public function get_results($query)
require __DIR__ . '/Fixtures/User.php';
require __DIR__ . '/Fixtures/SoftPost.php';
require __DIR__ . '/Fixtures/EventUser.php';
+require __DIR__ . '/Fixtures/SavedEventUser.php';
require __DIR__ . '/Fixtures/RetrieveUser.php';
require __DIR__ . '/Fixtures/CastModel.php';
+require __DIR__ . '/Fixtures/CastAliasModel.php';
require __DIR__ . '/Fixtures/AccessorModel.php';
+require __DIR__ . '/Fixtures/CreatingUser.php';
+require __DIR__ . '/Fixtures/ScopedSoftPost.php';
+require __DIR__ . '/Fixtures/UnscopedSoftPost.php';
+require __DIR__ . '/Fixtures/TimestampedRow.php';
+require __DIR__ . '/Fixtures/Role.php';
+require __DIR__ . '/Fixtures/Member.php';
+require __DIR__ . '/Fixtures/PrefixedModel.php';
+require __DIR__ . '/Fixtures/RelationSentinel.php';
+require __DIR__ . '/Fixtures/RelationBaseModel.php';
+require __DIR__ . '/Fixtures/RelationLeafModel.php';