274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
import * as smartmongo from '../ts/index.js';
|
|
|
|
const { QueryPlanner, IndexEngine, MemoryStorageAdapter, ObjectId } = smartmongo.tsmdb;
|
|
|
|
let storage: InstanceType<typeof MemoryStorageAdapter>;
|
|
let indexEngine: InstanceType<typeof IndexEngine>;
|
|
let queryPlanner: InstanceType<typeof QueryPlanner>;
|
|
|
|
const TEST_DB = 'testdb';
|
|
const TEST_COLL = 'testcoll';
|
|
|
|
// ============================================================================
|
|
// Setup
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: should create QueryPlanner instance', async () => {
|
|
storage = new MemoryStorageAdapter();
|
|
await storage.initialize();
|
|
await storage.createCollection(TEST_DB, TEST_COLL);
|
|
|
|
indexEngine = new IndexEngine(TEST_DB, TEST_COLL, storage);
|
|
queryPlanner = new QueryPlanner(indexEngine);
|
|
|
|
expect(queryPlanner).toBeTruthy();
|
|
});
|
|
|
|
tap.test('queryplanner: should insert test documents', async () => {
|
|
// Insert test documents
|
|
const docs = [
|
|
{ _id: new ObjectId(), name: 'Alice', age: 25, city: 'NYC', category: 'A' },
|
|
{ _id: new ObjectId(), name: 'Bob', age: 30, city: 'LA', category: 'B' },
|
|
{ _id: new ObjectId(), name: 'Charlie', age: 35, city: 'NYC', category: 'A' },
|
|
{ _id: new ObjectId(), name: 'Diana', age: 28, city: 'Chicago', category: 'C' },
|
|
{ _id: new ObjectId(), name: 'Eve', age: 32, city: 'LA', category: 'B' },
|
|
];
|
|
|
|
for (const doc of docs) {
|
|
await storage.insertOne(TEST_DB, TEST_COLL, doc);
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Basic Plan Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: empty filter should result in COLLSCAN', async () => {
|
|
const plan = await queryPlanner.plan({});
|
|
|
|
expect(plan.type).toEqual('COLLSCAN');
|
|
expect(plan.indexCovering).toBeFalse();
|
|
expect(plan.selectivity).toEqual(1.0);
|
|
expect(plan.explanation).toContain('No filter');
|
|
});
|
|
|
|
tap.test('queryplanner: null filter should result in COLLSCAN', async () => {
|
|
const plan = await queryPlanner.plan(null as any);
|
|
|
|
expect(plan.type).toEqual('COLLSCAN');
|
|
});
|
|
|
|
tap.test('queryplanner: filter with no matching index should result in COLLSCAN', async () => {
|
|
const plan = await queryPlanner.plan({ nonExistentField: 'value' });
|
|
|
|
expect(plan.type).toEqual('COLLSCAN');
|
|
expect(plan.explanation).toContain('No suitable index');
|
|
});
|
|
|
|
// ============================================================================
|
|
// Index Scan Tests (with indexes)
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: should create test indexes', async () => {
|
|
await indexEngine.createIndex({ age: 1 }, { name: 'age_1' });
|
|
await indexEngine.createIndex({ name: 1 }, { name: 'name_1' });
|
|
await indexEngine.createIndex({ city: 1, category: 1 }, { name: 'city_category_1' });
|
|
|
|
const indexes = await indexEngine.listIndexes();
|
|
expect(indexes.length).toBeGreaterThanOrEqual(4); // _id_ + 3 created
|
|
});
|
|
|
|
tap.test('queryplanner: simple equality filter should use IXSCAN', async () => {
|
|
const plan = await queryPlanner.plan({ age: 30 });
|
|
|
|
expect(plan.type).toEqual('IXSCAN');
|
|
expect(plan.indexName).toEqual('age_1');
|
|
expect(plan.indexFieldsUsed).toContain('age');
|
|
expect(plan.usesRange).toBeFalse();
|
|
});
|
|
|
|
tap.test('queryplanner: $eq operator should use IXSCAN', async () => {
|
|
const plan = await queryPlanner.plan({ name: { $eq: 'Alice' } });
|
|
|
|
expect(plan.type).toEqual('IXSCAN');
|
|
expect(plan.indexName).toEqual('name_1');
|
|
expect(plan.indexFieldsUsed).toContain('name');
|
|
});
|
|
|
|
tap.test('queryplanner: range filter ($gt) should use IXSCAN_RANGE', async () => {
|
|
const plan = await queryPlanner.plan({ age: { $gt: 25 } });
|
|
|
|
expect(plan.type).toEqual('IXSCAN_RANGE');
|
|
expect(plan.indexName).toEqual('age_1');
|
|
expect(plan.usesRange).toBeTrue();
|
|
});
|
|
|
|
tap.test('queryplanner: range filter ($lt) should use IXSCAN_RANGE', async () => {
|
|
const plan = await queryPlanner.plan({ age: { $lt: 35 } });
|
|
|
|
expect(plan.type).toEqual('IXSCAN_RANGE');
|
|
expect(plan.usesRange).toBeTrue();
|
|
});
|
|
|
|
tap.test('queryplanner: range filter ($gte, $lte) should use IXSCAN_RANGE', async () => {
|
|
const plan = await queryPlanner.plan({ age: { $gte: 25, $lte: 35 } });
|
|
|
|
expect(plan.type).toEqual('IXSCAN_RANGE');
|
|
expect(plan.usesRange).toBeTrue();
|
|
});
|
|
|
|
tap.test('queryplanner: $in operator should use IXSCAN', async () => {
|
|
const plan = await queryPlanner.plan({ age: { $in: [25, 30, 35] } });
|
|
|
|
expect(plan.type).toEqual('IXSCAN');
|
|
expect(plan.indexName).toEqual('age_1');
|
|
});
|
|
|
|
// ============================================================================
|
|
// Compound Index Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: compound index - first field equality should use index', async () => {
|
|
const plan = await queryPlanner.plan({ city: 'NYC' });
|
|
|
|
expect(plan.type).toEqual('IXSCAN');
|
|
expect(plan.indexName).toEqual('city_category_1');
|
|
expect(plan.indexFieldsUsed).toContain('city');
|
|
});
|
|
|
|
tap.test('queryplanner: compound index - both fields should use full index', async () => {
|
|
const plan = await queryPlanner.plan({ city: 'NYC', category: 'A' });
|
|
|
|
expect(plan.type).toEqual('IXSCAN');
|
|
expect(plan.indexName).toEqual('city_category_1');
|
|
expect(plan.indexFieldsUsed).toContain('city');
|
|
expect(plan.indexFieldsUsed).toContain('category');
|
|
expect(plan.indexFieldsUsed.length).toEqual(2);
|
|
});
|
|
|
|
// ============================================================================
|
|
// Selectivity Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: equality query should have low selectivity', async () => {
|
|
const plan = await queryPlanner.plan({ age: 30 });
|
|
|
|
expect(plan.selectivity).toBeLessThan(0.1);
|
|
});
|
|
|
|
tap.test('queryplanner: range query should have moderate selectivity', async () => {
|
|
const plan = await queryPlanner.plan({ age: { $gt: 25 } });
|
|
|
|
expect(plan.selectivity).toBeGreaterThan(0);
|
|
expect(plan.selectivity).toBeLessThan(1);
|
|
});
|
|
|
|
tap.test('queryplanner: $in query selectivity depends on array size', async () => {
|
|
const smallInPlan = await queryPlanner.plan({ age: { $in: [25] } });
|
|
const largeInPlan = await queryPlanner.plan({ age: { $in: [25, 26, 27, 28, 29, 30] } });
|
|
|
|
// Larger $in should have higher selectivity (less selective = more documents)
|
|
expect(largeInPlan.selectivity).toBeGreaterThanOrEqual(smallInPlan.selectivity);
|
|
});
|
|
|
|
// ============================================================================
|
|
// Index Covering Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: query covering all filter fields should be index covering', async () => {
|
|
const plan = await queryPlanner.plan({ age: 30 });
|
|
|
|
// All filter fields are covered by the index
|
|
expect(plan.indexCovering).toBeTrue();
|
|
});
|
|
|
|
tap.test('queryplanner: query with residual filter should not be index covering', async () => {
|
|
const plan = await queryPlanner.plan({ city: 'NYC', name: 'Alice' });
|
|
|
|
// 'name' is not in the compound index city_category, so it's residual
|
|
expect(plan.indexCovering).toBeFalse();
|
|
expect(plan.residualFilter).toBeTruthy();
|
|
});
|
|
|
|
// ============================================================================
|
|
// Explain Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: explain should return detailed plan info', async () => {
|
|
const explanation = await queryPlanner.explain({ age: 30 });
|
|
|
|
expect(explanation.queryPlanner).toBeTruthy();
|
|
expect(explanation.queryPlanner.plannerVersion).toEqual(1);
|
|
expect(explanation.queryPlanner.winningPlan).toBeTruthy();
|
|
expect(explanation.queryPlanner.rejectedPlans).toBeArray();
|
|
});
|
|
|
|
tap.test('queryplanner: explain should include winning and rejected plans', async () => {
|
|
const explanation = await queryPlanner.explain({ age: 30 });
|
|
|
|
expect(explanation.queryPlanner.winningPlan.type).toBeTruthy();
|
|
expect(explanation.queryPlanner.rejectedPlans.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
tap.test('queryplanner: explain winning plan should be the best plan', async () => {
|
|
const explanation = await queryPlanner.explain({ age: 30 });
|
|
|
|
// Winning plan should use an index, not collection scan (if index exists)
|
|
expect(explanation.queryPlanner.winningPlan.type).toEqual('IXSCAN');
|
|
|
|
// There should be a COLLSCAN in rejected plans
|
|
const hasCOLLSCAN = explanation.queryPlanner.rejectedPlans.some(p => p.type === 'COLLSCAN');
|
|
expect(hasCOLLSCAN).toBeTrue();
|
|
});
|
|
|
|
// ============================================================================
|
|
// $and Operator Tests
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: $and conditions should be analyzed', async () => {
|
|
const plan = await queryPlanner.plan({
|
|
$and: [
|
|
{ age: { $gte: 25 } },
|
|
{ age: { $lte: 35 } },
|
|
],
|
|
});
|
|
|
|
expect(plan.type).toEqual('IXSCAN_RANGE');
|
|
expect(plan.indexName).toEqual('age_1');
|
|
});
|
|
|
|
// ============================================================================
|
|
// Edge Cases
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: should handle complex nested operators', async () => {
|
|
const plan = await queryPlanner.plan({
|
|
age: { $gte: 20, $lte: 40 },
|
|
city: 'NYC',
|
|
});
|
|
|
|
expect(plan).toBeTruthy();
|
|
expect(plan.type).not.toBeUndefined();
|
|
});
|
|
|
|
tap.test('queryplanner: should handle $exists operator', async () => {
|
|
await indexEngine.createIndex({ email: 1 }, { name: 'email_1', sparse: true });
|
|
|
|
const plan = await queryPlanner.plan({ email: { $exists: true } });
|
|
|
|
// $exists can use sparse indexes
|
|
expect(plan).toBeTruthy();
|
|
});
|
|
|
|
// ============================================================================
|
|
// Cleanup
|
|
// ============================================================================
|
|
|
|
tap.test('queryplanner: cleanup', async () => {
|
|
await storage.close();
|
|
expect(true).toBeTrue();
|
|
});
|
|
|
|
export default tap.start();
|