1. The Problem
Build a tiny ORM. A base Model class lets subclasses declare a table name and a list of fields. Instances become objects whose declared fields are attributes; save() stores the record and all() loads every record back as objects of the right class.
Why this project? Django models, Prisma, and ActiveRecord all let you work with objects instead of rows. Building a miniature version shows how declared fields map to storage and back โ the "mapping" in object-relational mapping.
2. How to Think About This
The base Model holds a shared store keyed by table name (an array of plain records per table). The constructor copies each declared field off the passed-in data onto the instance. save() pushes a snapshot of the instance's fields into its table's array. all() reads that array back and re-wraps each record with new this(...) so callers get real model objects. (A production ORM swaps the array for SQL โ same shape, different backing store.)
3. The Build
// Shared in-memory store: table name -> array of records.
const STORE = {};
export class Model {
constructor(data = {}) {
for (const field of this.constructor.fields) {
this[field] = data[field];
}
}
save() {
const table = this.constructor.table;
STORE[table] = STORE[table] || [];
const record = {};
for (const field of this.constructor.fields) record[field] = this[field];
STORE[table].push(record);
return this;
}
static all() {
const rows = STORE[this.table] || [];
return rows.map(row => new this(row)); // re-wrap as instances
}
}
export class User extends Model {
static table = "users";
static fields = ["name", "email"];
}
new User({ name: "Alice", email: "alice@example.com" }).save();
new User({ name: "Bob", email: "bob@example.com" }).save();
for (const user of User.all()) {
console.log(user.name, user.email);
}
4. Test & Prove
import { User } from "./orm.mjs";
let pass = 0, fail = 0;
function test(desc, fn) {
try { fn(); console.log(" \u2713", desc); pass++; }
catch (e) { console.log(" \u2717", desc, "\u2014", e.message); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
test("declared fields become attributes", () => {
const u = new User({ name: "Bob", email: "b@x.com" });
assert(u.name === "Bob" && u.email === "b@x.com");
});
test("a saved object can be loaded back", () => {
new User({ name: "Alice", email: "a@x.com" }).save();
assert(User.all().some(u => u.name === "Alice"));
});
test("all() returns User instances, not plain objects", () => {
new User({ name: "Carol", email: "c@x.com" }).save();
assert(User.all().every(u => u instanceof User));
});
test("saving adds one record", () => {
const before = User.all().length;
new User({ name: "Dave", email: "d@x.com" }).save();
assert(User.all().length === before + 1);
});
console.log(`\n${pass} passed, ${fail} failed`);
5. The Interface
node orm.mjs Alice alice@example.com Bob bob@example.com
6. Run It
node orm.mjs
node orm.test.mjs๐ฏ Try this next
- Add a find(predicate) that filters records like a WHERE clause
- Add a real backing store by swapping the array for a JSON file
- Add update() and delete() to complete the CRUD set
- Swap the in-memory store for SQLite and keep the same object API