Query Planner

ObjectQuel includes a built-in query planner that records every optimization decision made before a query reaches the database. When development mode is enabled, the planner produces a log of what was evaluated and why — which joins were promoted or kept, which search strategy was chosen, how the query was decomposed into stages. This log is surfaced in the Canvas Inspector and is also available programmatically.

explanation

Enabling the Query Planner

The planner always runs — it is the component responsible for transforming an ObjectQuel query into an efficient execution plan. What development mode adds is logging: each decision is recorded and emitted as part of the debug signal that the Canvas Inspector consumes.

Pass a Configuration object with development mode enabled when setting up the entity manager:

$config = new Configuration();
$config->setDevelopmentMode(true);

$em = new EntityManager($connection, $config);
Overhead: Development mode adds a dry-run planning pass per query for all users while active. It is safe to enable temporarily in production, but disable it again once you are done.

Reading the Plan

In the Canvas Inspector, the Query Plan table appears below the ObjectQuel statement and generated SQL for each query. Each row is one decision, in the order the pipeline made it. The columns are:

  • Source: Which part of the pipeline made the decision — optimizer for the transformation phase, planner for the execution stage builder.
  • Category: The broad type of decision — join, aggregate, search, or stage.
  • Decision: A short label identifying the specific outcome.
  • Reason: A plain-English explanation of why that decision was made.
  • Subject: The range name, field, or aggregate the decision applies to.

Join Decisions

The optimizer analyzes each range in the query and determines the appropriate join type. It runs several passes — annotation-based promotion, WHERE clause analysis, and unused range removal — and records what it found at each step.

SINGLE_RANGE_INNER

When a query has only one range, it is automatically an INNER JOIN — there is nothing to join to. The optimizer records this immediately and skips further join analysis for that range:

range of p is App\Entity\PostEntity
retrieve (p) where p.published = true
optimizer  join  SINGLE_RANGE_INNER  Range 'p' is the only range in the query; forced INNER JOIN
planner    stage SINGLE_STAGE        Query executes in a single database stage

ANNOTATION_INNER

A @RequiredRelation annotation on a relationship declares that the related entity must always exist — the relationship is never optional. The optimizer promotes the range to INNER JOIN without inspecting the WHERE clause:

range of o is App\Entity\OrderEntity
range of u is App\Entity\UserEntity via o.user
retrieve (o, u.name)
optimizer  join  ANNOTATION_INNER  Range 'u' has @RequiredRelation on 'user'; forced INNER JOIN

LEFT_TO_INNER

When a WHERE clause references a non-nullable field on a LEFT JOIN range, any row where that range produced no match would have a NULL in that field and would be filtered out anyway. The LEFT JOIN and INNER JOIN are semantically equivalent in this case, so the optimizer promotes to INNER JOIN, which is cheaper to execute:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u) where o.total > 100
optimizer  join  LEFT_TO_INNER  Range 'o' references a non-nullable field in WHERE; LEFT JOIN promoted to INNER JOIN

FORCED_LEFT

When the WHERE clause contains an IS NULL or IS NOT NULL check on a range, the join must stay LEFT — converting to INNER would eliminate the rows the check is intended to find:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u) where o.id is null
optimizer  join  FORCED_LEFT  Range 'o' has IS NULL / IS NOT NULL in WHERE; cannot promote to INNER JOIN

LEFT_UNCHANGED

When no condition warrants promotion or demotion, the range stays as a LEFT JOIN and the optimizer records why — either no WHERE references were found for that range, or the references it did find were on nullable fields:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u, o.total)
optimizer  join  LEFT_UNCHANGED  Range 'o' has no WHERE references; kept as LEFT JOIN

UNUSED_JOIN_REMOVED

When a range is joined but never referenced in the SELECT, WHERE, or ORDER BY clauses, it has no effect on the result and is removed entirely:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u)
optimizer  join  UNUSED_JOIN_REMOVED  Range 'o' is not referenced in SELECT, WHERE, or ORDER BY; removed

EXISTS_TO_INNER

An exists() check in the WHERE clause is rewritten as an INNER JOIN, which is more efficient than a correlated subquery in most cases:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u) where exists(o)
optimizer  join  EXISTS_TO_INNER  Range 'o' used in EXISTS; converted to INNER JOIN

Aggregate Decisions

The optimizer chooses a strategy for each aggregate function independently, based on what else appears in the SELECT clause and whether the query spans multiple ranges.

DIRECT

The aggregate is evaluated inline. When non-aggregate fields also appear in the SELECT, a GROUP BY is added automatically:

range of o is App\Entity\OrderEntity
retrieve (o.userId, count(o.id))
optimizer  aggregate  DIRECT  Aggregate COUNT evaluated inline with GROUP BY

SUBQUERY

The aggregate is rewritten as a correlated subquery. This happens when the aggregate cannot be expressed directly alongside the other SELECT items — for example, when the aggregated range is different from the primary range:

range of u is App\Entity\UserEntity
range of o is App\Entity\OrderEntity via u.orders
retrieve (u, count(o.id))
optimizer  aggregate  SUBQUERY  Aggregate COUNT rewritten as correlated subquery

WINDOW

The aggregate is rewritten as a window function, which computes the result across a partition without collapsing rows. This strategy applies when the query is single-range and all SELECT items are uniform in their aggregate usage:

optimizer  aggregate  WINDOW  Aggregate COUNT rewritten as window function

Search Decisions

The search() function resolves to either a FULLTEXT index query or a LIKE pattern match, depending on whether a suitable index exists.

FULLTEXT

A FULLTEXT index covering all the searched columns was found. The query uses MATCH ... AGAINST:

range of p is App\Entity\PostEntity
retrieve (p) where search(p.title, p.body) = :term
optimizer  search  FULLTEXT  search() on [title, body] resolved to FULLTEXT INDEX (covering index found)

LIKE

No covering FULLTEXT index was found. The query falls back to a LIKE pattern match on each column:

optimizer  search  LIKE  search() on [title, body] resolved to LIKE (no covering FULLTEXT index found)

Stage Decisions

After optimization, the planner decomposes the query into execution stages. Most queries run in a single stage. Queries that reference non-SQL sources — such as JSON ranges — require those sources to be materialized into temp tables before the main query can run.

SINGLE_STAGE

The query runs as a single database operation with no intermediate materialization:

planner  stage  SINGLE_STAGE  Query executes in a single database stage; no temp table materialization required

TEMP_TABLE and EXECUTION_ORDER

When a range cannot be expressed as inline SQL, it is materialized into a temp table first. The planner records each temp table stage and then the execution order — which stages must complete before the main query can run:

planner  stage  TEMP_TABLE        Range 'jsonSrc' requires temp table materialization (contains external/JSON source)
planner  stage  EXECUTION_ORDER   Stage execution order: tmp_stage_abc -> main

Programmatic Access

To inspect a query plan directly — in a test, a CLI command, or a custom diagnostic tool — call explainQuery() on the entity manager. It runs the full planning pipeline and a SQL dry-run without executing the query against the database:

$plan = $em->explainQuery("
    range of u is App\Entity\UserEntity
    range of o is App\Entity\OrderEntity via u.orders
    retrieve (u) where o.total > 100
");

foreach ($plan->getNotes() as $note) {
    echo "[{$note->category}] {$note->decision}: {$note->reason}\n";
}

foreach ($plan->getSql() as $sql) {
    echo $sql . "\n";
}

explainQuery() is available regardless of whether development mode is enabled. QueryPlan implements JsonSerializable, so it can be passed directly to json_encode().