Skip to content

Relation/aggregate/event fixes + Concerns namespaces + Query\Grammar extraction#16

Open
abdul-kaioum wants to merge 61 commits into
mainfrom
refactor/namespace-grammar
Open

Relation/aggregate/event fixes + Concerns namespaces + Query\Grammar extraction#16
abdul-kaioum wants to merge 61 commits into
mainfrom
refactor/namespace-grammar

Conversation

@abdul-kaioum

@abdul-kaioum abdul-kaioum commented Jun 29, 2026

Copy link
Copy Markdown
Member

Supersedes #15 (this branch contains all of its commits). Diffs against main. Three layers:

1. Bug fixes (relation aggregates, soft delete, events, casts)

  • withMin/Max/Avg/Sum('rel.column') now parse and aggregate the column (was hardcoded * → silently no-op).
  • Soft delete writes deleted_at = <ts> on matched rows and honours the no-WHERE guard (was malformed SET <ts> = NULL, unguarded).
  • A saving handler returning false aborts the write; saving/saved fire on insert and update; retrieved fires after hydration.
  • aggregate()/count() no longer collapse a genuine 0/'0' to null; count() returns int.
  • 7.4-compatible nullable types (?type instead of 8.0 type|null).

2. Restructure (no behavior change)

  • Internal traits → BitApps\WPDatabase\Concerns (PSR-4, no public class moved).
  • SELECT compilation (~16 methods) extracted from the 1,889-line QueryBuilder into a stateless BitApps\WPDatabase\Query\Grammar collaborator; toSql() dispatches explicitly. QueryBuilder shed ~480 lines. toSql output byte-for-byte identical.

3. Review fixes (from Gemini Code Assist)

  • upsert: sort() the column list (was a ksort() no-op → value/column misalignment); in_array() for the created_atupdated_at swap (was array_key_exists(), always false).
  • Model: implement the documented 'boolean' cast.
  • Collection::pluck(): resolve dynamic/accessor attributes via getAttribute().
  • QueryBuilder::withCast(): chainable builder method.
  • Grammar: fix the LIKE operator check (strtoupper($op) === 'LIKE').

Verification

  • 85 tests, 123 assertions green; php-cs-fixer + PHPCompatibility 7.4- clean.
  • Reviewed: architecture (oracle) → implementation (momus: behavior-preserving) → quality (simplify agents) → security review: no HIGH/MEDIUM findings. All bug fixes are TDD (RED → fix → green).

Notes (pre-existing, out of scope)

  • join('table') double-prefixes the joined table (wp_wp_*) — pre-existing join-prefix bug.
  • P2b (move insert/update/delete compilation into Grammar) deferred — needs a keyed-binding rework first.

🤖 Generated with Claude Code

abdul-kaioum and others added 30 commits June 29, 2026 17:06
- update() and save() will return the model
- delete, destroy will return affected rows count
Move eager-loading and aggregate methods (with, withCount, withMin/Max/Avg/
Sum/Exists, whereHas, withWhereHas) out of the Relations trait into a new
QueriesRelationships trait on QueryBuilder. Because they are no longer declared
on Model, `Model::with()` etc. now resolve through __callStatic instead of
fatally erroring with "Non-static method ... cannot be called statically".
Relation definitions (hasMany/belongsTo) stay on the model's Relations trait.

- add Model::query() as a real, IDE-navigable builder entry point
- @mixin QueryBuilder + restored @method static tags for cross-IDE support
  (PhpStorm navigation + VS Code/Intelephense autocomplete)
- fix with(['a','b']) array form (previously dropped by func_get_args)
- DRY the relation-key lookup via Model::getActiveRelationKey()
- add PHPUnit suite (42 tests) with wpdb/WP stubs and fixtures
- document version breaking changes in docs/breaking-changes.md

Assisted-By: AI
Add docs/usage.md covering models, CRUD, the query builder, collections,
casts, relationships and aggregates, model events, transactions, raw queries
and the schema builder. Rewrite the README stub into a quick-start that links
the usage guide and breaking-changes notes.

- fix paginate() @method tag (arg order was reversed vs the real signature)

