Skip to content

Fix Eloquent collision recovery and query timeout placement - #16

Closed
binaryfire wants to merge 7 commits into
0.4from
fix/database-eloquent-query-timeout-correctness
Closed

Fix Eloquent collision recovery and query timeout placement#16
binaryfire wants to merge 7 commits into
0.4from
fix/database-eloquent-query-timeout-correctness

Conversation

@binaryfire

@binaryfire binaryfire commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

This change fixes two database correctness issues:

  • Eloquent create-or-retrieve fallbacks could read from a replica after a unique-constraint collision or report a pivot attachment as successful without proving the intended relationship exists.
  • MySQL and MariaDB query timeouts could be placed on an inner select instead of the statement the database executes. MariaDB also inherited a MySQL optimizer hint that does not enforce the documented timeout there.

It also aligns Query and Eloquent Builder subquery types with the inputs their implementations already support.

Eloquent collision recovery

Collision fallback reads now use the write PDO so they can observe the row that won a concurrent insert.

BelongsToMany no longer treats every pivot unique violation as proof that the requested attachment already exists. It checks the exact parent and related keys through the existing pivot query, including configured pivot predicates and morph discriminators. If that membership is not visible, the original attach exception is rethrown.

The related return annotations now distinguish models loaded through a relation, which carry a hydrated pivot, from models that were created or attached directly and do not.

Statement-owned query timeouts

Select compilation now separates raw select assembly from complete-statement timeout decoration. Only the statement sent to the database receives the timeout:

  • MySQL places MAX_EXECUTION_TIME after the first statement-level SELECT.
  • MariaDB uses SET STATEMENT max_statement_time=... FOR around the complete select.
  • Unions, union aggregates, exists(), grouped pagination counts, and locking selects receive one timeout at the statement root.
  • Embedded where, exists, and union fragments compile through their owning grammar without carrying statement-level decoration into the parent query.
  • PostgreSQL and SQLite internal update/delete subselects continue to use raw select compilation.

Timed embedded builders and timed EXPLAIN calls are rejected with direct diagnostics. Grouped pagination transfers the timeout from the derived-table clone to the outer count statement.

Subquery consistency

Relation and Eloquent subqueries are normalized once to the Query Builder snapshot that is actually retained. This avoids repeated global-scope application and ensures timeout inspection, SQL, and bindings all come from the same snapshot.

Retained fragments compile through their builder's grammar so connection-owned behavior such as table prefixes remains correct. Cross-database qualification applies only to plain table-name strings; tableless queries and opaque expressions remain unchanged, and opaque expressions must qualify their own database references.

Query and Eloquent Builder signatures now accept the Query Builder, Eloquent Builder, and Relation inputs handled by their forwarding and queryable paths.

Compatibility and performance

The public Laravel-style API remains intact. This adds no public grammar API, worker-lifetime state, session mutation, generic retry loop, or extra database/network work to ordinary successful queries.

Eloquent subqueries with global scopes now do less work because scopes are applied once. The only added database query is an exact pivot-membership check after a rare unique-constraint collision, where it is required to avoid false success.

Documentation

The database documentation now explains:

  • outer-statement timeout ownership on MySQL and MariaDB;
  • why timed embedded queries and timed EXPLAIN statements are rejected;
  • the repeatable-read boundary for create-or-retrieve collision handling; and
  • which concurrency failures transaction retries handle.

Verification

Coverage includes focused relation, builder, grammar, and documentation regressions; read/write split behavior; exact pivot collision behavior across supported database engines; and real timeout enforcement on MySQL and MariaDB, including unions, exists(), and blocked locking reads.

The formatter, static analysis, full parallel suite, Testbench suites, and the supported SQLite, MySQL, MariaDB, and PostgreSQL database matrices pass.

