Repository navigation
v2.0.0
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()
emitsWITH [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.WindowExpressionis 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().
UnlikeuseQuery()/withQuery(), these nest an independent sub-SELECT and
need no join relation. - Pessimistic locking.
Criteria::setLockForUpdate()/setLockForShare()
(withSKIP LOCKED/NOWAIT) and theModelCriteria::forUpdate()/forShare()
wrappers. MSSQL emitsWITH (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 noFOR SHARE, MSSQL noNOWAITequivalent). - Upsert.
BasePeer::doUpsert()backed byDBAdapter::supportsUpsert()/
getUpsertSql()—ON CONFLICT ... DO UPDATE/DO NOTHINGon
Postgres/SQLite,ON DUPLICATE KEY UPDATEon MySQL/MariaDB. MSSQL/Oracle
needMERGEand are deferred. - Column expressions in UPDATE.
Propulsion\Query\ColumnExpressionmakes 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()withDBAdapter::supportsBulkLoad()/
bulkLoad()— realCOPY FROM STDINon 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.0dependency): register one with
Propulsion::setQueryCachePool(), or name a shipped driver (array,apcu,
file,null) in the new optionalcache.queryconfig 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-queryCriteria::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 aCriteria— and
which tend to be the expensive ones.
Propulsion::rawQuery($sql, $params)->dependsOn('book', 'author')->cache(ttl: 300)->hydrate(BookPeer::class),
withrows()/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
QueryResultCacheowned bySession, keyed by SQL+params+dbName, wired into
ModelCriteria::find()/findOne()/count()and the generated Peer's
doSelect()/doCount(). Writes throughBasePeerinvalidate 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/EntityStatefor coordinated multi-object
flush ordering with pre/post-flush events, plus asuppressAutoCascade
escape hatch soBaseObject::save()'s FK-cascade logic defers to a
UnitOfWork-driven save order instead of double-saving. OptimisticLockBehavior, raisingConcurrencyExceptionon 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 apsr/event-dispatcher
dependency. - Real SAVEPOINT-based nested transactions on pgsql, mysql, and sqlite,
replacing the depth-counter emulation where an innerrollBack()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 legacyPHP_ARRAY/OBJECTserialize()storage. Native on
Postgres (author's choice of JSON or JSONB) and MySQL, text fallback
elsewhere; hydration usesJSON_THROW_ON_ERRORand 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\NumberforDECIMAL/NUMERIC,INTERVAL→DateInterval,
Postgres network types (inet/cidr/macaddr) andcitext, Postgres range
types (via a newPropulsion\Type\Range), pgvector/MariaDBVECTOR, a
GEOMETRYtype (deliberately WKT-text-emulated everywhere), and a native
MySQLSETtype.
Platform DDL parity
- PostgreSQL:
GENERATED ... AS IDENTITY(identity="true"), opt-in native
array columns (nativeArray="true"+ aPropulsion\Type\PgArraycodec; the
emulated" | "format remains the default), full-text search (TSVECTOR,
tsvectorFrom-generated STORED columns, GIN/GiST viaIndex::indexType),
partial and expression indexes, index storage parameters,INCLUDEcovering
columns,CONCURRENTLY,EXCLUDE USING gistexclusion constraints, and
classic table inheritance (inheritsFrom). PostgreSQL 16 documented as the
minimum. - MySQL/MariaDB: native
SET,unsigned/zerofillattributes,
FULLTEXT/SPATIAL indexes, generated/virtual columns, opt-in MariaDB-only
nativeCREATE 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_DOUBLEforREAL/DOUBLE. Floor raised to 12c+. - Generated-id retrieval folded into the INSERT on every platform via
RETURNING/OUTPUT(RETURNING ... INTOfor Oracle's OUT-bind shape), plus
UPDATE/DELETE ... RETURNINGfor affected-row hydration everywhere except
Oracle. MariaDB is detected at runtime (DBMySQL::isMariaDb()) to gate its
RETURNINGsupport.
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::$sessionand 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 thatruntime/Lib/legacy-class-map.phpdrives. 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 anamespace, 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 forcatch,instanceof,is_a()or a
parameter type check, so the failure is an unmatched catch or a false
instanceofrather 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
dedicatedintegration-mysql/integration-mssql/integration-oraclejobs
alongside the Postgres job, plus aPROPULSION_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. Includesglobal_query_cache_bench.php, which measures
genuine cross-request cache hits (it callsSession::reset()before each
iteration) per driver, with areset()-only control column.- Two CI gaps closed. New
analysejob, so the project's PHPStan level 9
requirement is enforced mechanically rather than by discipline; and a new
workerjob runningcomposer 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 withapc.enable_cli=1,
without whichApcuCacheTestwould silently skip and report green. - Worker harness extended with four global-query-cache profiles
(array,arraycross-thread,apcucross-thread,filecross-thread) and
a two-OS-process filesystem-cache test driven byproc_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.mdtrimmed
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 atWORKER_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 isWORKER_READY_TIMEOUT(default 60s).
Changed
-
PropulsionPDOis 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
andGenericPropulsionPDOextend plain\PDO. Shared connection behavior
(savepoints, query counting, logging, statement caching) moved into
PropulsionPDOTrait. Existing?PropulsionPDO $contype hints keep working;
code that instantiated or extended the concrete class does not. -
withQuery()split intowithQuery()/withTypedQuery(), fixing the
broken generic contract ofuseQuery()/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
throwingrequire*()accessors forTable/Column/Domainattach
invariants,XMLElementtyped-attribute helpers, and replacing
call_user_funcPeer 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 nativeOFFSET/FETCH, removing a
~150-lineROW_NUMBER()regex rewriter;getDropTableDDL()'s catalog
queries modernized fromsysobjects/sysreferencesto
sys.foreign_keys/sys.tables. Oracle'sapplyLimit()likewise replaced
itsROWNUMdouble-nested subquery withOFFSET ... 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()— whatBasePeerbuilds the outerORDER BY
from — rather than scanning the generated SQL, so anORDER BYbelonging to
a nested subquery, CTE or set-operation branch cannot be mistaken for the outer
query's own. -
PropulsionTypesandPropulsionColumnTypes, which had drifted apart,
deduplicated into one map (fixing two real PDO-type mismatches). -
getPhp84TypeHint()/getPhp84PropertyType()renamed togetPhp85*. -
Flat (global-namespace) generated code now imports the runtime classes it
uses, instead of relying on the global aliases. Namespaced generated code has
always emitteduse Propulsion\Query\Criteria;and friends; flat targets
deliberately suppressed those imports and leaned on
runtime/Lib/legacy-class-map.phpto provide a globalCriteriainstead. The
reason was real:PropulsionQuickBuilderconcatenates every generated class
into one script andeval()s it, and repeating an import in a single scope is
fatal — PHP rejects even two identicalusestatements. Each generated class
is now wrapped in its own bracednamespace { … }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 onOMBuilderwith 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 onServiceContainer, notSession, 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;
theparamCountguard 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 byCompiledQueryCache::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()andDebugPDOStatement::execute()caught a dropped-connection
PDOException, calledPropulsion::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 newPropulsionPDO::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 bareerror_log()calls on that
path are gone too; they broke the documented "Propulsion never writes anywhere
implicitly" rule.
Removed
buildtime-conf.xmlsupport. Build-time connection config is
plain-PHP-array only now (a.phpfile, or the
propulsion.buildtimeConfigArraybuild property):
GeneratorConfig::parseBuildConnections(), the XML/base64-string branches of
getBuildConnections(), the extension dispatch in
loadBuildConnectionsFile(), and the stale
propulsion.buildtime.conf.filedefault 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 inQueryInheritanceBuilder::addFactory()that emitted an
untypedcreate()signature to match an already-removedPHP5QueryBuilder
— 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:pNin the same statement. -
MSSQL: nested-transaction leak, silent INSERT failure, stale LOB stream
handles aftersave()on drivers that close them, several MARS "results
pending" cursor leaks, an auto-increment PK leaking intoUPDATE SET,
aliasedUPDATE, andapplyLimit()for offset-only and structurally
unrewritable queries (UNION etc.). -
Criteria::clear()now resets everything it claims to. It could null out
dbName, andprimaryTableName, the query comment, thesetQueryCache()
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 anxxh128digest of the datasource, the formatter, the full
SQL and the serialized parameters — hashed rather than concatenated verbatim,
which for a joined query with a sizeableIN (...)list was several kilobytes
held live as an array key and rehashed on every lookup. AndFORMAT_STATEMENT/
FORMAT_ON_DEMANDresults were cached despite being tied to a live statement,
so a second hit handed back an exhausted cursor; both formatters now bypass
caching entirely viaPropulsionFormatter::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_ocifailed 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=1makes 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 otherPROPULSION_TEST_DBvalues. -
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.
PropulsionOnDemandIteratorsuspended 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 nestedforeachover 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 aPropulsionstatic also meant an iterator that never resumed
left pooling disabled for every subsequent request that worker process served.
The transient case is nowSession::$instancePoolingSuspendCount— a counter,
so suspensions nest and pooling resumes only when the last scope does, and
request-scoped, soSession::reset()clears a leaked suspension. The explicit
Propulsion::disableInstancePooling()switch deliberately stays process-scoped
and survivesreset(): turning pooling off is deployment configuration, not
request state. -
Every iterated
PropulsionCollectionleaked until PHP's cycle collector
ran.getIterator()memoised the iterator it handed out, so that
getPosition()/getNext()/isLast()called from inside aforeachdescribe
that loop — butArrayObject'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 aWeakReference— a runningforeachholds
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::$memoholds one token per table
while$pendingis per connection, so when two connections write the same table
inside their own transactions the secondoverrideLocally()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()andgetLastExecutedQuery()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, andgetLastExecutedQuery()could hand back a statement
issued while serving someone else's request.Session::reset()now zeroes them
on every open connection via the newPropulsionPDO::resetDebugCounters(),
which leaves the debug mode untouched (unlikeuseDebug(false), which clears
the same two fields as part of switching mode). Note this adds a method to the
PropulsionPDOinterface: 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(whatTABLE_NAMEis for a table with aschema
attribute) was looked up ascontestand 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 theif (!$dbMap->hasTable(...))
guard in every generatedbuildTableMap()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 ofcontest.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 asetModelAlias('b', true)query depended on"b"while
every write path bumps"book". Only the right side of each join
contributed, soBookQuery::create()->join('Book.Author')depended onauthor
but not onbook. And a plain "select every row" query — no WHERE, no join —
recorded nothing at all, because the table appears only in the select columns
andsetPrimaryTableName()is called byconfigureSelectColumns()and
count()but never byfind()/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 andwithColumn()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 withdoInsert()/doUpdate()/doDeleteAll()/doUpsert(). -
MSSQL's
TOPrewrite silently stripped an aggregate's ownDISTINCT. The
leadingDISTINCThas to be removed from the select list because it is
re-emitted as part of theSELECT DISTINCT TOP nprefix, but the unanchored
str_ireplace('distinct ', '')removed every occurrence, so
SELECT DISTINCT a, COUNT(DISTINCT b) FROM tbecame
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
generatedsave()/delete()andUnitOfWork::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, whichPropulsionExceptioncannot carry as a$previous), and the
rollback's own failure is logged viaPropulsion::log()at warning level. -
A failed
commit()left the transaction depth counter stuck.
PropulsionPDOTrait::commit()decremented$nestedTransactionCountonly on
success, so aPDOExceptionout ofparent::commit()or out of the nested
RELEASE SAVEPOINTleft 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 afinally. TheisUncommitableguard 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(), orSession::rollBackDanglingTransactions()at the request
boundary, find and unwind it. -
A transaction could be left open when a non-
PropulsionExceptionescaped.
UnitOfWork::flush()andModelCriteria::delete()/deleteAll()/update()
each opened a transaction but caught onlyPropulsionExceptionfor the
rollback. A PSR-14 listener's own exception, aTypeError, or a raw
PDOExceptionfromcommit()propagated out with the transaction still open;
on PostgreSQL that poisons the connection for whatever reuses it next
("current transaction is aborted" until an explicitROLLBACK). All four now
catch\Throwable, roll back, and rethrow the original throwable unchanged. -
Rendering a criterion no longer rewrites it.
Criterion::appendLikeToPs()
assignedCriteria::ILIKEback onto$this->comparisonon 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
Criteriarendered for a Postgres datasource and then for a MySQL one carried
ILIKEonto 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 byCriteria::equals()and
addJoinObject()'s dedupe — reporting what the caller actually asked for. -
Cloning a
Criterianow deep-copies every nested query. Subqueries
(addSelectQuery()), set-operation branches (union()and friends), CTE
queries (withCte()) and theCriteria-valued$valuethat
addExistsQuery()/addInQuery()store on aCriterionwere all shallow-copied,
so a clone shared them with the original — andisKeepQuery()defaults to true,
meaning everyfind()/count()/update()clones precisely to avoid mutating
the caller's object, while SQL generation does write to aCriteriait renders.
A non-Criteriacriterion value is still copied as-is. -
A failed
prepare()is no longer cached. With
PROPEL_ATTR_CACHE_PREPARESenabled,prepare()stored whatever
PDO::prepare()returned — includingfalse, which it returns instead of
throwing underERRMODE_SILENT/ERRMODE_WARNING— andisset()reports a hit
for a storedfalse(it is only false fornull). 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
foreachas 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 afinally, which also covers
a consumer thatbreaks 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 writesi:0with no warning at
all, so nothing reached the surroundingcatch. 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 by0. Every row is now checked. -
PropulsionPDO::getLastExecutedQuery()could raise aTypeError. Declared
: string, but$lastExecutedQueryis 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 stringdocblock is why static analysis never caught it.) -
FileCache::prune()now collects orphaned.tmpfiles.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
ColumnExpressionupdate with more than one?now throws instead
of allocating placeholders nothing binds to and silently shifting every
subsequent:pNonto the wrong value. Only one bound value can be supplied
per column.
Security
- The
filecache driver deserializes withallowed_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
SharedQueryCacheis 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× oncount(),
~181× on rawdoSelect(). -
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); thefiledriver'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 — butSharedQueryCache::admit()
rejects the first execution of every query (min_sightingsdefaults 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.QueryResultCachehad 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 atQueryResultCache::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 twoclass_exists()probes, and
possibly instantiating aTableMap— 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 onserialize()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 loosein_array()comparison made them. -
ModelCriteria::doUpdate()with$forceIndividualSavesbuilt a
ReflectionMethodper column per row.callSetByName()ranmethod_exists()
and constructed a freshReflectionMethodon 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_PREPARESon,$preparedStatementsgrew
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 (anIN (...)criterion produces one placeholder arity per distinct
list length). It now caps atMAX_CACHED_PREPARED_STATEMENTS(256) and evicts
oldest-first. The cache key also moved frommd5()toxxh128: 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.