Skip to content

Commit ab561be

Browse files
authored
fix: handle null limit (#865)
Handle null parameter value for `LIMIT $1`.
1 parent c5188ce commit ab561be

2 files changed

Lines changed: 235 additions & 50 deletions

File tree

integration/js/pg_tests/test/postgres_js.js

Lines changed: 225 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ describe("postgres.js basic", function () {
3434
});
3535

3636
it("multiple rows", async function () {
37-
const rows =
38-
await sql`SELECT generate_series(1, 5)::int AS n ORDER BY n`;
37+
const rows = await sql`SELECT generate_series(1, 5)::int AS n ORDER BY n`;
3938
assert.strictEqual(rows.length, 5);
4039
assert.strictEqual(rows[0].n, 1);
4140
assert.strictEqual(rows[4].n, 5);
@@ -63,8 +62,7 @@ describe("postgres.js CRUD", function () {
6362
assert.strictEqual(item.name, "widget");
6463
assert.strictEqual(item.quantity, 5);
6564

66-
const [found] =
67-
await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
65+
const [found] = await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
6866
assert.strictEqual(found.name, "widget");
6967
});
7068

@@ -73,8 +71,7 @@ describe("postgres.js CRUD", function () {
7371
await sql`INSERT INTO pjs_items_9k (name, quantity) VALUES ('gizmo', 1) RETURNING *`;
7472
await sql`UPDATE pjs_items_9k SET quantity = 10 WHERE id = ${item.id}`;
7573

76-
const [found] =
77-
await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
74+
const [found] = await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
7875
assert.strictEqual(found.quantity, 10);
7976
});
8077

@@ -83,8 +80,7 @@ describe("postgres.js CRUD", function () {
8380
await sql`INSERT INTO pjs_items_9k (name, quantity) VALUES ('doomed', 0) RETURNING *`;
8481
await sql`DELETE FROM pjs_items_9k WHERE id = ${item.id}`;
8582

86-
const rows =
87-
await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
83+
const rows = await sql`SELECT * FROM pjs_items_9k WHERE id = ${item.id}`;
8884
assert.strictEqual(rows.length, 0);
8985
});
9086

@@ -122,8 +118,7 @@ describe("postgres.js transactions", function () {
122118
return await tx`INSERT INTO pjs_tx_9k (value) VALUES ('committed') RETURNING *`;
123119
});
124120

125-
const [found] =
126-
await sql`SELECT * FROM pjs_tx_9k WHERE id = ${item.id}`;
121+
const [found] = await sql`SELECT * FROM pjs_tx_9k WHERE id = ${item.id}`;
127122
assert.strictEqual(found.value, "committed");
128123
});
129124

@@ -140,8 +135,7 @@ describe("postgres.js transactions", function () {
140135
assert.strictEqual(e.message, "force rollback");
141136
}
142137

143-
const rows =
144-
await sql`SELECT * FROM pjs_tx_9k WHERE id = ${insertedId}`;
138+
const rows = await sql`SELECT * FROM pjs_tx_9k WHERE id = ${insertedId}`;
145139
assert.strictEqual(rows.length, 0);
146140
});
147141
});
@@ -239,9 +233,7 @@ describe("postgres.js unsafe (simple protocol)", function () {
239233
});
240234