Assisted-By: AI
Red tests documenting four confirmed defects to be fixed next:

- withMin/withMax/withAvg/withSum ignore the `relation.column` argument
  (hardcode `*`), so the aggregate is never emitted.
- Soft delete builds `SET <timestamp> = NULL` instead of
  `SET deleted_at = <timestamp>`, and bypasses the no-WHERE guard.
- A `saving` handler returning false does not abort the write; `retrieved`
  fires before the model is hydrated on single-row reads.
- count()/min()/aggregate() collapse a genuine 0/'0' to null via !empty().

Adds SoftPost, EventUser, RetrieveUser fixtures and wires them into the
bootstrap.

Assisted-By: AI
- withMin/withMax/withAvg/withSum now parse the `relation.column` argument
  and aggregate that column (was hardcoded `*` → silently emitted nothing).
- Soft delete writes `deleted_at = <ts>` on matched rows and honours the
  no-WHERE guard (was malformed `SET <ts> = NULL`, and unguarded).
- A `saving` handler returning false aborts the write; saving/saved fire on
  both insert and update; `retrieved` fires after the model is hydrated.
- aggregate()/count() no longer collapse a genuine 0/'0' to null; count()
  returns int; canonical selectRaw reset removes an undefined-key warning.

Assisted-By: AI
`callable|null` (Collection::first/last) and `array|null` (QueryBuilder::upsert)
are PHP 8.0+ union syntax and fatal-parse on 7.4, which composer.json still
supports (^7.4). Replace with the `?type` nullable form.

Assisted-By: AI
Relocate the three internal traits into `BitApps\WPDatabase\Concerns`
(Relations, QueriesRelationships, HasEvents) to separate cross-cutting concerns
from the public surface. Pure organization — no public class moved, no logic
change. Relations/QueriesRelationships gain `use` imports for the QueryBuilder
(and Model) types they reference; Model and QueryBuilder import the relocated
traits. PSR-4 resolves the new path automatically.

Assisted-By: AI
Pull the SELECT-side SQL compilation (~16 methods: compileSelect, getWhere,
getConditions, processConditions, getJoin/GroupBy/Having/OrderBy/From/Limit/
Offset, prepareColumnForWhere/ValueForWhere/OperatorForWhere, prepareRawSelect)
out of the 1,889-line QueryBuilder into a stateless `BitApps\WPDatabase\Query\
Grammar` collaborator. The builder keeps state, fluent setters, execution and
binding mutation; Grammar reads builder state via getters and appends bindings
through the builder. `toSql()` now dispatches explicitly (no stringly
`{'prepare'.$method}()`). Write-side compilation (insert/update/delete) and the
setter-time/static-coupled helpers stay on the builder. Behavior unchanged —
toSql output is byte-for-byte identical; adds Query\GrammarTest.

Assisted-By: AI
Post-extraction cleanup (no behavior change):
- Grammar: only compileSelect/getWhere/getJoin are called cross-object; make the
  other 13 compilation helpers private. Reword the class docblock to state it
  drives the builder's binding lifecycle (it is not side-effect-free).
- QueryBuilder: drop the unused getMethod() and getRawSql() (the latter dup'd
  prepareRaw()); guard getClauseList() to where/having only instead of exposing
  every property via $this->{$type}; initialise $sql in toSql().

Assisted-By: AI
Characterization tests asserting both compiled SQL and binding-array order
(the risk surface of the Query\Grammar extraction): inner/left joins, nested
closure grouping with flattened binding order, whereHas correlated exists
sub-query (plain + constrained), selectRaw-before-where binding order, when()
true/default branches, with() closure-constrained eager load, and bulk update.

