Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.94.2) (envelope-from ) id 1sTLJk-00FGFo-EY for pgsql-bugs@arkaria.postgresql.org; Mon, 15 Jul 2024 13:00:38 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.94.2) (envelope-from ) id 1sTLIi-00ACV5-NS for pgsql-bugs@arkaria.postgresql.org; Mon, 15 Jul 2024 12:59:32 +0000 Received: from makus.postgresql.org ([2001:4800:3e1:1::229]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.94.2) (envelope-from ) id 1sT1KD-0060ID-2C for pgsql-bugs@lists.postgresql.org; Sun, 14 Jul 2024 15:39:45 +0000 Received: from mout-p-102.mailbox.org ([2001:67c:2050:0:465::102]) by makus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.94.2) (envelope-from ) id 1sT1K4-0021n3-Af for pgsql-bugs@lists.postgresql.org; Sun, 14 Jul 2024 15:39:42 +0000 Received: from smtp2.mailbox.org (smtp2.mailbox.org [IPv6:2001:67c:2050:b231:465::2]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (4096 bits) server-digest SHA256) (No client certificate requested) by mout-p-102.mailbox.org (Postfix) with ESMTPS id 4WMTz02snxz9sbl for ; Sun, 14 Jul 2024 17:39:24 +0200 (CEST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=mailbox.org; s=mail20150812; t=1720971564; h=from:from:reply-to:subject:subject:date:date:message-id:message-id: to:to:cc:mime-version:mime-version:content-type:content-type: content-transfer-encoding:content-transfer-encoding; bh=pEUVPivaTeMInMVk+RzMqBEB/Q9LqdEWsn45tM9aYHw=; b=PO1KAWg9wMntXOEKx7Xgas5+XyYDHoEdqceYWV9kEz1ZTPkEtHpuCZfVm4RbxoaBIbrsmm QcZLzfRlZfwNPQanXTaJlFY8PoH3kWi2kIuXJu+JaQzD2/d1Bv8IWKePS2A4/CIXFkYw/6 eStITaRlgLxncSHxRzq8hCpp9OtxzrZ+cXCm/HkoegfZ5leoR2ua0UV7kzouZhhmQk7U9S Uhoxsa8GD8CCJLL9BGJ34FB2uTLb6nDztdKflS23P+E2aZpXaZmkd3J/FIuPn/cnRnuGwR DzKT3drlA0V5u/muQFCRJGtrktoKa10vC9e77PzxkoUKS0JhXsS5PHrd/bEWfw== Message-ID: <1df84daa-7d0d-e8cc-4762-85523e45e5e7@mailbox.org> Date: Sun, 14 Jul 2024 17:39:22 +0200 MIME-Version: 1.0 Content-Language: en-US To: pgsql-bugs@lists.postgresql.org From: Vasilii Smirnov Subject: libpq: unexpected return code from PQexecParams with a DO INSTEAD rule present Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit X-MBO-RS-ID: e99d1b91d495f634941 X-MBO-RS-META: tgruw5c7kmk1z6qqjjy1rxhgp6sns8zq List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk Let's say I have a table "_users", and also a view "users" that excludes all soft-deleted records from that table: > CREATE SCHEMA test_libpq; > CREATE TABLE test_libpq._users ( > id SERIAL PRIMARY KEY, > name VARCHAR(255) UNIQUE, > deleted_at TIMESTAMP > ); > > CREATE VIEW test_libpq.users AS > SELECT * FROM test_libpq._users > WHERE deleted_at IS NULL; Let's also add a DO INSTEAD rule that translates DELETE statements on the view into soft-delete UPDATE statements: > CREATE RULE soft_delete AS > ON DELETE TO test_libpq.users DO INSTEAD ( > UPDATE test_libpq._users SET deleted_at = NOW() > WHERE id = OLD.id AND deleted_at IS NULL > RETURNING * > ); Cool, now if I go into psql command line and do a few inserts and deletes, everything works as intended: > postgres=# INSERT INTO test_libpq.users (name) VALUES ('Joe'); > INSERT 0 1 > postgres=# DELETE FROM test_libpq.users WHERE name = 'Joe' RETURNING id; > id > ---- > 1 > (1 row) > > DELETE 0 > postgres=# SELECT * FROM test_libpq.users; > id | name | deleted_at > ----+------+------------ > (0 rows) > > postgres=# SELECT * FROM test_libpq._users; > id | name | deleted_at > ----+------+---------------------------- > 1 | Joe | 2024-07-14 14:55:05.585433 > (1 row) The problems begin when I translate that into C code using libpq. That DELETE statement should return with a status code PGRES_TUPLES_OK and I should be able to read a list of deleted ids. And indeed it works like that with PQexec(). But not PQexecParams()! With PQexecParams() it instead returns PGRES_COMMAND_OK and doesn't return the list of deleted ids. This only happens with that DO INSTEAD rule present, without it the function works as expected. But obviously does a hard delete instead of a soft one. This behavior isn't documented. 34.3.1. Main Functions [1] says this about PQexecParams: > PQexecParams is like PQexec, but offers additional > functionality: parameter values can be specified separately from > the command string proper, and query results can be requested in > either text or binary format. > [...] > Unlike PQexec, PQexecParams allows at most one SQL command in > the given string. (There can be semicolons in it, but not more > than one nonempty command.) This is a limitation of the > underlying protocol, but has some usefulness as an extra defense > against SQL-injection attacks. [1]: https://www.postgresql.org/docs/16/libpq-exec.html#LIBPQ-PQEXECPARAMS The manual mentions some limitations in the protocol regarding semicolons, but nothing about rules. Here is a complete C++ program to reproduce the issue: https://paste.sr.ht/~uh/80d023b772bcfe8c1eda8f6b69b5b4e2c0352dc1 (also pasted at the end of the email). I tested it with a postgresql instance launched like this: > docker run -it --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16.3 I also noticed that this same problem affects another unofficial postgresql library, but for Go: https://github.com/lib/pq. So this problem is not local to libpq, but affects other clients as well. The C++ program that I linked to above: > #include > #include > #include > #include > #include > #include > #include > #include > > #define TEST(EXPR) \ > try { \ > EXPR; \ > std::cout << "PASSED: " #EXPR "\n"; \ > } catch (const std::runtime_error& e) { \ > std::cout << "FAILED: " #EXPR "\n" << e.what(); \ > exit(EXIT_FAILURE); \ > } > > void assertRes(PGconn* conn, PGresult* res, ExecStatusType expected) { > auto status = PQresultStatus(res); > if (status != expected) { > auto err = std::stringstream() > << "query failed with status " << PQresStatus(status) > << "\nbut expected: " << PQresStatus(expected) > << "\nmessage: " << PQerrorMessage(conn); > throw std::runtime_error(err.str()); > } > } > > PGconn* setup() { > PGconn* conn = PQconnectdb("postgresql://postgres:postgres@localhost:5432/postgres"); > if (PQstatus(conn) != CONNECTION_OK) { > fprintf(stderr, "connection error: %s\n", PQerrorMessage(conn)); > exit(EXIT_FAILURE); > } > > const char* query = R"( > DROP SCHEMA IF EXISTS test_libpq CASCADE; > CREATE SCHEMA test_libpq; > > CREATE TABLE test_libpq._users ( > id SERIAL PRIMARY KEY, > name VARCHAR(255) UNIQUE, > deleted_at TIMESTAMP > ); > > INSERT INTO test_libpq._users > (name) > VALUES > ('a user'); > > -- A view that only contains non-deleted users: > CREATE VIEW test_libpq.users AS > SELECT * FROM test_libpq._users > WHERE deleted_at IS NULL; > > -- DELETE on this view does a soft delete instead: > CREATE RULE soft_delete AS > ON DELETE TO test_libpq.users DO INSTEAD ( > UPDATE test_libpq._users SET deleted_at = NOW() > WHERE id = OLD.id AND deleted_at IS NULL > RETURNING * > ); > )"; > > PGresult* res = PQexec(conn, query); > assertRes(conn, res, PGRES_COMMAND_OK); > PQclear(res); > > return conn; > } > > static const char* softDelete = R"( > DELETE FROM test_libpq.users > RETURNING id > )"; > > static const char* hardDelete = R"( > DELETE FROM test_libpq._users > RETURNING id > )"; > > using execFn = std::function; > > void testDelete(execFn execQuery, const char* query) { > PGconn* conn = setup(); > PGresult* res = execQuery(conn, query); > assertRes(conn, res, PGRES_TUPLES_OK); > PQclear(res); > PQfinish(conn); > } > > PGresult* usingPQExec(PGconn* conn, const char* query) { > return PQexec(conn, query); > } > > PGresult* usingPQExecParams(PGconn* conn, const char* query) { > return PQexecParams(conn, query, > 0, nullptr, nullptr, > nullptr, nullptr, 0); > } > > int main() { > TEST(testDelete(usingPQExec, hardDelete)); > TEST(testDelete(usingPQExecParams, hardDelete)); > TEST(testDelete(usingPQExec, softDelete)); > TEST(testDelete(usingPQExecParams, softDelete)); // this fails > } Cheers!