Summary by CodeRabbit

  • New Features

    • Added more flexible query and relationship inputs across database and Eloquent APIs.
    • Added statement-level query timeout support for MySQL and MariaDB, including unions, aggregates, and existence checks.
    • Improved collision recovery for relationship creation while preserving genuine constraint errors.
    • Added support for reliable fallback reads from the write connection.
  • Bug Fixes

    • Prevented invalid timeouts in embedded queries, relationship constraints, and EXPLAIN.
  • Documentation

    • Expanded guidance for concurrency handling, query timeouts, and concurrent record creation.

Route create-or-first collision fallback reads through the write PDO so a configured replica cannot hide the winning row.

Verify BelongsToMany pivot collisions against the exact parent, related model, pivot constraints, and morph discriminator before reporting attachment success. Rethrow the original attach violation when that membership cannot be proven.

Correct return annotations for create, save, and create-or-retrieve paths that attach pivot rows without hydrating a pivot model. Add focused unit coverage, a SQLite read/write split regression, and shared four-engine collision coverage.
Split raw select assembly from complete-statement decoration so MySQL and MariaDB apply query timeouts exactly once at the executed statement root. Cover unions, exists queries, grouped pagination, locking reads, and retained fragments while preserving each child builder's grammar and table prefix.

Normalize Relation and Eloquent subqueries to one Query Builder snapshot before timeout checks, SQL compilation, bindings, or cross-database qualification. Reject timed embedded queries and timed EXPLAIN statements with clear diagnostics, and keep opaque or tableless sources safe during qualification.

Align Query Builder's supported queryable input types, remove the obsolete SQLite group-limit fallback, preserve raw PostgreSQL and SQLite DML subselects, and add extensive grammar, builder, and real MySQL/MariaDB enforcement coverage.
Widen Eloquent forwarding methods to the Query, Eloquent, and Relation inputs their Query Builder callees already support, preserving Laravel-style application-facing query composition.

Validate relationship constraint timeouts after scope and relation constraints are merged but before the child query is stored or its bindings are added. Apply one relationship-specific diagnostic across withAggregate, withExists, and both whereHas execution strategies.

Add family-level coverage for the widened forwarding APIs and regressions for aggregate, exists, and count constraint timeout rejection.
Document query timeouts as outer select-statement limits on MySQL and MariaDB, including the rejection of timed embedded queries and EXPLAIN statements.

Explain the caller-owned transaction retry required when repeatable-read snapshots cannot observe a concurrent create-or-first winner. Rename the transaction retry guidance around the broader concurrency errors it actually detects while making clear that unique violations are not retried automatically.
Capture the final reviewed design for truthful Eloquent collision recovery, exact pivot membership proof, statement-owned MySQL and MariaDB timeouts, retained-fragment grammar ownership, and queryable Relation parity.

Record the supported transaction boundaries, performance constraints, driver-specific verification matrix, regression coverage, and the rule that opaque cross-database expressions must qualify their own database-owned references.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 70242544-bcb9-4eac-b385-f7fcf5379574

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request corrects Eloquent collision recovery, applies MySQL and MariaDB timeouts to complete select statements, normalizes relations as queryable inputs, updates related type declarations, and adds documentation plus unit and integration coverage.

Changes

Eloquent collision recovery

