ORM wave: 24 defects patched across 10 ORMs (157 sites, 62 ecosystems)

Hibernate (5 HIGH): addColumn/addReferencedColumn/addIndex ArrayList→LinkedHashSet (19x)
  FK second-pass LinkedHashSet, orderHierarchy LinkedHashSet
MyBatis (1 MEDIUM): sortConstructorMappings indexOf→HashMap (12x)
EF Core (2 HIGH + 1 MEDIUM): FindGenerationProperty HashSet (250x),
  AddPrincipals HashSet (250x), FK discovery HashSet (6x)
Diesel (3 MEDIUM): SQLite/MySQL row position()→BTreeMap (51x)
SQLAlchemy (2 HIGH): _values_bindparam Set (500x), evaluated_keys Set (500x)
Peewee (1 MEDIUM): _SortedFieldList.index() bisect (42x)
Sequelize (2 HIGH): bulkInsert Set (50x), expandIncludeAll Set (250x)
TypeORM (3 HIGH): OrmUtils.uniq Map (500x), diffColumns Set (125x),
  updatedColumns Set (100x)
Doctrine ORM (1 HIGH + 2 MEDIUM): hydrator discriminator (26x),
  addSubClass (250x), SqlWalker partial (130x)
GORM (1 MEDIUM): sortCallbacks getRIndex→map (194x)
SQLite: SqliteTest unit proof 4/4 PASS (101x)

Unit tests: all PASS — Hibernate/MyBatis/EfCore/Diesel/SQLAlchemy/Peewee/
  Sequelize/TypeORM/Doctrine/GORM
Whitepaper: 157 sites, 62 ecosystems; PDF 752K
This commit is contained in:
russell@unturf.com 2026-03-27 13:34:26 -04:00
parent db2986ae44
commit d4ed2dff91
49 changed files with 4025 additions and 7 deletions

View file

@ -0,0 +1,105 @@
# doctrine-orm-0001 — AbstractHydrator: O(rows × subclasses) discriminatorValues linear scan per result row
**Status:** OPEN
**Severity:** HIGH
**Component:** `doctrine/orm``src/Doctrine/ORM/Internal/Hydration/AbstractHydrator.php`
**Affects:** All queries over inheritance hierarchies (CTI/STI) that return multiple rows
---
## Root Cause
`AbstractHydrator.php:328` — inside `gatherRowData()`, called once per DB result row:
```php
// DEFECTIVE — O(subclasses) per row, per column with inheritance field collision
if (
isset($cacheKeyInfo['discriminatorColumn'], $data[$cacheKeyInfo['discriminatorColumn']])
&& ! in_array((string) $data[$cacheKeyInfo['discriminatorColumn']], $cacheKeyInfo['discriminatorValues'], true)
) {
break;
}
```
`$cacheKeyInfo['discriminatorValues']` is a PHP array of discriminator string values, built in
`getDiscriminatorValues()` (line 541551) from `$classMetadata->subClasses[]`.
`in_array` performs a full linear scan of this array.
The outer loop (`foreach ($data as $key => $value)`) runs once per column per result row.
`gatherRowData()` is called per row in the `while` loop at `AbstractHydrator.php:114`:
```php
while (true) {
$row = $this->statement()->fetchAssociative();
...
$this->hydrateRowData($row, $result); // calls gatherRowData()
```
`ObjectHydrator.php:327` confirms this is the hot path:
```php
$rowData = $this->gatherRowData($row, $id, $nonemptyComponents);
```
The same pattern appears in `SimpleObjectHydrator.php:128`:
```php
if (isset($cacheKeyInfo['discriminatorValues']) && ! in_array((string) $discrColumnValue, $cacheKeyInfo['discriminatorValues'], true)) {
continue;
}
```
---
## Complexity Analysis
| Metric | Defective | Fixed |
|--------|-----------|-------|
| Discriminator check per row | O(subclasses) | O(1) |
| Full query (N rows, C affected columns) | O(N × C × subclasses) | O(N × C) |
| Growth pattern | Quadratic in subclasses | Linear |
For a CTI hierarchy with 50 subclasses, a 10-column SELECT returning 10,000 rows performs
50 × 10 × 10,000 = 5,000,000 comparisons for discriminator membership alone.
---
## Fix
`getDiscriminatorValues()` (line 541) builds a flat array. Change it to build a `Set`-equivalent
(`array` keyed by discriminator value for O(1) `isset` lookup):
```php
// FIXED — O(1) per row
private function getDiscriminatorValues(ClassMetadata $classMetadata): array
{
$values = [];
foreach ($classMetadata->subClasses as $subClass) {
$val = (string) $this->getClassMetadata($subClass)->discriminatorValue;
$values[$val] = true;
}
$values[(string) $classMetadata->discriminatorValue] = true;
return $values;
}
```
Then change both call sites from `in_array($val, $discriminatorValues, true)` to
`isset($discriminatorValues[$val])`.
---
## Call Sites
| File | Line | Path |
|------|------|------|
| `src/Internal/Hydration/AbstractHydrator.php` | 328 | `gatherRowData()` — ObjectHydrator hot path |
| `src/Internal/Hydration/SimpleObjectHydrator.php` | 128 | `hydrateRowData()` — SimpleObjectHydrator hot path |
---
## Speedup Estimate
For queries over CTI/STI hierarchies with many subclasses: **linear in subclass count**.
A hierarchy with S subclasses sees S× speedup on the discriminator-check portion of hydration.
Real-world inheritance hierarchies of 20100 subclasses → 20100× reduction in comparisons
on the discriminator check path.

