Skip to content

v2.0.0

Choose a tag to compare

@github-actions github-actions released this 04 Aug 20:15
· 105 commits to main since this release

The first major release since v1.0.0 (2026-07-07). It adds a
large batch of query-layer capabilities, new column types, per-platform DDL
parity, a two-tier query result cache, and support for running under a
persistent-worker SAPI; it reaches full test-suite parity on
MySQL/MSSQL/Oracle and takes the whole project from a PHPStan level 6 baseline
to a clean level 9. It also removes several legacy code paths, hence the major
version bump.

Added

Query layer

  • Common table expressions and window functions. Criteria::withCte()
    emits WITH [RECURSIVE] name AS (...); the CTE name then resolves anywhere
    a table name does. ModelCriteria::useCteQuery() adds a closure-scoped
    convenience for the non-recursive case. WindowExpression is a fluent
    <fn>(...) OVER (PARTITION BY ... ORDER BY ... <frame>) builder
    (rowNumber/rank/lag/lead/sum/avg/…, partitionBy(),
    orderBy(), rowsBetween(), rangeBetween()), accepted by
    ModelCriteria::withColumn().
  • Set operations. Criteria::union()/unionAll()/intersect()/except(),
    chainable and recursively composable. The outer query's
    orderBy()/limit()/offset() apply to the combined result.
  • EXISTS / IN correlated subqueries. Criteria::addExistsQuery() and
    addInQuery() as the low-level primitives, plus closure-scoped
    ModelCriteria::useExistsQuery()/useNotExistsQuery()/useInQuery().
    Unlike useQuery()/withQuery(), these nest an independent sub-SELECT and
    need no join relation.
  • Pessimistic locking. Criteria::setLockForUpdate()/setLockForShare()
    (with SKIP LOCKED/NOWAIT) and the ModelCriteria::forUpdate()/forShare()
    wrappers. MSSQL emits WITH (UPDLOCK|HOLDLOCK, ROWLOCK[, READPAST]) table
    hints instead of a trailing clause. Capability flags make unsupported
    combinations throw rather than emit invalid SQL (SQLite has no row locking,
    Oracle no FOR SHARE, MSSQL no NOWAIT equivalent).
  • Upsert. BasePeer::doUpsert() backed by DBAdapter::supportsUpsert()/
    getUpsertSql() — ON CONFLICT ... DO UPDATE/DO NOTHING on
    Postgres/SQLite, ON DUPLICATE KEY UPDATE on MySQL/MariaDB. MSSQL/Oracle
    need MERGE and are deferred.
  • Column expressions in UPDATE. Propulsion\Query\ColumnExpression makes a
    column's new value a raw SQL expression (view_count = view_count + 1)
    instead of a PHP-computed bound literal, removing the read-modify-write
    lost-update race.
  • Bulk load. BasePeer::doBulkInsert() with DBAdapter::supportsBulkLoad()/
    bulkLoad() — real COPY FROM STDIN on Postgres, LOAD DATA LOCAL INFILE
    on MySQL/MariaDB.
  • explain() on query objects for Postgres, MySQL/MariaDB, and SQLite;
    throws on MSSQL/Oracle, which need a multi-statement mechanism.
  • Global (cross-process) query result cache. A second, process-/host-shared
    tier behind the request-scoped one, so a cached query survives the worker
    request boundary instead of dying with it. Backed by any PSR-16 pool (new
    psr/simple-cache ^3.0 dependency): register one with
    Propulsion::setQueryCachePool(), or name a shipped driver (array, apcu,
    file, null) in the new optional cache.query config section.
    Propulsion ships no Redis or Memcached client — use any third-party PSR-16
    implementation. Cached payloads are raw pre-hydration rows, so a hit
    re-hydrates through the normal instance-pool path and never leaks an object
    graph across requests. Invalidation is by table version token: an ORM
    write replaces the token of each written table, making every cache key derived
    from it unreachable across every process sharing the pool, with no index to
    maintain. Bumps are buffered until commit, and queries inside a transaction
    are never published (they can see uncommitted rows). Includes admission
    control
    (an entry is stored only on its second sighting, so a flood of
    never-repeating keys cannot grow the cache) and stampede protection
    (probabilistic early recomputation everywhere, plus strict single-flight on
    backends that can create-if-absent atomically). Off by default: both the
    config flag and the existing per-query Criteria::setQueryCache() opt-in must
    be present, so an upgrade changes nothing until you ask it to. See
    docs/CACHING.md.
  • Propulsion::rawQuery(): hand-written SQL through the same cache, for the
    complex queries that are easier to write by hand than as a Criteria — and
    which tend to be the expensive ones.
    Propulsion::rawQuery($sql, $params)->dependsOn('book', 'author')->cache(ttl: 300)->hydrate(BookPeer::class),
    with rows()/one()/formatWith() as alternative terminals. Tables must be
    declared rather than inferred: Propulsion will not parse SQL to guess them,
    because a parser would be wrong about CTEs, views and aliases, and
    dependsOn() validates each name against the DatabaseMap so a typo throws
    instead of silently never invalidating.
  • Propulsion::invalidateQueryCacheForTables() for writes Propulsion cannot
    see (raw SQL, other applications, migrations).
  • Query result cache (opt-in, Criteria::setQueryCache()): a request-scoped
    QueryResultCache owned by Session, keyed by SQL+params+dbName, wired into
    ModelCriteria::find()/findOne()/count() and the generated Peer's
    doSelect()/doCount(). Writes through BasePeer invalidate by table.
  • Compiled-query cache (Criteria::setCompiledQueryCache()): caches the
    compiled SELECT SQL string itself, keyed by a caller-supplied shape key, for
    long-lived worker processes. Scoped to plain SELECTs.

