Files
smartmongo/ts/tsmdb/engine/QueryPlanner.ts

394 lines
11 KiB
TypeScript
Raw Normal View History

import * as plugins from '../tsmdb.plugins.js';
import type { Document, IStoredDocument } from '../types/interfaces.js';
import { IndexEngine } from './IndexEngine.js';
/**
* Query execution plan types
*/
export type TQueryPlanType = 'IXSCAN' | 'COLLSCAN' | 'FETCH' | 'IXSCAN_RANGE';
/**
* Represents a query execution plan
*/
export interface IQueryPlan {
/** The type of scan used */
type: TQueryPlanType;
/** Index name if using an index */
indexName?: string;
/** Index key specification */
indexKey?: Record<string, 1 | -1 | string>;
/** Whether the query can be fully satisfied by the index */
indexCovering: boolean;
/** Estimated selectivity (0-1, lower is more selective) */
selectivity: number;
/** Whether range operators are used */
usesRange: boolean;
/** Fields used from the index */
indexFieldsUsed: string[];
/** Filter conditions that must be applied post-index lookup */
residualFilter?: Document;
/** Explanation for debugging */
explanation: string;
}
/**
* Filter operator analysis
*/
interface IFilterOperatorInfo {
field: string;
operators: string[];
equality: boolean;
range: boolean;
in: boolean;
exists: boolean;
regex: boolean;
values: Record<string, any>;
}
/**
* QueryPlanner - Analyzes queries and selects optimal execution plans
*/
export class QueryPlanner {
private indexEngine: IndexEngine;
constructor(indexEngine: IndexEngine) {
this.indexEngine = indexEngine;
}
/**
* Generate an execution plan for a query filter
*/
async plan(filter: Document): Promise<IQueryPlan> {
await this.indexEngine['initialize']();
// Empty filter = full collection scan
if (!filter || Object.keys(filter).length === 0) {
return {
type: 'COLLSCAN',
indexCovering: false,
selectivity: 1.0,
usesRange: false,
indexFieldsUsed: [],
explanation: 'No filter specified, full collection scan required',
};
}
// Analyze the filter
const operatorInfo = this.analyzeFilter(filter);
// Get available indexes
const indexes = await this.indexEngine.listIndexes();
// Score each index
let bestPlan: IQueryPlan | null = null;
let bestScore = -1;
for (const index of indexes) {
const plan = this.scoreIndex(index, operatorInfo, filter);
if (plan.selectivity < 1.0) {
const score = this.calculateScore(plan);
if (score > bestScore) {
bestScore = score;
bestPlan = plan;
}
}
}
// If no suitable index found, fall back to collection scan
if (!bestPlan || bestScore <= 0) {
return {
type: 'COLLSCAN',
indexCovering: false,
selectivity: 1.0,
usesRange: false,
indexFieldsUsed: [],
explanation: 'No suitable index found for this query',
};
}
return bestPlan;
}
/**
* Analyze filter to extract operator information per field
*/
private analyzeFilter(filter: Document, prefix = ''): Map<string, IFilterOperatorInfo> {
const result = new Map<string, IFilterOperatorInfo>();
for (const [key, value] of Object.entries(filter)) {
// Skip logical operators at the top level
if (key.startsWith('$')) {
if (key === '$and' && Array.isArray(value)) {
// Merge $and conditions
for (const subFilter of value) {
const subInfo = this.analyzeFilter(subFilter, prefix);
for (const [field, info] of subInfo) {
if (result.has(field)) {
// Merge operators
const existing = result.get(field)!;
existing.operators.push(...info.operators);
existing.equality = existing.equality || info.equality;
existing.range = existing.range || info.range;
existing.in = existing.in || info.in;
Object.assign(existing.values, info.values);
} else {
result.set(field, info);
}
}
}
}
continue;
}
const fullKey = prefix ? `${prefix}.${key}` : key;
const info: IFilterOperatorInfo = {
field: fullKey,
operators: [],
equality: false,
range: false,
in: false,
exists: false,
regex: false,
values: {},
};
if (typeof value !== 'object' || value === null || value instanceof plugins.bson.ObjectId || value instanceof Date) {
// Direct equality
info.equality = true;
info.operators.push('$eq');
info.values['$eq'] = value;
} else if (Array.isArray(value)) {
// Array equality (rare, but possible)
info.equality = true;
info.operators.push('$eq');
info.values['$eq'] = value;
} else {
// Operator object
for (const [op, opValue] of Object.entries(value)) {
if (op.startsWith('$')) {
info.operators.push(op);
info.values[op] = opValue;
switch (op) {
case '$eq':
info.equality = true;
break;
case '$ne':
case '$not':
// These can use indexes but with low selectivity
break;
case '$in':
info.in = true;
break;
case '$nin':
// Can't efficiently use indexes
break;
case '$gt':
case '$gte':
case '$lt':
case '$lte':
info.range = true;
break;
case '$exists':
info.exists = true;
break;
case '$regex':
info.regex = true;
break;
}
} else {
// Nested object - recurse
const nestedInfo = this.analyzeFilter({ [op]: opValue }, fullKey);
for (const [nestedField, nestedFieldInfo] of nestedInfo) {
result.set(nestedField, nestedFieldInfo);
}
}
}
}
if (info.operators.length > 0) {
result.set(fullKey, info);
}
}
return result;
}
/**
* Score an index for the given filter
*/
private scoreIndex(
index: { name: string; key: Record<string, any>; unique?: boolean; sparse?: boolean },
operatorInfo: Map<string, IFilterOperatorInfo>,
filter: Document
): IQueryPlan {
const indexFields = Object.keys(index.key);
const usedFields: string[] = [];
let usesRange = false;
let canUseIndex = true;
let selectivity = 1.0;
let residualFilter: Document | undefined;
// Check each index field in order
for (const field of indexFields) {
const info = operatorInfo.get(field);
if (!info) {
// Index field not in filter - stop here
break;
}
usedFields.push(field);
// Calculate selectivity based on operator
if (info.equality) {
// Equality has high selectivity
selectivity *= 0.01; // Assume 1% match
} else if (info.in) {
// $in selectivity depends on array size
const inValues = info.values['$in'];
if (Array.isArray(inValues)) {
selectivity *= Math.min(0.5, inValues.length * 0.01);
} else {
selectivity *= 0.1;
}
} else if (info.range) {
// Range queries have moderate selectivity
selectivity *= 0.25;
usesRange = true;
// After range, can't use more index fields efficiently
break;
} else if (info.exists) {
// $exists can use sparse indexes
selectivity *= 0.5;
} else {
// Other operators may not be indexable
canUseIndex = false;
break;
}
}
if (!canUseIndex || usedFields.length === 0) {
return {
type: 'COLLSCAN',
indexCovering: false,
selectivity: 1.0,
usesRange: false,
indexFieldsUsed: [],
explanation: `Index ${index.name} cannot be used for this query`,
};
}
// Build residual filter for conditions not covered by index
const coveredFields = new Set(usedFields);
const residualConditions: Record<string, any> = {};
for (const [field, info] of operatorInfo) {
if (!coveredFields.has(field)) {
// This field isn't covered by the index
if (info.equality) {
residualConditions[field] = info.values['$eq'];
} else {
residualConditions[field] = info.values;
}
}
}
if (Object.keys(residualConditions).length > 0) {
residualFilter = residualConditions;
}
// Unique indexes have better selectivity for equality
if (index.unique && usedFields.length === indexFields.length) {
selectivity = Math.min(selectivity, 0.001); // At most 1 document
}
return {
type: usesRange ? 'IXSCAN_RANGE' : 'IXSCAN',
indexName: index.name,
indexKey: index.key,
indexCovering: Object.keys(residualConditions).length === 0,
selectivity,
usesRange,
indexFieldsUsed: usedFields,
residualFilter,
explanation: `Using index ${index.name} on fields [${usedFields.join(', ')}]`,
};
}
/**
* Calculate overall score for a plan (higher is better)
*/
private calculateScore(plan: IQueryPlan): number {
let score = 0;
// Lower selectivity is better (fewer documents to fetch)
score += (1 - plan.selectivity) * 100;
// Index covering queries are best
if (plan.indexCovering) {
score += 50;
}
// More index fields used is better
score += plan.indexFieldsUsed.length * 10;
// Equality scans are better than range scans
if (!plan.usesRange) {
score += 20;
}
return score;
}
/**
* Explain a query - returns detailed plan information
*/
async explain(filter: Document): Promise<{
queryPlanner: {
plannerVersion: number;
namespace: string;
indexFilterSet: boolean;
winningPlan: IQueryPlan;
rejectedPlans: IQueryPlan[];
};
}> {
await this.indexEngine['initialize']();
// Analyze the filter
const operatorInfo = this.analyzeFilter(filter);
// Get available indexes
const indexes = await this.indexEngine.listIndexes();
// Score all indexes
const plans: IQueryPlan[] = [];
for (const index of indexes) {
const plan = this.scoreIndex(index, operatorInfo, filter);
plans.push(plan);
}
// Add collection scan as fallback
plans.push({
type: 'COLLSCAN',
indexCovering: false,
selectivity: 1.0,
usesRange: false,
indexFieldsUsed: [],
explanation: 'Full collection scan',
});
// Sort by score (best first)
plans.sort((a, b) => this.calculateScore(b) - this.calculateScore(a));
return {
queryPlanner: {
plannerVersion: 1,
namespace: `${this.indexEngine['dbName']}.${this.indexEngine['collName']}`,
indexFilterSet: false,
winningPlan: plans[0],
rejectedPlans: plans.slice(1),
},
};
}
}