Skip to content

Fix callback conditions in present() ifExists() being dropped or rewritten and add whereNotIn() to the Entity builder - #355

Merged
techmahedy merged 1 commit into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Oct 9, 2026
Merged

techmahedy merged 1 commit into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? yes
New feature? yes (whereNotIn() / orWhereNotIn())
Deprecations? no
Issues -
License MIT

Description

Two small, related changes to the Entity ORM query builder.

1. Fix: conditions inside present() callbacks lose their meaning

present(), orPresent(), ifExists() and their nested forms (articles.notes) build an EXISTS subquery. The conditions a callback adds were not compiled; they were copied one by one into hand-written SQL (addCallbackConditions() and a similar loop in InteractsWithNestedRelations). That loop:

  • skipped every condition that has a type (nested where(fn ...) groups, whereRaw()),
  • hardcoded AND and ignored the stored boolean, so orWhere() became AND,
  • produced an empty fragment for whereNull() / whereNotNull() (its NULL branch only handled = and !=),
  • wrote values into the SQL as escaped literals instead of bindings.

The wrong result is silent, and it usually returns more rows than it should:

// Expected: articles with a note whose body is "first" OR "third"  -> [1, 3]
Article::query()->present('notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third'))->get(); // []  (OR became AND)

// Expected [1]
Article::query()->present('notes', fn($q) => $q->where(fn($g) => $g->where('body', 'first')))->get();     // [1, 3]  (group ignored)
Article::query()->present('notes', fn($q) => $q->whereRaw("body = 'first'"))->get();                       // [1, 3]  (raw ignored)

// Expected []
Article::query()->present('notes', fn($q) => $q->whereNull('body'))->get();                               // [1, 3]  (condition ignored)

Fix. The callback's query is now compiled with the regular WHERE compiler (the same one the main query uses):

  • OR, nested groups, raw conditions and NULL checks keep their meaning.
  • Plain column names are qualified with the related table, as before, so many-to-many joins stay unambiguous.
  • Values are returned as bindings on the EXISTS condition instead of being inlined.
  • The soft delete constraint is still appended after the callback conditions, so withTrashed() / onlyTrashed() inside a callback keep working.

whereLinked() takes a single condition rather than a callback, so it was not affected.

2. Feature: whereNotIn() / orWhereNotIn()

The Entity builder had whereIn() / orWhereIn() but no NOT IN counterpart. Calling whereNotIn() fell through to the dynamic where handler and generated invalid SQL (not_in body ?).

User::whereNotIn('id', [1, 2])->get();
User::where('id', 1)->orWhereNotIn('id', [1, 2, 3])->get();
Article::query()->present('notes', fn($q) => $q->whereNotIn('body', ['first']))->get();
  • An empty list excludes nothing (1=1), the inverse of the empty-IN case (1=0).
  • Values are bound, and a plain column is qualified with the table (or its alias), so it is not ambiguous in a join().
  • NOT IN is handled by the WHERE compiler, the whereLinked() clause builder, the legacy nested-binding path and Debuggable.
  • Works in count, aggregates, bulk update / delete, nested groups and present() callbacks.

Tests

  • tests/Model/RelationExists/ (13 tests x 3 drivers): OR, nested groups, raw conditions, NULL checks, the remaining operators, quoted / injection-style values, binding order around the callback, count(), many-to-many, nested relations, ifExists(), and the interplay with soft deleted rows.
  • tests/Model/Query/EntityModelQueryBehavior.php: six whereNotIn / orWhereNotIn tests next to testWhereIn (empty list, key gaps, OR, binding order, nested group, count / sum / bulk update, join qualification).
  • The new relation tests fail on the unfixed code (10 of 13 on SQLite) and pass with the fix.
  • Run locally against SQLite, MySQL 8.0 and PostgreSQL 16: full suite 4305 tests passing, same 4 skips as before. PHPStan is clean.

Checklist

  • Tests have been added or updated
  • Documentation has been updated
  • Code follows the project coding standards
  • All tests pass locally

…itten and add whereNotIn() to the Entity builder
@techmahedy techmahedy added bug Something isn't working feat new feature labels Oct 9, 2026
@techmahedy
techmahedy merged commit e864a9c into doppar:4.x Oct 9, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant