Skip to content

Commit e2c7549

Browse files
authored
fix(core): plugin storage where-filters fail on Postgres with boolean = integer (#920) (#1898)
* fix(core): plugin storage where-filters fail on Postgres (#920) query() and count() wrapped the JSON-extraction predicate in `(...) = 1` to fit eb(lhs, "=", rhs). SQLite coerces booleans to integers so it worked there, but Postgres has a strict boolean type and rejected every filtered call with "operator does not exist: boolean = integer". Use the predicate directly as a boolean WHERE expression instead; dialect-parity regression tests included. * fix: cast text JSON column to jsonb for Postgres extraction CI's PG job surfaced the layer below the `= 1` wrapper: _plugin_storage.data is a text column, so `data->>'field'` fails with "operator does not exist: text ->> unknown". Cast via `(data)::jsonb` in jsonExtractExpr — shared by queries and expression indexes, so the planner keeps matching them. Also switch test .sort() to .toSorted() for the type-aware lint. * fix: coerce count() to a number for the pg driver pg returns COUNT(*) (bigint) as a string; Number() it like every other repository count in the codebase. * chore: retrigger CI (labeler job hit a GitHub API 5xx)
1 parent c80cab6 commit e2c7549

4 files changed

Lines changed: 128 additions & 33 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes plugin storage `query()` and `count()` failing on PostgreSQL with "operator does not exist: boolean = integer" whenever a `where` filter was supplied.

packages/core/src/database/dialect-helpers.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,17 +215,23 @@ export function binaryType(db: Kysely<any>): ColumnDataType {
215215
}
216216

217217
/**
218-
* SQL expression for extracting a field from a JSON/JSONB column.
218+
* SQL expression for extracting a field from a JSON column stored as text.
219219
*
220220
* sqlite: json_extract(column, '$.path')
221-
* postgres: column->>'path'
221+
* postgres: (column)::jsonb->>'path'
222+
*
223+
* The Postgres cast is required because JSON columns (e.g.
224+
* `_plugin_storage.data`) are `text`, and `text ->> unknown` is not an
225+
* operator. The cast is immutable, so the same expression works in
226+
* expression indexes — queries and indexes must build it through this
227+
* helper so the planner can match them.
222228
*/
223229
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
224230
export function jsonExtractExpr(db: Kysely<any>, column: string, path: string): string {
225231
validateIdentifier(column, "JSON column name");
226232
validateJsonFieldName(path, "JSON path");
227233
if (isPostgres(db)) {
228-
return `${column}->>'${path}'`;
234+
return `(${column})::jsonb->>'${path}'`;
229235
}
230236
return `json_extract(${column}, '$.${path}')`;
231237
}

packages/core/src/database/repositories/plugin-storage.ts

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @see PLUGIN-SYSTEM.md § Plugin Storage > Full API Reference
88
*/
99

10-
import type { Kysely } from "kysely";
10+
import type { Kysely, RawBuilder } from "kysely";
1111
import { sql } from "kysely";
1212

1313
import {
@@ -27,6 +27,26 @@ import { withTransaction } from "../transaction.js";
2727
import type { Database } from "../types.js";
2828
import { encodeCursor, decodeCursor } from "./types.js";
2929

30+
/**
31+
* Interleave a `?`-placeholder SQL string with its params into a single
32+
* boolean raw expression. Used as a WHERE predicate directly — wrapping it
33+
* in `(...) = 1` breaks on Postgres, which has a strict boolean type (#920).
34+
*/
35+
function rawWhereExpr(sqlText: string, params: unknown[]): RawBuilder<boolean> {
36+
const parts: ReturnType<typeof sql>[] = [];
37+
let paramIndex = 0;
38+
const sqlParts = sqlText.split("?");
39+
for (let i = 0; i < sqlParts.length; i++) {
40+
if (i > 0) {
41+
parts.push(sql`${params[paramIndex++]}`);
42+
}
43+
if (sqlParts[i]) {
44+
parts.push(sql.raw(sqlParts[i]));
45+
}
46+
}
47+
return sql<boolean>`(${sql.join(parts, sql.raw(""))})`;
48+
}
49+
3050
/**
3151
* Plugin Storage Repository
3252
*
@@ -211,19 +231,7 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
211231
// Add JSON extraction WHERE conditions
212232
const whereResult = buildWhereClause(this.db, where);
213233
if (whereResult.sql) {
214-
// Use sql template to add the raw WHERE conditions with params
215-
const whereSqlParts: ReturnType<typeof sql>[] = [];
216-
let paramIndex = 0;
217-
const sqlParts = whereResult.sql.split("?");
218-
for (let i = 0; i < sqlParts.length; i++) {
219-
if (i > 0) {
220-
whereSqlParts.push(sql`${whereResult.params[paramIndex++]}`);
221-
}
222-
if (sqlParts[i]) {
223-
whereSqlParts.push(sql.raw(sqlParts[i]));
224-
}
225-
}
226-
query = query.where(({ eb }) => eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")));
234+
query = query.where(rawWhereExpr(whereResult.sql, whereResult.params));
227235
}
228236

229237
// Handle cursor-based pagination — throws on invalid cursor.
@@ -289,26 +297,13 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
289297
if (where && Object.keys(where).length > 0) {
290298
const whereResult = buildWhereClause(this.db, where);
291299
if (whereResult.sql) {
292-
// Use sql template to add the raw WHERE conditions with params
293-
const whereSqlParts: ReturnType<typeof sql>[] = [];
294-
let paramIndex = 0;
295-
const sqlParts = whereResult.sql.split("?");
296-
for (let i = 0; i < sqlParts.length; i++) {
297-
if (i > 0) {
298-
whereSqlParts.push(sql`${whereResult.params[paramIndex++]}`);
299-
}
300-
if (sqlParts[i]) {
301-
whereSqlParts.push(sql.raw(sqlParts[i]));
302-
}
303-
}
304-
query = query.where(({ eb }) =>
305-
eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")),
306-
);
300+
query = query.where(rawWhereExpr(whereResult.sql, whereResult.params));
307301
}
308302
}
309303

310304
const result = await query.executeTakeFirst();
311-
return result?.count ?? 0;
305+
// Number() because the pg driver returns COUNT(*) (bigint) as a string.
306+
return Number(result?.count ?? 0);
312307
}
313308
}
314309

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Plugin storage query/count with `where` filters, on both dialects (#920).
3+
*
4+
* The filtered paths wrapped the JSON-extraction predicate in `(...) = 1`.
5+
* SQLite coerces booleans to integers so that worked, but Postgres has a
6+
* strict boolean type and rejected every filtered list()/count() with
7+
* "operator does not exist: boolean = integer". This runs the filtered
8+
* paths end-to-end on both dialects to guard the predicate shape.
9+
*/
10+
11+
import type { Kysely } from "kysely";
12+
import { afterEach, beforeEach, expect, it } from "vitest";
13+
14+
import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js";
15+
import type { Database } from "../../../src/database/types.js";
16+
import {
17+
type DialectTestContext,
18+
describeEachDialect,
19+
setupForDialect,
20+
teardownForDialect,
21+
} from "../../utils/test-db.js";
22+
23+
interface ProviderDoc {
24+
provider: string;
25+
active: boolean;
26+
priority: number;
27+
}
28+
29+
describeEachDialect("plugin storage filtered query/count (#920)", (dialect) => {
30+
let ctx: DialectTestContext;
31+
32+
beforeEach(async () => {
33+
ctx = await setupForDialect(dialect);
34+
});
35+
afterEach(async () => {
36+
await teardownForDialect(ctx);
37+
});
38+
39+
function makeRepo() {
40+
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- dialect context db vs Database type
41+
const db = ctx.db as unknown as Kysely<Database>;
42+
return new PluginStorageRepository<ProviderDoc>(db, "emdash-smtp", "providers", [
43+
"provider",
44+
"priority",
45+
]);
46+
}
47+
48+
async function seed(repo: PluginStorageRepository<ProviderDoc>) {
49+
await repo.put("p1", { provider: "resend", active: true, priority: 1 });
50+
await repo.put("p2", { provider: "sendgrid", active: false, priority: 2 });
51+
await repo.put("p3", { provider: "resend", active: false, priority: 3 });
52+
}
53+
54+
it("query() with a where filter returns matching documents", async () => {
55+
const repo = makeRepo();
56+
await seed(repo);
57+
58+
const result = await repo.query({ where: { provider: "resend" } });
59+
expect(result.items.map((i) => i.id).toSorted()).toEqual(["p1", "p3"]);
60+
});
61+
62+
it("query() combines where with orderBy", async () => {
63+
const repo = makeRepo();
64+
await seed(repo);
65+
66+
const result = await repo.query({
67+
where: { provider: "resend" },
68+
orderBy: { priority: "desc" },
69+
});
70+
expect(result.items.map((i) => i.id)).toEqual(["p3", "p1"]);
71+
});
72+
73+
it("query() supports operator objects in where", async () => {
74+
const repo = makeRepo();
75+
await seed(repo);
76+
77+
const result = await repo.query({ where: { priority: { gt: 1 } } });
78+
expect(result.items.map((i) => i.id).toSorted()).toEqual(["p2", "p3"]);
79+
});
80+
81+
it("count() with a where filter counts matching documents", async () => {
82+
const repo = makeRepo();
83+
await seed(repo);
84+
85+
expect(await repo.count({ provider: "resend" })).toBe(2);
86+
expect(await repo.count({ provider: "sendgrid" })).toBe(1);
87+
expect(await repo.count({ provider: "nope" })).toBe(0);
88+
});
89+
});

0 commit comments

Comments
 (0)