Search by

byte-kitsune / mago-doctrine-query-budget

seyon

Conservative Doctrine query budgets per PHP entrypoint for Mago.

Package info

github.com/Byte-Kitsune/mago-doctrine-query-budget

pkg:composer/byte-kitsune/mago-doctrine-query-budget

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.2 2026-10-13 12:07 UTC

README

Tests Security Check

Conservative database-statement estimates for PHP entrypoints in Mago. This package is an Analyzer plugin. It follows modeled calls from controllers and commands, reports lower and upper bounds, and marks unknown paths explicitly. It never executes PHP or connects to a database.

The runnable report example shows why two harmless-looking service calls can cross a query budget, and why an ORM result cannot always get a finite static upper bound.

Install and run

The Composer requirement ^1.50 permits later Mago 1.x releases and excludes 2.x. Mago 1.50 remains the tested API floor; older releases are not covered. CI checks this floor and the latest compatible 1.x release.

Requires PHP 8.2+ and Mago >=1.50.0,<2.0.0. Pin the stable release in your project:

composer require --dev "carthage-software/mago:^1.50" byte-kitsune/mago-doctrine-query-budget:1.1.2

Add an extension host to mago.toml:

[extension-hosts.php]
command = ["php", ".mago/extensions.php"]

Create .mago/extensions.php:

<?php

use ByteKitsune\MagoDoctrineQueryBudget\QueryBudgetExtension;
use Mago\Sdk\Worker;

require dirname(__DIR__) . '/vendor/autoload.php';

(new Worker(QueryBudgetExtension::create(
    entrypointSuffixes: ['Controller.php', 'Command.php'],
    warningThreshold: 10,
    errorThreshold: 25,
    incompleteIssueLimit: 25,
)))->run();

Run vendor/bin/mago analyze. A selector must be a PHP filename suffix. Both the filename and class name must end with its stem. Controller methods are public actions; for Command.php, protected or public execute() and public __invoke() are entrypoints, matching ordinary Symfony commands. Set thresholds to positive integers with errorThreshold >= warningThreshold.

If your project uses mago-symfony-wiring, pass the compiled dev container bindings explicitly. Generate its sanitized reference as described in that extension's README, then use one loader for both maps:

use ByteKitsune\MagoSymfonyWiring\ContainerReferenceLoader;
use ByteKitsune\MagoSymfonyWiring\SymfonyWiringExtension;

$root = dirname(__DIR__);
$reference = '.mago/container-reference.dev.json';
$loader = new ContainerReferenceLoader($root, $reference);
$map = $loader->load();

$queryBudget = QueryBudgetExtension::create(
    classBindings: $map->classBindings(),
    constructorBindings: $loader->constructorClassBindings(),
    warningThreshold: 10,
    errorThreshold: 25,
);
(new Worker(
    SymfonyWiringExtension::fromContainerReference($root, $reference),
    $queryBudget,
))->run();

The positional constructor map takes precedence over a general interface alias, including when a service has an explicit per-argument override. A null position stays unknown instead of borrowing the default alias. Installing both extensions alone does not transfer bindings; pass both maps to the query-budget extension. The source-only ServiceConfigLoader remains available for explicit literal service files, but cannot establish the full compiled Symfony container.

Inspect one controller or command

Set inspectEntrypoints to an exact fully qualified class name or Class::method in the worker. A class selection covers its public entrypoints; a method selection covers just that action. The extension then emits query-budget-inspection with bounds and unknown-call reasons even when the estimate is zero or below the warning threshold. An unmatched selection is an error, so a typo cannot look clean.

For a reusable local command, read the selection from an operator-set environment variable in your worker:

$selection = getenv('MAGO_QUERY_BUDGET_INSPECT');
$queryBudget = QueryBudgetExtension::create(
    inspectEntrypoints: $selection === false || $selection === '' ? [] : [$selection],
    incompleteIssueLimit: 0,
);
MAGO_QUERY_BUDGET_INSPECT='App\Controller\ReportController::index' vendor/bin/mago analyze

incompleteIssueLimit accepts 0..1000 and defaults to 25. At 0, individual query-budget-incomplete warnings are omitted, but one query-budget-incomplete-summary warning retains the total and omitted counts. Threshold and recursion findings are never suppressed. The analysis-attestation note also records analyzed, incomplete, reported, and omitted entrypoint counts; an omitted warning is not proof of a finite query bound.

What the estimate means

The model counts selected Doctrine DBAL Connection, Statement, and DBAL query-builder execution methods as one statement each. Constructing a query is not execution. ORM Query executions and standard repository methods are recognized, but cache, hydration and lazy-loading effects leave their upper bound unknown. Calls to project-defined top-level functions and unadapted trait methods are followed through the same source snapshot. self:: and parent:: resolve against source-visible classes. Trait adaptations and late-bound static:: remain incomplete. Conditional early returns keep a finite upper bound when all reachable calls are modeled. try/catch/finally bodies are followed with a conservative upper bound: a catch may run after part of the try body, and finally runs afterward. A source-visible non-Doctrine search method inside a try block can therefore be proven query-free.

