Quarry
Quarryは、SQL文字列の代わりにASTオブジェクトを生成するPetraDB用の型安全なクエリビルダーで、パーサーを完全にバイパスします。スキーマ定義がDDL、クエリ、コンパイル時TypeScript型の唯一の情報源となります。
インストール
Section titled “インストール”npm install @petradb/quarryセットアップ
Section titled “セットアップ”import { Session } from "@petradb/engine";import { quarry } from "@petradb/quarry";
const session = new Session({ storage: "memory" });const db = quarry(session);ストレージモード
Section titled “ストレージモード”// インメモリ(デフォルト)new Session({ storage: "memory" });
// ファイルベースの永続ストレージnew Session({ storage: "persistent", path: "./mydb.petra" });スキーマ定義
Section titled “スキーマ定義”Quarryのカラムコンストラクタを使用してテーブルを定義します。スキーマはテーブル作成、クエリ構築、TypeScript型推論を駆動します。
import { table, serial, text, integer, boolean } from "@petradb/quarry";
const users = table("users", { id: serial("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull().unique(), age: integer("age"), active: boolean("active").notNull().default(true),});| コンストラクタ | SQL型 | TypeScript型 |
|---|---|---|
serial(name) | SERIAL | number |
bigserial(name) | BIGSERIAL | number |
text(name) | TEXT | string |
varchar(name, length?) | VARCHAR(n) | string |
char(name, length?) | CHAR(n) | string |
integer(name) | INTEGER | number |
smallint(name) | SMALLINT | number |
bigint(name) | BIGINT | number |
doublePrecision(name) | DOUBLE | number |
real(name) | REAL | number |
numeric(name, precision?, scale?) | NUMERIC(p,s) | number |
boolean(name) | BOOLEAN | boolean |
uuid(name) | UUID | string |
timestamp(name) | TIMESTAMP | string |
timestamptz(name) | TIMESTAMPTZ | string |
date(name) | DATE | string |
time(name) | TIME | string |
timetz(name) | TIMETZ | string |
interval(name) | INTERVAL | string |
json(name) | JSON | unknown |
bytea(name) | BYTEA | number[] |
カラム修飾子
Section titled “カラム修飾子”| 修飾子 | 効果 |
|---|---|
.notNull() | カラムはNULL不可。InferSelect型からnullを除外 |
.default(value) | InferInsertでカラムがオプションに |
.primaryKey() | 主キー。notNull + hasDefaultを暗黙的に設定(serialでは自動インクリメント) |
.unique() | ユニーク制約を追加 |
.references(table, column) | 外部キー参照を追加 |
推論される型
Section titled “推論される型”Quarryは各テーブル定義から2つの型を推論します。
import type { InferSelect, InferInsert } from "@petradb/quarry";
type User = InferSelect<typeof users>;// { id: number, name: string, email: string, age: number | null, active: boolean }
type NewUser = InferInsert<typeof users>;// { name: string, email: string, age?: number | null, active?: boolean, id?: number }InferSelect — クエリが返す行の型です。
notNullカラム → 非NULLable型- NULLableカラム →
type | null
InferInsert — .values()が受け入れる型です。
- デフォルトのない
notNullカラム → 必須 - デフォルト付きカラム(
.default()、.primaryKey()、serial) → オプション - NULLableカラム → オプション、
nullを受け入れ
テーブル作成
Section titled “テーブル作成”await db.createTable(users);スキーマ定義からCREATE TABLEコマンドを生成して実行します — SQLは不要です。
Insert
Section titled “Insert”// 単一行 — すべてのカラムを含む挿入された行を返しますconst [user] = await db .insert(users) .values({ name: "Alice", email: "alice@example.com", age: 30 }) .execute();// user.id → 自動生成されたserial// user.active → true(デフォルト)
// 複数行await db .insert(users) .values( { name: "Bob", email: "bob@example.com", age: 25 }, { name: "Charlie", email: "charlie@example.com" }, ) .execute();InsertにはデフォルトのないnotNullカラムがすべて必要です。オプションフィールドは省略できます。TypeScriptがこれをコンパイル時に強制します。
RETURNING
Section titled “RETURNING”デフォルトでは、insertはすべてのカラム(*)を返します。.returning()で特定のカラムを選択します。
const [{ id }] = await db .insert(users) .values({ name: "Alice", email: "alice@example.com" }) .returning(users.id) .execute();Upsert(ON CONFLICT)
Section titled “Upsert(ON CONFLICT)”onConflictDoNothing()またはonConflictDoUpdate()でinsert時のコンフリクトを処理します。
// コンフリクト行をサイレントにスキップawait db .insert(users) .values({ name: "Alice", email: "alice@example.com" }) .onConflictDoNothing() .execute();
// コンフリクト発生時に特定のカラムを更新await db .insert(users) .values({ name: "Alice", email: "alice@example.com", age: 31 }) .onConflictDoUpdate(["email"], { name: "Alice Updated", age: 31 }) .execute();onConflictDoUpdateの最初の引数はコンフリクトカラムを指定し、2番目は更新するカラムを指定します。どちらも型安全で、TypeScriptが有効なカラムキーのみが使用されることを強制します。
INSERT…SELECT
Section titled “INSERT…SELECT”リテラル値の代わりにクエリから行を挿入します。
// すべてのアクティブユーザーをアーカイブconst query = db .select(users.name, users.email) .from(users) .where(eq(users.active, true));
await db.insertFrom(archive, query, ["name", "email"]).execute();2番目の引数はselectビルダーです。オプションの3番目の引数は、どのターゲットカラムに値を入れるかを指定します — 省略した場合、エンジンはクエリがすべてのカラムの値を生成することを期待します。
// カラムリストなし(クエリがすべてのターゲットカラムに一致する必要があります)await db.insertFrom(archive, query).execute();
// onConflictDoNothing付きawait db.insertFrom(archive, query, ["name", "email"]).onConflictDoNothing().execute();Select
Section titled “Select”import { eq, gt, asc, desc } from "@petradb/quarry";
// すべての行(SELECT *)const allUsers = await db.from(users).execute();
// WHERE句const alice = await db .from(users) .where(eq(users.name, "Alice")) .execute();
// 特定のカラムconst names = await db .select(users.name, users.email) .from(users) .execute();
// ORDER、LIMIT、OFFSETconst page = await db .from(users) .orderBy(asc(users.name)) .limit(10) .offset(20) .execute();
// DISTINCTconst statuses = await db .select(users.active) .from(users) .distinct() .execute();
// DISTINCT ON — 指定カラムの各ユニーク値ごとに1行const perCategory = await db .from(products) .distinctOn(products.category) .orderBy(asc(products.category), asc(products.price)) .execute();// 各カテゴリで最も安い商品を返しますカラムはテーブルオブジェクトのプロパティとして直接アクセスします。TypeScriptはスキーマに存在しないカラムへのアクセスを防止します。
users.name; // ✓ コンパイル可能users.title; // ✗ コンパイルエラー — 'title'はusersに存在しないimport { eq, ne, gt, gte, lt, lte, like, ilike } from "@petradb/quarry";import { notLike, notIlike, isDistinctFrom, isNotDistinctFrom } from "@petradb/quarry";
eq(users.name, "Alice") // name = 'Alice'ne(users.name, "Bob") // name != 'Bob'gt(users.age, 21) // age > 21gte(users.age, 18) // age >= 18lt(users.age, 65) // age < 65lte(users.age, 30) // age <= 30like(users.name, "A%") // name LIKE 'A%'notLike(users.name, "A%") // name NOT LIKE 'A%'ilike(users.email, "%@x%") // email ILIKE '%@x%'notIlike(users.email, "%@x%")
// NULL安全な比較isDistinctFrom(users.age, null) // age IS DISTINCT FROM NULLisNotDistinctFrom(users.age, null) // age IS NOT DISTINCT FROM NULLimport { and, or, not } from "@petradb/quarry";
and(eq(users.active, true), gt(users.age, 18))or(eq(users.name, "Alice"), eq(users.name, "Bob"))not(eq(users.active, false))and()とor()は任意の数の引数を受け取ります。
and(cond1, cond2, cond3) // cond1 AND cond2 AND cond3NULLチェック
Section titled “NULLチェック”import { isNull, isNotNull } from "@petradb/quarry";
isNull(users.age) // age IS NULLisNotNull(users.age) // age IS NOT NULLブーリアンテスト
Section titled “ブーリアンテスト”import { isTrue, isNotTrue, isFalse, isNotFalse, isUnknown, isNotUnknown } from "@petradb/quarry";
isTrue(users.active) // active IS TRUEisNotTrue(users.active) // active IS NOT TRUEisFalse(users.active) // active IS FALSEisNotFalse(users.active) // active IS NOT FALSEisUnknown(users.active) // active IS UNKNOWNisNotUnknown(users.active) // active IS NOT UNKNOWNコレクション
Section titled “コレクション”import { inList, notInList, between, notBetween, betweenSymmetric } from "@petradb/quarry";
inList(users.name, ["Alice", "Bob", "Charlie"]) // name IN (...)notInList(users.id, [1, 2, 3]) // id NOT IN (...)between(users.age, 18, 65) // age BETWEEN 18 AND 65notBetween(users.age, 18, 65) // age NOT BETWEEN 18 AND 65betweenSymmetric(users.age, 65, 18) // age BETWEEN SYMMETRIC 65 AND 18notBetweenSymmetric(users.age, 65, 18) // age NOT BETWEEN SYMMETRIC 65 AND 18import { add, sub, mul, div, mod, pow, neg } from "@petradb/quarry";
add(users.age, 10) // age + 10sub(users.age, 5) // age - 5mul(users.age, 2) // age * 2div(users.age, 3) // age / 3mod(users.age, 2) // age % 2pow(users.age, 2) // age ^ 2neg(users.age) // -age文字列演算子
Section titled “文字列演算子”import { concat } from "@petradb/quarry";
concat(users.name, " Jr.") // name || ' Jr.'ビット演算子
Section titled “ビット演算子”import { bitAnd, bitOr, bitXor, bitNot, leftShift, rightShift } from "@petradb/quarry";
bitAnd(users.flags, 0xFF) // flags & 255bitOr(users.flags, 1) // flags | 1bitXor(users.flags, 0xFF) // flags # 255bitNot(users.flags) // ~flagsleftShift(users.flags, 2) // flags << 2rightShift(users.flags, 1) // flags >> 1JSON演算子
Section titled “JSON演算子”import { jsonGet, jsonGetText, jsonPath, jsonPathText } from "@petradb/quarry";import { jsonContains, jsonContainedBy, jsonHasKey, jsonHasAnyKey, jsonHasAllKeys } from "@petradb/quarry";
jsonGet(t.data, "name") // data -> 'name'jsonGetText(t.data, "name") // data ->> 'name'jsonPath(t.data, path) // data #> pathjsonPathText(t.data, path) // data #>> pathjsonContains(t.data, other) // data @> otherjsonContainedBy(t.data, other) // data <@ otherjsonHasKey(t.data, "key") // data ? 'key'jsonHasAnyKey(t.data, keys) // data ?| keysjsonHasAllKeys(t.data, keys) // data ?& keysimport { arrayOverlap } from "@petradb/quarry";
arrayOverlap(t.tags, t.otherTags) // tags && otherTags(配列のオーバーラップ)名前付きヘルパーでカバーされない演算子にはop()とunaryOp()を使用します。
import { op, unaryOp } from "@petradb/quarry";
op(users.age, ">=", 18) // age >= 18unaryOp("NOT", eq(users.active, true))import { caseWhen, literal } from "@petradb/quarry";
caseWhen( [ { when: gt(users.age, 60), then: literal("senior") }, { when: gt(users.age, 18), then: literal("adult") }, ], "minor", // else)import { cast } from "@petradb/quarry";
cast(users.age, "text") // CAST(age AS TEXT)cast(users.age, "double") // CAST(age AS DOUBLE)エイリアスとリテラル
Section titled “エイリアスとリテラル”import { alias, literal } from "@petradb/quarry";
alias(add(users.age, 10), "age_plus_10")
literal("hello") // 文字列literal(42) // 数値literal(true) // ブーリアンliteral(null) // null集計とグルーピング
Section titled “集計とグルーピング”組み込み集計関数
Section titled “組み込み集計関数”import { count, sum, avg, min, max, alias } from "@petradb/quarry";import { stringAgg, arrayAgg, boolAnd, boolOr, jsonAgg, jsonObjectAgg } from "@petradb/quarry";
// すべての行をカウントconst [{ total }] = await db .select(alias(count(), "total")) .from(users) .execute();
// GROUP BYと集計const stats = await db .select(users.active, alias(count(), "cnt")) .from(users) .groupBy(users.active) .execute();
// HAVINGconst popular = await db .select(users.active, alias(count(), "cnt")) .from(users) .groupBy(users.active) .having(gt(alias(count(), "cnt"), 5)) .execute();
// その他の集計関数sum(users.age) // SUM(age)avg(users.age) // AVG(age)min(users.age) // MIN(age)max(users.age) // MAX(age)stringAgg(users.name, ", ") // STRING_AGG(name, ', ')arrayAgg(users.name) // ARRAY_AGG(name)boolAnd(users.active) // BOOL_AND(active)boolOr(users.active) // BOOL_OR(active)jsonAgg(users.name) // JSON_AGG(name)jsonObjectAgg(users.name, users.age) // JSON_OBJECT_AGG(name, age)import { variance, varSamp, varPop, stddev, stddevSamp, stddevPop } from "@petradb/quarry";
variance(emp.salary) // VARIANCE(salary) — 標本分散varSamp(emp.salary) // VAR_SAMP(salary) — varianceと同じvarPop(emp.salary) // VAR_POP(salary) — 母分散stddev(emp.salary) // STDDEV(salary) — 標本標準偏差stddevSamp(emp.salary) // STDDEV_SAMP(salary) — stddevと同じstddevPop(emp.salary) // STDDEV_POP(salary) — 母標準偏差import { bitAndAgg, bitOrAgg, bitXorAgg } from "@petradb/quarry";
bitAndAgg(emp.flags) // BIT_AND(flags)bitOrAgg(emp.flags) // BIT_OR(flags)bitXorAgg(emp.flags) // BIT_XOR(flags)import { every } from "@petradb/quarry";
every(emp.active) // EVERY(active) — すべての行がtrueの場合にtrue集計FILTER
Section titled “集計FILTER”filter()で集計が処理する行を制限します。
import { filter } from "@petradb/quarry";
// COUNT(*) FILTER (WHERE salary > 100)filter(count(), gt(emp.salary, 100))
// SUM(salary) FILTER (WHERE active = true)filter(sum(emp.salary), eq(emp.active, true))複数のフィルター付き集計の例:
const [row] = await db .select( alias(count(), "total"), alias(filter(count(), gt(employees.salary, 100)), "high_earners"), alias(filter(sum(employees.salary), eq(employees.active, true)), "active_payroll"), ) .from(employees) .execute();fn()で任意のSQL関数を呼び出します。
import { fn } from "@petradb/quarry";
fn("upper", users.name) // UPPER(name)fn("coalesce", users.age, 0) // COALESCE(age, 0)fn("length", users.name) // LENGTH(name)fn("lower", users.email) // LOWER(email)fn("abs", users.age) // ABS(age)fn("round", users.score, 2) // ROUND(score, 2)Quarryはinner、left、right、full、crossジョインをコンパイル時の結果型付きでサポートします。
Innerジョイン
Section titled “Innerジョイン”両テーブルのすべてのカラムが結果に含まれます。NULLabilityは元のスキーマから保持されます。
const posts = table("posts", { id: serial("id").primaryKey(), userId: integer("user_id").notNull(), title: text("title").notNull(), body: text("body"),});
const rows = await db .from(users) .innerJoin(posts, eq(users.id, posts.userId)) .where(eq(users.name, "Alice")) .execute();
// 結果型: (InferSelect<users> & InferSelect<posts>)[]// rows[0].name → string// rows[0].title → string// rows[0].body → string | null (postsスキーマでNULLable)Leftジョイン
Section titled “Leftジョイン”ジョインされたテーブルのカラムはすべてNULLableになります(マッチしない行はnullを生成するため)。
const rows = await db .from(users) .leftJoin(posts, eq(users.id, posts.userId)) .execute();
// 結果型: (InferSelect<users> & Nullable<InferSelect<posts>>)[]// rows[0].name → string (ベーステーブル、影響なし)// rows[0].title → string | null (leftジョインでNULLableに)// rows[0].userId → number | null (leftジョインでNULLableに)Rightジョイン
Section titled “Rightジョイン”ベーステーブルのカラムがNULLableになり、ジョインされたテーブルのカラムは元のNULLabilityを保持します。
const rows = await db .from(users) .rightJoin(posts, eq(users.id, posts.userId)) .execute();
// 結果型: (Nullable<InferSelect<users>> & InferSelect<posts>)[]// rows[0].name → string | null (rightジョインでベーステーブルがNULLableに)// rows[0].title → string (ジョインテーブル、影響なし)Fullジョイン
Section titled “Fullジョイン”両サイドがNULLableになります。
const rows = await db .from(users) .fullJoin(posts, eq(users.id, posts.userId)) .execute();
// 結果型: (Nullable<InferSelect<users>> & Nullable<InferSelect<posts>>)[]// rows[0].name → string | null// rows[0].title → string | nullCrossジョイン
Section titled “Crossジョイン”両テーブルのデカルト積を生成します — on条件なし。
const rows = await db .from(users) .crossJoin(posts) .execute();
// 結果型: (InferSelect<users> & InferSelect<posts>)[]// user × postのすべての組み合わせチェーンジョイン
Section titled “チェーンジョイン”複数のジョインは型を正しく蓄積します。
const comments = table("comments", { id: serial("id").primaryKey(), postId: integer("post_id").notNull(), content: text("content").notNull(),});
const rows = await db .from(users) .innerJoin(posts, eq(users.id, posts.userId)) .leftJoin(comments, eq(posts.id, comments.postId)) .execute();
// postsカラム: 非NULL (innerジョイン)// commentsカラム: NULLable (leftジョイン)// rows[0].title → string (innerジョイン)// rows[0].content → string | null (leftジョイン)カラム選択付きジョイン
Section titled “カラム選択付きジョイン”const rows = await db .select(users.name, posts.title) .from(users) .innerJoin(posts, eq(users.id, posts.userId)) .execute();集計付きジョイン
Section titled “集計付きジョイン”const rows = await db .select(users.name, alias(count(), "post_count")) .from(users) .innerJoin(posts, eq(users.id, posts.userId)) .groupBy(users.name) .orderBy(desc(alias(count(), "post_count"))) .execute();テーブルエイリアス
Section titled “テーブルエイリアス”tableAs()でエイリアス付きテーブルを作成し、セルフジョインや同じテーブルが複数回出現する場合に使用します。
import { tableAs } from "@petradb/quarry";
const mgr = tableAs(employees, "mgr");const emp = tableAs(employees, "emp");
const rows = await db .select( alias(emp.name, "employee"), alias(mgr.name, "manager"), ) .from(emp) .leftJoin(mgr, eq(emp.managerId, mgr.id)) .execute();エイリアスは型安全です — mgr.nameはemployeesスキーマにnameが存在することを引き続き強制します。
INサブクエリ
Section titled “INサブクエリ”import { inSubquery, notInSubquery } from "@petradb/quarry";
// 少なくとも1つの投稿があるユーザーconst rows = await db .from(users) .where( inSubquery(users.id, db.select(posts.userId).from(posts)), ) .execute();
// 投稿がないユーザーconst rows = await db .from(users) .where( notInSubquery(users.id, db.select(posts.userId).from(posts)), ) .execute();EXISTSサブクエリ
Section titled “EXISTSサブクエリ”import { exists } from "@petradb/quarry";
const rows = await db .from(users) .where( exists( db.select(literal(1)).from(posts).where(eq(posts.userId, users.id)), ), ) .execute();スカラーサブクエリ
Section titled “スカラーサブクエリ”subquery()でselectをスカラー値としてラップします。
import { subquery } from "@petradb/quarry";
// 平均年齢より上のユーザーconst rows = await db .from(users) .where( gt(users.age, subquery(db.select(avg(users.age)).from(users))), ) .execute();サブクエリ関数(subquery、exists、inSubquery、notInSubquery)は任意のクエリビルダーを直接受け入れます — 中間変換は不要です。
import { asc, desc } from "@petradb/quarry";
// 基本的なソートdb.from(users).orderBy(asc(users.name))db.from(users).orderBy(desc(users.age))
// 複数カラムdb.from(users).orderBy(asc(users.name), desc(users.age))
// NULLS FIRST / NULLS LASTdb.from(users).orderBy(asc(users.age, { nulls: "first" }))db.from(users).orderBy(desc(users.age, { nulls: "last" }))nullsが指定されていない場合、エンジンはデフォルトの動作を使用します(昇順ではNULLが最後、降順ではNULLが最初にソートされます)。
Update
Section titled “Update”// WHERE付きUpdateconst result = await db .update(users) .set({ age: 31 }) .where(eq(users.name, "Alice")) .execute();// result.rowCount → 1
// 複数フィールドの更新await db .update(users) .set({ name: "Alice Smith", age: 32, active: false }) .where(eq(users.id, 1)) .execute();
// NULLに設定await db .update(users) .set({ age: null }) .where(eq(users.name, "Bob")) .execute();.set()メソッドはPartial<InferSelect<T>>を受け入れます — TypeScriptが有効なカラム名と型を強制します。
UPDATE…FROM
Section titled “UPDATE…FROM”別のテーブルをジョインして更新を駆動します。
const priceUpdates = table("price_updates", { id: serial("id").primaryKey(), productName: text("product_name").notNull(), newPrice: integer("new_price").notNull(),});
await db .update(products) .set({ price: 0 }) // 値を設定; 条件ロジックにはWHEREでカラム参照を使用 .from(priceUpdates) .where(eq(products.name, priceUpdates.productName)) .execute();.from()は複数テーブルを受け入れます。
db.update(t1).set({ ... }).from(t2, t3).where(and(...))RETURNING
Section titled “RETURNING”UpdateとDeleteは.returning()で影響を受けた行を取得できます。
const result = await db .update(users) .set({ active: false }) .where(lt(users.age, 18)) .returning(users.id, users.name) .execute();// result.rows → [{ id: 3, name: "Charlie" }, ...]Delete
Section titled “Delete”const result = await db .delete(users) .where(eq(users.name, "Alice")) .execute();// result.rowCount → 1DELETE…USING
Section titled “DELETE…USING”別のテーブルをジョインして削除する行を決定します。
const deleteList = table("delete_list", { id: serial("id").primaryKey(), userName: text("user_name").notNull(),});
await db .delete(users) .using(deleteList) .where(eq(users.name, deleteList.userName)) .execute();.using()は複数テーブルを受け入れます。
db.delete(t1).using(t2, t3).where(and(...))トランザクション
Section titled “トランザクション”自動コミット/ロールバック付きで複数の操作をトランザクションにラップします。
const result = await db.transaction(async (tx) => { const [user] = await tx .insert(users) .values({ name: "Alice", email: "alice@example.com" }) .execute();
await tx .insert(posts) .values({ userId: user.id, title: "First Post" }) .execute();
return user;});// いずれかの操作がスローした場合、トランザクション全体がロールバックされますコールバックはトランザクションにスコープされたQuarryDBインスタンスを受け取ります。コールバックの戻り値がtransaction()の戻り値になり、型は保持されます。
すべてのビルダーには.toAST()メソッドがあり、実行せずに生のASTオブジェクトを返します。デバッグ、ログ記録、または高レベルの抽象化の構築に便利です。
const ast = db .from(users) .where(eq(users.name, "Alice")) .orderBy(asc(users.id)) .limit(10) .toAST();
console.log(JSON.stringify(ast, null, 2));// {// "kind": "query",// "query": {// "kind": "select",// "exprs": [{ "kind": "star" }],// "from": [{ "kind": "table", "name": "users" }],// "where": { "kind": "binary", "left": ..., "op": "=", "right": ... },// "orderBy": [{ "expr": ..., "direction": "asc" }],// "limit": 10// }// }Quarryはクエリのv ASTを表すプレーンなJavaScriptオブジェクト(kindフィールドを持つ判別共用体)を構築します。.execute()を呼び出すと、これらのオブジェクトはエンジンのexecuteAST()メソッドに渡され、エンジンの内部Scala ASTに直接変換されます — SQL文字列の生成とパースを完全にスキップします。
スキーマ → ビルダーAPI → JS ASTオブジェクト → エンジンAST → 書き換え → 実行 ↑ SQLパーサーなしこれによりQuarryはSQLと同じクエリ機能を持ちながら、パースのオーバーヘッドを排除し、完全なコンパイル時型安全性を実現します。
クリーンアップ
Section titled “クリーンアップ”await session.close();