241235
it("unsafe insert and select", async function () {
242-
await sql.unsafe(
243-
"INSERT INTO pjs_unsafe_9k (name) VALUES ('unsafe_item')",
244-
);
236+
await sql.unsafe("INSERT INTO pjs_unsafe_9k (name) VALUES ('unsafe_item')");
245237
const rows = await sql.unsafe(
246238
"SELECT * FROM pjs_unsafe_9k WHERE name = 'unsafe_item'",
247239
);
@@ -255,9 +247,7 @@ describe("postgres.js unsafe (simple protocol)", function () {
255247
});
256248

257249
it("unsafe multi-statement", async function () {
258-
const rows = await sql.unsafe(
259-
"SELECT 1 AS a; SELECT 2 AS b",
260-
);
250+
const rows = await sql.unsafe("SELECT 1 AS a; SELECT 2 AS b");
261251
// postgres.js returns the last result for multi-statement
262252
assert.ok(rows.length >= 1);
263253
});
@@ -335,7 +325,8 @@ describe("postgres.js dynamic fragments", function () {
335325

336326
it("dynamic column names", async function () {
337327
const columns = ["name", "score"];
338-
const rows = await sql`SELECT ${sql(columns)} FROM pjs_dyn_9k ORDER BY score`;
328+
const rows =
329+
await sql`SELECT ${sql(columns)} FROM pjs_dyn_9k ORDER BY score`;
339330
assert.strictEqual(rows.length, 3);
340331
assert.strictEqual(rows[0].name, "alice");
341332
assert.strictEqual(rows[0].score, 10);
@@ -350,7 +341,8 @@ describe("postgres.js dynamic fragments", function () {
350341

351342
it("dynamic ORDER BY", async function () {
352343
const orderCol = "score";
353-
const rows = await sql`SELECT * FROM pjs_dyn_9k ORDER BY ${sql(orderCol)} DESC`;
344+
const rows =
345+
await sql`SELECT * FROM pjs_dyn_9k ORDER BY ${sql(orderCol)} DESC`;
354346
assert.strictEqual(rows[0].name, "charlie");
355347
});
356348
});
@@ -414,7 +406,8 @@ describe("postgres.js reserve (dedicated connection)", function () {
414406
await reserved`CREATE TABLE IF NOT EXISTS pjs_reserve_9k (id SERIAL PRIMARY KEY, val TEXT)`;
415407
await reserved`TRUNCATE TABLE pjs_reserve_9k`;
416408
await reserved`INSERT INTO pjs_reserve_9k (val) VALUES ('reserved')`;
417-
const [row] = await reserved`SELECT * FROM pjs_reserve_9k WHERE val = 'reserved'`;
409+
const [row] =
410+
await reserved`SELECT * FROM pjs_reserve_9k WHERE val = 'reserved'`;
418411
assert.strictEqual(row.val, "reserved");
419412
await reserved`DROP TABLE IF EXISTS pjs_reserve_9k`;
420413
} finally {
@@ -440,7 +433,8 @@ describe("postgres.js sql.array()", function () {
440433

441434
it("WHERE id = ANY with sql.array()", async function () {
442435
const ids = [1, 2];
443-
const rows = await sql`SELECT * FROM pjs_arr_9k WHERE id = ANY(${sql.array(ids, 23)})`;
436+
const rows =
437+
await sql`SELECT * FROM pjs_arr_9k WHERE id = ANY(${sql.array(ids, 23)})`;
444438
assert.strictEqual(rows.length, 2);
445439
});
446440

@@ -451,47 +445,236 @@ describe("postgres.js sql.array()", function () {
451445
});
452446
});
453447

454-
describe("postgres.js unsafe stress test (50k unique statements)", function () {
448+
describe("postgres.js LIMIT NULL", function () {
449+
before(async function () {
450+
await adminSet("prepared_statements", "extended_anonymous");
451+
await sqlNoPrepare`CREATE TABLE IF NOT EXISTS pjs_limit_9k (
452+
id SERIAL PRIMARY KEY,
453+
value TEXT
454+
)`;
455+
await sqlNoPrepare`TRUNCATE TABLE pjs_limit_9k`;
456+
await sqlNoPrepare`INSERT INTO pjs_limit_9k (value) VALUES ('a'), ('b'), ('c'), ('d'), ('e')`;
457+
});
458+
459+
after(async function () {
460+
await sqlNoPrepare`DROP TABLE IF EXISTS pjs_limit_9k`;
461+
await adminSet("prepared_statements", "extended");
462+
});
463+
464+
it("LIMIT with null parameter returns all rows", async function () {
465+
const limit = null;
466+
const rows =
467+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k ORDER BY id LIMIT ${limit}`;
468+
assert.strictEqual(rows.length, 5);
469+
});
470+
471+
it("LIMIT with non-null parameter limits rows", async function () {
472+
const limit = 2;
473+
const rows =
474+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k ORDER BY id LIMIT ${limit}`;
475+
assert.strictEqual(rows.length, 2);
476+
});
477+
478+
it("LIMIT null with WHERE clause and multiple params", async function () {
479+
const value = "a";
480+
const limit = null;
481+
const rows =
482+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k WHERE value >= ${value} ORDER BY id LIMIT ${limit}`;
483+
assert.strictEqual(rows.length, 5);
484+
});
485+
486+
it("LIMIT and OFFSET both null", async function () {
487+
const limit = null;
488+
const offset = null;
489+
const rows =
490+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k ORDER BY id LIMIT ${limit} OFFSET ${offset}`;
491+
assert.strictEqual(rows.length, 5);
492+
});
493+
494+
it("LIMIT null with OFFSET non-null", async function () {
495+
const limit = null;
496+
const offset = 3;
497+
const rows =
498+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k ORDER BY id LIMIT ${limit} OFFSET ${offset}`;
499+
assert.strictEqual(rows.length, 2);
500+
});
501+
502+
it("LIMIT non-null with OFFSET null", async function () {
503+
const limit = 2;
504+
const offset = null;
505+
const rows =
506+
await sqlNoPrepare`SELECT * FROM pjs_limit_9k ORDER BY id LIMIT ${limit} OFFSET ${offset}`;
507+
assert.strictEqual(rows.length, 2);
508+
});
509+
});
510+
511+
describe("postgres.js unsafe stress test (50k unique statements, 5 clients)", function () {
455512
this.timeout(300000);
456513

514+
const TIMESTAMPTZ_OID = 1184;
515+
const timestampType = {
516+
to: TIMESTAMPTZ_OID,
517+
from: [TIMESTAMPTZ_OID],
518+
serialize: (value) =>
519+
(value instanceof Date ? value : new Date(value)).toISOString(),
520+
parse: (value) => new Date(value),
521+
};
522+
523+
const NUM_CLIENTS = 5;
524+
const clients = [];
525+
457526
before(async function () {
458527
await adminSet("prepared_statements", "extended_anonymous");
459-
// Warmup: ensure pool connections are established after databases::init()
460-
// recreates backend pools (same pattern as other test suites).
461-
await sql.unsafe("SELECT 1");
528+
for (let i = 0; i < NUM_CLIENTS; i++) {
529+
const c = postgres("postgres://pgdog:pgdog@127.0.0.1:6432/pgdog", {
530+
prepare: false,
531+
connection: { application_name: `stress_client_${i}` },
532+
types: { timestamp: timestampType },
533+
});
534+
await c.unsafe("SELECT 1"); // warmup
535+
clients.push(c);
536+
}
462537
});
463538

464539
after(async function () {
540+
await Promise.all(clients.map((c) => c.end()));
465541
await adminSet("prepared_statements", "extended");
466542
});
467543

468-
it("50k unique query texts with 25 rotating parameters", async function () {
544+
it("50k mixed queries (unsafe + tagged template) across 5 clients", async function () {
469545
const TOTAL_QUERIES = 50000;
470-
const NUM_PARAMS = 25;
471546
const BATCH_SIZE = 100;
472547

473-
const params = Array.from({ length: NUM_PARAMS }, (_, i) => i * 7 + 1);
548+
// Build a query with 1..numParams parameters mixing ints and timestamps.
549+
function buildQuery(i) {
550+
const numParams = (i % 8) + 1; // 1 to 8 parameters
551+
const useTimestamp = i % 3 === 0; // every 3rd query includes a timestamp
552+
const vals = [];
553+
const selectParts = [];
554+
const expected = {};
555+
556+
for (let k = 0; k < numParams; k++) {
557+
const paramIdx = k + 1;
558+
if (useTimestamp && k === numParams - 1) {
559+
const ts = new Date(1700000000000 + i * 1000);
560+
vals.push(ts);
561+
selectParts.push(`$${paramIdx}::timestamptz AS ts_q${i}`);
562+
expected[`ts_q${i}`] = (val) => {
563+
const got = val instanceof Date ? val : new Date(val);
564+
assert.ok(!isNaN(got.getTime()), `invalid timestamp at query ${i}: ${val}`);
565+
assert.ok(Math.abs(got.getTime() - ts.getTime()) < 60000, `timestamp mismatch at query ${i}: expected ~${ts.toISOString()}, got ${got.toISOString()}`);
566+
};
567+
} else {
568+
const intVal = (i + k) * 7 + 1;
569+
vals.push(intVal);
570+
selectParts.push(`$${paramIdx}::int * ${k + 1} AS c${k}_q${i}`);
571+
expected[`c${k}_q${i}`] = intVal * (k + 1);
572+
}
573+
}
574+
575+
const queryText = `SELECT ${selectParts.join(", ")}`;
576+
return { queryText, vals, expected };
577+
}
578+
579+
// Tagged template queries that exercise unnamed prepared statements.
580+
// These reuse the same SQL text with different parameter values,
581+
// which is the normal postgres.js pattern with prepare: false.
582+
function taggedQuery(client, i) {
583+
const variant = i % 6;
584+
switch (variant) {
585+
case 0: {
586+
// Simple parameterized select
587+
const val = i * 3 + 1;
588+
return client`SELECT ${val}::int AS v`.then((rows) => {
589+
assert.strictEqual(rows[0].v, val);
590+
});
591+
}
592+
case 1: {
593+
// Multiple parameters
594+
const a = i % 100;
595+
const b = i % 50;
596+
return client`SELECT ${a}::int + ${b}::int AS sum`.then((rows) => {
597+
assert.strictEqual(rows[0].sum, a + b);
598+
});
599+
}
600+
case 2: {
601+
// String parameter
602+
const name = `item_${i}`;
603+
return client`SELECT ${name}::text AS name`.then((rows) => {
604+
assert.strictEqual(rows[0].name, name);
605+
});
606+
}
607+
case 3: {
608+
// Boolean + int parameters
609+
const flag = i % 2 === 0;
610+
const num = i % 1000;
611+
return client`SELECT ${flag}::bool AS flag, ${num}::int AS num`.then((rows) => {
612+
assert.strictEqual(rows[0].flag, flag);
613+
assert.strictEqual(rows[0].num, num);
614+
});
615+
}
616+
case 4: {
617+
// Timestamp parameter
618+
const ts = new Date(1700000000000 + i * 1000);
619+
return client`SELECT ${ts}::timestamptz AS ts`.then((rows) => {
620+
const got = rows[0].ts instanceof Date ? rows[0].ts : new Date(rows[0].ts);
621+
assert.ok(Math.abs(got.getTime() - ts.getTime()) < 60000);
622+
});
623+
}
624+
case 5: {
625+
// Many parameters (4)
626+
const a = i % 100, b = i % 50, c = i % 25, d = i % 10;
627+
return client`SELECT ${a}::int AS a, ${b}::int AS b, ${c}::int AS c, ${d}::int AS d`.then((rows) => {
628+
assert.strictEqual(rows[0].a, a);
629+
assert.strictEqual(rows[0].b, b);
630+
assert.strictEqual(rows[0].c, c);
631+
assert.strictEqual(rows[0].d, d);
632+
});
633+
}
634+
}
635+
}
474636

475637
let completed = 0;
476638
const errors = [];
477639

478-
for (let batchStart = 0; batchStart < TOTAL_QUERIES; batchStart += BATCH_SIZE) {
640+
for (
641+
let batchStart = 0;
642+
batchStart < TOTAL_QUERIES;
643+
batchStart += BATCH_SIZE
644+
) {
479645
const batchEnd = Math.min(batchStart + BATCH_SIZE, TOTAL_QUERIES);
480646
const promises = [];
481647

482648
for (let i = batchStart; i < batchEnd; i++) {
483-
const paramVal = params[i % NUM_PARAMS];
484-
const queryText = `SELECT $1::int AS r_${i}`;
485-
486-
const p = sql
487-
.unsafe(queryText, [paramVal])
488-
.then((rows) => {
489-
assert.strictEqual(rows[0][`r_${i}`], paramVal);
490-
completed++;
491-
})
492-
.catch((err) => {
493-
errors.push({ i, err: err.message });
494-
});
649+
const client = clients[i % NUM_CLIENTS];
650+
let p;
651+
652+
if (i % 2 === 0) {
653+
// Even: unsafe query (unique query text each time)
654+
const { queryText, vals, expected } = buildQuery(i);
655+
p = client
656+
.unsafe(queryText, vals)
657+
.then((rows) => {
658+
for (const [col, val] of Object.entries(expected)) {
659+
if (typeof val === "function") {
660+
val(rows[0][col]);
661+
} else {
662+
assert.strictEqual(
663+
rows[0][col],
664+
val,
665+
`mismatch at query ${i}, col ${col}`,
666+
);
667+
}
668+
}
669+
});
670+
} else {
671+
// Odd: tagged template (reused SQL text, unnamed prepared statements)
672+
p = taggedQuery(client, i);
673+
}
674+
675+
p = p
676+
.then(() => { completed++; })
677+
.catch((err) => { errors.push({ i, err: err.message }); });
495678

496679
promises.push(p);
497680
}
@@ -507,8 +690,6 @@ describe("postgres.js unsafe stress test (50k unique statements)", function () {
507690
assert.strictEqual(completed, TOTAL_QUERIES);
508691

509692
// Verify backend prepared statement evictions are happening.
510-
// With 50k unique statements, pool_size=10, and capacity=500,
511-
// each connection handles ~5k queries → ~4500 evictions each.
512693
const res = await fetch("http://localhost:9090");
513694
const metrics = await res.text();
514695
const evictions = metrics

0 commit comments

Comments
 (0)