The Codex / Projects / Mini ORM / JavaScript
Tier 4 โ€” Advanced

Mini ORM

Build your own object-relational mapper: classes that save and load themselves from a store, no SQL in sight. The magic behind Django/Prisma models.

๐Ÿ Python ๐ŸŸจ JavaScript

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

orm.mjs
// 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

orm.test.mjs
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

  1. Add a find(predicate) that filters records like a WHERE clause
  2. Add a real backing store by swapping the array for a JSON file
  3. Add update() and delete() to complete the CRUD set
  4. Swap the in-memory store for SQLite and keep the same object API

What you practised

Class inheritance with static config, mapping declared fields to attributes in the constructor, re-wrapping stored records as instances with new this(), and the idea that the backing store is swappable behind the same object API.

Reference: Classes ยท Objects ยท Arrays

Try It Yourself