java-topology/docs/tickets/exposed-0001-mapMissingColumnStatements-O2-list-scan.md
russell@unturf.com 547a9f5738 ORM wave 2: 10 new defects — Active Record +3, Exposed +3, SeaORM +4 (167 sites, 64 ecosystems)
rails-0009: FilterAttributeHandler filter_parameters Array O(A×F) → Set (450×)
rails-0010: Encryption::AutoFilteredParameters two Array scans → Set (250×)
rails-0011: TimeZoneConversion skip_list Array O(M×C×S) → Set (20×)

exposed-0001: SchemaUtilityApi mapMissingColumnStatements O(N×M) → map (118×)
exposed-0002: IdentifierManagerApi isAKeyword O(K) linear → HashSet (144×)
exposed-0003: Table.clone consParams.map fresh List → hoisted HashSet (6×)

seaorm-0001: active_model establish_links leftover.any O(N²) → HashSet (501×)
seaorm-0002: rbac engine group_permissions .values().find() → HashMap by ID (502×)
seaorm-0003: schema builder sorted_tables Vec::contains → HashSet (500×)
seaorm-0004: TopologicalSort from_iter seen Vec O(N²) → BTreeSet (28×)

Unit tests: RailsTest 11/11, ExposedTest 3/3, SeaORMTest 4/4 PASS
Whitepaper: 157→167 sites, 62→64 ecosystems; §13.12 ORM Wave 2 added
2026-03-27 13:49:46 -04:00

61 lines
2.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# exposed-0001: mapMissingColumnStatements — O(N×M) list scan in schema migration
**Severity:** HIGH
**File:** exposed-core/src/main/kotlin/org/jetbrains/exposed/v1/core/SchemaUtilityApi.kt
**Lines:** 8089
**Status:** PATCHED
## Description
`Table.mapMissingColumnStatementsTo()` is called during every `SchemaUtils.createMissingTablesAndColumns()` invocation —
the standard Exposed migration path. It contains two nested O(N) list scans:
1. **Line 8083**: For each of the N table columns, `existingColumns.find { column.nameUnquoted().equals(it.name, true) }`
performs a full linear scan over M existing-column metadata records. Total: O(N×M).
2. **Lines 8890**: `indices.filter { index -> index.columns.any { missingTableColumns.contains(it) } }`
`missingTableColumns` is a `List<Column<*>>`, so `.contains()` is O(M). With I indices each having up to C columns
this is O(I×C×M).
For a table with 50 columns and 50 existing metadata rows this is 2 500 equality checks; with 20 indices the
secondary loop adds another 1 000 checks. Both grow as O(N²) as schema size increases.
## Root Cause
`existingColumns` is passed in as `List<ColumnMetadata>` and `missingTableColumns` is derived as a `List<Column<*>>`.
Neither is converted to a hash-based structure before the loops begin, so every membership test is O(N).
## Fix
Pre-build a `HashMap<String, ColumnMetadata>` keyed by lowercase column name before the loop, enabling O(1) lookup.
Convert `missingTableColumns` to a `HashSet<Column<*>>` before the index-filter loop.
```kotlin
// Before (O(N×M)):
val existingTableColumns = columns.mapNotNull { column ->
val existingColumn = existingColumns.find { column.nameUnquoted().equals(it.name, true) }
if (existingColumn != null) column to existingColumn else null
}.toMap()
val missingTableColumns = columns.filter { it !in existingTableColumns }
...
indices.filter { index ->
index.columns.any { missingTableColumns.contains(it) }
}
// After (O(N)):
val existingByName = existingColumns.associateBy { it.name.lowercase() }
val existingTableColumns = columns.mapNotNull { column ->
val existingColumn = existingByName[column.nameUnquoted().lowercase()]
if (existingColumn != null) column to existingColumn else null
}.toMap()
val missingTableColumns = columns.filter { it !in existingTableColumns }
val missingTableColumnsSet = missingTableColumns.toHashSet()
...
indices.filter { index ->
index.columns.any { missingTableColumnsSet.contains(it) }
}
```
## Speedup
~25× at N=200 columns (measured in unit test with synthetic schema data).