Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ nested writes, startup migration, or rollback migrations.
## Define one database

```ts
import { defineDatabase, defineQuery, table, text, uuid } from "@askrjs/orm";
import {
defineDatabase,
defineQuery,
escapeLikePattern,
like,
table,
text,
uuid,
} from "@askrjs/orm";
import { postgres } from "@askrjs/orm/postgres";
import { generated } from "./generated.js";

Expand Down Expand Up @@ -55,6 +63,9 @@ await db.users.upsert({ id: userId, email });
await db.users.upsertMany(rows);

const rows = await db.queries.byEmail({ email });

const search = `%${escapeLikePattern(userInput)}%`;
const matches = await db.users.where(({ users }) => like(users.email, search)).execute();
```

Composite primary keys accept only key objects; single-column keys also accept
Expand All @@ -64,6 +75,10 @@ must be atomic. Nested transactions use savepoints, and a transaction client
throws after its callback completes. If rollback cleanup itself fails, the
original callback error remains the error surfaced to the caller.

`escapeLikePattern()` escapes `\\`, `%`, and `_` for literal-text searches.
`like()` and `ilike()` bind the pattern and emit the matching `ESCAPE '\\'`
clause; `ilike()` is PostgreSQL-only.

Read builders are immutable and parameterized, support typed projections and
joins, and expose preparation, streaming, and `toSQL()`. Dynamic identifiers
must use the identifier API; arbitrary SQL requires the explicit unsafe
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export {
compileKeyedSql,
compileSql,
eq,
escapeLikePattern,
executeKeyedSql,
gt,
gte,
Expand Down
22 changes: 21 additions & 1 deletion src/sql.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { describe, expect, it } from "vitest";
import { and, compileKeyedSql, compileSql, eq, identifier, inArray, literal, sql } from "./index";
import {
and,
columnRef,
compileKeyedSql,
compileSql,
eq,
escapeLikePattern,
identifier,
inArray,
like,
literal,
sql,
} from "./index";
import { rewritePlaceholders, sqlStructure } from "./placeholders";

describe("SQL boundaries", () => {
Expand Down Expand Up @@ -37,6 +49,14 @@ describe("SQL boundaries", () => {
});
});

it("should escape literal LIKE wildcards and declare the escape character", () => {
const search = String.raw`50%_off\today`;
expect(compileSql(like(columnRef("items", "name"), `%${escapeLikePattern(search)}%`))).toEqual({
text: `"items"."name" LIKE $1 ESCAPE '\\'`,
values: [String.raw`%50\%\_off\\today%`],
});
});

it("should require static keyed SQL and reuse repeated named parameters", () => {
const query = sql.key("users.by-email", { email: "" })`
SELECT id FROM users WHERE email = :email OR backup_email = :email
Expand Down
14 changes: 10 additions & 4 deletions src/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,18 @@ export const lt = <T>(left: Expression<T>, right: Expression<T> | T): SqlFragmen
/** Builds a `<=` comparison predicate. */
export const lte = <T>(left: Expression<T>, right: Expression<T> | T): SqlFragment<boolean> =>
binary(left, "<=", right);
/** Builds a `LIKE` predicate. */

/** Escapes a string for literal matching in a `LIKE` or `ILIKE` pattern. */
export function escapeLikePattern(input: string): string {
return input.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}

/** Builds a `LIKE` predicate using backslash as the pattern escape character. */
export const like = (left: Expression<string>, pattern: string): SqlFragment<boolean> =>
binary(left, "LIKE", pattern);
/** Builds an `ILIKE` predicate. PostgreSQL only. */
sql<boolean>`${binary(left, "LIKE", pattern)} ESCAPE '\\'`;
/** Builds an `ILIKE` predicate using backslash as the pattern escape character. PostgreSQL only. */
export const ilike = (left: Expression<string>, pattern: string): SqlFragment<boolean> =>
binary(left, "ILIKE", pattern);
sql<boolean>`${binary(left, "ILIKE", pattern)} ESCAPE '\\'`;
/** Builds an `IS NULL` predicate. */
export const isNull = (value: Expression): SqlFragment<boolean> =>
sql<boolean>`${expressionSql(value)} IS NULL`;
Expand Down
31 changes: 30 additions & 1 deletion src/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,41 @@ import { tmpdir } from "node:os";
import { DatabaseSync } from "node:sqlite";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { defineDatabase, defineQuery, table, text } from "./index";
import {
createDatabaseClient,
defineDatabase,
defineQuery,
escapeLikePattern,
like,
table,
text,
} from "./index";
import { jsonb } from "./postgres";
import { sqlite } from "./sqlite";
import { createMigrationsApi } from "./migrations";

describe("SQLite dialect", () => {
it("should match escaped LIKE wildcards as literal text", async () => {
const items = table("items", { value: text().primaryKey() });
const adapter = await sqlite({ filename: ":memory:" }).open();
const db = createDatabaseClient({ items }, adapter);
try {
await adapter.execute({
text: 'CREATE TABLE "public"."items" ("value" text PRIMARY KEY)',
values: [],
});
await db.items.insertMany([{ value: "save 50%_today" }, { value: "save 500Xtoday" }]);

await expect(
db.items
.where(({ items: columns }) => like(columns.value, `%${escapeLikePattern("50%_today")}%`))
.execute(),
).resolves.toEqual([{ value: "save 50%_today" }]);
} finally {
await adapter.close?.();
}
});

it("should preserve the callback error when rollback fails", async () => {
const adapter = await sqlite({ filename: ":memory:" }).open();
const callbackError = new Error("callback failed");
Expand Down
Loading