Objection.js
Objection.jsは、Node.js向けのSQL優先ORMで、開発者の邪魔をせず、SQL本来の力とデータベースエンジンの機能を最大限活用できるように設計されています。Knex.jsクエリビルダーの上に構築されており、SQLクエリビルダーの利点と強力なリレーション操作ツールの両方を提供。従来のORMというよりも「リレーショナル・クエリビルダー」として、SQLの透明性を保ちながら一般的な操作を簡単で楽しいものにします。直接的なSQL制御とオブジェクトマッピングの利点を絶妙にバランスさせた実用的なソリューションです。
GitHub概要
Vincit/objection.js
An SQL-friendly ORM for Node.js
トピックス
スター履歴
ライブラリ
Objection.js
概要
Objection.jsは、Node.js向けのSQL優先ORMで、開発者の邪魔をせず、SQL本来の力とデータベースエンジンの機能を最大限活用できるように設計されています。Knex.jsクエリビルダーの上に構築されており、SQLクエリビルダーの利点と強力なリレーション操作ツールの両方を提供。従来のORMというよりも「リレーショナル・クエリビルダー」として、SQLの透明性を保ちながら一般的な操作を簡単で楽しいものにします。直接的なSQL制御とオブジェクトマッピングの利点を絶妙にバランスさせた実用的なソリューションです。
詳細
Objection.js 2025年版は、Node.jsエコシステムにおけるSQL透明性とORM便利性の理想的なバランスを実現する成熟したライブラリとして確固たる地位を維持しています。Knex.jsの堅牢なクエリビルダー基盤により、PostgreSQL、MySQL、SQLiteを完全サポートし、各データベースの特性を活かした開発が可能。ES6クラスベースのモデル定義、JSONスキーマバリデーション、グラフ操作による柔軟なリレーション管理が特徴。複雑なJOIN、サブクエリ、ウィンドウ関数など高度なSQL機能をJavaScript/TypeScriptで自然に記述でき、SQLの知識を直接活かせる設計思想。リレーション定義の豊富さとクエリの最適化能力により、エンタープライズレベルのアプリケーション開発に適しています。
主な特徴
- SQL優先設計: Knex.js基盤によるSQLの透明性と直接制御
- リッチなリレーション機能: hasMany、belongsTo、manyToMany等の豊富なリレーション
- JSONスキーマバリデーション: モデル定義時の自動バリデーション機能
- グラフ操作: 複雑な入れ子構造データの効率的な操作
- TypeScript完全対応: 型安全性と優れた開発者体験
- 軽量かつ高性能: 最小限のオーバーヘッドで高いパフォーマンス
メリット・デメリット
メリット
- SQL知識を直接活かせる透明性の高いアーキテクチャ
- Knex.jsの堅牢な基盤による安定性とデータベース互換性
- 豊富なリレーション定義と直感的なグラフ操作API
- TypeScript対応による型安全性と優れた開発者体験
- 軽量設計による高いパフォーマンスと低いメモリ使用量
- 詳細なドキュメントと豊富なコミュニティサポート
デメリット
- SQL知識が必要で、初心者には学習コストが高い
- ActiveRecordパターンなどの自動化機能は限定的
- マイグレーション機能はKnex.jsに依存し、別途学習が必要
- 複雑なリレーションでは手動でのクエリ最適化が必要
- 最新のPrismaやDrizzleと比較すると型推論が限定的
- スキーマファーストなアプローチは非対応
デメリット
- SQL知識が必要で初心者には学習コストが高い
- スキーマ変更時の自動化機能が限定的
- モダンなORMと比較して型推論が限定的
- 複雑なリレーションでの手動最適化が必要
- マイグレーション機能がKnex.js依存
- リアルタイム機能やサブスクリプション非対応
参考ページ
書き方の例
インストールとプロジェクト設定
# Objection.jsとKnex.js、データベースドライバーのインストール
npm install objection knex
# PostgreSQL用
npm install pg
npm install --save-dev @types/pg
# MySQL用
npm install mysql2
# SQLite用
npm install sqlite3
# TypeScript用型定義
npm install --save-dev @types/node
// knexfile.js - データベース設定
module.exports = {
development: {
client: 'postgresql',
connection: {
host: 'localhost',
database: 'myapp_development',
user: 'username',
password: 'password'
},
pool: {
min: 2,
max: 10
},
migrations: {
tableName: 'knex_migrations',
directory: './migrations'
},
seeds: {
directory: './seeds'
}
},
production: {
client: 'postgresql',
connection: process.env.DATABASE_URL,
pool: {
min: 2,
max: 10
},
migrations: {
tableName: 'knex_migrations'
}
}
};
// app.js - 基本設定
const Knex = require('knex');
const { Model } = require('objection');
const knexConfig = require('./knexfile');
const knex = Knex(knexConfig.development);
// ObjectionにKnexインスタンスを渡す
Model.knex(knex);
基本的なモデル定義
// models/Person.js
const { Model } = require('objection');
class Person extends Model {
static get tableName() {
return 'persons';
}
static get idColumn() {
return 'id';
}
// JSONスキーマバリデーション
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 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0, maximum: 150 },
address: {
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' },
zipCode: { type: 'string' }
}
}
}
};
}
// 関連の定義
static get relationMappings() {
return {
pets: {
relation: Model.HasManyRelation,
modelClass: require('./Pet'),
join: {
from: 'persons.id',
to: 'pets.ownerId'
}
},
movies: {
relation: Model.ManyToManyRelation,
modelClass: require('./Movie'),
join: {
from: 'persons.id',
through: {
from: 'persons_movies.personId',
to: 'persons_movies.movieId'
},
to: 'movies.id'
}
},
mother: {
relation: Model.BelongsToOneRelation,
modelClass: Person,
join: {
from: 'persons.motherId',
to: 'persons.id'
}
},
children: {
relation: Model.HasManyRelation,
modelClass: Person,
join: {
from: 'persons.id',
to: 'persons.motherId'
}
}
};
}
// インスタンスメソッド
fullName() {
return `${this.firstName} ${this.lastName}`;
}
// 仮想属性
static get virtualAttributes() {
return ['fullName'];
}
}
module.exports = Person;
// TypeScript版モデル定義
import { Model, ModelObject, RelationMappings } from 'objection';
export class Person extends Model {
id!: number;
firstName!: string;
lastName!: string;
email?: string;
age?: number;
address?: {
street: string;
city: string;
zipCode: string;
};
// リレーション型定義
pets?: Pet[];
movies?: Movie[];
mother?: Person;
children?: Person[];
static tableName = 'persons';
static jsonSchema = {
type: 'object',
required: ['firstName', 'lastName'],
properties: {
id: { type: 'integer' },
firstName: { type: 'string', minLength: 1, maxLength: 255 },
lastName: { type: 'string', minLength: 1, maxLength: 255 },
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 0, maximum: 150 }
}
};
static relationMappings: RelationMappings = {
pets: {
relation: Model.HasManyRelation,
modelClass: () => Pet,
join: {
from: 'persons.id',
to: 'pets.ownerId'
}
}
};
fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
export type PersonShape = ModelObject<Person>;
CRUD操作とクエリ
const Person = require('./models/Person');
// CREATE - 新規作成
async function createPerson() {
const person = await Person.query().insert({
firstName: '太郎',
lastName: '田中',
email: '[email protected]',
age: 30,
address: {
street: '1-1-1 東京',
city: '東京都',
zipCode: '100-0001'
}
});
console.log('作成されたユーザー:', person);
return person;
}
// READ - 検索とフィルタリング
async function findPersons() {
// 全件取得
const allPersons = await Person.query();
// 条件付き検索
const adults = await Person.query()
.where('age', '>=', 18)
.orderBy('lastName')
.orderBy('firstName');
// 複雑な条件
const filteredPersons = await Person.query()
.where('age', '>', 25)
.where('email', 'like', '%@example.com')
.orWhere(builder => {
builder
.where('firstName', 'like', '太郎%')
.andWhere('age', '<', 40);
});
// JSON カラムの検索
const tokyoPersons = await Person.query()
.where('address:city', '東京都');
// ページネーション
const page = await Person.query()
.page(0, 10); // 0ページ目、10件ずつ
console.log('総件数:', page.total);
console.log('結果:', page.results);
return { allPersons, adults, filteredPersons };
}
// UPDATE - 更新
async function updatePerson(personId) {
// 単一レコード更新
const updatedPerson = await Person.query()
.findById(personId)
.patch({
age: 31,
'address.city': '大阪府'
});
// 複数レコード更新
const updatedCount = await Person.query()
.where('age', '<', 18)
.patch({ status: 'minor' });
// upsert(存在しなければ作成、存在すれば更新)
const upsertedPerson = await Person.query()
.upsertGraph({
id: personId,
firstName: '次郎',
lastName: '田中',
email: '[email protected]'
});
return { updatedPerson, updatedCount };
}
// DELETE - 削除
async function deletePerson(personId) {
// ID指定削除
const deletedCount = await Person.query()
.deleteById(personId);
// 条件指定削除
const deletedInactiveCount = await Person.query()
.where('lastLoginAt', '<', new Date(Date.now() - 365 * 24 * 60 * 60 * 1000))
.delete();
return { deletedCount, deletedInactiveCount };
}
// 実行例
async function runCrudExamples() {
try {
const newPerson = await createPerson();
const searchResults = await findPersons();
const updateResults = await updatePerson(newPerson.id);
console.log('CRUD操作完了');
} catch (error) {
console.error('エラー:', error);
}
}
リレーション操作とグラフクエリ
// リレーションを含む検索
async function findWithRelations() {
// 1対多リレーション
const personsWithPets = await Person.query()
.withGraphFetched('pets')
.where('age', '>', 20);
// 深いネスト
const personsWithMoviesAndActors = await Person.query()
.withGraphFetched('[pets, movies.[actors, director]]')
.findById(1);
// フィルタリング付きリレーション
const personWithRecentMovies = await Person.query()
.withGraphFetched('movies(recentMovies)')
.findById(1);
// モディファイアの定義
Person.modifiers = {
recentMovies(builder) {
builder.where('releaseDate', '>', '2020-01-01');
}
};
return { personsWithPets, personsWithMoviesAndActors };
}
// グラフの挿入・更新・削除
async function graphOperations() {
// 関連データ込みで作成
const personWithRelations = await Person.query()
.upsertGraph({
firstName: '花子',
lastName: '佐藤',
pets: [
{
name: 'ポチ',
species: 'dog'
},
{
name: 'タマ',
species: 'cat'
}
],
movies: [
{
'#dbRef': 1 // 既存のID 1の映画を関連付け
},
{
name: '新作映画',
releaseDate: '2024-01-01'
}
]
});
// 部分的なグラフ更新
await Person.query()
.upsertGraph({
id: 1,
firstName: '更新された名前',
pets: [
{
id: 1,
name: '更新されたペット名'
},
{
// IDなし = 新規作成
name: '新しいペット',
species: 'bird'
}
]
});
// リレーションのみ操作
const person = await Person.query().findById(1);
// ペットを追加
await person.$relatedQuery('pets')
.insert({
name: 'ハム太郎',
species: 'hamster'
});
// 映画との関連を追加(多対多)
await person.$relatedQuery('movies')
.relate(movieId); // 既存の映画ID
// 関連の削除
await person.$relatedQuery('pets')
.where('species', 'hamster')
.delete();
return personWithRelations;
}
// 集約とグループ化
async function aggregationQueries() {
// 基本的な集約
const stats = await Person.query()
.select('age')
.count('id as personCount')
.avg('age as averageAge')
.min('age as minAge')
.max('age as maxAge')
.groupBy('age')
.having('personCount', '>', 1);
// リレーションでの集約
const personsWithPetCount = await Person.query()
.select('persons.*')
.count('pets.id as petCount')
.leftJoin('pets', 'persons.id', 'pets.ownerId')
.groupBy('persons.id');
// サブクエリ集約
const personsWithStats = await Person.query()
.select('*')
.select(
Person.relatedQuery('pets')
.count()
.as('petCount')
)
.select(
Person.relatedQuery('movies')
.avg('rating')
.as('averageMovieRating')
);
return { stats, personsWithPetCount, personsWithStats };
}
高度なクエリとRaw SQL
// Raw SQLの使用
async function rawSqlQueries() {
// Raw SQL式
const personsWithComputedField = await Person.query()
.select('*')
.select(Person.raw('UPPER(first_name || \' \' || last_name) as full_name_upper'))
.where(Person.raw('age + ? > ?', [5, 30]));
// Raw SQLクエリ
const customQuery = await Person.knex().raw(`
SELECT p.first_name, p.last_name, COUNT(pets.id) as pet_count
FROM persons p
LEFT JOIN pets ON p.id = pets.owner_id
WHERE p.age > ?
GROUP BY p.id, p.first_name, p.last_name
HAVING COUNT(pets.id) > ?
`, [25, 1]);
// With句を使った複雑なクエリ
const withQuery = await Person.query()
.with('adult_persons', Person.query()
.select('*')
.where('age', '>=', 18)
)
.select('*')
.from('adult_persons as ap')
.join('pets', 'ap.id', 'pets.owner_id');
return { personsWithComputedField, customQuery, withQuery };
}
// ウィンドウ関数とCTE
async function advancedSqlFeatures() {
// ウィンドウ関数
const rankedPersons = await Person.query()
.select('*')
.select(Person.raw('ROW_NUMBER() OVER (ORDER BY age DESC) as age_rank'))
.select(Person.raw('RANK() OVER (PARTITION BY LEFT(last_name, 1) ORDER BY age) as name_group_rank'));
// 再帰CTE(階層データ)
const familyTree = await Person.knex().raw(`
WITH RECURSIVE family_tree AS (
-- Base case: 親がいない人(ルート)
SELECT id, first_name, last_name, mother_id, 0 as level
FROM persons
WHERE mother_id IS NULL
UNION ALL
-- Recursive case: 子供を追加
SELECT p.id, p.first_name, p.last_name, p.mother_id, ft.level + 1
FROM persons p
INNER JOIN family_tree ft ON p.mother_id = ft.id
)
SELECT * FROM family_tree
ORDER BY level, last_name, first_name
`);
return { rankedPersons, familyTree };
}
トランザクションとエラーハンドリング
// トランザクション管理
async function transactionExamples() {
// 基本的なトランザクション
const result = await Person.transaction(async (trx) => {
// 新しい人を作成
const person = await Person.query(trx)
.insert({
firstName: '取引',
lastName: 'テスト',
email: '[email protected]'
});
// ペットを追加
const pet = await person.$relatedQuery('pets', trx)
.insert({
name: 'トランザクションペット',
species: 'dog'
});
// 何らかの条件でロールバック
if (person.firstName === '失敗') {
throw new Error('意図的な失敗');
}
return { person, pet };
});
console.log('トランザクション成功:', result);
}
// エラーハンドリング
async function errorHandlingExamples() {
try {
// バリデーションエラー
await Person.query().insert({
firstName: '', // 必須フィールドが空
lastName: 'テスト'
});
} catch (error) {
if (error instanceof ValidationError) {
console.log('バリデーションエラー:', error.data);
}
}
try {
// 一意制約エラー
await Person.query().insert({
firstName: '重複',
lastName: 'ユーザー',
email: '[email protected]' // 既存のメール
});
} catch (error) {
if (error.code === '23505') { // PostgreSQL unique violation
console.log('一意制約違反:', error.detail);
}
}
// カスタムエラーハンドリング
const handleDbError = (error) => {
switch (error.code) {
case '23505': // Unique violation
throw new Error('このメールアドレスは既に使用されています');
case '23503': // Foreign key violation
throw new Error('参照されているデータが存在しません');
case '23502': // Not null violation
throw new Error('必須項目が入力されていません');
default:
throw error;
}
};
try {
await Person.query().insert(invalidData);
} catch (error) {
handleDbError(error);
}
}
// 実行例
async function runAdvancedExamples() {
try {
await findWithRelations();
await graphOperations();
await aggregationQueries();
await rawSqlQueries();
await transactionExamples();
} catch (error) {
console.error('高度な操作でエラー:', error);
}
}
パフォーマンス最適化とベストプラクティス
// N+1問題の解決
async function optimizedQueries() {
// ❌ N+1問題が発生するパターン
const persons = await Person.query();
for (const person of persons) {
person.pets = await person.$relatedQuery('pets'); // N回のクエリ
}
// ✅ 最適化されたパターン
const personsWithPets = await Person.query()
.withGraphFetched('pets');
// Eager loading with フィルタ
const optimizedQuery = await Person.query()
.withGraphFetched('[pets(onlyDogs), movies(recentMovies)]')
.modifiers({
onlyDogs(builder) {
builder.where('species', 'dog');
},
recentMovies(builder) {
builder.where('releaseDate', '>', '2020-01-01');
}
});
return personsWithPets;
}
// データベースコネクション管理
function connectionManagement() {
// コネクションプール設定
const knex = require('knex')({
client: 'pg',
connection: {
host: 'localhost',
user: 'username',
password: 'password',
database: 'myapp'
},
pool: {
min: 2, // 最小コネクション数
max: 10, // 最大コネクション数
acquireTimeoutMillis: 30000, // 取得タイムアウト
idleTimeoutMillis: 600000 // アイドルタイムアウト
},
acquireConnectionTimeout: 60000,
debug: false // 本番環境ではfalse
});
// 適切なクリーンアップ
process.on('SIGINT', async () => {
console.log('アプリケーション終了中...');
await knex.destroy();
process.exit(0);
});
}
// クエリデバッグとロギング
function debuggingAndLogging() {
// クエリログの有効化
const knex = require('knex')({
client: 'pg',
connection: connectionConfig,
debug: true // 開発環境でのみtrue
});
// カスタムクエリログ
knex.on('query', (queryData) => {
console.log('SQL:', queryData.sql);
console.log('Bindings:', queryData.bindings);
});
// 実行時間の監視
knex.on('query-response', (response, queryData, builder) => {
console.log(`Query took ${Date.now() - queryData.__queryStartTime}ms`);
});
// エラーログ
knex.on('query-error', (error, queryData) => {
console.error('Query error:', error);
console.error('Failed query:', queryData.sql);
});
}
// バリデーションのカスタマイズ
class PersonWithCustomValidation extends Person {
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
// カスタムバリデーション
if (this.age < 0) {
throw new ValidationError({
type: 'ModelValidation',
message: '年齢は0以上である必要があります'
});
}
// 自動的なタイムスタンプ
this.createdAt = new Date().toISOString();
this.updatedAt = new Date().toISOString();
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
this.updatedAt = new Date().toISOString();
}
// 非同期バリデーション
async $afterValidate(json, opt) {
await super.$afterValidate(json, opt);
// メールアドレスの重複チェック
if (json.email) {
const existing = await Person.query()
.where('email', json.email)
.whereNot('id', this.id || 0)
.first();
if (existing) {
throw new ValidationError({
type: 'UniqueViolation',
message: 'このメールアドレスは既に使用されています'
});
}
}
}
}