NexusCS

Objection.js

JavaScript
SQL-friendly ORM for Node.js built on Knex.js. Supports PostgreSQL, MySQL, and SQLite3.
orm
sql
knex
nodejs

Getting started

Installation

# Install Objection.js and Knex
npm install objection knex

# Install database driver (choose one)
npm install pg          # PostgreSQL
npm install mysql       # MySQL
npm install sqlite3     # SQLite3

Knex Configuration

const Knex = require("knex");
const { Model } = require("objection");

// Initialize knex
const knex = Knex({
  client: "pg",
  connection: {
    host: "127.0.0.1",
    user: "postgres",
    password: "password",
    database: "mydb",
  },
});

// Bind all Models to knex instance
Model.knex(knex);

Basic Model

const { Model } = require("objection");

class Person extends Model {
  static get tableName() {
    return "persons";
  }

  static get idColumn() {
    return "id"; // Optional, defaults to 'id'
  }
}

module.exports = Person;

Person model mapped to persons table.

Model Definition

Required Properties

Property Description
tableName Database table name
class Person extends Model {
  static get tableName() {
    return "persons";
  }
}

Optional Properties

Property Description
idColumn Primary key column(s)
jsonSchema JSON Schema for validation
relationMappings Model relationships
class Person extends Model {
  static get idColumn() {
    return "id";
  }

  // Composite primary key
  static get idColumn() {
    return ["id", "tenant_id"];
  }
}

JSON Schema Validation

class Person extends Model {
  static get tableName() {
    return "persons";
  }

  static get jsonSchema() {
    return {
      type: "object",
      required: ["firstName", "lastName"],

      properties: {
        id: { type: "integer" },
        firstName: { type: "string", minLength: 1, maxLength: 255 },
        lastName: { type: "string", minLength: 1, maxLength: 255 },
        age: { type: "number" },
        email: { type: "string", format: "email" },
      },
    };
  }
}

Validates data on insert, update, and patch operations.

Relationships

Relation Types

Type Description
BelongsToOneRelation Many-to-one / one-to-one
HasManyRelation One-to-many
HasOneRelation One-to-one
ManyToManyRelation Many-to-many via join table
HasOneThroughRelation One-to-one via join table

BelongsToOneRelation

const { Model } = require("objection");

class Pet extends Model {
  static get tableName() {
    return "pets";
  }

  static get relationMappings() {
    return {
      owner: {
        relation: Model.BelongsToOneRelation,
        modelClass: Person,
        join: {
          from: "pets.owner_id",
          to: "persons.id",
        },
      },
    };
  }
}

Pet belongs to one Person.

HasManyRelation

class Person extends Model {
  static get tableName() {
    return "persons";
  }

  static get relationMappings() {
    return {
      pets: {
        relation: Model.HasManyRelation,
        modelClass: Pet,
        join: {
          from: "persons.id",
          to: "pets.owner_id",
        },
      },
    };
  }
}

Person has many Pets.

ManyToManyRelation

class Person extends Model {
  static get relationMappings() {
    return {
      movies: {
        relation: Model.ManyToManyRelation,
        modelClass: Movie,
        join: {
          from: "persons.id",
          through: {
            from: "persons_movies.person_id",
            to: "persons_movies.movie_id",
          },
          to: "movies.id",
        },
      },
    };
  }
}

Person has many Movies through join table.

HasOneRelation

class Person extends Model {
  static get relationMappings() {
    return {
      profile: {
        relation: Model.HasOneRelation,
        modelClass: Profile,
        join: {
          from: "persons.id",
          to: "profiles.person_id",
        },
      },
    };
  }
}

Person has one Profile.

Circular Dependencies

// person.js
class Person extends Model {
  static get relationMappings() {
    // Require inside function to avoid circular dependency
    const Pet = require("./pet");

    return {
      pets: {
        relation: Model.HasManyRelation,
        modelClass: Pet,
        join: {
          from: "persons.id",
          to: "pets.owner_id",
        },
      },
    };
  }
}

Use dynamic require inside relationMappings().

Query Operations

Find Queries

// Find all
const people = await Person.query();

// Find by ID
const person = await Person.query().findById(1);