Layer / File(s) Summary
Write-routed collision recovery and pivot verification
src/database/src/Eloquent/Relations/*, tests/Database/DatabaseEloquent*, tests/Integration/Database/*, types/Database/Eloquent/Relations.php, src/docs/database.md, src/docs/eloquent.md
Collision rereads use the write connection. Many-to-many operations verify the exact pivot membership, including configured constraints and morph types. Unproven attach violations are rethrown. Related-model return annotations no longer require pivot intersections.

Queryable relation normalization

Layer / File(s) Summary
Relation and builder query inputs
src/database/src/Query/Builder.php, src/database/src/Eloquent/Builder.php, tests/Database/DatabaseQueryBuilderTest.php, tests/Database/DatabaseEloquentBuilderTest.php
Relations, Eloquent builders, and base builders are normalized across subqueries, predicates, joins, ordering, unions, and insert operations. Tests cover SQL, bindings, cross-database qualification, and hydrated results.

Statement-scoped query timeouts

Layer / File(s) Summary
Complete-statement timeout compilation
src/database/src/Query/Grammars/*, src/database/src/Concerns/ExplainsQueries.php, src/database/src/Query/Builder.php, src/database/src/Eloquent/Concerns/QueriesRelationships.php
Select assembly is separated from final timeout decoration. MySQL preserves parenthesized prefixes. MariaDB uses SET STATEMENT max_statement_time. Embedded timed queries and timed EXPLAIN calls are rejected. Grouped pagination transfers the timeout to the outer count query.
Timeout validation and database coverage
tests/Database/Database*QueryGrammarTest.php, tests/Integration/Database/*QueryTimeoutTest.php, tests/Database/DatabaseQueryBuilderTest.php, src/docs/queries.md
Tests cover unions, aggregates, locking selects, exists queries, nested queries, grouped pagination, state preservation, and real MySQL/MariaDB timeout errors. Documentation describes timeout scope and restrictions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant QueryBuilder
  participant QueryGrammar
  participant MySQLOrMariaDB
  QueryBuilder->>QueryGrammar: compile the complete outer select
  QueryGrammar->>QueryGrammar: leave embedded selects undecorated
  QueryGrammar->>MySQLOrMariaDB: execute the driver-specific timeout statement
  MySQLOrMariaDB-->>QueryBuilder: return results or a timeout error
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the PR's two main changes: Eloquent collision recovery and query timeout placement.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/database-eloquent-query-timeout-correctness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@binaryfire

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

Greptile Summary

The PR makes Eloquent collision recovery read through the writer and verify exact pivot membership, while moving MySQL and MariaDB timeouts to the executed statement root.

  • Separates raw subquery compilation from statement-level timeout decoration.
  • Rejects timed embedded, relationship, and EXPLAIN queries.
  • Normalizes supported Eloquent and relation subqueries to retained Query Builder snapshots.
  • Expands collision, timeout, type-contract, and database-integration coverage.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
src/database/src/Eloquent/Relations/BelongsToMany.php Adds writer-routed collision reads and exact pivot-membership verification before swallowing attachment uniqueness errors.
src/database/src/Eloquent/Relations/HasOneOrManyThrough.php Routes create-or-first collision recovery through the write connection.
src/database/src/Query/Builder.php Normalizes retained subqueries, rejects child-owned timeouts, and transfers grouped-pagination timeouts to the outer count statement.
src/database/src/Query/Grammars/Grammar.php Separates raw select assembly from complete-statement timeout decoration and uses raw compilation for embedded fragments.
src/database/src/Query/Grammars/MySqlGrammar.php Places the execution-time optimizer hint after the first statement-level SELECT.
src/database/src/Query/Grammars/MariaDbGrammar.php Wraps complete selects with MariaDB’s per-statement timeout syntax.
src/database/src/Eloquent/Concerns/QueriesRelationships.php Rejects relationship constraints carrying their own timeout before embedding or mutating the outer selection.
src/database/src/Concerns/ExplainsQueries.php Rejects timed EXPLAIN calls before compiling or executing invalid statement combinations.

Reviews (3): Last reviewed commit: "Refine relationship timeout regression c..." | Re-trigger Greptile

Update BelongsToMany type fixtures for create, save, attach, and create-or-retrieve paths that return plain related models without hydrating a pivot property.

Keep pivot intersections on models loaded through the relation and narrow mixed paths to their truthful common Role type. This matches the corrected production PHPDoc and prevents static analysis from approving unsafe pivot access.
@binaryfire

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
src/database/src/Query/Builder.php (1)

1756-1757: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Relation is missing from the whereExists and union parameter unions. This PR widened where, whereBetween, orderBy, from, insertUsing, and the join helpers to accept Relation, and isQueryable() already returns true for Relation. The exists and union families were not widened, so they reject relations with a TypeError even though Relation::toBase() returns the exact Builder these methods need.

  • src/database/src/Query/Builder.php#L1756-L1757: add Relation to the $callback union and docblock for whereExists, orWhereExists, whereNotExists, and orWhereNotExists, or state in the docblock that relations are not supported here.
  • src/database/src/Query/Builder.php#L2708-L2722: add Relation to the $query union for union and unionAll, and normalize it in the existing toBase() branch.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/database/src/Query/Builder.php` around lines 1756 - 1757, Add Relation to
the whereExists family callback type unions and docblocks in
src/database/src/Query/Builder.php at lines 1756-1757, covering whereExists,
orWhereExists, whereNotExists, and orWhereNotExists. Also add Relation to the
union and unionAll query unions at src/database/src/Query/Builder.php lines
2708-2722, and pass relations through the existing toBase() normalization
branch.
tests/Database/DatabaseEloquentBuilderTest.php (1)

1827-1848: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the repeated timeout-rejection assertion into a helper.

Four tests repeat the same snapshot, try/catch, message assertion, and post-state assertions. tests/Database/DatabaseQueryBuilderTest.php already uses assertTimedEmbeddingRejectedBeforeQueryIsEmbedded for the same shape. A matching helper here would keep the two suites consistent.

♻️ Suggested helper
protected function assertConstraintTimeoutRejected(Builder $builder, Closure $apply): void
{
    $sql = $builder->toSql();
    $bindings = $builder->getBindings();

    try {
        $apply();
        $this->fail('Expected the relationship constraint timeout to be rejected.');
    } catch (InvalidArgumentException $exception) {
        $this->assertSame(
            'A relationship constraint cannot define its own query timeout. Apply the timeout to the outer query instead.',
            $exception->getMessage()
        );
    }

    $this->assertSame($sql, $builder->toSql());
    $this->assertSame($bindings, $builder->getBindings());
}

Also applies to: 2004-2048

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Database/DatabaseEloquentBuilderTest.php` around lines 1827 - 1848,
Extract the repeated timeout-rejection setup, exception/message assertion, and
builder post-state checks from the affected tests into a shared
assertConstraintTimeoutRejected helper, matching the existing
assertTimedEmbeddingRejectedBeforeQueryIsEmbedded pattern. Define the helper
with the appropriate Builder and Closure parameters, then update
testWithCountRejectsConstraintTimeoutBeforeEmbeddingTheConstraint and the other
three affected tests to pass their builder and timeout-applying callback through
it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/database/src/Query/Builder.php`:
- Around line 1756-1757: Add Relation to the whereExists family callback type
unions and docblocks in src/database/src/Query/Builder.php at lines 1756-1757,
covering whereExists, orWhereExists, whereNotExists, and orWhereNotExists. Also
add Relation to the union and unionAll query unions at
src/database/src/Query/Builder.php lines 2708-2722, and pass relations through
the existing toBase() normalization branch.

In `@tests/Database/DatabaseEloquentBuilderTest.php`:
- Around line 1827-1848: Extract the repeated timeout-rejection setup,
exception/message assertion, and builder post-state checks from the affected
tests into a shared assertConstraintTimeoutRejected helper, matching the
existing assertTimedEmbeddingRejectedBeforeQueryIsEmbedded pattern. Define the
helper with the appropriate Builder and Closure parameters, then update
testWithCountRejectsConstraintTimeoutBeforeEmbeddingTheConstraint and the other
three affected tests to pass their builder and timeout-applying callback through
it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fe62ec8-70d2-4ba8-93de-707abd35f05e

📥 Commits

Reviewing files that changed from the base of the PR and between c3992ac and dbec7f6.

📒 Files selected for processing (27)
  • docs/plans/2026-08-12-1351-database-eloquent-collision-and-query-timeout-correctness.md
  • src/database/src/Concerns/ExplainsQueries.php
  • src/database/src/Eloquent/Builder.php
  • src/database/src/Eloquent/Concerns/QueriesRelationships.php
  • src/database/src/Eloquent/Relations/BelongsToMany.php
  • src/database/src/Eloquent/Relations/HasOneOrManyThrough.php
  • src/database/src/Query/Builder.php
  • src/database/src/Query/Grammars/Grammar.php
  • src/database/src/Query/Grammars/MariaDbGrammar.php
  • src/database/src/Query/Grammars/MySqlGrammar.php
  • src/database/src/Query/Grammars/PostgresGrammar.php
  • src/database/src/Query/Grammars/SQLiteGrammar.php
  • src/docs/database.md
  • src/docs/eloquent.md
  • src/docs/queries.md
  • tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php
  • tests/Database/DatabaseEloquentBuilderTest.php
  • tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php
  • tests/Database/DatabaseMariaDbQueryGrammarTest.php
  • tests/Database/DatabaseMySqlQueryGrammarTest.php
  • tests/Database/DatabaseQueryBuilderTest.php
  • tests/Integration/Database/EloquentBelongsToManyCreateOrFirstCollisionTest.php
  • tests/Integration/Database/MariaDb/QueryTimeoutTest.php
  • tests/Integration/Database/MySql/QueryTimeoutTest.php
  • tests/Integration/Database/QueryTimeoutTestCase.php
  • tests/Integration/Database/Sqlite/EloquentCreateOrFirstReadWriteTest.php
  • types/Database/Eloquent/Relations.php

Consolidate the four relationship timeout rejection tests behind one focused helper while preserving each public API path and the unchanged outer-query assertions.\n\nReplace the plan's implementation-only Relation exclusion rationale with the behavioral reason: an instance-bound relation would produce an uncorrelated exists subquery, while whereHas provides the correlated relationship API. Keep union inputs aligned with Laravel's declared builder contract.
@binaryfire

Copy link
Copy Markdown
Collaborator Author

@coderabbitai Addressed the review:

  • Extracted the repeated relationship-timeout rejection setup, exception check, and unchanged-query assertions into assertRelationshipConstraintTimeoutRejected(). The four tests now contain only the distinct API call they exercise.
  • Kept Relation out of whereExists*() and union*(). whereExists*() needs a correlated relationship query; accepting an instance-bound relation would create an uncorrelated exists clause that can silently match every outer row or none, while whereHas*() is the correct API. union*() remains aligned with Laravel's declared Query/Eloquent Builder contract. The plan now records this behavioral rationale.

The targeted Eloquent builder tests and PHP CS Fixer check pass.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@binaryfire Thanks for the update.

The extracted helper keeps the relationship-timeout tests focused on each API call.

The Relation exclusions are correct. whereExists*() requires a correlated query. whereHas*() is the appropriate API for relationship existence checks. Keeping union*() limited to Query Builder and Eloquent Builder inputs also preserves the Laravel contract.


✏️ Learnings added
Learnt from: binaryfire
URL: https://github.com/hypervel/components-backup/pull/16

Timestamp: 2026-08-12T19:12:58.139Z
Learning: In Hypervel Database Query Builder, do not accept an instance-bound `Relation` in `whereExists*()` APIs. These APIs require a correlated relationship query; using an instance-bound relation can produce an uncorrelated EXISTS clause. Use Eloquent `whereHas*()` APIs for relationship existence checks.

Learnt from: binaryfire
URL: https://github.com/hypervel/components-backup/pull/16

Timestamp: 2026-08-12T19:12:58.139Z
Learning: In Hypervel Database Query Builder, keep `union*()` inputs limited to Query Builder and Eloquent Builder types. Do not add `Relation` support because Laravel declares the same Query Builder and Eloquent Builder contract.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@binaryfire binaryfire closed this Aug 12, 2026
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.

1 participant