Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3041ef1
fix: change() emits MODIFY COLUMN, not ADD COLUMN CHANGE COLUMN
abdul-kaioum Jun 30, 2026
9184539
fix: direct-call schema drop helpers emit full SQL
abdul-kaioum Jun 30, 2026
b37aaa7
feat: subscribable creating/created model events
abdul-kaioum Jun 30, 2026
9bceb0f
fix: bulk insert always returns a Collection
abdul-kaioum Jun 30, 2026
3e5db3a
fix: joins no longer double-prefix and qualify ON column
abdul-kaioum Jun 30, 2026
0e4b29a
feat: add Schema::withWpPrefix() opt-in for WP-prefixed tables
abdul-kaioum Jun 30, 2026
ea54365
feat: opt-in soft-delete read scope with withTrashed/onlyTrashed
abdul-kaioum Jun 30, 2026
e78915e
fix: parenthesize user WHERE when injecting soft-delete scope
abdul-kaioum Jun 30, 2026
9677449
fix: upsert manages updated_at; reorganise review-fix tests
abdul-kaioum Jun 30, 2026
3cac80c
feat: add pivot-table many-to-many to belongsToMany (read-side)
abdul-kaioum Jun 30, 2026
d7b28a3
fix: join and pivot tables keep wp_ prefix for custom-$prefix models
abdul-kaioum Jun 30, 2026
7aa24f2
fix: bulk insert and upsert JSON-encode array/object values
abdul-kaioum Jun 30, 2026
8b21d24
docs: document JSON-encoded insert/upsert values and getTablePrefix j…
abdul-kaioum Jun 30, 2026
a43e80e
fix: select() handles `column AS alias` without back-ticking the alias
abdul-kaioum Jun 30, 2026
08b8384
fix: eager-load key subquery ignores parent selectRaw
abdul-kaioum Jun 30, 2026
acc3652
fix: drop ORDER BY from eager-load key subquery (keep when LIMIT-bound)
abdul-kaioum Jun 30, 2026
234c805
test: edge-case characterization for where/select/join/aggregate/rela…
abdul-kaioum Jun 30, 2026
42ba9b9
fix: repair insert/where/aggregate/soft-delete edge cases (zero-BC Ph…
abdul-kaioum Jun 30, 2026
018290c
docs: forceDelete/restore + Phase 1 invalid-SQL repair notes
abdul-kaioum Jun 30, 2026
3810f3b
fix: order/group injection guard, cast aliases, relation-dirty, ragge…
abdul-kaioum Jun 30, 2026
c710ad0
docs: Phase 2 behavioral notes (order/group validation, cast aliases,…
abdul-kaioum Jun 30, 2026
65fff45
fix: eager empty relation resolves to [] without an N+1 re-query (C3)
abdul-kaioum Jun 30, 2026
ede27bb
fix: soft-delete reads exclude trashed rows by default
abdul-kaioum Jul 1, 2026
02ab558
docs: add relations reference; soft-delete default-filter notes
abdul-kaioum Jul 1, 2026
6b61445
test: add RED relation-resolution safety tests (impl pending)
abdul-kaioum Jul 1, 2026
51db655
fix: reject non-relation names in with/withCount/whereHas (option alpha)
abdul-kaioum Jul 1, 2026
cf1055f
docs: note non-obvious v2.0 migration gotchas (from bit-pi audit)
abdul-kaioum Jul 1, 2026
a37a885
fix: save() treats a 0-row UPDATE as success, not failure
abdul-kaioum Jul 1, 2026
273fe83
fix: save() insert decides success from exec(), keeps lastInsertId()
abdul-kaioum Jul 1, 2026
4292e15
test: cover session-change edge cases; fix null-offset deprecation
abdul-kaioum Jul 1, 2026
21c4fd6
test: close remaining coverage gaps (memoization, base-class relation…
abdul-kaioum Jul 1, 2026
6d8b1d3
build: require PHP >=8.2, add phpunit dev-dep + composer test
abdul-kaioum Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 8 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,34 @@
]
},
"require": {
"php": "^7.4 || ^8.0"
"php": ">=8.2"
},
"autoload": {
"psr-4": {
"BitApps\\WPDatabase\\": "./src"
}
},
"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": {
"dev-main": "1.x-dev"
}
},
"config": {
"platform": {
"php": "8.2.0"
},
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
Expand Down
174 changes: 169 additions & 5 deletions docs/breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.

---

Expand Down Expand Up @@ -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_<col>` attributes (the parent link is always exposed as
`pivot_<foreignPivotKey>`). 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
Expand Down
Loading