// Where clause
const adults = await Person.query().where("age", ">=", 18);

// Multiple conditions
const results = await Person.query()
  .where("age", ">", 18)
  .where("firstName", "like", "J%");

// Order and limit
const people = await Person.query().orderBy("lastName").limit(10).offset(20);

Insert Operations

// Insert single record
const person = await Person.query().insert({
  firstName: "John",
  lastName: "Doe",
  age: 30,
});

// Insert multiple records
const people = await Person.query().insert([
  { firstName: "John", lastName: "Doe" },
  { firstName: "Jane", lastName: "Smith" },
]);

// Insert and return
const person = await Person.query()
  .insert({ firstName: "John" })
  .returning("*");

Update Operations

// Update by ID
const updated = await Person.query().findById(1).patch({ age: 31 });

// Update with where
const count = await Person.query()
  .where("age", "<", 18)
  .patch({ status: "minor" });

// Update and fetch
const person = await Person.query().patchAndFetchById(1, { age: 31 });

// Full update (requires all fields)
await Person.query()
  .findById(1)
  .update({ firstName: "John", lastName: "Doe", age: 31 });

patch() for partial updates, update() for full replacement.

Delete Operations

// Delete by ID
await Person.query().deleteById(1);

// Delete with where
const count = await Person.query().where("age", "<", 18).delete();

// Delete and return
const deleted = await Person.query().delete().where("id", 1).returning("*");

Complex Queries

// Raw SQL
const people = await Person.query().whereRaw("age > ?", [18]);

// Subqueries
const people = await Person.query().where(
  "age",
  ">",
  Person.query().avg("age"),
);

// Joins
const results = await Person.query()
  .join("pets", "persons.id", "pets.owner_id")
  .where("pets.name", "Fluffy");

// Select specific columns
const people = await Person.query().select("id", "firstName", "lastName");

// Grouping
const counts = await Person.query()
  .groupBy("age")
  .select("age")
  .count("* as count");

Relation Queries

$relatedQuery (Instance)

// Find related records
const person = await Person.query().findById(1);
const pets = await person.$relatedQuery("pets");

// Filter related
const dogs = await person.$relatedQuery("pets").where("species", "dog");

// Insert related
const newPet = await person
  .$relatedQuery("pets")
  .insert({ name: "Fluffy", species: "cat" });

// Update related
await person
  .$relatedQuery("pets")
  .patch({ vaccinated: true })
  .where("species", "dog");

// Delete related
await person.$relatedQuery("pets").delete().where("id", 5);

Works on model instances.

relatedQuery (Static)

// Same operations on class
const pets = await Person.relatedQuery("pets").for(1);

// For multiple parents
const pets = await Person.relatedQuery("pets").for([1, 2, 3]);

// With where on parent
const pets = await Person.relatedQuery("pets").for(
  Person.query().where("lastName", "Doe"),
);

Works on model classes.

Relate/Unrelate

// Relate existing records (many-to-many)
const person = await Person.query().findById(1);

// Add existing movie to person
await person.$relatedQuery("movies").relate(5);

// Relate multiple
await person.$relatedQuery("movies").relate([5, 6, 7]);

// Unrelate
await person.$relatedQuery("movies").unrelate().where("id", 5);

// Unrelate all
await person.$relatedQuery("movies").unrelate();

For existing records in many-to-many relationships.

Eager Loading

withGraphFetched

// Basic eager loading
const people = await Person.query().withGraphFetched("pets");

// Multiple relations
const people = await Person.query().withGraphFetched("[pets, children]");

// Nested relations
const people = await Person.query().withGraphFetched(
  "[pets, children.[pets, movies]]",
);

// Recursive relations
const people = await Person.query().withGraphFetched("[children.^]"); // All descendants

// Limited recursion
const people = await Person.query().withGraphFetched("[children.^3]"); // 3 levels

Uses multiple queries (safer for large datasets).

withGraphJoined

// Eager loading with joins
const people = await Person.query().withGraphJoined("pets");

// Nested with joins
const people = await Person.query().withGraphJoined("[pets, children.pets]");

// Filter by relation
const people = await Person.query()
  .withGraphJoined("pets")
  .where("pets.species", "dog");

