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

2.5 KiB
Raw Permalink Blame History

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.

// 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).