diff --git a/README.md b/README.md index 0fded53..fb03d75 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -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 @@ -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 diff --git a/src/index.ts b/src/index.ts index 2b23be9..ad316d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,6 +95,7 @@ export { compileKeyedSql, compileSql, eq, + escapeLikePattern, executeKeyedSql, gt, gte, diff --git a/src/sql.test.ts b/src/sql.test.ts index 0a8f8af..a84a737 100644 --- a/src/sql.test.ts +++ b/src/sql.test.ts @@ -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", () => { @@ -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 diff --git a/src/sql.ts b/src/sql.ts index e1c90a7..0d1b7bb 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -215,12 +215,18 @@ export const lt = (left: Expression, right: Expression | T): SqlFragmen /** Builds a `<=` comparison predicate. */ export const lte = (left: Expression, right: Expression | T): SqlFragment => 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, pattern: string): SqlFragment => - binary(left, "LIKE", pattern); -/** Builds an `ILIKE` predicate. PostgreSQL only. */ + sql`${binary(left, "LIKE", pattern)} ESCAPE '\\'`; +/** Builds an `ILIKE` predicate using backslash as the pattern escape character. PostgreSQL only. */ export const ilike = (left: Expression, pattern: string): SqlFragment => - binary(left, "ILIKE", pattern); + sql`${binary(left, "ILIKE", pattern)} ESCAPE '\\'`; /** Builds an `IS NULL` predicate. */ export const isNull = (value: Expression): SqlFragment => sql`${expressionSql(value)} IS NULL`; diff --git a/src/sqlite.test.ts b/src/sqlite.test.ts index aeea434..a09e025 100644 --- a/src/sqlite.test.ts +++ b/src/sqlite.test.ts @@ -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");