Unit of Work, events, transactions

  • Unit of Work. UnitOfWork/EntityState for coordinated multi-object
    flush ordering with pre/post-flush events, plus a suppressAutoCascade
    escape hatch so BaseObject::save()'s FK-cascade logic defers to a
    UnitOfWork-driven save order instead of double-saving.
  • OptimisticLockBehavior, raising ConcurrencyException on a
    version-column mismatch instead of silently overwriting concurrent changes.
  • PSR-14 event dispatching for model lifecycle events, mirroring the
    existing PSR-3 logger facade: Propulsion::setEventDispatcher()/dispatch(),
    a no-op when nothing is registered. Covers per-object hooks
    (preSave/postSave/preInsert/…) and the bulk
    ModelCriteria::update()/delete()/deleteAll() path. Pre-events implement
    StoppableEventInterface, so a listener can veto. Adds a psr/event-dispatcher
    dependency.
  • Real SAVEPOINT-based nested transactions on pgsql, mysql, and sqlite,
    replacing the depth-counter emulation where an inner rollBack() merely
    poisoned the outer transaction. Platforms without savepoints keep the old
    fallback.

Column types

  • JSON/JSONB — stored as real JSON text (json_encode/json_decode)
    rather than the legacy PHP_ARRAY/OBJECT serialize() storage. Native on
    Postgres (author's choice of JSON or JSONB) and MySQL, text fallback
    elsewhere; hydration uses JSON_THROW_ON_ERROR and names the offending
    column on malformed data.
  • UUID — native on Postgres, CHAR(36) elsewhere; the generated setter
    validates the canonical 8-4-4-4-12 form and normalizes to lowercase.
  • Native PHP enum mapping (enumClass), native enum DDL (nativeEnum),
    BcMath\Number for DECIMAL/NUMERIC, INTERVAL → DateInterval,
    Postgres network types (inet/cidr/macaddr) and citext, Postgres range
    types (via a new Propulsion\Type\Range), pgvector/MariaDB VECTOR, a
    GEOMETRY type (deliberately WKT-text-emulated everywhere), and a native
    MySQL SET type.

Platform DDL parity

  • PostgreSQL: GENERATED ... AS IDENTITY (identity="true"), opt-in native
    array columns (nativeArray="true" + a Propulsion\Type\PgArray codec; the
    emulated " | " format remains the default), full-text search (TSVECTOR,
    tsvectorFrom-generated STORED columns, GIN/GiST via Index::indexType),
    partial and expression indexes, index storage parameters, INCLUDE covering
    columns, CONCURRENTLY, EXCLUDE USING gist exclusion constraints, and
    classic table inheritance (inheritsFrom). PostgreSQL 16 documented as the
    minimum.
  • MySQL/MariaDB: native SET, unsigned/zerofill attributes,
    FULLTEXT/SPATIAL indexes, generated/virtual columns, opt-in MariaDB-only
    native CREATE SEQUENCE (nativeSequence) and native UUID (nativeUuid).
  • SQLite: generated columns (GENERATED ALWAYS AS (expr) VIRTUAL|STORED)
    and partial/expression indexes.
  • MSSQL: CREATE SEQUENCE, computed columns (AS (expr) [PERSISTED]),
    rowVersion="true" → ROWVERSION, clustered/nonclustered control on
    indexes/uniques/PKs, temporal tables (SYSTEM_VERSIONING), IDENTITY_INSERT
    support, and real savepoints. Supported floor raised to SQL Server 2012+
    (2016+/Azure SQL for temporal tables).
  • Oracle: GENERATED ... AS IDENTITY, native JSON (21c+), native
    BINARY_FLOAT/BINARY_DOUBLE for REAL/DOUBLE. Floor raised to 12c+.
  • Generated-id retrieval folded into the INSERT on every platform via
    RETURNING/OUTPUT (RETURNING ... INTO for Oracle's OUT-bind shape), plus
    UPDATE/DELETE ... RETURNING for affected-row hydration everywhere except
    Oracle. MariaDB is detected at runtime (DBMySQL::isMariaDb()) to gate its
    RETURNING support.

Worker mode

  • docs/WORKER_MODE.md: what to account for when Propulsion runs under a
    persistent-worker SAPI — the process-scoped/request-scoped split, the rules new
    code has to follow, a review checklist, and the currently-known gaps.

  • Session::reset() as the request boundary. Propulsion::$session and every
    connection it holds are one instance shared by every worker thread in a
    process, so request-boundary isolation — not per-thread separation — is what
    makes concurrent requests safe. reset() clears the instance pools, the
    request-scoped result cache, any leaked instance-pooling suspension, and the
    per-connection debug counters, and rolls back dangling transactions.

  • PROPULSION_SKIP_LEGACY_CLASS_ALIASES, to skip installing the 102 global
    class_alias() calls that runtime/Lib/legacy-class-map.php drives. Measured
    saving when skipped: 176 loaded classes/interfaces down to 1, and 3,208 KB
    down to 115 KB
    , per process.

    Safe to set when nothing references the bare historic names (Criteria,
    PropulsionPDO, PropelException, ...) — which is the case for any project
    whose schemas declare a namespace, since namespaced generated code has always
    imported the runtime classes properly, and as of the change below so does newly
    generated flat code. It is opt-out rather than opt-in because hand-written
    application code using the bare names breaks silently without the aliases:
    PHP does not consult the autoloader for catch, instanceof, is_a() or a
    parameter type check, so the failure is an unmatched catch or a false
    instanceof rather than an error. Define it before Propulsion is loaded:

    define('PROPULSION_SKIP_LEGACY_CLASS_ALIASES', true);
    

Testing & tooling

  • Full test-suite parity on MySQL, MSSQL, and Oracle, all wired into CI as
    dedicated integration-mysql/integration-mssql/integration-oracle jobs
    alongside the Postgres job, plus a PROPULSION_TEST_DB=mariadb
    testcontainer path.
  • Reverse-engineering test coverage for MSSQL, Oracle, and SQLite.
  • bench/ — a generated-code performance harness (file-based generated
    classes on in-memory SQLite) plus a correctness check, with results in
    bench/RESULTS.md. Includes global_query_cache_bench.php, which measures
    genuine cross-request cache hits (it calls Session::reset() before each
    iteration) per driver, with a reset()-only control column.
  • Two CI gaps closed. New analyse job, so the project's PHPStan level 9
    requirement is enforced mechanically rather than by discipline; and a new
    worker job running composer test:worker, which is the only place the
    worker-mode request-boundary guarantees — including the global query cache
    surviving a boundary while the request-scoped tier does not — are actually
    provable. APCu is now installed in the unit job with apc.enable_cli=1,
    without which ApcuCacheTest would silently skip and report green.
  • Worker harness extended with four global-query-cache profiles
    (array, array cross-thread, apcu cross-thread, file cross-thread) and
    a two-OS-process filesystem-cache test driven by proc_open, which needs no
    Docker and runs in the fast unit tier.
  • New docs: PLATFORM_FEATURES.md (platform/feature roadmap),
    UNIT_OF_WORK.md, TECH_DEBT.md, DOCS_TODO.md. KNOWN_ISSUES.md trimmed
    back to actually-open items.
  • A PHPStan extension for ModelCriteria's magic methods, and
    Propulsion\OM\WritableModelInterface, implemented by generated models
    exactly when the generic mutators (setByName()/setByPosition()/
    fromArray()) are emitted.
  • FrankenPHP worker-mode harness (test/worker/, composer test:worker):
    runs a request-boundary-safety matrix against SQLite and Postgres on a single
    worker thread, plus a cross-thread matrix at WORKER_THREAD_COUNT=4. The
    harness allocates its own host ports from a fixed range above the kernel's
    ephemeral range and retries a container that comes up healthy but unreachable,
    while still failing immediately — without retrying — for a container that
    actually exited, so a real regression cannot be retried into silence. Readiness
    budget is WORKER_READY_TIMEOUT (default 60s).

Changed

  • PropulsionPDO is now an interface, not a concrete class. Every
    connection Propulsion constructs is a driver-specific subclass of the
    matching PHP 8.4 \Pdo\* class — PgsqlPropulsionPDO extends \Pdo\Pgsql,
    MysqlPropulsionPDO extends \Pdo\Mysql, SqlitePropulsionPDO extends \Pdo\Sqlite, MssqlPropulsionPDO extends \Pdo\Dblib; OraclePropulsionPDO
    and GenericPropulsionPDO extend plain \PDO. Shared connection behavior
    (savepoints, query counting, logging, statement caching) moved into
    PropulsionPDOTrait. Existing ?PropulsionPDO $con type hints keep working;
    code that instantiated or extended the concrete class does not.

  • withQuery() split into withQuery()/withTypedQuery(), fixing the
    broken generic contract of useQuery()/withQuery(); endUse() is now
    guarded.

  • PHPStan baseline raised from level 6 to level 9, project-wide and clean
    (generator, runtime, bin). This ran through thousands of real findings and
    fixed a long tail of genuine nullability, mixed-type, and dynamic-dispatch
    bugs along the way rather than suppressing them. Supporting changes include
    throwing require*() accessors for Table/Column/Domain attach
    invariants, XMLElement typed-attribute helpers, and replacing
    call_user_func Peer dispatch with dynamic static calls.

  • ObjectBuilder's per-column-type codegen extracted into
    generator/Lib/Builder/OM/ColumnType/ handler classes with a resolving
    registry, replacing ~15 column-type checks duplicated across six parallel
    elseif chains in a 4363-line file.

  • MysqlPlatform's default storage engine changed from MyISAM to InnoDB —
    matching what every test fixture already overrode it to, and what
    supportsNativeDeleteTrigger() requires.

  • DBMSSQL::applyLimit() rewritten to native OFFSET/FETCH, removing a
    ~150-line ROW_NUMBER() regex rewriter; getDropTableDDL()'s catalog
    queries modernized from sysobjects/sysreferences to
    sys.foreign_keys/sys.tables. Oracle's applyLimit() likewise replaced
    its ROWNUM double-nested subquery with OFFSET ... FETCH NEXT ... ROWS ONLY.
    Both adapters splice in a no-op ordering when the query has none, because the
    ANSI OFFSET/FETCH clause requires one; the decision comes from
    Criteria::getOrderByColumns() — what BasePeer builds the outer ORDER BY
    from — rather than scanning the generated SQL, so an ORDER BY belonging to
    a nested subquery, CTE or set-operation branch cannot be mistaken for the outer
    query's own.

  • PropulsionTypes and PropulsionColumnTypes, which had drifted apart,
    deduplicated into one map (fixing two real PDO-type mismatches).

  • getPhp84TypeHint()/getPhp84PropertyType() renamed to getPhp85*.

  • Flat (global-namespace) generated code now imports the runtime classes it
    uses, instead of relying on the global aliases.
    Namespaced generated code has
    always emitted use Propulsion\Query\Criteria; and friends; flat targets
    deliberately suppressed those imports and leaned on
    runtime/Lib/legacy-class-map.php to provide a global Criteria instead. The
    reason was real: PropulsionQuickBuilder concatenates every generated class
    into one script and eval()s it, and repeating an import in a single scope is
    fatal — PHP rejects even two identical use statements. Each generated class
    is now wrapped in its own braced namespace { … } block there, and each braced
    block has its own import scope, so the constraint no longer applies. Classes
    still land in the global namespace exactly as before.

    Alongside this, the four near-identical getUseStatements() overrides on
    ObjectBuilder/PeerBuilder/QueryBuilder/TableMapBuilder are gone, replaced by one
    implementation on OMBuilder with the runtime-class map factored out to
    getRuntimeClassFqcnMap(). That implementation also stops emitting
    non-compound imports in a global-namespace target (use DateTime;,
    use PDO;, use BookPeer;), which PHP warns about as having no effect.

    Existing generated code is unaffected and needs no regeneration: the aliases
    still install by default. Regenerating is what lets you then set
    PROPULSION_SKIP_LEGACY_CLASS_ALIASES.

  • The compiled-query cache is process-scoped, and keyed by datasource. It
    lives on ServiceContainer, not Session, so a worker stops recompiling
    identical SELECT text on every request — the whole benefit is meant to accrue
    across requests, and an entry is a pure function of the datasource and the
    query shape (no bound values, no request data, nothing identifying who asked),
    which is the definition of process-scoped state. The datasource is part of the
    key because the SQL text depends on the adapter for identifier quoting and
    LIMIT/OFFSET dialect, and two datasources running the same generated method
    with the documented __METHOD__ key would otherwise serve each other's SQL;
    the paramCount guard cannot catch that, since the shapes match and only the
    dialect differs.

    Two things follow from process scoping: Propulsion::setConfiguration() clears
    the cache, so a reconfiguration cannot leave the previous adapter's SQL
    reachable; and the cache is bounded by CompiledQueryCache::MAX_ENTRIES (1000,
    oldest-first) since it is no longer emptied every request.
    Session::getCompiledQueryCache() is deprecated in favour of
    ServiceContainer::getCompiledQueryCache(), though it still works and returns
    the same instance.

  • A dropped connection is no longer "retried". PropulsionPDOTrait::exec()/
    query() and DebugPDOStatement::execute() caught a dropped-connection
    PDOException, called Propulsion::forceReconnect(), and re-issued the same
    statement on the same object — which PDO cannot revive, so the retry could
    only fail again, this time with an uncaught exception. forceReconnect() also
    takes a datasource name and defaulted to the default one, so a drop on any
    other datasource evicted an unrelated healthy pair of connections instead of
    the dead one. Retrying transparently is not safe in any case: losing the
    connection loses any open transaction with it, so re-running a statement on a
    fresh connection would execute it outside the transaction the caller believes
    it is in. The new PropulsionPDO::handleDroppedConnection() instead zeroes the
    transaction depth, discards the connection's buffered shared-cache version
    bumps (as on a rollback — those writes never committed), drops the
    prepared-statement cache, evicts the connection from the pool via the new
    Propulsion::discardConnection(), and rethrows unchanged so recovery happens
    where the transaction boundary is known. The bare error_log() calls on that
    path are gone too; they broke the documented "Propulsion never writes anywhere
    implicitly" rule.

Removed

  • buildtime-conf.xml support. Build-time connection config is
    plain-PHP-array only now (a .php file, or the
    propulsion.buildtimeConfigArray build property):
    GeneratorConfig::parseBuildConnections(), the XML/base64-string branches of
    getBuildConnections(), the extension dispatch in
    loadBuildConnectionsFile(), and the stale
    propulsion.buildtime.conf.file default are all gone. runtime-conf.xml
    (a separate legacy format) and schema-XML parsing are untouched.
  • --target-platform=php5. The option's only remaining behavior was a
    dead branch in QueryInheritanceBuilder::addFactory() that emitted an
    untyped create() signature to match an already-removed PHP5QueryBuilder
    — passing it today produced a fatal signature clash.
  • The legacy QueryCacheBehavior, replaced by the real result cache above.
    It only cached the rendered SQL string (queries still hit the DB), called APC
    functions that don't exist on PHP ≥ 8.5, had no invalidation, and had no test
    coverage.
  • Leftover SVN $Id$ keyword tags in test docblocks; PHP5-named test
    files/classes renamed to their real subject.

Fixed

Selected fixes; many more came out of the level 7/8/9 cleanup and the live
multi-platform test runs.

  • Cross-namespace extends= resolution in single-table inheritance.
    MultiExtendObjectBuilder::getParentClasspath() used the schema value
    verbatim, so a relative-qualified ancestor name silently resolved against the
    generated file's own namespace instead of the intended one.

  • Raw-expression parameter misalignment in BasePeer::doUpdate(). A raw
    expression with no ? placeholder still got an entry in the positional bound
    params array, silently shifting every later :pN in the same statement.

  • MSSQL: nested-transaction leak, silent INSERT failure, stale LOB stream
    handles after save() on drivers that close them, several MARS "results
    pending" cursor leaks, an auto-increment PK leaking into UPDATE SET,
    aliased UPDATE, and applyLimit() for offset-only and structurally
    unrewritable queries (UNION etc.).

  • Criteria::clear() now resets everything it claims to. It could null out
    dbName, and primaryTableName, the query comment, the setQueryCache()
    TTL/shared flags and the pending _or() combine operator all survived it — so
    a reused Criteria could carry a stale primary table, or OR its first new
    condition onto nothing.

  • Two request-scoped query-cache bugs, both found while building the global
    tier.
    getQueryCacheKey() ignored the formatter, so within one request an
    ARRAY-formatted and an object-formatted query producing identical SQL shared
    a cache entry and whichever ran second silently received the other's result.
    The key is now an xxh128 digest of the datasource, the formatter, the full
    SQL and the serialized parameters — hashed rather than concatenated verbatim,
    which for a joined query with a sizeable IN (...) list was several kilobytes
    held live as an array key and rehashed on every lookup. And FORMAT_STATEMENT /
    FORMAT_ON_DEMAND results were cached despite being tied to a live statement,
    so a second hit handed back an exhausted cursor; both formatters now bypass
    caching entirely via PropulsionFormatter::supportsRowCaching(). The latter
    is a behaviour change: a cache-enabled query using either formatter is no
    longer cached at all, at either tier.

  • A testcontainer startup race that silently skipped ~1300 MSSQL/Oracle tests,
    and an Oracle Instant Client download 404, both in CI.

  • Oracle CI: pdo_oci failed to build (SDK extraction) and then to load
    (Instant Client not on the loader path). Both fixed.

  • CI: a testcontainer that failed to start skipped the entire integration tier
    and still reported success. PROPULSION_REQUIRE_INTEGRATION=1 makes it fail.

  • CI coverage reporting: pcov instrumented nothing on PHP 8.5.

  • Test harness now bootstraps Propulsion config unconditionally; Postgres-only
    generator tests skip cleanly under other PROPULSION_TEST_DB values.

  • Overlapping on-demand iterations re-enabled instance pooling underneath one
    another, and an abandoned one disabled it for the rest of the worker's life.

    PropulsionOnDemandIterator suspended pooling by calling
    Propulsion::disableInstancePooling() and restoring based on its "did I change
    it" return value. That cannot nest: with two on-demand iterations alive at once
    (a nested foreach over two streamed queries — legitimate, and the reason
    on-demand mode exists) the inner one saw "already disabled" and recorded that it
    must not restore, so when the outer one finished it re-enabled pooling while
    the inner was still streaming, and the inner silently began pooling every
    remaining row — the exact unbounded growth on-demand mode exists to avoid. The
    same flag being a Propulsion static also meant an iterator that never resumed
    left pooling disabled for every subsequent request that worker process served.
    The transient case is now Session::$instancePoolingSuspendCount — a counter,
    so suspensions nest and pooling resumes only when the last scope does, and
    request-scoped, so Session::reset() clears a leaked suspension. The explicit
    Propulsion::disableInstancePooling() switch deliberately stays process-scoped
    and survives reset(): turning pooling off is deployment configuration, not
    request state.

  • Every iterated PropulsionCollection leaked until PHP's cycle collector
    ran.
    getIterator() memoised the iterator it handed out, so that
    getPosition()/getNext()/isLast() called from inside a foreach describe
    that loop — but ArrayObject's iterator refers back to the collection, making
    each iterated collection part of a reference cycle. A cycle is reclaimed only
    when the collector runs, not when its refcount drops, and a worker has no
    process exit to fall back on: measured at ~1 KB of retained overhead per
    iterated collection, on top of the collection's own contents. getIterator()
    now remembers the iterator through a WeakReference — a running foreach holds
    it strongly for the duration of the loop, so the position helpers behave exactly
    as before — and the strongly-held cursor is built only by
    getInternalIterator(), which genuinely needs a position that survives between
    calls.

  • One connection's rollback discarded another connection's uncommitted table
    version override.
    TableVersionRegistry::$memo holds one token per table
    while $pending is per connection, so when two connections write the same table
    inside their own transactions the second overrideLocally() supersedes the
    first's memo entry. discardPending() then unset it unconditionally, erasing
    the live override belonging to a transaction that had rolled back nothing; its
    own subsequent reads fell back to the published token and could be served its
    own pre-write rows from the shared tier, breaking read-your-own-writes inside an
    open transaction. It now only drops an override the rolling-back connection
    still owns.

  • getQueryCount() and getLastExecutedQuery() reported process-lifetime
    values under a worker.
    Connections are process-scoped and deliberately
    survive the request boundary, but those two counters are read as per-request
    figures — which is what they meant under PHP-FPM, where the connection died
    with the request. getQueryCount() instead accumulated across every request the
    worker had served, and getLastExecutedQuery() could hand back a statement
    issued while serving someone else's request. Session::reset() now zeroes them
    on every open connection via the new PropulsionPDO::resetDebugCounters(),
    which leaves the debug mode untouched (unlike useDebug(false), which clears
    the same two fields as part of switching mode). Note this adds a method to the
    PropulsionPDO interface: every bundled implementation gets it from
    PropulsionPDOTrait, but a third-party class implementing the interface
    directly has to add it.

  • DatabaseMap::hasTable() answered "no" for every schema-qualified table. It
    truncated the name at the first dot unconditionally, so a table registered as
    contest.bookstore_contest (what TABLE_NAME is for a table with a schema
    attribute) was looked up as contest and reported absent, disagreeing with
    getTable(), which keys on the full name. That made
    Propulsion::rawQuery(...)->dependsOn('contest.bookstore_contest') reject a
    perfectly valid table name outright, and turned the if (!$dbMap->hasTable(...))
    guard in every generated buildTableMap() into a permanent no-op for such
    tables. The exact name is now checked first, with the historic first-segment
    behaviour kept as a fallback so it can only ever answer "yes" where it used to.
    DatabaseMap::getColumn() likewise split on the first dot, taking
    ('contest', 'bookstore_contest') out of contest.bookstore_contest.ID — a
    nonexistent table and a column name that was really the table; it now splits on
    the last dot, and rejects an unqualified name with a clear message instead of a
    PHP error.

  • Cached queries recorded table dependencies that no write ever invalidated.
    Criteria::getQueryCacheTouchedTables() derived its list from the criterion-map
    keys, getPrimaryTableName() and the joins, which left three holes. The
    criterion keys carry the alias for an aliased query (b.TITLE, not
    book.TITLE), so a setModelAlias('b', true) query depended on "b" while
    every write path bumps "book". Only the right side of each join
    contributed, so BookQuery::create()->join('Book.Author') depended on author
    but not on book. And a plain "select every row" query — no WHERE, no join —
    recorded nothing at all, because the table appears only in the select columns
    and setPrimaryTableName() is called by configureSelectColumns() and
    count() but never by find()/findOne(). An entry with no dependencies is
    unreachable by every invalidation path, including
    Propulsion::invalidateQueryCacheForTables(), the documented escape hatch, so
    every process kept serving pre-write rows until the TTL lapsed. Both sides of
    every join are now included, every name is resolved through the alias map
    before being recorded, and select columns and withColumn() expressions are
    read as dependency sources — the latter because they reach the SELECT list
    without contributing a FROM entry, so a correlated expression can name a table
    appearing nowhere else in the query. TieredQueryCache::remember() additionally
    refuses to cache a result with no dependencies at all, degrading to an uncached
    read rather than storing something nothing can evict. BasePeer::doDelete() was
    the one write path invalidating under the alias rather than the real table name,
    so it now agrees with doInsert()/doUpdate()/doDeleteAll()/doUpsert().

  • MSSQL's TOP rewrite silently stripped an aggregate's own DISTINCT. The
    leading DISTINCT has to be removed from the select list because it is
    re-emitted as part of the SELECT DISTINCT TOP n prefix, but the unanchored
    str_ireplace('distinct ', '') removed every occurrence, so
    SELECT DISTINCT a, COUNT(DISTINCT b) FROM t became
    SELECT DISTINCT TOP n a, COUNT(b) FROM t — wrong results, with no error to
    notice it by. Only the leading keyword is removed now.

  • A failed rollback no longer hides the failure it was cleaning up after. The
    generated save()/delete() and UnitOfWork::flush() all rolled back inside
    catch (\Throwable $e) before rethrowing, unguarded. When that rollback also
    failed — which is precisely what happens when the original throwable came from
    commit(), since the transaction is already resolved and PDO then raises
    "There is no active transaction" — the rollback's exception superseded the one
    being handled, because an exception thrown from inside a catch block replaces
    it. The constraint violation, deadlock or hook refusal that actually failed the
    write was discarded in favour of a message about rollback. The original is now
    always rethrown unwrapped (callers catch specific types here, and it may be an
    Error, which PropulsionException cannot carry as a $previous), and the
    rollback's own failure is logged via Propulsion::log() at warning level.

  • A failed commit() left the transaction depth counter stuck.
    PropulsionPDOTrait::commit() decremented $nestedTransactionCount only on
    success, so a PDOException out of parent::commit() or out of the nested
    RELEASE SAVEPOINT left a pooled connection permanently claiming a depth it no
    longer had. isInTransaction() then kept returning true, which silently
    disabled the shared query cache tier for the rest of the request, and a caller
    that handled the failure and retried unwound one level too few. The decrement
    now happens in a finally. The isUncommitable guard is deliberately excluded:
    it throws without issuing anything, so the transaction genuinely is still open
    and the depth must stay to say so — which is what lets the caller's own
    rollBack(), or Session::rollBackDanglingTransactions() at the request
    boundary, find and unwind it.

  • A transaction could be left open when a non-PropulsionException escaped.
    UnitOfWork::flush() and ModelCriteria::delete()/deleteAll()/update()
    each opened a transaction but caught only PropulsionException for the
    rollback. A PSR-14 listener's own exception, a TypeError, or a raw
    PDOException from commit() propagated out with the transaction still open;
    on PostgreSQL that poisons the connection for whatever reuses it next
    ("current transaction is aborted" until an explicit ROLLBACK). All four now
    catch \Throwable, roll back, and rethrow the original throwable unchanged.

  • Rendering a criterion no longer rewrites it. Criterion::appendLikeToPs()
    assigned Criteria::ILIKE back onto $this->comparison on the ignore-case
    Postgres path, so a criterion rendered once stayed rewritten. That is invisible
    on Postgres alone (the rewrite is idempotent) but not across adapters: one
    Criteria rendered for a Postgres datasource and then for a MySQL one carried
    ILIKE onto MySQL, which has no such operator, so the second query failed with
    a syntax error. The operator is now resolved into a local, leaving
    getComparison() — public API, and read by Criteria::equals() and
    addJoinObject()'s dedupe — reporting what the caller actually asked for.

  • Cloning a Criteria now deep-copies every nested query. Subqueries
    (addSelectQuery()), set-operation branches (union() and friends), CTE
    queries (withCte()) and the Criteria-valued $value that
    addExistsQuery()/addInQuery() store on a Criterion were all shallow-copied,
    so a clone shared them with the original — and isKeepQuery() defaults to true,
    meaning every find()/count()/update() clones precisely to avoid mutating
    the caller's object, while SQL generation does write to a Criteria it renders.
    A non-Criteria criterion value is still copied as-is.

  • A failed prepare() is no longer cached. With
    PROPEL_ATTR_CACHE_PREPARES enabled, prepare() stored whatever
    PDO::prepare() returned — including false, which it returns instead of
    throwing under ERRMODE_SILENT/ERRMODE_WARNING — and isset() reports a hit
    for a stored false (it is only false for null). One transient failure (a
    table not yet created by a concurrent migration, a connection that dropped
    mid-request) therefore poisoned that SQL string for the whole life of the
    connection, which for a pooled or worker-mode connection outlasts the condition
    that caused it by a long way. Only successful prepares are cached now.

  • count() left its statement's cursor open. StatementRows::iterate()
    closed the cursor after its loop, which only runs when the generator is driven
    to exhaustion. ModelCriteria::countFromRows() returns from inside its
    foreach as soon as it has the scalar, leaving the generator suspended and the
    cursor never closed — the exact FreeTDS/pdo_dblib "results pending" hazard the
    class exists to centralise. The close moved into a finally, which also covers
    a consumer that breaks out or throws.

  • Shared cache could store BLOB streams as the integer 0.
    SharedQueryCache's resource check only inspected the first row, and
    serialize() does not fail on a resource — it writes i:0 with no warning at
    all, so nothing reached the surrounding catch. A result set whose first row
    carried a NULL blob while a later row carried a real stream was published with
    its blob columns silently replaced by 0. Every row is now checked.

  • PropulsionPDO::getLastExecutedQuery() could raise a TypeError. Declared
    : string, but $lastExecutedQuery is only ever assigned inside
    if ($this->useDebug) branches and was left implicitly null, so calling it on
    a connection that had never run with debugging on returned null from a
    non-nullable return type. Now initialised to ''. (Untyped property plus a
    @var string docblock is why static analysis never caught it.)

  • FileCache::prune() now collects orphaned .tmp files. set() writes a
    temp file then renames it, so a process killed in between left one behind
    forever: nothing reads it, clear() only sweeps the root, and the entry walk
    only matched *.pcache. Only files older than an hour are collected, so an
    in-flight write is never disturbed.

  • A raw ColumnExpression update with more than one ? now throws instead
    of allocating placeholders nothing binds to and silently shifting every
    subsequent :pN onto the wrong value. Only one bound value can be supplied
    per column.

Security

  • The file cache driver deserializes with allowed_classes: false. Its
    whole selling point is being shared across processes and SAPIs, which means its
    directory is writable by everything using it — and possibly by another
    application sharing it — so what comes back out is untrusted input. Running
    unserialize() with objects allowed would hand anyone who could drop a file
    into that directory a __wakeup()/__destruct() gadget-chain foothold in every
    reader. Nothing Propulsion stores through the driver is an object (row arrays,
    version tokens, admission counters), and a class payload reaching
    SharedQueryCache is rejected as "not a row set" and degrades to a clean miss.
    This narrows one use only: treating the driver as a general-purpose PSR-16 pool
    for your own objects.

Performance

Measured with bench/ on PHP 8.5 with JIT on; see bench/RESULTS.md.

  • Generated ORM hot paths (instance-pool state hoisted out of per-row loops,
    modified-column tracking as a set instead of a list, single-read hydration
    cells):

    • doSelectJoin (cold): 4330 → 1117 ns/op (−74%, ≈3.9×)
    • read, pool on, warm hits: 763 → 661 ns/op (−13%)
    • read+hydrate, pool on, cold: 2460 → 2282 ns/op (−7%)
    • read+hydrate, pool off: 2290 → 2148 ns/op (−6%)
    • setter churn + buildCriteria(): 1855 → 1769 ns/op (−5%)
  • Query result cache on repeated queries: ~139× on find(), ~31× on count(),
    ~181× on raw doSelect().

  • Global (cross-request) cache tier: an L2 hit costs ~45µs against ~89µs
    uncached and ~6µs for a request-scoped hit — the two tiers are complementary,
    since L2 skips the query but still hydrates while L1 skips both. The three
    drivers land within ~20% of each other on the hit path, so choose a driver
    on sharing semantics, not speed
    (docs/CACHING.md); the file driver's
    real cost is on writes (~769µs to store, ~98µs to bump a table version,
    against ~80µs and ~1.5µs in-memory), because each is an atomic
    temp-file-plus-rename. Note the 2× headline understates production: the
    benchmark's baseline is in-memory SQLite, where the round trip an L2 hit
    avoids is only ~89µs, so it largely measures hydration against a free
    database — against a networked Postgres the same hit avoids 0.5–2ms.

  • Compiled-query cache: ~1.5× faster createSelectSql() for a joined query.

  • Bulk load (COPY/LOAD DATA) is an order of magnitude faster than multi-row
    INSERT for seeding and imports.

  • A cold cached query no longer holds its whole result set twice. With a
    shared (L2) tier configured, TieredQueryCache::remember() materialised every
    miss's rows into an array so they could be stored — but SharedQueryCache::admit()
    rejects the first execution of every query (min_sightings defaults to 2) and
    every execution of a query whose key never repeats, so the common cold case paid
    for a full row array plus the formatted result and then discarded the array.
    Admission is decided before the query runs, and a rejection formats straight off
    the live statement, preserving the streaming memory profile. An entry that
    already exists is exempt from the re-check, because that is the stale
    early-refresh path and its sighting marker has usually expired by then —
    re-asking would have quietly disabled probabilistic early recomputation
    altogether.

  • The request-scoped result cache is bounded, and its table index no longer
    grows without limit.
    QueryResultCache had no entry cap, so a request running
    a cached query in a loop with varying bound parameters retained every formatted
    result set it had ever built — an out-of-memory condition rather than a cache.
    It now caps at QueryResultCache::MAX_ENTRIES (500) with oldest-first eviction;
    evicting is always safe, since the worst consequence of a miss is re-running the
    query. Separately, invalidateTable() had to remove an evicted entry from every
    table list it appeared in and did so by walking each other table's complete
    key list — O(writes × total index size) for a request mixing cached reads with
    writes — while dropping only the invalidated table's own list, leaving evicted
    keys listed under every other table they depended on and re-appending them on
    re-cache. A reverse key-to-tables index makes eviction proportional to what is
    actually evicted, and the forward index is a set per table, so a re-cached key
    cannot append a duplicate.

  • DatabaseMap::getTableByPhpName() re-ran its slow path on every call for
    some names.
    The resolution — a regex, up to two class_exists() probes, and
    possibly instantiating a TableMap — was only ever cached under the map's own
    getClassname(). When the requested phpName differed from that (a generated
    ...\OM\Base* name, or one that resolved via the \Map\ namespace insertion)
    the fast-path lookup kept missing and the whole probe sequence repeated on every
    single call. The resolution is now also indexed under the name that was asked
    for.

  • Hydrating a one-to-many with() was quadratic in the number of joined
    rows.
    PropulsionObjectFormatter's duplicate-row check was
    in_array($pk, $pks, true) over a growing list, i.e. ~12.5M comparisons for a
    5,000-row result set — the exact shape that branch exists to handle. It is now a
    keyed set, keyed on serialize() of the primary key, which also makes the
    dedupe strict for composite keys: ['1'] and [1], or [0] and [null], no
    longer collapse into one row the way loose in_array() comparison made them.

  • ModelCriteria::doUpdate() with $forceIndividualSaves built a
    ReflectionMethod per column per row.
    callSetByName() ran method_exists()
    and constructed a fresh ReflectionMethod on every call, and its only caller
    invokes it once per updated column per matched row — O(rows × columns)
    reflection objects for one update. The lookup depends only on the class, so it
    is memoised per class now.

  • The per-connection prepared-statement cache is bounded. With
    PropulsionPDO::PROPEL_ATTR_CACHE_PREPARES on, $preparedStatements grew
    without limit for the life of the connection — which under a persistent worker
    means the life of the process, while the set of distinct SQL strings is bounded
    by nothing (an IN (...) criterion produces one placeholder arity per distinct
    list length). It now caps at MAX_CACHED_PREPARED_STATEMENTS (256) and evicts
    oldest-first. The cache key also moved from md5() to xxh128: faster over the
    multi-kilobyte SQL a joined query produces, and 128 bits puts a collision —
    which here would hand back a statement prepared for different SQL — out of
    reach.