View file

@ -0,0 +1,85 @@
# doctrine-orm-0002 — ClassMetadata::addSubClass: O(n²) subclass dedup during metadata loading
**Status:** OPEN
**Severity:** MEDIUM
**Component:** `doctrine/orm``src/Doctrine/ORM/Mapping/ClassMetadata.php`
**Affects:** Application startup / metadata cache warming; entity class hierarchies with many subclasses
---
## Root Cause
`ClassMetadata.php:2313``addSubClass()`:
```php
// DEFECTIVE — O(subclasses) linear scan per addSubClass call
public function addSubClass(string $className): void
{
if (is_subclass_of($className, $this->name) && ! in_array($className, $this->subClasses, true)) {
$this->subClasses[] = $className;
}
}
```
`$this->subClasses` is a plain PHP array (`public array $subClasses = []`, line 314).
`in_array` scans the entire array for every call.
`addSubClass()` is called inside loops in `ClassMetadataFactory`:
- `ClassMetadataFactory.php:152`: `$class->addSubClasses($parent->subClasses)` — bulk add loop
- `ClassMetadataFactory.php:386`: inside `foreach ($parentClasses as $parentClass)` loop inside
`findAbstractEntityClassesNotListedInDiscriminatorMap()`
The second call site is the worst case: for each discriminator map entry, `getParentClasses()`
returns all ancestor classes, and `addSubClass` is called for each. With S subclasses and
D depth, total comparisons: O(S × D × S) = O(S²×D).
---
## Complexity Analysis
| Metric | Defective | Fixed |
|--------|-----------|-------|
| Single addSubClass | O(current subclass count) | O(1) |
| Full hierarchy load (S subclasses) | O(S²) | O(S) |
| Growth pattern | Quadratic | Linear |
---
## Fix
Replace `$this->subClasses` with a parallel indexed structure, or use a `Set`-equivalent
(array keyed by class name):
```php
// Keep public array $subClasses for backward compat, add private set for O(1) lookup
private array $subClassSet = [];
public function addSubClass(string $className): void
{
if (is_subclass_of($className, $this->name) && ! isset($this->subClassSet[$className])) {
$this->subClassSet[$className] = true;
$this->subClasses[] = $className;
}
}
```
Alternatively, key `$subClasses` by class name directly (breaking change to indexed access).
---
## Call Sites
| File | Line | Context |
|------|------|---------|
| `src/Mapping/ClassMetadata.php` | 2313 | `addSubClass()` |
| `src/Mapping/ClassMetadataFactory.php` | 152 | bulk inherit from parent |
| `src/Mapping/ClassMetadataFactory.php` | 386 | discriminator map abstract class discovery |
---
## Speedup Estimate
Metadata loading for a hierarchy with 50 subclasses: ~50× reduction in membership check work.
Impact is startup/warm-up only (metadata is cached after first load). Applications without
metadata caching (e.g., dev mode with `auto_generate_proxy_classes`) see this on every request.

View file