Uses joins (faster but can cause issues with large datasets).

Relation Expression Syntax

Expression Description
pets Load pets relation
[pets, movies] Load multiple relations
children.pets Nested relation
children.[pets, movies] Multiple nested
children.^ Recursive (all levels)
children.^3 Recursive (3 levels)
children.^1 Recursive (1 level)

Modifiers

// Define modifiers in model
class Person extends Model {
  static get modifiers() {
    return {
      dogs(builder) {
        builder.where("species", "dog");
      },

      orderByName(builder) {
        builder.orderBy("name");
      },
    };
  }
}

// Use in queries
const people = await Person.query().withGraphFetched("pets(dogs, orderByName)");

// Multiple modifiers
const people = await Person.query().withGraphFetched("pets(dogs, orderByName)");

modifyGraph

// Modify eager loaded relations
const people = await Person.query()
  .withGraphFetched("[pets, children.pets]")
  .modifyGraph("pets", (builder) => {
    builder.where("species", "dog");
  })
  .modifyGraph("children.pets", (builder) => {
    builder.orderBy("name");
  });

Runtime modification of relation queries.

Graph Inserts

Basic Graph Insert

// Insert with nested relations
const person = await Person.query().insertGraph({
  firstName: "John",
  lastName: "Doe",

  pets: [
    { name: "Fluffy", species: "cat" },
    { name: "Spot", species: "dog" },
  ],

  children: [
    {
      firstName: "Jane",
      pets: [{ name: "Max", species: "dog" }],
    },
  ],
});

Inserts person, pets, and children in one operation.

References

// Reference syntax for relations
const person = await Person.query().insertGraph({
  firstName: "John",

  pets: [
    {
      "#id": "fluffy",
      name: "Fluffy",
      species: "cat",
    },
  ],

  children: [
    {
      firstName: "Jane",
      // Relate to Fluffy using reference
      pets: [{ "#ref": "fluffy" }],
    },
  ],
});

#id creates reference, #ref uses it.

Insert Options

// Allow references
await Person.query().insertGraph(graph, {
  allowRefs: true,
});

// Relate existing records
await Person.query().insertGraph(
  {
    firstName: "John",
    movies: [
      { id: 10 }, // Relate existing movie
      { name: "New Movie" }, // Insert new
    ],
  },
  {
    relate: true,
  },
);

// Restrict allowed relations
await Person.query().insertGraph(graph, {
  allowGraph: "[pets, children.pets]",
});

Graph Upserts

Basic Upsert

// Update if ID present, insert if not
await Person.query().upsertGraph({
  id: 1, // Existing person - will update
  firstName: "John",

  pets: [
    { id: 5, name: "Fluffy" }, // Update existing
    { name: "Spot" }, // Insert new
  ],
});

Automatically inserts or updates based on presence of ID.

Upsert Options

// Relate existing records
await Person.query().upsertGraph(graph, {
  relate: true,
});

// Unrelate missing records
await Person.query().upsertGraph(
  {
    id: 1,
    movies: [{ id: 5 }],
  },
  {
    unrelate: true, // Unrelate all movies except 5
  },
);

// Don't delete missing records
await Person.query().upsertGraph(graph, {
  noDelete: true,
});

// Restrict allowed relations
await Person.query().upsertGraph(graph, {
  allowGraph: "[pets, children]",
});

Complex Upsert

await Person.query().upsertGraph(
  {
    id: 1,
    firstName: "John",

    pets: [
      { id: 5, name: "Fluffy Updated" }, // Update
      { name: "New Pet" }, // Insert
      // Pet with id=6 will be deleted if existed
    ],

    movies: [
      { id: 10 }, // Relate existing
    ],
  },
  {
    relate: ["movies"], // Only relate for movies
    unrelate: ["movies"], // Unrelate missing movies
    noDelete: ["pets"], // Don't delete pets
  },
);

Transactions

Transaction Callback

const { transaction } = require("objection");

// Automatic transaction
const person = await transaction(Person.knex(), async (trx) => {
  const person = await Person.query(trx).insert({ firstName: "John" });

  await person.$relatedQuery("pets", trx).insert({ name: "Fluffy" });

  return person;
});