Assisted-By: AI

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the QueryBuilder class by extracting its SQL compilation logic into a new Grammar class, separating query building from SQL generation. It also introduces comprehensive tests to verify the compiled SQL and binding order. A critical bug was identified in the new Grammar class where a misplaced parenthesis in the LIKE operator check results in an incorrect boolean comparison inside strtoupper().

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/Query/Grammar.php Outdated
$sql = rtrim($sql, ',') . ')';
} elseif (isset($clause['operator']) && strpos($clause['operator'], 'IS') !== false) {
$sql .= ' ' . $clause['value'];
} elseif (isset($clause['operator']) && strtoupper($clause['operator'] === 'LIKE')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a misplaced parenthesis in the LIKE operator check. The expression strtoupper($clause['operator'] === 'LIKE') compares the operator to 'LIKE' first (resulting in a boolean), and then passes that boolean to strtoupper(). This should be corrected to apply strtoupper() to the operator first, and then compare it to 'LIKE'.

        } elseif (isset($clause['operator']) && strtoupper($clause['operator']) === 'LIKE') {

`strtoupper($clause['operator'] === 'LIKE')` uppercased a boolean instead of
the operator, so the LIKE branch only matched the exact string 'LIKE' and never
a lowercase/mixed-case operator. Apply strtoupper() to the operator, then
compare. Behavior is unchanged for the realistic case (a string LIKE value is
bound as %s by the fall-through branch either way); this fixes the intent and a
contrived numeric-operand edge. Reported by Gemini Code Assist on the PR.

Assisted-By: AI
- upsert: sort() the column-name list (was ksort() — a no-op on the numeric
  list) so columns line up with the alphabetically-ksorted row values; use
  in_array() instead of array_key_exists() to detect created_at in the update
  list (the latter checked numeric keys, so the created_at→updated_at rewrite
  on duplicate key never fired).
- Model: add castToBoolean() (the documented 'boolean' cast dispatched to a
  missing method and was silently ignored; only 'bool' worked).
- Collection::pluck(): resolve dynamic/accessor attributes via Model::getAttribute()
  ($item->{$key} ?? null short-circuits __isset and returns null for unloaded
  relations/accessors).
- QueryBuilder::withCast(): add a chainable builder-level method (forwarding to
  the model returned the Model and broke fluent chaining).

Reported by Gemini Code Assist on the PR. Adds regression tests + CastModel/
AccessorModel fixtures.

Assisted-By: AI
@abdul-kaioum abdul-kaioum changed the title Refactor: namespaces (Concerns/) + extract Query\Grammar from QueryBuilder Relation/aggregate/event fixes + Concerns namespaces + Query\Grammar extraction Jun 29, 2026
@abdul-kaioum abdul-kaioum changed the base branch from refactor/relations-static-access to main June 29, 2026 13:41
- breaking-changes §3: count() returns int and preserves a real 0; note the
  Concerns/ + Query\Grammar internal relocation (public API unchanged).
- §4.5: bool/boolean cast; chainable QueryBuilder::withCast().

Assisted-By: AI
- Replace broken Deal::belongsTo(Contact::class,'contact_id','id') example
  which generated WHERE contacts.contact_id=deals.id (wrong column table).
  Correct call is belongsTo(Contact::class,'id','contact_id') which
  generates WHERE contacts.id IN (SELECT contact_id FROM deals).
- Document that hasOne() is a literal alias of belongsTo() (oneToOne type,
  same implementation); no separate reverse-direction exists.
- Clarify key convention: $foreignKey = column on related table,
  $localKey = column on calling table (applies to all relation methods).
- Add belongsToMany() caveat: declared but has no pivot-table logic.
- Show 'relation as alias' aliasing form for aggregate methods.
- Note that withMin/Max/Avg/Sum require 'relation.column' form; bare
  relation name defaults to * which is only meaningful for count/exists.

Assisted-By: AI
…ns section

- Model events: remove creating/created from example and events list; they
  fire internally but HasEvents has no registrar for them. Note boot hooks
  (booting/booted) are overridable methods, not Closure registrars. Add
  saving/saved fire on both insert and update.
- Schema builder: replace full column/modifier/FK/altering content with a
  one-screen teaser + link to docs/schema.md (detail moves to Task 8).
- Add ## Limitations & known issues section covering joins (double-prefix,
  ON-column, prepareOn reuse), belongsToMany pivot, belongsTo/hasOne alias
  + reversed key naming, soft-delete write-only, upsert MySQL-only +
  updated_at bug, creating/created unsubscribable, bulk insert bare-array
  fallback.
- TOC: add Limitations & known issues entry.

Assisted-By: AI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants