A lightweight and easy to use micro ORM for Node/TypeScript.
SQMap does not try to replace SQL. It gives you a thin, typed layer for common query patterns while keeping SQL generation explicit and predictable.
- Core query builder (
sqmap): DB-agnostic SQL + params generation. - PostgreSQL adapter (
sqmap/postgres-pg): executes generated SQL withpg. - SQLite adapter (
sqmap/sqlite-bun): executes generated SQL with Bun'sbun:sqlite.
npm i sqmapSQMap parameterizes values, but SQL identifiers (table names, schema names, column names) are interpolated into query strings.
DO NOT pass untrusted user input as identifiers.
Only use identifiers that come from trusted, static application code.
Define one row interface per table, then generate a typed API.
import * as SQM from "sqmap";
export interface UserRow {
id?: number;
created?: Date;
updated?: Date;
status?: "active" | "banned" | "deactivated";
username?: string;
email?: string;
}
// tableName, defaultSchema, format
export const users = SQM.genAPI<UserRow>("users", "app", SQM.FORMATS.POSTGRES_PG);Built-in formats:
SQM.FORMATS.POSTGRES_PGSQM.FORMATS.SQLITE_BUN
genAPI returns SQL only:
interface SQLData {
query: string;
params: any[];
}const sql = users.insert({
cols: ["status", "username", "email"],
rows: [{
status: "active",
email: "one@example.com",
password: "ignored" as any
}],
return: ["id", "email"]
});
console.log(sql.query);
console.log(sql.params);Generated SQL and params:
INSERT INTO "app"."users" ("status", "username", "email") VALUES ($1, $2, $3) RETURNING "id", "email";["active", undefined, "one@example.com"]Notes:
colscontrols both generated columns and param order.- Extra keys in
rowsare ignored. - Missing values in selected
colsbecomeundefined. - Multi-row insert is supported.
return: "*"andreturn: ["colA", "colB"]are supported.return: []omitsRETURNING.colsandrowsmust both be non-empty.
const sql = users.select({
distinct: true,
cols: ["id", "email"],
where: [{ id: 42 }],
in: [["status", "IN", ["active", "banned"]]],
like: [["email", "ILIKE", "user%"]],
order: { by: "id", type: "DESC" },
shift: { limit: 10, offset: 20 }
});Generated SQL:
SELECT DISTINCT "id", "email" FROM "app"."users" WHERE "id" = $1 AND "status" IN ($2, $3) AND "email" ILIKE $4 ORDER BY "id" DESC LIMIT $5 OFFSET $6;[42, "active", "banned", "user%", 10, 20]Capabilities:
colssupports explicit columns and"*".distinct: emitsSELECT DISTINCT ...when enabled.wheresupports:- Comparison operators:
=,<,>,>=,<=,!= - Object form:
[{ id: 1, email: "x@x.com" }, "!=", "OR"] - Object defaults: operator
=and predicate joinANDwhen omitted - Object null rewrite:
[{ verified_at: null, id: 7 }]becomes"verified_at" IS NULL AND "id" = $1 - Tokenized form:
[["id", "=", 1], "OR", "NOT", ["email", "=", "x@x.com"]] - Tokenized null rewrite:
[["verified_at", "=", null], "OR", ["stripe_id", "!=", null]]becomesIS NULL/IS NOT NULL
- Comparison operators:
in:INandNOT INlike:LIKE,NOT LIKE,ILIKE,NOT ILIKEbetween: controls howwhere,in, andlikegroups are joined (ANDdefault, orOR). It does not change tokenized logic inside each group.order:ASCorDESCshift:LIMIT/OFFSET(nulland negative values are ignored, limit-only and offset-only are both supported)
Note:
- Null rewrite applies to both object and tokenized
wherepredicates for=and!=only.
const sql = users.update({
set: { status: "deactivated" },
where: [{ id: 42 }],
in: [["status", "IN", ["active", "banned"]]],
like: [["email", "ILIKE", "user%"]],
between: "OR",
return: ["id", "status"]
});Generated SQL:
UPDATE "app"."users" SET "status" = $1 WHERE "id" = $2 OR "status" IN ($3, $4) OR "email" ILIKE $5 RETURNING "id", "status";["deactivated", 42, "active", "banned", "user%"]Rules:
setmust be non-empty.- At least one filter key is required (
where,in, orlike). - At least one non-empty filter group is required after parsing (
where,in, orlike). returnworks like insert.return: []omitsRETURNING.- Using only empty/token-only filter arrays throws.
const sql = users.delete({
where: [{ id: 42 }],
in: [["status", "IN", ["deactivated"]]],
like: [["email", "LIKE", "user%"]],
return: "*"
});Generated SQL:
DELETE FROM "app"."users" WHERE "id" = $1 AND "status" IN ($2) AND "email" LIKE $3 RETURNING *;[42, "deactivated", "user%"]Rules:
- At least one filter key is required (
where,in, orlike). - At least one non-empty filter group is required after parsing (
where,in, orlike). returnsupports"*"or selected columns.return: []omitsRETURNING.- Using only empty/token-only filter arrays throws.
Each query supports schema?: string | null:
- Omitted: use default schema from
genAPI(...). - String value: override schema for that query.
null: remove schema prefix entirely.- Empty string: falls back to default schema.
For tokenized where / in / like arrays:
- Allowed tokens:
AND,OR,NOT. - Leading
AND/ORand trailingAND/OR/NOTare normalized away. - Invalid tokens, missing logical operators, invalid
NOTplacement, and consecutive operators throw. INpredicates require at least one value.
Behavior differences:
selecttolerates token-only/empty filter arrays by emitting noWHERE.updateanddeletereject token-only/empty filters when they resolve to no predicates (safety guard).
import { Pool } from "pg";
import { genPostgresPGAPI, expectOne } from "sqmap/postgres-pg";
interface UserRow {
id?: number;
email?: string;
}
const pool = new Pool();
const users = genPostgresPGAPI<UserRow>("users", "app"); // optional third arg: custom format
const client = await pool.connect();
try {
const result = await users.select(client, {
cols: ["id", "email"],
where: [{ id: 1 }]
});
const user = expectOne(result, new Error("User not found"));
console.log(user.email);
} finally {
client.release();
}import { Database } from "bun:sqlite";
import { genSQLiteBunAPI, expect } from "sqmap/sqlite-bun";
interface UserRow {
id?: number;
email?: string;
}
const db = new Database(":memory:");
const users = genSQLiteBunAPI<UserRow>("users"); // optional second arg: custom format
const rows = users.select(db, { cols: ["id", "email"] });
const exactlyTwo = expect(rows, 2, new Error("Expected 2 users"));Both adapters also expose:
sql(...): run raw SQL with paramsexpect(...): assert exact row countexpectOne(...): assert exactly one row
Adapter notes:
genPostgresPGAPIaccepts(tableName, schema, format?).genSQLiteBunAPIaccepts(tableName, format?)and always generates SQL without schema prefix.
You can provide your own Format:
import * as SQM from "sqmap";
const customFormat: SQM.Format = {
paramsPrefix: "?",
paramsStartIndex: 0,
paramsAppendIndex: false,
quotingChar: "`"
};
const users = SQM.genAPI<{ id?: number }>("users", "app", customFormat);
const sql = users.select({ cols: ["id"], where: [{ id: 7 }] });
console.log(sql.query);
// SELECT `id` FROM `app`.`users` WHERE `id` = ?;