// Automatically commits on success, rolls back on error

Recommended pattern - handles commit/rollback automatically.

Binding Models

const trx = await Person.startTransaction();

try {
  // Bind models to transaction
  const BoundPerson = Person.bindTransaction(trx);
  const BoundPet = Pet.bindTransaction(trx);

  const person = await BoundPerson.query().insert({ firstName: "John" });

  await BoundPet.query().insert({ name: "Fluffy", owner_id: person.id });

  await trx.commit();
} catch (err) {
  await trx.rollback();
  throw err;
}

Bind models to avoid passing trx to every query.

Manual Transactions

const knex = Person.knex();
const trx = await knex.transaction();

try {
  await Person.query(trx).insert({ firstName: "John" });

  await Pet.query(trx).insert({ name: "Fluffy" });

  await trx.commit();
} catch (err) {
  await trx.rollback();
  throw err;
}

Manual control over commit/rollback.

Validation

JSON Schema

class Person extends Model {
  static get jsonSchema() {
    return {
      type: "object",
      required: ["firstName", "lastName"],

      properties: {
        id: { type: "integer" },
        firstName: {
          type: "string",
          minLength: 1,
          maxLength: 255,
        },
        lastName: {
          type: "string",
          minLength: 1,
          maxLength: 255,
        },
        age: {
          type: "number",
          minimum: 0,
          maximum: 150,
        },
        email: {
          type: "string",
          format: "email",
        },
      },
    };
  }
}

Validates on insert, update, and patch.

Validation Errors

const { ValidationError } = require("objection");

try {
  await Person.query().insert({
    firstName: "J", // Too short
    age: -5, // Invalid
  });
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(err.data); // Validation error details
    // {
    //   firstName: [...],
    //   age: [...]
    // }
  }
}

Custom Validation

class Person extends Model {
  $beforeInsert(context) {
    await super.$beforeInsert(context);
    this.createdAt = new Date().toISOString();

    // Custom validation
    if (this.age < 0) {
      throw new Error('Age must be positive');
    }
  }

  $beforeUpdate(opt, context) {
    await super.$beforeUpdate(opt, context);
    this.updatedAt = new Date().toISOString();
  }
}

Lifecycle Hooks

Instance Hooks

class Person extends Model {
  async $beforeInsert(queryContext) {
    await super.$beforeInsert(queryContext);
    this.createdAt = new Date().toISOString();
  }

  async $afterInsert(queryContext) {
    await super.$afterInsert(queryContext);
    console.log("Person inserted:", this.id);
  }

  async $beforeUpdate(opt, queryContext) {
    await super.$beforeUpdate(opt, queryContext);
    this.updatedAt = new Date().toISOString();
  }

  async $afterUpdate(opt, queryContext) {
    await super.$afterUpdate(opt, queryContext);
    console.log("Person updated:", this.id);
  }

  async $beforeDelete(queryContext) {
    await super.$beforeDelete(queryContext);
    console.log("About to delete:", this.id);
  }

  async $afterDelete(queryContext) {
    await super.$afterDelete(queryContext);
    console.log("Person deleted");
  }
}

Called for each model instance.

Static Hooks

class Person extends Model {
  static async beforeInsert({ asFindQuery, inputItems }) {
    // Called once before all inserts
    console.log("Inserting", inputItems.length, "items");
  }

  static async afterInsert({ asFindQuery, inputItems }) {
    // Called once after all inserts
    console.log("Inserted", inputItems.length, "items");
  }

  static async beforeUpdate({ asFindQuery, inputItems }) {
    // Called once before update
  }

  static async afterUpdate({ asFindQuery, inputItems }) {
    // Called once after update
  }
}

Called once per query, not per instance.

Data Transformation

class Person extends Model {
  // Parse JSON from database
  $parseDatabaseJson(json) {
    json = super.$parseDatabaseJson(json);

    // Convert snake_case to camelCase
    if (json.first_name) {
      json.firstName = json.first_name;
      delete json.first_name;
    }

    return json;
  }

