Aller au contenu

Quarry

Quarry est un constructeur de requêtes type-safe pour PetraDB qui génère des objets AST au lieu de chaînes SQL, contournant entièrement le parseur. Les définitions de schémas servent de source unique de vérité pour le DDL, les requêtes et les types TypeScript à la compilation.

Fenêtre de terminal
npm install @petradb/quarry
import { Session } from "@petradb/engine";
import { quarry } from "@petradb/quarry";
const session = new Session({ storage: "memory" });
const db = quarry(session);
// En mémoire (par défaut)
new Session({ storage: "memory" });
// Stockage persistant sur fichier
new Session({ storage: "persistent", path: "./mydb.petra" });

Définissez les tables en utilisant les constructeurs de colonnes de Quarry. Le schéma pilote la création de tables, la construction de requêtes et l’inférence de types 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),
});
ConstructeurType SQLType TypeScript
serial(name)SERIALnumber
bigserial(name)BIGSERIALnumber
text(name)TEXTstring
varchar(name, length?)VARCHAR(n)string
char(name, length?)CHAR(n)string
integer(name)INTEGERnumber
smallint(name)SMALLINTnumber
bigint(name)BIGINTnumber
doublePrecision(name)DOUBLEnumber
real(name)REALnumber
numeric(name, precision?, scale?)NUMERIC(p,s)number
boolean(name)BOOLEANboolean
uuid(name)UUIDstring
timestamp(name)TIMESTAMPstring
timestamptz(name)TIMESTAMPTZstring
date(name)DATEstring
time(name)TIMEstring
timetz(name)TIMETZstring
interval(name)INTERVALstring
json(name)JSONunknown
bytea(name)BYTEAnumber[]
ModificateurEffet
.notNull()La colonne ne peut pas être null ; le type InferSelect exclut null
.default(value)La colonne est optionnelle dans InferInsert
.primaryKey()Clé primaire ; implique notNull + hasDefault (auto-incrément pour serial)
.unique()Ajoute une contrainte d’unicité
.references(table, column)Ajoute une référence de clé étrangère

Quarry infère deux types à partir de chaque définition de table :

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 — le type de ligne retourné par les requêtes :

  • colonnes notNull -> type non-nullable
  • colonnes nullables -> type | null

InferInsert — le type accepté par .values() :

  • colonnes notNull sans valeur par défaut -> obligatoire
  • colonnes avec valeur par défaut (.default(), .primaryKey(), serial) -> optionnel
  • colonnes nullables -> optionnel, accepte null
await db.createTable(users);

Cela génère et exécute une commande CREATE TABLE à partir de la définition du schéma — pas de SQL nécessaire.

// Ligne unique — retourne la ligne insérée avec toutes les colonnes
const [user] = await db
.insert(users)
.values({ name: "Alice", email: "alice@example.com", age: 30 })
.execute();
// user.id → serial auto-généré
// user.active → true (valeur par défaut)
// Lignes multiples
await db
.insert(users)
.values(
{ name: "Bob", email: "bob@example.com", age: 25 },
{ name: "Charlie", email: "charlie@example.com" },
)
.execute();

L’insertion requiert toutes les colonnes notNull sans valeur par défaut. Les champs optionnels peuvent être omis. TypeScript impose cela à la compilation.

Par défaut, l’insertion retourne toutes les colonnes (*). Utilisez .returning() pour sélectionner des colonnes spécifiques :

const [{ id }] = await db
.insert(users)
.values({ name: "Alice", email: "alice@example.com" })
.returning(users.id)
.execute();

Gérez les conflits lors de l’insertion avec onConflictDoNothing() ou onConflictDoUpdate() :

// Ignorer silencieusement les lignes en conflit
await db
.insert(users)
.values({ name: "Alice", email: "alice@example.com" })
.onConflictDoNothing()
.execute();
// Mettre à jour des colonnes spécifiques en cas de conflit
await db
.insert(users)
.values({ name: "Alice", email: "alice@example.com", age: 31 })
.onConflictDoUpdate(["email"], { name: "Alice Updated", age: 31 })
.execute();

Le premier argument de onConflictDoUpdate spécifie les colonnes de conflit, le second spécifie les colonnes à mettre à jour. Les deux sont type-safe — TypeScript impose que seules les clés de colonnes valides soient utilisées.

