Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions packages/zqlite/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export class Database implements Disposable {
this.#db = new SQLite3Database(path, options);
this.#threshold = slowQueryThreshold;

// Match Postgres LIKE/ILIKE semantics. Postgres LIKE is case-sensitive,
// but SQLite's LIKE operator is case-insensitive by default; enable
// case-sensitive LIKE so the bare operator matches Postgres. Case-
// insensitive ILIKE is handled in query-builder.ts by lower()-ing both
// operands (using the Unicode-aware lower() that @rocicorp/zero-sqlite3
// provides via ICU).
this.#db.pragma('case_sensitive_like = ON');

Comment thread
arv marked this conversation as resolved.
const [{page_size: pageSize}] = this.pragma<{page_size: number}>(
'page_size',
);
Expand Down
46 changes: 45 additions & 1 deletion packages/zqlite/src/query-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {createSilentLogContext} from '../../shared/src/logging-test-utils.ts';
import type {SchemaValue} from '../../zero-schema/src/table-schema.ts';
import {Database} from './db.ts';
import {format} from './internal/sql.ts';
import {buildSelectQuery, multiConstraintToSQL} from './query-builder.ts';
import {
buildSelectQuery,
filtersToSQL,
multiConstraintToSQL,
type NoSubqueryCondition,
} from './query-builder.ts';

test('non-nullable cursor columns use range and equality operators without IS NULL guards', () => {
const columns = {
Expand Down Expand Up @@ -258,3 +263,42 @@ test('multiConstraints compound row-value IN uses index (EXPLAIN QUERY PLAN)', (
.join('\n');
expect(plan).toMatch(/SEARCH pairs USING/);
});

function likeSQL(
op: 'LIKE' | 'NOT LIKE' | 'ILIKE' | 'NOT ILIKE',
pattern: string,
) {
return format(
filtersToSQL({
type: 'simple',
left: {type: 'column', name: 'name'},
op,
right: {type: 'literal', value: pattern},
} as NoSubqueryCondition),
);
}

test('LIKE is case-sensitive and uses an explicit backslash escape', () => {
const {text, values} = likeSQL('LIKE', 'a%');
// Bare LIKE operator; case-sensitivity comes from PRAGMA case_sensitive_like.
expect(text).toBe(`"name" LIKE ? ESCAPE '\\'`);
expect(values).toEqual(['a%']);
});

test('NOT LIKE keeps the operator and the backslash escape', () => {
const {text, values} = likeSQL('NOT LIKE', 'a%');
expect(text).toBe(`"name" NOT LIKE ? ESCAPE '\\'`);
expect(values).toEqual(['a%']);
});

test('ILIKE lowers both operands for Unicode case-insensitive matching', () => {
const {text, values} = likeSQL('ILIKE', 'A%');
expect(text).toBe(`lower("name") LIKE lower(?) ESCAPE '\\'`);
expect(values).toEqual(['A%']);
});

test('NOT ILIKE lowers both operands and negates', () => {
const {text, values} = likeSQL('NOT ILIKE', 'A%');
expect(text).toBe(`lower("name") NOT LIKE lower(?) ESCAPE '\\'`);
expect(values).toEqual(['A%']);
});
43 changes: 36 additions & 7 deletions packages/zqlite/src/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,46 @@ function simpleConditionToSQL(filter: SimpleCondition): SQLQuery {
);
}
}
if (
op === 'LIKE' ||
op === 'NOT LIKE' ||
op === 'ILIKE' ||
op === 'NOT ILIKE'
) {
return likeConditionToSQL(filter);
}

return sql`${valuePositionToSQL(filter.left)} ${sql.__dangerous__rawValue(
// SQLite's LIKE operator is case-insensitive by default, so we
// convert ILIKE to LIKE and NOT ILIKE to NOT LIKE.
filter.op === 'ILIKE'
? 'LIKE'
: filter.op === 'NOT ILIKE'
? 'NOT LIKE'
: filter.op,
filter.op,
)} ${valuePositionToSQL(filter.right)}`;
}

function likeConditionToSQL(filter: SimpleCondition): SQLQuery {
const {op} = filter;
// Mirror Postgres pattern-matching semantics:
// * LIKE is case-sensitive. The replica connection runs with
// `PRAGMA case_sensitive_like = ON` (see db.ts), so the bare LIKE
// operator is case-sensitive.
// * ILIKE is case-insensitive. We lower() both operands using the
// Unicode-aware lower() that @rocicorp/zero-sqlite3 provides via ICU,
// mirroring the toLowerCase() used by the in-memory IVM matcher
// (see zql/src/builder/like.ts).
// * Backslash is the default escape character in Postgres and in the IVM
// matcher, but SQLite has no default, so we specify `ESCAPE '\'`
// explicitly. The SQL literal '\' is a single backslash (SQLite does not
// process backslash escapes inside string literals).
const caseInsensitive = op === 'ILIKE' || op === 'NOT ILIKE';
const negated = op === 'NOT LIKE' || op === 'NOT ILIKE';
const likeOp = sql.__dangerous__rawValue(negated ? 'NOT LIKE' : 'LIKE');

const left = valuePositionToSQL(filter.left);
const right = valuePositionToSQL(filter.right);
if (caseInsensitive) {
return sql`lower(${left}) ${likeOp} lower(${right}) ESCAPE '\\'`;
}
return sql`${left} ${likeOp} ${right} ESCAPE '\\'`;
}

function valuePositionToSQL(value: ValuePosition): SQLQuery {
switch (value.type) {
case 'column':
Expand Down
Loading