@ -0,0 +1,82 @@
# doctrine-orm-0003 — SqlWalker::walkObjectExpression: O(fields²) partial field set check per query
**Status:** OPEN
**Severity:** MEDIUM
**Component:** `doctrine/orm``src/Doctrine/ORM/Query/SqlWalker.php`
**Affects:** DQL queries using partial object selects (`SELECT PARTIAL p.{id,name}`)
---
## Root Cause
`SqlWalker.php:1405` and `1445``walkObjectExpression()`:
```php
// DEFECTIVE — O(partialFieldSet) per field, inside foreach fieldMappings
foreach ($class->fieldMappings as $fieldName => $mapping) {
if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) {
continue;
}
// ... build SQL column list
}
// And for subclasses (line 1445):
foreach ($subClass->fieldMappings as $fieldName => $mapping) {
if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
continue;
}
```
`$partialFieldSet` is a plain PHP array of field name strings. For each field in
`$class->fieldMappings` (F fields), `in_array` scans all P entries in `$partialFieldSet`.
Total: O(F × P) per class, O(F × P × subclasses) for STI/CTI hierarchies.
Called once per query compilation (not per result row), but DQL parsing without a query
result cache repeats this on every request.
---
## Complexity Analysis
| Metric | Defective | Fixed |
|--------|-----------|-------|
| Single field check | O(partialFieldSet size P) | O(1) |
| Full entity (F fields) | O(F × P) | O(F) |
| With S subclasses (STI) | O(F × P × S) | O(F × S) |
---
## Fix
Convert `$partialFieldSet` array to a hash set before entering the loop:
```php
// FIXED — build O(1)-lookup set once, before the loops
$partialFieldIndex = $partialFieldSet ? array_flip($partialFieldSet) : null;
foreach ($class->fieldMappings as $fieldName => $mapping) {
if ($partialFieldIndex !== null && ! isset($partialFieldIndex[$fieldName])) {
continue;
}
// ...
}
```
Same fix applies at line 1445 for the subclass loop.
---
## Call Sites
| File | Line | Context |
|------|------|---------|
| `src/Query/SqlWalker.php` | 1405 | `walkObjectExpression()` — primary class fields |
| `src/Query/SqlWalker.php` | 1445 | `walkObjectExpression()` — STI/CTI subclass fields |
---
## Speedup Estimate
Queries with large partial field sets (P > 10) or large entities (F > 20) with deep STI
hierarchies (S > 10): O(P)× improvement in query compilation time on the partial-select
field filter path.

View file

@ -0,0 +1,93 @@
# gorm-0001 — sortCallbacks: O(n²) getRIndex linear scans during callback registration
**Status:** OPEN
**Severity:** MEDIUM
**Component:** `go-gorm/gorm``callbacks.go`
**Affects:** Application startup; every call to `Register()`, `Remove()`, or `Replace()`
---
## Root Cause
`callbacks.go:252``getRIndex()`:
```go
// DEFECTIVE — O(n) linear scan of string slice
func getRIndex(strs []string, str string) int {
for i := len(strs) - 1; i >= 0; i-- {
if strs[i] == str {
return i
}
}
return -1
}
```
`getRIndex` is called 13 times inside `sortCallbacks()` (lines 278, 287, 291, 297, 305, 309,
315, 335, 349) against `names []string` and `sorted []string`, both of length equal to the
total callback count N.
`sortCallbacks()` is called on every `Register()`, `Remove()`, and `Replace()` invocation
(`callbacks.go:231`, `239`, `248`). Each call re-sorts the full callback list from scratch.
For N callbacks, each registration triggers O(N) `getRIndex` calls, each O(N) → O(N²) total
per registration, O(N³) across all N registrations (amortized O(N²) for the full startup
sequence).
GORM registers ~26 default callbacks at init time across 6 processors (create, query, update,
delete, row, raw).
---
## Complexity Analysis
| Metric | Defective | Fixed |
|--------|-----------|-------|
| `getRIndex(names, name)` | O(N) | O(1) with `map[string]int` |
| `getRIndex(sorted, name)` | O(N) | O(1) with `map[string]int` |
| Full `sortCallbacks(N)` | O(N²) | O(N) |
| All N registrations combined | O(N³) | O(N²) |
For N=26 default callbacks: ~8,788 comparisons vs ~676 with map-based index. The absolute
count is small, but each additional `Register()` call (plugin registration, per-test setup)
re-runs the full O(N²) sort. Long-lived GORM applications with many plugins accumulate
registration cost.
---
## Fix
Replace `names []string` and `sorted []string` with `map[string]int` for O(1) index lookup:
```go
// FIXED — use maps for O(1) index lookup
func sortCallbacks(cs []*callback) (fns []func(*DB), err error) {
namesMap := map[string]int{} // name -> last index in cs
sortedMap := map[string]int{} // name -> index in sorted
sorted := []string{}
// ...
// Replace: getRIndex(names, c.name) -> namesMap[c.name] (+ existence check)
// Replace: getRIndex(sorted, c.name) -> sortedMap[c.name] (+ existence check)
}
```
The `getRIndex` function returns the *right-most* index (for replace/remove semantics).
The map can track the most-recently-appended index; update on each `names = append(names, c.name)`.
---
## Call Sites
| File | Lines | Context |
|------|-------|---------|
| `callbacks.go` | 252258 | `getRIndex` definition |
| `callbacks.go` | 276349 | `sortCallbacks` — outer loop + inner `sortCallback` |
| `callbacks.go` | 231, 239, 248 | `Register`, `Remove`, `Replace` — trigger on every call |
---
## Speedup Estimate
For N=26 default callbacks: modest absolute savings (startup only). For applications with
many plugin registrations or test suites that reinitialize GORM (recreate DB with callbacks):
O(N)× improvement per `sortCallbacks` invocation. The primary value is eliminating quadratic
scaling as N grows with plugin count.