Insérez des lignes à partir d’une requête au lieu de valeurs littérales :

// Archiver tous les utilisateurs actifs
const query = db
.select(users.name, users.email)
.from(users)
.where(eq(users.active, true));
await db.insertFrom(archive, query, ["name", "email"]).execute();

Le deuxième argument est un select builder. Le troisième argument optionnel spécifie les colonnes cibles à remplir — s’il est omis, le moteur attend que la requête produise des valeurs pour toutes les colonnes.

// Sans liste de colonnes (la requête doit correspondre à toutes les colonnes cibles)
await db.insertFrom(archive, query).execute();
// Avec onConflictDoNothing
await db.insertFrom(archive, query, ["name", "email"]).onConflictDoNothing().execute();
import { eq, gt, asc, desc } from "@petradb/quarry";
// Toutes les lignes (SELECT *)
const allUsers = await db.from(users).execute();
// Clause where
const alice = await db
.from(users)
.where(eq(users.name, "Alice"))
.execute();
// Colonnes spécifiques
const names = await db
.select(users.name, users.email)
.from(users)
.execute();
// Tri, limite, décalage
const page = await db
.from(users)
.orderBy(asc(users.name))
.limit(10)
.offset(20)
.execute();
// Distinct
const statuses = await db
.select(users.active)
.from(users)
.distinct()
.execute();
// Distinct on — une ligne par valeur distincte des colonnes données
const perCategory = await db
.from(products)
.distinctOn(products.category)
.orderBy(asc(products.category), asc(products.price))
.execute();
// Retourne le produit le moins cher dans chaque catégorie

Les colonnes sont accessibles directement comme propriétés sur l’objet table. TypeScript empêche l’accès aux colonnes qui n’existent pas dans le schéma :

users.name; // ✓ compile
users.title; // ✗ erreur de compilation — 'title' n'existe pas dans 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 > 21
gte(users.age, 18) // age >= 18
lt(users.age, 65) // age < 65
lte(users.age, 30) // age <= 30
like(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%")
// Comparaison null-safe
isDistinctFrom(users.age, null) // age IS DISTINCT FROM NULL
isNotDistinctFrom(users.age, null) // age IS NOT DISTINCT FROM NULL
import { 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() et or() acceptent un nombre quelconque d’arguments :

and(cond1, cond2, cond3) // cond1 AND cond2 AND cond3
import { isNull, isNotNull } from "@petradb/quarry";
isNull(users.age) // age IS NULL
isNotNull(users.age) // age IS NOT NULL
import { isTrue, isNotTrue, isFalse, isNotFalse, isUnknown, isNotUnknown } from "@petradb/quarry";
isTrue(users.active) // active IS TRUE
isNotTrue(users.active) // active IS NOT TRUE
isFalse(users.active) // active IS FALSE
isNotFalse(users.active) // active IS NOT FALSE
isUnknown(users.active) // active IS UNKNOWN
isNotUnknown(users.active) // active IS NOT UNKNOWN
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 65
notBetween(users.age, 18, 65) // age NOT BETWEEN 18 AND 65
betweenSymmetric(users.age, 65, 18) // age BETWEEN SYMMETRIC 65 AND 18
notBetweenSymmetric(users.age, 65, 18) // age NOT BETWEEN SYMMETRIC 65 AND 18
import { add, sub, mul, div, mod, pow, neg } from "@petradb/quarry";
add(users.age, 10) // age + 10
sub(users.age, 5) // age - 5
mul(users.age, 2) // age * 2
div(users.age, 3) // age / 3
mod(users.age, 2) // age % 2
pow(users.age, 2) // age ^ 2
neg(users.age) // -age
import { concat } from "@petradb/quarry";
concat(users.name, " Jr.") // name || ' Jr.'
import { bitAnd, bitOr, bitXor, bitNot, leftShift, rightShift } from "@petradb/quarry";
bitAnd(users.flags, 0xFF) // flags & 255
bitOr(users.flags, 1) // flags | 1
bitXor(users.flags, 0xFF) // flags # 255
bitNot(users.flags) // ~flags
leftShift(users.flags, 2) // flags << 2
rightShift(users.flags, 1) // flags >> 1
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 #> path
jsonPathText(t.data, path) // data #>> path
jsonContains(t.data, other) // data @> other
jsonContainedBy(t.data, other) // data <@ other
jsonHasKey(t.data, "key") // data ? 'key'
jsonHasAnyKey(t.data, keys) // data ?| keys
jsonHasAllKeys(t.data, keys) // data ?& keys
import { arrayOverlap } from "@petradb/quarry";
arrayOverlap(t.tags, t.otherTags) // tags && otherTags (les tableaux se chevauchent)

Pour les opérateurs non couverts par un helper nommé, utilisez op() et unaryOp() :

import { op, unaryOp } from "@petradb/quarry";
op(users.age, ">=", 18) // age >= 18
unaryOp("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", // sinon
)
import { cast } from "@petradb/quarry";
cast(users.age, "text") // CAST(age AS TEXT)
cast(users.age, "double") // CAST(age AS DOUBLE)
import { alias, literal } from "@petradb/quarry";
alias(add(users.age, 10), "age_plus_10")
literal("hello") // chaîne
literal(42) // nombre
literal(true) // booléen
literal(null) // null
import { count, sum, avg, min, max, alias } from "@petradb/quarry";
import { stringAgg, arrayAgg, boolAnd, boolOr, jsonAgg, jsonObjectAgg } from "@petradb/quarry";
// Compter toutes les lignes
const [{ total }] = await db
.select(alias(count(), "total"))
.from(users)
.execute();
// Regrouper avec agrégat
const stats = await db
.select(users.active, alias(count(), "cnt"))
.from(users)
.groupBy(users.active)
.execute();
// Having
const popular = await db
.select(users.active, alias(count(), "cnt"))
.from(users)
.groupBy(users.active)
.having(gt(alias(count(), "cnt"), 5))
.execute();
// Autres agrégats
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) — variance échantillon
varSamp(emp.salary) // VAR_SAMP(salary) — identique à variance
varPop(emp.salary) // VAR_POP(salary) — variance de population
stddev(emp.salary) // STDDEV(salary) — écart type échantillon
stddevSamp(emp.salary) // STDDEV_SAMP(salary) — identique à stddev
stddevPop(emp.salary) // STDDEV_POP(salary) — écart type de population
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) — vrai quand toutes les lignes sont vraies