Explicit global calls to \mb_trim, \max, and \min are treated as query-free only for literal scalar arguments or variables that Mago proves scalar. Explicit global calls to \array_merge, \array_keys, \array_values, \array_reverse, and \array_slice are query-free after their arguments are analyzed; these helpers do not invoke callbacks or object methods. Unqualified namespaced calls may resolve to application functions outside the analyzed snapshot, so they remain incomplete unless the function is source-visible. Set assumeGlobalScalarBuiltins: true only when your operator policy has verified that these three unqualified names are not overridden outside the source snapshot. The extension still follows source-visible overrides and imported function aliases before applying that assumption, and still requires scalar arguments. Modeled callback consumers are described below. External collection methods and response serialization are not assumed query-free: their callbacks or lazy work can reach Doctrine. Unresolved calls keep their names in the evidence. Unknown receivers, dynamic calls, other unsupported control flow and unbounded loops also remain incomplete. Static estimates are not measured SQL counts.

Findings include:

Code Meaning
query-budget-exceeded The bound exceeds a configured threshold. A guaranteed lower bound above the error threshold is an error; a possible upper bound above the warning threshold is a warning.
query-budget-incomplete A finite upper bound could not be proven.
query-budget-incomplete-summary Some detailed incomplete warnings were omitted; the note gives exact counts.
query-budget-inspection Bounds for an explicitly selected entrypoint, even below thresholds.
inspection-target-not-found An explicit selection matched no public entrypoint.
recursive-call A reached recursive cycle has no proven breaker.

Thresholds use greater than, so exactly 10 does not exceed a warning threshold of 10. The query-budget-evidence note contains entrypoint, lower_bound, nullable upper_bound, unknown reasons and cycles. A narrow guarded decreasing self-call can avoid a recursion error, but does not by itself prove a finite query bound.

The source model accepts at most 25,000 PHP files and 512 MiB of PHP source bytes. Each file is limited to 1 MiB; per-entrypoint call work and depth are also bounded. Exceeding a project limit fails the analysis instead of publishing a partial estimate. Benchmark and review incomplete findings on your own codebase before making this a required CI gate.

After a PHP source run, the Analyzer emits one analysis-attestation note with a bounded extension-attestation payload. It identifies the version, query_budget capability, source-file count, inspected entrypoints and incomplete/omitted counts. complete confirms that the source model finished; it does not claim every query bound is finite. A gate that requires query-budget coverage should check the attestation even when no threshold is exceeded.

Develop

composer install
sh tests/smoke.sh
sh tests/scale-smoke.sh
sh tests/inspection-scale-smoke.sh
sh tests/snapshot-failure-smoke.sh

The fictional fixture covers bounded DBAL calls, ORM uncertainty and recursion. The scale check generates more than 20,000 PHP files and confirms an explicit failure above the 25,000-file limit. The inspection scale check compares every report from ten 2,000-file batches against one 20,000-file snapshot request and prints timings without imposing a machine-specific speed threshold. Override MAGO_INSPECTION_BENCHMARK_FILES to test another size between 2,001 and 25,000 files. The benchmark permits a 180-second extension callback and up to 2 GiB for the PHP worker; production memory requirements depend on source and method sizes. Licensed under MIT.

Inspect any PHP file or method

From an SDK after-analysis hook, call QueryBudgetExtension::inspectFile($context->analysis, 'src/Service.php'). The source path must exactly match a file in the Mago snapshot. Unlike controller/command diagnostics, this API returns all modeled methods, including private methods and constructors, with zero and below-threshold estimates. Each method contains symbol, path, line, lowerBound, nullable upperBound, unknown and cycles. The result has schemaVersion: "1" and a complete, incomplete, unsupported or failed status. Estimates describe one invocation of each method; adding every method's bounds does not describe one execution of the file.

Keep the whole-project snapshot so transitive service and repository calls remain available. Supply compiled Symfony classBindings and positional constructorBindings when available. To inspect one exact method, pass method: 'App\Service::run'; missing files or methods return unsupported, and an abstract implementation remains unknown rather than zero. This API is read-only, works independently of diagnostic thresholds and does not execute application code.

For project indexing, QueryBudgetExtension::inspectFiles($context->analysis, ['src/Service.php', 'src/Other.php']) returns the same per-file reports keyed by source path. It constructs the full project model once for the batch, preserving transitive calls and compiled constructor bindings. A batch accepts at most 2,000 distinct source paths.

