diff --git a/README.md b/README.md index dc334f7..aa3c060 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ mongosh --port 27017 `$exists` `$regex` (hand-rolled engine: anchors, `.`, `* + ?`, character classes, groups, alternation, `i`/`s` options) `$not` `$and` `$or` `$nor` `$size` `$all` `$elemMatch`, with dot paths and array multikey semantics. +- **Secondary indexes**: `createIndex`/`listIndexes`/`dropIndex` via the + three driver commands, single-field and compound, with `unique` and + `sparse` options, persisted in the log and rebuilt on open (compaction + re-emits them). The query planner turns equality / `$in` / range + predicates into index lookups across `find`, `count`, `update`, + `delete`, `findAndModify`, and a leading `$match` in `aggregate`; every + candidate is re-checked against the full filter, so an index that + over-approximates is merely slow, never wrong. - **Update operators**: `$set` `$unset` `$inc` `$push` (`$each`) `$pull` `$rename`, with dot-path creation (including array indices). - **Storage**: append-only record log (CRC32-checked, `fsync` per write, @@ -59,22 +67,59 @@ mongosh --port 27017 src/ bson.zig BSON parse/serialize, ObjectId, canonical comparison order wire.zig OP_MSG/OP_QUERY framing, message + reply builders - commands.zig command dispatch (hello, CRUD, aggregate, admin) + commands.zig command dispatch (hello, CRUD, aggregate, admin, indexes) server.zig TCP accept loop, per-connection handlers db.zig in-memory engine: db → collection → _id → document maps storage.zig append-only log: records, replay, CRC validation query.zig filter matcher, regex engine, sort, projection + index.zig secondary indexes: entries, search, query planner update.zig update operators with dot-path navigation main.zig CLI: --port, --bind, --db ``` +## Indexes + +`collection.createIndex({field: 1})` works against every driver; the index +is persisted in the log, survives restarts and compaction, and is used by +the query planner to narrow scans. + +- **Key patterns**: single-field and compound (up to 32 fields), each key + `1` or `-1`. Descending order is metadata (entries are always stored + value-ascending); the default index name is MongoDB's `a_1_b_-1`. + `createIndex({_id: 1})` is an idempotent no-op — the docs map is the + `_id_` index — and `dropIndex("_id_")` errors. +- **Options**: `unique` (a conflicting write fails with E11000 naming the + index; per-document entries are deduped first, so `{a: [1,1]}` is legal) + and `sparse` (documents missing an indexed field are skipped). +- **Multikey**: an array at an indexed path is indexed as a whole *and* + element-wise, mirroring the query matcher exactly, so both + `{tags: "a"}` and `{tags: ["a","b"]}` hit the index. A compound index + over two array paths rejects the document with MongoDB's "cannot index + parallel arrays". +- **Planner**: picks the index covering the longest leading run of + equality/`$in` predicates (cartesian product capped at 100 lookups), + optionally with a range on the next key. Ranges with both bounds fall + back to a scan on multikey indexes (a doc with `{a: [1,2]}` can satisfy + `{a: {$gt: 5, $lt: 25}}` across two entries), and sparse indexes are + never used for `null`-valued predicates. The `_id_` fast path resolves + `{_id: ...}` through the docs map unless the value's compare class is + serialization-ambiguous (int32 1, int64 1, double 1.0 compare equal but + hash differently — those fall back to a scan, as do string/symbol/code). + +v1 limits: no index-accelerated sort, no hashed/text/geo/TTL/partial +indexes, and entry insert/removal is O(n) (a sorted array) — fine for a +light database, with a B-tree or id→entry map as the follow-up. + ## Not (yet) implemented - Authentication (SCRAM) — run without credentials - Real cursors (all results are returned in one batch, cursor id 0) -- Indexes (O(n) scans) - Transactions, change streams, replicasets - Compression (OP_COMPRESSED) +- `dropCollection`/`dropDatabase` write no log record, so a dropped + collection (and its index definitions) resurrect on restart; and + compaction never resets `log_bytes`, so every write after the first + compaction re-triggers the threshold check ## Code style diff --git a/tests/e2e/e2e3.js b/tests/e2e/e2e3.js new file mode 100644 index 0000000..c65c19f --- /dev/null +++ b/tests/e2e/e2e3.js @@ -0,0 +1,104 @@ +// E2E part 3: secondary indexes through the official Node driver. +// createIndex / getIndexes / dropIndex, unique + sparse + compound indexes, +// and a find constrained by a non-_id unique index. +const { MongoClient } = require('mongodb'); + +const URL = 'mongodb://127.0.0.1:27020'; +const results = []; +function check(name, cond, detail = '') { + results.push({ name, ok: !!cond, detail: String(detail) }); + if (!cond) console.error(` ✗ ${name} ${detail}`); +} + +async function main() { + const client = new MongoClient(URL, { serverSelectionTimeoutMS: 5000 }); + await client.connect(); + const db = client.db('e2e3'); + const coll = db.collection('users'); + await coll.drop().catch(() => {}); + + // --- createIndex + getIndexes shape --- + await coll.createIndex({ email: 1 }, { unique: true }); + const idxs = await coll.indexes(); + const names = idxs.map((i) => i.name).sort(); + check('getIndexes has _id_', names.includes('_id_'), names.join(',')); + check('getIndexes has email_1', names.includes('email_1'), names.join(',')); + const emailIdx = idxs.find((i) => i.name === 'email_1'); + check('index spec shape', emailIdx && emailIdx.key.email === 1 && emailIdx.unique === true, JSON.stringify(emailIdx)); + + // Idempotent re-create of the same spec. + await coll.createIndex({ email: 1 }, { unique: true }); + check('idempotent re-create', (await coll.indexes()).filter((i) => i.name === 'email_1').length === 1); + + // --- unique constraint: _id-less unique index constrains finds --- + await coll.insertOne({ name: 'alice', email: 'a@x.io' }); + let dupErr = null; + try { + await coll.insertOne({ name: 'bob', email: 'a@x.io' }); + } catch (e) { + dupErr = e; + } + check('unique constraint rejected (11000)', dupErr && dupErr.code === 11000, dupErr && dupErr.message); + check('dup key message names the index', dupErr && dupErr.message.includes('email_1'), dupErr && dupErr.message); + + // The unique index is a real index: find by the indexed field works and + // the constrained doc is found. + const found = await coll.find({ email: 'a@x.io' }).toArray(); + check('find via unique index', found.length === 1 && found[0].name === 'alice', JSON.stringify(found)); + const cnt = await coll.countDocuments({ email: 'a@x.io' }); + check('count via unique index', cnt === 1, cnt); + + // --- compound index --- + await coll.createIndex({ name: 1, age: 1 }, { name: 'name_1_age_1' }); + await coll.insertMany([ + { name: 'carol', age: 30, email: 'c@x.io' }, + { name: 'carol', age: 35, email: 'd@x.io' }, + { name: 'dave', age: 40, email: 'e@x.io' }, + ]); + const compound = await coll.find({ name: 'carol' }).sort({ age: 1 }).toArray(); + check('compound prefix find', compound.length === 2 && compound[0].age === 30, JSON.stringify(compound.map((d) => d.age))); + const compound2 = await coll.find({ name: 'carol', age: { $gt: 32 } }).count(); + check('compound equality + range', compound2 === 1, compound2); + + // --- sparse index --- + await coll.createIndex({ nickname: 1 }, { sparse: true, name: 'nickname_1_sparse' }); + // Only one doc has a nickname; the sparse index covers it. + await coll.updateOne({ name: 'dave' }, { $set: { nickname: 'davie' } }); + const sparse = await coll.find({ nickname: 'davie' }).toArray(); + check('sparse index find', sparse.length === 1 && sparse[0].name === 'dave', JSON.stringify(sparse)); + // Docs without the field stay findable by other fields (scan fallback). + const all = await coll.countDocuments({}); + check('count unaffected by sparse index', all === 4, all); + + // --- descending key + default names --- + await coll.createIndex({ score: -1 }); + const after = await coll.indexes(); + const scoreIdx = after.find((i) => i.key && i.key.score === -1); + check('descending index created', !!scoreIdx, JSON.stringify(after)); + + // --- dropIndex --- + await coll.dropIndex('name_1_age_1'); + const afterDrop = await coll.indexes(); + check('dropIndex removes it', !afterDrop.some((i) => i.name === 'name_1_age_1'), JSON.stringify(afterDrop.map((i) => i.name))); + check('_id_ still present after drops', afterDrop.some((i) => i.name === '_id_')); + + // dropIndexes("*") removes the rest. + await coll.dropIndexes(); + const afterAll = await coll.indexes(); + check('dropIndexes("*") keeps only _id_', afterAll.length === 1 && afterAll[0].name === '_id_', JSON.stringify(afterAll)); + + await client.close(); + + const failed = results.filter((r) => !r.ok); + console.log(`\n${results.length - failed.length}/${results.length} checks passed`); + if (failed.length) { + console.log('FAILED:', failed.map((f) => f.name).join(', ')); + process.exit(1); + } + console.log('E2E3_OK'); +} + +main().catch((e) => { + console.error('E2E3_FAIL', e); + process.exit(1); +});