Restreignez les lignes traitées par un agrégat avec 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))

Exemple avec plusieurs agrégats filtrés :

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();

Appelez toute fonction SQL avec fn() :

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 supporte les jointures inner, left, right, full et cross avec typage des résultats à la compilation.

Toutes les colonnes des deux tables sont incluses dans le résultat. La nullabilité est préservée depuis le schéma original :

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();
// Type du résultat : (InferSelect<users> & InferSelect<posts>)[]
// rows[0].name → string
// rows[0].title → string
// rows[0].body → string | null (nullable dans le schéma posts)

Les colonnes de la table jointe deviennent toutes nullables, car les lignes non correspondantes produisent null :

const rows = await db
.from(users)
.leftJoin(posts, eq(users.id, posts.userId))
.execute();
// Type du résultat : (InferSelect<users> & Nullable<InferSelect<posts>>)[]
// rows[0].name → string (table de base, non affectée)
// rows[0].title → string | null (la jointure left la rend nullable)
// rows[0].userId → number | null (la jointure left la rend nullable)

Les colonnes de la table de base deviennent nullables, les colonnes de la table jointe préservent leur nullabilité originale :

const rows = await db
.from(users)
.rightJoin(posts, eq(users.id, posts.userId))
.execute();
// Type du résultat : (Nullable<InferSelect<users>> & InferSelect<posts>)[]
// rows[0].name → string | null (la jointure right rend la table de base nullable)
// rows[0].title → string (table jointe, non affectée)

Les deux côtés deviennent nullables :

const rows = await db
.from(users)
.fullJoin(posts, eq(users.id, posts.userId))
.execute();
// Type du résultat : (Nullable<InferSelect<users>> & Nullable<InferSelect<posts>>)[]
// rows[0].name → string | null
// rows[0].title → string | null

Produit le produit cartésien des deux tables — pas de condition on :

const rows = await db
.from(users)
.crossJoin(posts)
.execute();
// Type du résultat : (InferSelect<users> & InferSelect<posts>)[]
// Chaque combinaison utilisateur x article

Les jointures multiples accumulent les types correctement :

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();
// colonnes posts : non-null (jointure inner)
// colonnes comments : nullable (jointure left)
// rows[0].title → string (jointure inner)
// rows[0].content → string | null (jointure left)
const rows = await db
.select(users.name, posts.title)
.from(users)
.innerJoin(posts, eq(users.id, posts.userId))
.execute();
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();

