A lightweight, cost-based query optimizer for SharpCoreDB with logical/physical plan separation, predicate pushdown, join reordering, and subquery elimination.
Key Characteristics:
- ✅ Cost-based optimization
- ✅ Struct-based plan nodes (zero allocation)
- ✅ No LINQ, no async
- ✅ Seamless QueryCache integration
- ✅ Reusable physical plans
Parse AST → Logical Plan → Optimize → Physical Plan → Execute
(5 phases)
Phase 1: Logical Planning
- Convert AST to logical operators (Scan, Join, Filter, Aggregate, Sort)
- No execution decisions yet
- Preserves query semantics
Phase 2: Predicate Pushdown
- Move filters as close to data source as possible
- Split AND-connected conditions
- Reduce intermediate row counts
Phase 3: Subquery Elimination
- Convert EXISTS to semi-join
- Convert NOT EXISTS to anti-join
- Convert IN (SELECT) to hash semi-join
- Inline scalar subqueries when beneficial
Phase 4: Join Reordering
- Estimate cost of different join orders
- Choose order minimizing intermediate results
- Small-to-large strategy
Phase 5: Physical Planning
- Coerce logical operators to physical execution steps
- Choose hash vs nested loop join
- Generate execution step sequence
QueryOptimizer.Core
├── CostEstimator (estimated costs & cardinality)
├── PredicatePushdown (filter optimization)
├── SubqueryOptimizer (EXISTS/IN/scalar elimination)
├── JoinReorderer (join order optimization)
└── QueryOptimizerExtensions (cache integration)
Main Entry Point: QueryOptimizer.Optimize(SelectNode query)
Output: PhysicalPlan (execution steps in order)
Key Classes:
LogicalPlan: Logical operators organized by phasePhysicalPlan: Ordered execution stepsPhysicalStep: Individual execution operationLogicalOperatorhierarchy: Scan, Join, Filter, Aggregate, Sort
Example:
var optimizer = new QueryOptimizer(
costEstimator,
predicatePushdown,
subqueryOptimizer,
joinReorderer);
var physicalPlan = optimizer.Optimize(selectNode);
// physicalPlan.Steps contains:
// 1. TableScan(A)
// 2. Filter(id > 5)
// 3. HashJoin(B on A.id = B.id)
// 4. Aggregate(GROUP BY dept)
// 5. Sort(by salary DESC)
// 6. LimitOffset(10, 0)
// 7. Project(id, name, salary)Purpose: Estimates execution cost and cardinality
Cost Model:
TableScan: 1.0 × row_count
IndexScan: 1.0 + log₂(row_count)
HashJoin: left_cost + right_cost + build(smaller) + probe(larger)
NestedLoopJoin: left_cost + (right_cost × left_rows)
Filter: input_cost × selectivity (default 0.1 = 10%)
Aggregate: input_cost × 0.1 (groups ≈ 10% of input rows)
Sort: input_cost + n × log₂(n)
Cardinality Estimation:
TableScan: exact row count from statistics
Filter: estimated_rows × selectivity
Join(INNER): (left_rows × right_rows) × 0.5 / max(distinct_L, distinct_R)
Join(LEFT): left_rows
Join(FULL): left_rows + right_rows
Usage:
var estimator = new CostEstimator(tableStatistics);
var scanCost = estimator.EstimateCost(
new ScanOperator { TableName = "orders" });
Console.WriteLine($"Cost: {scanCost.Cost}, Rows: {scanCost.OutputRows}");
// Output: Cost: 10000.0, Rows: 10000Purpose: Move filters closer to data sources
Transformations:
BEFORE: AFTER:
Scan(A) → Join(B) → Filter(a.x=5) Filter(a.x=5) → Scan(A) → Join(B)
AND-Predicate Splitting:
WHERE a.x = 5 AND a.y > 10 AND b.z = 'X'
→ Push [a.x = 5, a.y > 10] to table A
→ Keep [b.z = 'X'] for join filter
Benefits:
- Reduces rows processed by joins
- Fewer comparisons in nested loops
- Better memory efficiency
Example:
var pushdown = new PredicatePushdown();
logicalPlan = pushdown.PushdownPredicates(logicalPlan);
// Original: Scan(orders) → Filter(amount > 1000 AND status = 'shipped')
// Optimized: Filter(amount > 1000 AND status = 'shipped') → Scan(orders)Purpose: Convert subqueries to joins (better optimization)
Conversions:
EXISTS → Semi-Join:
WHERE EXISTS (SELECT 1 FROM B WHERE B.id = A.id)
→ INNER JOIN B ON B.id = A.id (with DISTINCT)NOT EXISTS → Anti-Join:
WHERE NOT EXISTS (SELECT 1 FROM B WHERE B.id = A.id)
→ LEFT JOIN B ON B.id = A.id WHERE B.id IS NULLIN (SELECT) → Hash Semi-Join:
WHERE A.id IN (SELECT B.id FROM B WHERE B.active = 1)
→ INNER JOIN B ON A.id = B.id WHERE B.active = 1 (DISTINCT on A.id)Scalar Subquery Elimination:
// Scalar subqueries detected by SubqueryClassifier
// Non-correlated: Cached by SubqueryCache (see Execution module)
// Correlated: Can be inlined into join filtersExample:
var subqueryOpt = new SubqueryOptimizer();
logicalPlan = subqueryOpt.EliminateSubqueries(logicalPlan);
// BEFORE: Scan(orders) → Filter(EXISTS subquery)
// AFTER: Scan(orders) → SemiJoin(customers, on order.cust_id = customer.id)Purpose: Reorder joins to minimize intermediate result size
Strategy: Small-to-large ordering
Query: A ⋈ B ⋈ C
Stats: |A| = 10M, |B| = 1K, |C| = 5M
Bad Order: (A ⋈ B) ⋈ C = (10M × 1K) ⋈ C = 10B rows ⋈ 5M (huge!)
Good Order: (B ⋈ A) ⋈ C = (1K × 10M) ⋈ C = 10M rows ⋈ 5M (manageable)
Cost Estimation:
- Tries different orderings
- Estimates total cost including intermediate results
- Chooses minimum cost order
Limitations:
- Current: Greedy ordering (fast)
- Future: Full Selinger DP (optimal but slower)
Example:
var reorderer = new JoinReorderer();
logicalPlan = reorderer.ReorderJoins(logicalPlan, costEstimator);
// BEFORE: orders ⋈ customers ⋈ products
// AFTER: customers ⋈ orders ⋈ products (customers likely smallest)Purpose: Integrate optimizer with QueryCache
Caching Strategy:
QueryCache OptimizerCache
┌──────────────┐ ┌──────────────┐
│ Parsed AST │ │ Physical Plan │
│ + Parts │ │ + Cost est. │
└──────────────┘ └──────────────┘
↑ ↑
└──── Same key ──────┘
Cache Invalidation:
- On table statistics change
- On INSERT/UPDATE/DELETE (table modifications)
- Manual clear via
InvalidateOptimizationCache()
Integration Points:
// In QueryCache
public void GetOrAdd(string sql, SelectNode query)
{
// Get parsed AST from cache (existing)
var parsed = cache.GetParsed(sql);
// Get optimized plan from optimizer cache (new)
var physical = OptimizerIntegration.GetOrOptimize(
sql, parsed, costEstimator, ...);
// Execute physical plan
return ExecutePhysicalPlan(physical);
}Query Component Time Complexity
─────────────────────────────────────
Logical Planning O(n) where n = operators
Predicate Pushdown O(n) where n = predicates
Subquery Elimination O(m) where m = subqueries
Join Reordering O(k! / 2) worst case, O(k) greedy where k = joins
Physical Planning O(n) where n = logical operators
Total: O(n + k! / 2) [bounded by join count k]
Typical: <1ms for 5-10 table queries
Logical Plan: O(n) nodes
Physical Plan: O(n) steps
Cost Estimates: O(k) join combinations
Total: ~KB for typical queries
Optimization Result Execution Speedup
──────────────────────────────────────────────
Predicate Pushdown 2-5x (fewer rows)
Join Reordering (bad→good order) 5-20x (less memory)
Subquery→Join Conversion 10-100x (cache reuse)
Combined 50-1000x possible
// Setup components
var costEstimator = new CostEstimator(tableStatistics);
var predicatePushdown = new PredicatePushdown();
var subqueryOptimizer = new SubqueryOptimizer();
var joinReorderer = new JoinReorderer();
// Create optimizer
var optimizer = new QueryOptimizer(
costEstimator,
predicatePushdown,
subqueryOptimizer,
joinReorderer);
// Optimize parsed AST
var physicalPlan = optimizer.Optimize(selectNode);
// Inspect plan
foreach (var step in physicalPlan.Steps)
{
Console.WriteLine($"{step.Type}: {step.TableName ?? "N/A"}");
}// First execution: optimize
var plan1 = OptimizerIntegration.GetOrOptimize(
"SELECT * FROM orders WHERE id > 100",
selectNode, costEstimator, ...);
// Second execution: retrieve from cache
var plan2 = OptimizerIntegration.GetOrOptimize(
"SELECT * FROM orders WHERE id > 100",
selectNode, costEstimator, ...);
// plan1 and plan2 are identical, cache hit!var estimator = new CostEstimator(statistics);
// Estimate scan cost
var scanOp = new ScanOperator { TableName = "orders" };
var scanCost = estimator.EstimateCost(scanOp);
// Estimate join cost
var joinOp = new JoinOperator
{
JoinType = JoinNode.JoinType.Inner,
Left = scanOp,
Right = new ScanOperator { TableName = "customers" }
};
var joinCost = estimator.EstimateCost(joinOp);
Console.WriteLine($"Scan: {scanCost.Cost}, Join: {joinCost.Cost}");var columns = PredicateAnalyzer.GetReferencedColumns(whereCondition);
// Returns: {"orders.id", "orders.status", "customers.country"}
var isSimple = PredicateAnalyzer.IsSimpleEquality(
condition, out var col, out var value);
// Returns: true, col = "orders.id", value = 5Physical plan maps directly to execution:
PhysicalStep(TableScan, "orders")
↓ (executed by TableScanExecutor)
Dictionary<string, object>[] rows from orders
PhysicalStep(Filter, "id > 100")
↓ (executed by FilterExecutor)
Filtered rows
PhysicalStep(HashJoin, customers)
↓ (executed by JoinExecutor)
Joined rows
... etc
// In QueryExecutor
public List<Dictionary<string, object>> Execute(PhysicalPlan plan)
{
var results = ExecuteSource(); // First step
foreach (var step in plan.Steps.Skip(1))
{
results = step.Type switch
{
PhysicalStepType.Filter => ExecuteFilter(step, results),
PhysicalStepType.HashJoin => ExecuteJoin(step, results),
PhysicalStepType.Aggregate => ExecuteAggregate(step, results),
PhysicalStepType.Sort => ExecuteSort(step, results),
... etc
};
}
return results;
}✅ YES:
- Simple equality:
a.id = 5→ push to scan - AND-connected:
a.id = 5 AND a.status = 'X'→ push both - Type-safe: Can determine column belongs to table
❌ NO:
- Functions:
UPPER(a.name) = 'JOHN'(can't push) - Multi-table:
a.id = b.id(join predicate, keep at join) - Correlated subqueries: (keep at join level)
✅ YES:
- EXISTS with simple join condition
- NOT EXISTS with simple join condition
- IN (SELECT col) with single column result
❌ NO:
- Scalar subqueries (cached instead)
- Correlated subqueries without join equality
- Subqueries with aggregates (complex)
✅ Reorder:
- Multiple independent joins
- Different table sizes (significant difference)
- No complex join conditions
❌ Don't Reorder:
- OUTER JOINs (may change semantics)
- Cross joins (already optimal)
- Single join (nothing to reorder)
- Selectivity Estimation: Use column statistics for better filter estimates
- Index-Aware Optimization: Choose IndexScan when beneficial
- Lateral Joins: PostgreSQL-style lateral join optimization
- Partition Pruning: Skip unneeded partitions
- Parallel Execution: Multi-threaded operator execution
- Query Rewriting: Algebraic equivalences (e.g., (a ⋈ b) ⋈ c ≡ a ⋈ (b ⋈ c))
- Approximate Selectivity: ML-based predicate selectivity prediction
- Plan Statistics: Track plan performance, adapt future optimizations
- HOT PATH: All hot paths (optimize, estimate cost, pushdown) use zero-allocation algorithms
- Cache-Friendly: Struct-based nodes in PhysicalPlan enable plan reuse
- Extensible: Easy to add new optimizer rules by creating new transformation classes
- Production-Ready: Handles edge cases, null checks, error conditions
- .NET 10 + C# 14: Uses latest features (init properties, required, patterns)