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
This commit is contained in:
russell@unturf.com 2026-03-27 13:49:46 -04:00
parent d4ed2dff91
commit 547a9f5738
37 changed files with 2043 additions and 26 deletions

View file

@ -0,0 +1,298 @@
package unit;
import java.util.*;
/**
* Unit test for Exposed ORM CWE-407 defects.
*
* exposed-0001: mapMissingColumnStatements — O(N×M) list scan during schema migration
* SchemaUtilityApi.kt:80-89 — find{} over existingColumns per column, contains() over List per index-column
*
* exposed-0002: isAKeyword — O(K) linear keyword scan per identifier (cache-miss path)
* IdentifierManagerApi.kt:72 — keywords.any { equals(it, true) } over ~500-entry list
*
* exposed-0003: Table.clone — O(P×C) repeated List allocation in property filter
* Table.kt:1686 — consParams.map(KParameter::name) allocated fresh each filter predicate call
*/
public class ExposedTest {
// -------------------------------------------------------------------------
// exposed-0001: schema migration column lookup
// -------------------------------------------------------------------------
/** Simulate a column name (just a String for this model). */
static String[] makeColumns(int n) {
String[] cols = new String[n];
for (int i = 0; i < n; i++) cols[i] = "column_" + i;
return cols;
}
/** Simulate existing DB column metadata (same schema, all present). */
static String[] makeExistingColumns(int n) {
String[] existing = new String[n];
for (int i = 0; i < n; i++) existing[i] = "column_" + i;
return existing;
}
/**
* Slow path: for each column, do a linear scan over existingColumns to find it (as Exposed does).
* Returns ops count.
*/
static long schemaMigrationSlow(String[] columns, String[] existingColumns) {
long ops = 0;
// Step 1: find existing columns — O(N*M)
Map<String, String> existingTableColumns = new HashMap<>();
for (String col : columns) {
for (String existing : existingColumns) {
ops++;
if (col.equalsIgnoreCase(existing)) {
existingTableColumns.put(col, existing);
break;
}
}
}
// Step 2: compute missing columns
List<String> missingTableColumns = new ArrayList<>();
for (String col : columns) {
if (!existingTableColumns.containsKey(col)) missingTableColumns.add(col);
}
// Step 3: for each index (simulate 20 indices with 3 cols each), check if any col is missing — O(I*C*M)
int numIndices = 20;
int colsPerIndex = 3;
for (int i = 0; i < numIndices; i++) {
for (int c = 0; c < colsPerIndex; c++) {
String indexCol = columns[(i * colsPerIndex + c) % columns.length];
for (String missing : missingTableColumns) {
ops++;
if (indexCol.equals(missing)) break;
}
}
}
return ops;
}
/**
* Fast path: pre-build HashMap for O(1) lookup (the CWE-407 fix).
* Returns ops count.
*/
static long schemaMigrationFast(String[] columns, String[] existingColumns) {
long ops = 0;
// Step 1: build O(1) lookup map — O(M) once
Map<String, String> existingByName = new HashMap<>();
for (String existing : existingColumns) {
ops++;
existingByName.put(existing.toLowerCase(), existing);
}
Map<String, String> existingTableColumns = new HashMap<>();
for (String col : columns) {
ops++;
String found = existingByName.get(col.toLowerCase());
if (found != null) existingTableColumns.put(col, found);
}
// Step 2: compute missing columns
List<String> missingTableColumns = new ArrayList<>();
for (String col : columns) {
if (!existingTableColumns.containsKey(col)) missingTableColumns.add(col);
}
// Step 3: use HashSet for O(1) missing-column membership — O(I*C)
Set<String> missingSet = new HashSet<>(missingTableColumns);
int numIndices = 20;
int colsPerIndex = 3;
for (int i = 0; i < numIndices; i++) {
for (int c = 0; c < colsPerIndex; c++) {
String indexCol = columns[(i * colsPerIndex + c) % columns.length];
ops++;
missingSet.contains(indexCol);
}
}
return ops;
}
// -------------------------------------------------------------------------
// exposed-0002: keyword lookup
// -------------------------------------------------------------------------
static List<String> makeKeywordList(int k) {
List<String> kw = new ArrayList<>(k);
for (int i = 0; i < k; i++) kw.add("KEYWORD_" + i);
// Add a few real SQL keywords at the end to ensure misses go all the way
kw.add("SELECT"); kw.add("FROM"); kw.add("WHERE"); kw.add("TABLE");
return kw;
}
/**
* Slow: linear scan with case-insensitive compare (as Exposed does via .any { equals(it, true) }).
*/
static long keywordLookupSlow(List<String> keywords, String[] identifiers) {
long ops = 0;
Map<String, Boolean> cache = new HashMap<>();
for (String id : identifiers) {
String lower = id.toLowerCase();
if (!cache.containsKey(lower)) {
boolean found = false;
for (String kw : keywords) {
ops++;
if (lower.equalsIgnoreCase(kw)) { found = true; break; }
}
cache.put(lower, found);
}
}
return ops;
}
/**
* Fast: pre-built lowercase HashSet, O(1) lookup per cache miss.
*/
static long keywordLookupFast(List<String> keywords, String[] identifiers) {
long ops = 0;
// Pre-build lowercased set — O(K) once
Set<String> keywordsLower = new HashSet<>();
for (String kw : keywords) { ops++; keywordsLower.add(kw.toLowerCase()); }
Map<String, Boolean> cache = new HashMap<>();
for (String id : identifiers) {
String lower = id.toLowerCase();
if (!cache.containsKey(lower)) {
ops++;
cache.put(lower, keywordsLower.contains(lower));
}
}
return ops;
}
// -------------------------------------------------------------------------
// exposed-0003: clone() constructor parameter name lookup
// -------------------------------------------------------------------------
static String[] makePropertyNames(int p) {
String[] props = new String[p];
for (int i = 0; i < p; i++) props[i] = "prop_" + i;
return props;
}
static String[] makeConsParamNames(int c) {
String[] params = new String[c];
for (int i = 0; i < c; i++) params[i] = "prop_" + i; // first C props are constructor params
return params;
}
/**
* Slow: for each property, call consParams.map(name) fresh — allocates a new List per property.
* Simulates the Kotlin lambda `it.name in consParams.map(KParameter::name)`.
* Op count = P * C (each property scans all C param names via List.contains).
*/
static long cloneFilterSlow(String[] memberProperties, String[] consParamNames, int cloneCalls) {
long ops = 0;
Set<String> mutableProps = new HashSet<>(Arrays.asList(memberProperties)); // all are mutable in sim
for (int call = 0; call < cloneCalls; call++) {
for (String prop : memberProperties) {
// Simulate: consParams.map(KParameter::name) — creates a new List, then List.contains = O(C)
List<String> paramNameList = new ArrayList<>(Arrays.asList(consParamNames));
for (String pn : paramNameList) {
ops++;
if (pn.equals(prop)) break;
}
boolean inMutable = mutableProps.contains(prop);
@SuppressWarnings("unused")
boolean keep = inMutable;
}
}
return ops;
}
/**
* Fast: pre-compute consParamNames as HashSet once before the filter loop.
* Op count = C (build set once) + P (O(1) lookups).
*/
static long cloneFilterFast(String[] memberProperties, String[] consParamNames, int cloneCalls) {
long ops = 0;
Set<String> mutableProps = new HashSet<>(Arrays.asList(memberProperties));
for (int call = 0; call < cloneCalls; call++) {
// Pre-compute once per clone() call — O(C)
Set<String> paramNameSet = new HashSet<>();
for (String pn : consParamNames) { ops++; paramNameSet.add(pn); }
for (String prop : memberProperties) {
ops++;
boolean inParams = paramNameSet.contains(prop);
boolean inMutable = mutableProps.contains(prop);
@SuppressWarnings("unused")
boolean keep = inMutable || inParams;
}
}
return ops;
}
// -------------------------------------------------------------------------
// Harness
// -------------------------------------------------------------------------
static void bench(String label, Runnable slow, Runnable fast, long sOps, long fOps) {
slow.run(); fast.run(); // warmup
long t0 = System.nanoTime(); slow.run(); long sMs = (System.nanoTime() - t0) / 1_000_000;
long t1 = System.nanoTime(); fast.run(); long fMs = (System.nanoTime() - t1) / 1_000_000;
double r = fOps > 0 ? (double) sOps / fOps : 0;
System.out.printf(" %-52s slow:%4dms (%,d ops) fast:%4dms (%,d ops) speedup:%.0fx%n",
label, sMs, sOps, fMs, fOps, r);
}
public static void main(String[] args) {
System.out.println("=== UNIT exposed-0001..: Exposed ORM CWE-407 ===");
// exposed-0001: schema migration — N=500 columns, M=500 existing
final int N0 = 500;
final String[] cols0 = makeColumns(N0);
final String[] existing0 = makeExistingColumns(N0);
final long[] ops0 = new long[2];
ops0[0] = schemaMigrationSlow(cols0, existing0);
ops0[1] = schemaMigrationFast(cols0, existing0);
bench(
"exposed-0001 schemaMigration N=500cols/M=500existing",
() -> schemaMigrationSlow(cols0, existing0),
() -> schemaMigrationFast(cols0, existing0),
ops0[0], ops0[1]
);
// exposed-0002: keyword lookup — K=504 keywords, 2000 identifiers
final int K = 504;
final int I = 2000;
final List<String> keywords = makeKeywordList(K);
final String[] identifiers = new String[I];
for (int i = 0; i < I; i++) identifiers[i] = "col_" + (i % 200); // 200 distinct
final long[] ops2 = new long[2];
ops2[0] = keywordLookupSlow(keywords, identifiers);
ops2[1] = keywordLookupFast(keywords, identifiers);
bench(
"exposed-0002 keywordLookup K=504 I=2000",
() -> keywordLookupSlow(keywords, identifiers),
() -> keywordLookupFast(keywords, identifiers),
ops2[0], ops2[1]
);
// exposed-0003: clone filter — P=20 properties, C=15 constructor params, 5000 clone calls
final int P = 20, C = 15, CALLS = 5000;
final String[] memberProps = makePropertyNames(P);
final String[] consParams3 = makeConsParamNames(C);
final long[] ops3 = new long[2];
ops3[0] = cloneFilterSlow(memberProps, consParams3, CALLS);
ops3[1] = cloneFilterFast(memberProps, consParams3, CALLS);
bench(
"exposed-0003 cloneFilter P=20 C=15 calls=5000",
() -> cloneFilterSlow(memberProps, consParams3, CALLS),
() -> cloneFilterFast(memberProps, consParams3, CALLS),
ops3[0], ops3[1]
);
// Assertions
int pass = 0;
long s0 = ops0[0], f0 = ops0[1];
long s2 = ops2[0], f2 = ops2[1];
long s3 = ops3[0], f3 = ops3[1];
assert s0 > f0 * 5 : "exposed-0001 expected >5x ops reduction; slow=" + s0 + " fast=" + f0;
pass++;
assert s2 > f2 * 5 : "exposed-0002 expected >5x ops reduction; slow=" + s2 + " fast=" + f2;
pass++;
assert s3 > f3 * 2 : "exposed-0003 expected >2x ops reduction; slow=" + s3 + " fast=" + f3;
pass++;
System.out.printf("%d/3 PASS%n", pass);
}
}