docs/e2e: indexes out of 'not implemented'; e2e3 covers driver index APIs
README documents supported key patterns, unique/sparse/multikey behavior, planner rules (multikey two-bound range fallback, sparse/null bail, the _id fast-path guards), and v1 limits plus the two pre-existing issues the work surfaces (drop-collection resurrection, compact log_bytes). e2e3.js exercises createIndex/getIndexes/dropIndex/dropIndexes, the unique-constraint 11000 path, compound, sparse, and descending indexes through the official Node driver.
This commit is contained in:
49
README.md
49
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
|
||||
|
||||
|
||||
104
tests/e2e/e2e3.js
Normal file
104
tests/e2e/e2e3.js
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user