Utilisez tableAs() pour créer des tables avec alias pour les auto-jointures ou quand la même table apparaît plusieurs fois :

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();

Les alias sont type-safe — mgr.name impose toujours que name existe dans le schéma employees.

import { inSubquery, notInSubquery } from "@petradb/quarry";
// Utilisateurs ayant au moins un article
const rows = await db
.from(users)
.where(
inSubquery(users.id, db.select(posts.userId).from(posts)),
)
.execute();
// Utilisateurs n'ayant AUCUN article
const rows = await db
.from(users)
.where(
notInSubquery(users.id, db.select(posts.userId).from(posts)),
)
.execute();
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();

Utilisez subquery() pour encapsuler une sélection comme valeur scalaire :

import { subquery } from "@petradb/quarry";
// Utilisateurs plus âgés que l'âge moyen
const rows = await db
.from(users)
.where(
gt(users.age, subquery(db.select(avg(users.age)).from(users))),
)
.execute();

Les fonctions de sous-requête (subquery, exists, inSubquery, notInSubquery) acceptent directement n’importe quel query builder — aucune conversion intermédiaire n’est nécessaire.

import { asc, desc } from "@petradb/quarry";
// Tri simple
db.from(users).orderBy(asc(users.name))
db.from(users).orderBy(desc(users.age))
// Colonnes multiples
db.from(users).orderBy(asc(users.name), desc(users.age))
// NULLS FIRST / NULLS LAST
db.from(users).orderBy(asc(users.age, { nulls: "first" }))
db.from(users).orderBy(desc(users.age, { nulls: "last" }))

Lorsque nulls n’est pas spécifié, le moteur utilise le comportement par défaut (les null sont triés en dernier en ordre ascendant, en premier en ordre descendant).

// Mise à jour avec where
const result = await db
.update(users)
.set({ age: 31 })
.where(eq(users.name, "Alice"))
.execute();
// result.rowCount → 1
// Mettre à jour plusieurs champs
await db
.update(users)
.set({ name: "Alice Smith", age: 32, active: false })
.where(eq(users.id, 1))
.execute();
// Mettre à null
await db
.update(users)
.set({ age: null })
.where(eq(users.name, "Bob"))
.execute();

La méthode .set() accepte Partial<InferSelect<T>> — TypeScript impose des noms et types de colonnes valides.

Joignez une autre table pour piloter les mises à jour :

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 }) // valeur définie ; utilisez les références de colonnes dans WHERE pour la logique conditionnelle
.from(priceUpdates)
.where(eq(products.name, priceUpdates.productName))
.execute();

.from() accepte plusieurs tables :

db.update(t1).set({ ... }).from(t2, t3).where(and(...))

Update et delete supportent .returning() pour récupérer les lignes affectées :

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" }, ...]
const result = await db
.delete(users)
.where(eq(users.name, "Alice"))
.execute();
// result.rowCount → 1

Joignez une autre table pour déterminer les lignes à supprimer :

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() accepte plusieurs tables :

db.delete(t1).using(t2, t3).where(and(...))

Encapsulez plusieurs opérations dans une transaction avec commit/rollback automatique :

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;
});
// Si une opération échoue, la transaction entière est annulée

Le callback reçoit une instance QuarryDB scopée à la transaction. La valeur de retour du callback devient la valeur de retour de transaction(), avec le type préservé.

Chaque builder dispose d’une méthode .toAST() qui retourne l’objet AST brut sans l’exécuter. Utile pour le débogage, la journalisation ou la construction d’abstractions de niveau supérieur :

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 construit de simples objets JavaScript (unions discriminées avec un champ kind) qui représentent l’AST de la requête. Lorsque vous appelez .execute(), ces objets sont passés à la méthode executeAST() du moteur, qui les convertit directement dans l’AST interne Scala du moteur — en évitant entièrement la génération et l’analyse de chaînes SQL.

Schéma → API Builder → Objets AST JS → AST Moteur → Réécriture → Exécution
↑ pas de parseur SQL

Cela donne à Quarry les mêmes capacités de requête que le SQL tout en éliminant le coût d’analyse et en permettant une sécurité de type complète à la compilation.

await session.close();