For larger indexes, QueryBudgetExtension::inspectSnapshot($context->analysis, $files) accepts up to 50,000 distinct source paths and constructs the full project model only once for the entire selection. It accepts the same classBindings and constructorBindings arguments and returns identical per-file reports, including unsupported reports for absent paths. Keep the complete Mago snapshot even when selecting only some files: transitive callees may live outside the selection. The existing 25,000 PHP source / 512 MiB model limits and per-method evaluation limits still apply. Unlike chunking calls to inspectFiles(), this avoids repeatedly parsing and indexing the whole project. File-local evaluation failures (including the 512-method limit) return a failed report with no partial method list, while other selected files continue. Invalid selectors and model-wide source limits still reject the entire request. The model lives only for this call and is never reused across snapshot generations.

QueryBudgetExtension::inspectThresholds($phpSource) statically reads top-level create configuration, including imported class aliases, named or positional threshold arguments, and simple preceding literal assignments/constants. It never executes PHP configuration. Missing arguments inherit the extension API defaults (warning 10, error 25); incompleteIssueLimit is not a query threshold. Dynamic expressions, deferred/conditional calls and conflicting configurations return an explicit unresolved status without guessed values.

Native computational backend

Rust is the default computational backend on this development branch. The version-matched PHP SDK adapter remains. Missing native binaries are installed from the platform release asset into a shared cache after checksum verification. Linux x86_64 and both macOS architectures are covered by native-mode CI.

See native backend details for offline installation, source builds, explicit reference-backend comparisons and the pending project-specific recursion investigation.

Callback and query effects (1.2 candidate)

The model resolves source-visible closures, arrows, string/array/first-class callables, invokable objects, Closure::fromCallable, simple callback-returning factories, named/default arguments and PHP 8.5 pipes and constant-expression callables. Creating a callable does not execute it. Callees and arguments follow PHP evaluation order; mutable bindings, reference captures and unknown callback properties retain uncertainty.

call_user_func, call_user_func_array, array_map, array_filter, array_walk, array_reduce, array_any, array_all, array_find and array_find_key propagate callback effects. Literal arrays provide cardinalities; short-circuit consumers retain ranges. Unknown multiplicities remain incomplete when the callback has effects. Scalar builtin proofs do not establish that arbitrary object arguments or user overrides are harmless. The existing assumeGlobalScalarBuiltins opt-in remains restricted to mb_trim, max and min.

Doctrine recognition uses fully qualified types, source-visible ancestry/interfaces, receiver provenance and modeled factories, rather than a class basename such as Query. Source-visible OpenSearch/vector-style query classes can be proven query-free by their bodies. An opaque external method is an unknown call, not proof of a Doctrine query: analysis continues and retains later known executions. Provide compiled Symfony constructor/class bindings for interface-to-service resolution.

DBAL Connection/Statement and query-builder execution contracts count logical API executions; fetching an already obtained DBAL Result does not add another execution. ORM query/cache/hydration and UnitOfWork effects remain uncertain. transactional/wrapInTransaction follow callbacks with the supplied connection/entity manager. Symfony cache get follows its callback while retaining uncertainty for backend work, misses and invocation frequency; registering a deferred callback does not execute its body.

Arbitrary callback properties, complex returned callbacks, reflective invocation, unsupported consumers, unresolved dynamic types and unknown namespace fallback remain incomplete. These static bounds do not measure physical SQL: caches, middleware, transaction statements and deferred ORM behavior require runtime instrumentation for actual database counts.

The neutral callback corpus exercises 60 entrypoints, including query-free search classes, external-call continuation, real cycles, evaluation order and mutable captures. PHP/Rust fixture parity does not establish parity on your project. Until candidate assets are published, build engine 0.4.0 and set MAGO_NATIVE_ENGINE as described in NATIVE_BACKEND.md.

Framework hooks and additional PHP consumers

The candidate now includes comparator/regex/recursive-walk/iterator consumers, supported Closure/reflection invocation and deferred registration families. See CALLBACK_COVERAGE.md for the exact contract matrix and limits.

Framework hook candidates are discovered automatically by exact attributes and framework ancestry, including methods in arbitrary service filenames. query-budget-hook-inspection describes one invocation and preserves declaration/registration evidence; it does not add every listener or asynchronous handler to a controller's budget. Supply compiled tags with Symfony Wiring when methods are configured rather than declared:

$extension = QueryBudgetExtension::create(
    classBindings: $loader->load()->classBindings(),
    constructorBindings: $loader->constructorClassBindings(),
    hookEntrypoints: $loader->frameworkHookEntrypoints(),
);

Inspect $loader->frameworkHookRegistrations() for unresolved defaults/subscribers. inspectFrameworkHooks($analysis) is also exposed by all three extensions. Disable automatic source discovery with discoverFrameworkHooks: false; explicit hook targets still apply. Query Budget's Rust path reuses native declaration metadata rather than collecting a second full project source snapshot in PHP.