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
31 changes: 31 additions & 0 deletions .changeset/driver-sql-fail-loud-null-operators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/driver-sql": patch
"@objectstack/spec": patch
---

fix(driver-sql): fail-loud on unknown filter operators; real IS NULL / IS NOT NULL; $not support (#2704)

The SQL driver used to forward any filter operator it didn't recognise straight
to Knex. On a null comparand that silently compiled to a whole-table match, so a
permission/assignment-scoped list view could leak every row (e.g. an
`is_null` / `is_empty` operator from the client). It also had no real
null-check: `field = null` never renders `IS NULL` in SQL.

This change makes the driver:

- Render null predicates as real SQL — `is_null` / `isnull` / `is_empty`
(and the not-null variants) → `IS NULL` / `IS NOT NULL`, unified with
`equals` + null; `!= null` → `IS NOT NULL`.
- Support the full spec operator set plus client alias spellings across both
filter shapes (array `[field, op, value]` and object `{field: {$op: value}}`):
`$between`, `$startsWith`, `$endsWith`, `$notContains`, `$null`, `$exists`,
and the logical `$not` (a negated sub-condition, matching driver-mongodb /
driver-memory — CEL `!expr` permission scopes compile to it).
- LIKE-escape `contains` / `startsWith` / `endsWith` values with an explicit
`ESCAPE '\'` so `%` / `_` in user input can't widen the match.
- **Throw on a genuinely unknown operator** in both paths instead of silently
passing it through — no more silent whole-table results.

`@objectstack/spec` recognises the client alias operator spellings
(`isnull` / `is_empty` / …) in `VALID_AST_OPERATORS` and maps them to `$null`
so the array-AST → object-filter conversion is consistent with the driver.
119 changes: 119 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

/**
* Regression tests for issue #2704 — driver-sql must give IS NULL / IS NOT NULL
* filters a real SQL rendering and must NOT forward an unknown operator to Knex
* verbatim (which silently returned the whole table on a null comparand — a
* filter-bypass on permission/assignment-scoped list views).
*/
describe('SqlDriver — null / empty operators (#2704)', () => {
let driver: SqlDriver;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});

const k = (driver as any).knex;
await k.schema.createTable('tasks', (t: any) => {
t.string('id').primary();
t.string('title');
t.string('assignee').nullable();
});

await k('tasks').insert([
{ id: '1', title: 'A', assignee: 'alice' },
{ id: '2', title: 'B', assignee: null },
{ id: '3', title: 'C', assignee: 'carol' },
{ id: '4', title: 'D', assignee: null },
]);
});

afterEach(async () => {
await driver.disconnect();
});

const ids = (rows: any[]) => rows.map((r) => r.id).sort();

describe('array-format where', () => {
it('equals + null → IS NULL (baseline that already worked)', async () => {
const rows = await driver.find('tasks', { where: [['assignee', '=', null]] } as any);
expect(ids(rows)).toEqual(['2', '4']);
});

it.each(['is_null', 'isnull', 'is_empty'])('%s → IS NULL', async (op) => {
const rows = await driver.find('tasks', { where: [['assignee', op, true]] } as any);
expect(ids(rows)).toEqual(['2', '4']);
});

it.each(['is_not_null', 'isnotnull', 'is_not_empty'])('%s → IS NOT NULL', async (op) => {
const rows = await driver.find('tasks', { where: [['assignee', op, true]] } as any);
expect(ids(rows)).toEqual(['1', '3']);
});

it('!= null → IS NOT NULL (not a `<> NULL` that matches nothing)', async () => {
const rows = await driver.find('tasks', { where: [['assignee', '!=', null]] } as any);
expect(ids(rows)).toEqual(['1', '3']);
});

it('unknown operator throws instead of returning the whole table', async () => {
await expect(
driver.find('tasks', { where: [['assignee', 'totally_bogus', null]] } as any),
).rejects.toThrow(/Unsupported filter operator/);
});

it('count with is_null is scoped, not the whole table', async () => {
const count = await driver.count('tasks', { where: [['assignee', 'isnull', true]] } as any);
expect(count).toBe(2);
});
});

describe('object-format where ($-operators)', () => {
it('$null: true → IS NULL', async () => {
const rows = await driver.find('tasks', { where: { assignee: { $null: true } } } as any);
expect(ids(rows)).toEqual(['2', '4']);
});

it('$null: false → IS NOT NULL', async () => {
const rows = await driver.find('tasks', { where: { assignee: { $null: false } } } as any);
expect(ids(rows)).toEqual(['1', '3']);
});

it('$ne: null → IS NOT NULL', async () => {
const rows = await driver.find('tasks', { where: { assignee: { $ne: null } } } as any);
expect(ids(rows)).toEqual(['1', '3']);
});

it('$startsWith → prefix LIKE', async () => {
const rows = await driver.find('tasks', { where: { assignee: { $startsWith: 'a' } } } as any);
expect(ids(rows)).toEqual(['1']);
});

it('$regex (better-auth contains) → substring LIKE, not exact match', async () => {
const rows = await driver.find('tasks', { where: { assignee: { $regex: 'aro' } } } as any);
expect(ids(rows)).toEqual(['3']);
});

it('$not (CEL `!expr` scope filter) → negated sub-condition, not a bogus "$not" column', async () => {
// `!(assignee == 'alice')` → { $not: { assignee: { $eq: 'alice' } } }.
// SQL `NOT (assignee = 'alice')` excludes alice AND is null-safe only for
// the rows it can evaluate — rows 2/4 have null assignee so `NOT (null = 'alice')`
// is UNKNOWN and they are excluded, leaving carol.
const rows = await driver.find('tasks', {
where: { $not: { assignee: { $eq: 'alice' } } },
} as any);
expect(ids(rows)).toEqual(['3']);
});

it('unknown $-operator throws instead of a silent equality compare', async () => {
await expect(
driver.find('tasks', { where: { assignee: { $bogus: 1 } } } as any),
).rejects.toThrow(/Unsupported filter operator/);
});
});
});
Loading