  // Format JSON for database
  $formatDatabaseJson(json) {
    json = super.$formatDatabaseJson(json);

    // Convert camelCase to snake_case
    if (json.firstName) {
      json.first_name = json.firstName;
      delete json.firstName;
    }

    return json;
  }

  // Parse external JSON (from API)
  $parseJson(json, opt) {
    json = super.$parseJson(json, opt);
    return json;
  }

  // Format for external JSON
  $formatJson(json) {
    json = super.$formatJson(json);
    return json;
  }
}

Examples

Complete CRUD Example

const { Model } = require("objection");

class Person extends Model {
  static get tableName() {
    return "persons";
  }
}

// Create
const person = await Person.query().insert({
  firstName: "John",
  lastName: "Doe",
  age: 30,
});

// Read
const allPeople = await Person.query();
const onePerson = await Person.query().findById(1);
const filtered = await Person.query().where("age", ">", 18).orderBy("lastName");

// Update
await Person.query().findById(1).patch({ age: 31 });

// Delete
await Person.query().deleteById(1);

Relationship Example

// Models
class Person extends Model {
  static get tableName() {
    return "persons";
  }

  static get relationMappings() {
    const Pet = require("./Pet");
    const Movie = require("./Movie");

    return {
      pets: {
        relation: Model.HasManyRelation,
        modelClass: Pet,
        join: {
          from: "persons.id",
          to: "pets.owner_id",
        },
      },

      movies: {
        relation: Model.ManyToManyRelation,
        modelClass: Movie,
        join: {
          from: "persons.id",
          through: {
            from: "persons_movies.person_id",
            to: "persons_movies.movie_id",
          },
          to: "movies.id",
        },
      },
    };
  }
}

// Queries
const people = await Person.query().withGraphFetched("[pets, movies]");

const person = await Person.query().findById(1);
const pets = await person.$relatedQuery("pets");

await person.$relatedQuery("pets").insert({
  name: "Fluffy",
  species: "cat",
});

Eager Loading with Modifiers

class Person extends Model {
  static get modifiers() {
    return {
      // Filter modifier
      adults(builder) {
        builder.where("age", ">=", 18);
      },

      // Sort modifier
      orderByAge(builder) {
        builder.orderBy("age", "desc");
      },

      // Select modifier
      minimal(builder) {
        builder.select("id", "firstName", "lastName");
      },
    };
  }
}

// Use modifiers
const people = await Person.query().modify("adults").modify("orderByAge");

// In eager loading
const people = await Person.query().withGraphFetched(
  "children(adults, orderByAge)",
);

// Runtime modification
const people = await Person.query()
  .withGraphFetched("[pets, children]")
  .modifyGraph("children", (builder) => {
    builder.where("age", ">", 10);
  });

Transaction Example

const { transaction } = require("objection");

async function transferOwnership(petId, newOwnerId) {
  return await transaction(Person.knex(), async (trx) => {
    // Get pet
    const pet = await Pet.query(trx).findById(petId);
    if (!pet) throw new Error("Pet not found");

    // Get new owner
    const newOwner = await Person.query(trx).findById(newOwnerId);
    if (!newOwner) throw new Error("Owner not found");

    // Update pet
    await pet.$query(trx).patch({ owner_id: newOwnerId });

    // Log transfer
    await TransferLog.query(trx).insert({
      pet_id: petId,
      new_owner_id: newOwnerId,
      transferred_at: new Date(),
    });

    return pet;
  });
}

Complex Query Example

// Find people with specific pets and movies
const people = await Person.query()
  .withGraphFetched("[pets(dogs), movies(action)]")
  .modifiers({
    dogs(builder) {
      builder.where("species", "dog");
    },
    action(builder) {
      builder.where("genre", "action");
    },
  })
  .where("age", ">", 18)
  .orderBy("lastName")
  .page(0, 20);

// Aggregation query
const stats = await Person.query()
  .select("age")
  .count("* as count")
  .avg("age as avgAge")
  .groupBy("age")
  .having("count", ">", 1);

// Subquery
const activePeople = await Person.query().where(
  "id",
  "in",
  Person.query()
    .select("owner_id")
    .from("pets")
    .where("last_checkup", ">", "2025-01-01"),
);

Also see