diff --git a/.gitignore b/.gitignore
index 35dfb13..07d3264 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ pglabAll.pro.user.4.8-pre1
*.user
/pglabAll.pro.user*
/public/
+/Testing/Temporary
\ No newline at end of file
diff --git a/.idea/pgLab.iml b/.idea/pgLab.iml
deleted file mode 100644
index 4c94235..0000000
--- a/.idea/pgLab.iml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/.junie/skills/pg-integration-tests/SKILL.md b/.junie/skills/pg-integration-tests/SKILL.md
new file mode 100644
index 0000000..9cc3328
--- /dev/null
+++ b/.junie/skills/pg-integration-tests/SKILL.md
@@ -0,0 +1,43 @@
+---
+name: pg-integration-tests
+description: Write and run pgLab tests against a real PostgreSQL server (14–17) using the pgintegration harness (DbTest fixture, template database, REQUIRE_PG_AT_LEAST). Use whenever a test needs a live server, e.g. catalog introspection, generated SQL that must be executed, version-specific behaviour, or when the user mentions integration tests, IntegrationTests, docker-compose Postgres or TEST_DATABASE_URL.
+---
+
+# Integration tests against real PostgreSQL
+
+Build/run commands live in `AGENTS.md` (Build and test); don't duplicate them here.
+
+## When to use
+Use for behaviour that depends on a real server: catalog queries, `pg_get_*` output, executing generated SQL, version differences. Pure string/logic tests belong in `PgsqlTests` / `pglabtests` instead.
+
+## Layout
+- `tests/pgintegration/` – static lib: `PgEnv` (global environment, builds template DB `tmpl_` from `fixtures/schema.sql`), `DbTest` fixture, helpers.
+- `tests/integration/` – `IntegrationTests` executable, `tst_*.cpp`, `fixtures/schema.sql`, `docker-compose.yml`.
+- The binary only reads `TEST_DATABASE_URL`; unset means every `DbTest` skips.
+
+## Writing a test
+```cpp
+#include "DbTest.h"
+#include "PgTestEnvironment.h"
+class MyFeature : public pgtest::DbTest {};
+
+TEST_F(MyFeature, Works)
+{
+ REQUIRE_PG_AT_LEAST(15); // skip on older servers
+ auto r = conn().query("SELECT 1"); // Pgsql::Connection, throws on SQL error
+ EXPECT_EQ(r.rows(), 1);
+}
+```
+- Derive from `pgtest::DbTest`. Each test gets a fresh database (`t__`) cloned from the template and dropped afterwards, so never rely on state from another test and freely modify data/schema.
+- `conn()` is the test connection, `dbUrl()` gives a URL for code under test that opens its own connection (close those before the test ends; teardown uses `DROP DATABASE ... WITH (FORCE)`).
+- Gate version-specific features with `REQUIRE_PG_AT_LEAST(v)`; use `pgtest::serverVersionNum()` for finer checks. Also test graceful behaviour on PG 14.
+- `query()` throws on error; use `EXPECT_THROW` for expected failures.
+
+## Adding things
+- New test file: add it to the `add_executable(IntegrationTests ...)` list in `tests/integration/CMakeLists.txt` (no globbing).
+- Shared schema: extend `tests/integration/fixtures/schema.sql` (plain multi-statement SQL, loaded once per process into the template). Keep it small; it must work on all of 14–17. Version-specific schema should be created inside the test.
+- Cluster-wide objects (roles, tablespaces) are not isolated by cloning: use unique names (include `pgtest::processId()`) and drop them in `TearDown`.
+
+## Running and checking
+- One version manually: set `TEST_DATABASE_URL` and use `--gtest_filter` (see AGENTS.md); start servers with `docker compose -f tests/integration/docker-compose.yml up -d --wait pg16`, stop with `down -v`.
+- After a run no `t_*` or `tmpl_*` databases should remain: `psql -Atc "select datname from pg_database where datname ~ '^(t|tmpl)_'"`.
diff --git a/AGENTS.md b/AGENTS.md
index b9c08b0..3244f6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,6 +18,7 @@ cmake --build build
ctest --test-dir build --output-on-failure
```
- Run one suite: `./build/tests/pglabtests/pglabtests --gtest_filter='SqlLexer*'` (same for `PgsqlTests`).
+- Integration tests against real PostgreSQL 14–17 (`IntegrationTests`, label `integration`): configure with `-DPGLAB_INTEGRATION_TESTS=ON` (needs Docker), then `ctest --test-dir build -L integration -j4`. This starts `tests/integration/docker-compose.yml` (ports 54314–54317), runs the binary once per version and tears it down. Manual run against any server: `TEST_DATABASE_URL=postgresql://postgres:test@localhost:54316/postgres ./build/tests/integration/IntegrationTests --gtest_filter='Smoke*'`. Without the variable the tests skip. Developer-facing only, no reno note. See the `pg-integration-tests` skill.
- `-DBUILD_TESTS=OFF` skips tests. `build/` is git-ignored; the first configure builds dependencies and is slow.
- Run the app headless with `QT_QPA_PLATFORM=offscreen ./build/pglab/pglab`. It needs a PostgreSQL server for anything useful.
diff --git a/pglab/catalog/models/DatabasesTableModel.cpp b/pglab/catalog/models/DatabasesTableModel.cpp
index 289eea2..789c981 100644
--- a/pglab/catalog/models/DatabasesTableModel.cpp
+++ b/pglab/catalog/models/DatabasesTableModel.cpp
@@ -205,26 +205,9 @@ void DatabasesTableModel::StartLoadDatabaseSizes(std::map oidIndex)
DatabasesTableModel::DatabaseSizes DatabasesTableModel::QueryDatabaseSizes()
{
- std::string q =
- "SELECT pg_database.oid, "
- " case when has_database_privilege(current_role, pg_database.oid, \n"
- " 'connect') then pg_database_size(pg_database.oid) else -1 end \n"
- "FROM pg_database";
-
Pgsql::Connection connection;
connection.connect(openDatabase->config().connectionString());
- Pgsql::Result sizesResult = connection.query(q.c_str());
-
- DatabaseSizes result;
- result.reserve(sizesResult.rows());
- for (auto& row : sizesResult)
- {
- DatabaseSize ds;
- ds.oid = row.get(0);
- ds.totalBytes = row.get(1);
- result.push_back(ds);
- }
- return result;
+ return loadDatabaseSizes(connection);
}
void DatabasesTableModel::PopulateSizes(std::map oidIndex, DatabaseSizes sizes)
diff --git a/pglab/catalog/models/DatabasesTableModel.h b/pglab/catalog/models/DatabasesTableModel.h
index 8e46aaa..c25550c 100644
--- a/pglab/catalog/models/DatabasesTableModel.h
+++ b/pglab/catalog/models/DatabasesTableModel.h
@@ -3,6 +3,7 @@
#include "BaseTableModel.h"
#include "catalog/PgDatabase.h"
+#include "catalog/PgDatabaseSizes.h"
#include
class OpenDatabase;
@@ -45,17 +46,8 @@ public:
protected:
virtual QVariant getDataMeaning(const QModelIndex &index) const override;
private:
- class DatabaseSize {
- public:
- Oid oid;
- int64_t totalBytes;
-
- DatabaseSize()
- : oid(InvalidOid)
- , totalBytes(-1)
- {}
- };
- using DatabaseSizes = std::vector;
+ using DatabaseSize = PgDatabaseSize;
+ using DatabaseSizes = PgDatabaseSizes;
class Database {
public:
diff --git a/pglablib/CMakeLists.txt b/pglablib/CMakeLists.txt
index 1734d09..249e59c 100644
--- a/pglablib/CMakeLists.txt
+++ b/pglablib/CMakeLists.txt
@@ -21,6 +21,7 @@ add_library(pglablib STATIC
catalog/PgIndexContainer.cpp
catalog/PgConstraint.cpp
catalog/PgConstraintContainer.cpp
+ catalog/PgDatabaseSizes.cpp
ParamListJson.cpp
ParamListModel.cpp
sqlite/SQLiteConnection.cpp
@@ -95,6 +96,7 @@ add_library(pglablib STATIC
catalog/PgIndexContainer.h
catalog/PgConstraint.h
catalog/PgConstraintContainer.h
+ catalog/PgDatabaseSizes.h
ParamListJson.h
ParamListModel.h
sqlite/SQLiteConnection.h
diff --git a/pglablib/catalog/PgDatabaseSizes.cpp b/pglablib/catalog/PgDatabaseSizes.cpp
new file mode 100644
index 0000000..244509a
--- /dev/null
+++ b/pglablib/catalog/PgDatabaseSizes.cpp
@@ -0,0 +1,25 @@
+#include "PgDatabaseSizes.h"
+
+#include "Pgsql_Result.h"
+
+PgDatabaseSizes loadDatabaseSizes(Pgsql::Connection &connection)
+{
+ std::string q =
+ "SELECT pg_database.oid, "
+ " case when has_database_privilege(current_role, pg_database.oid, \n"
+ " 'connect') then pg_database_size(pg_database.oid) else -1 end \n"
+ "FROM pg_database";
+
+ Pgsql::Result sizesResult = connection.query(q.c_str());
+
+ PgDatabaseSizes result;
+ result.reserve(sizesResult.rows());
+ for (auto& row : sizesResult)
+ {
+ PgDatabaseSize ds;
+ ds.oid = row.get(0);
+ ds.totalBytes = row.get(1);
+ result.push_back(ds);
+ }
+ return result;
+}
diff --git a/pglablib/catalog/PgDatabaseSizes.h b/pglablib/catalog/PgDatabaseSizes.h
new file mode 100644
index 0000000..3c39671
--- /dev/null
+++ b/pglablib/catalog/PgDatabaseSizes.h
@@ -0,0 +1,25 @@
+#ifndef PGDATABASESIZES_H
+#define PGDATABASESIZES_H
+
+#include "Pgsql_Connection.h"
+#include
+#include
+#include
+
+class PgDatabaseSize {
+public:
+ Oid oid;
+ int64_t totalBytes;
+
+ PgDatabaseSize()
+ : oid(InvalidOid)
+ , totalBytes(-1)
+ {}
+};
+
+using PgDatabaseSizes = std::vector;
+
+/// Returns the size of every database. The size is -1 when the current role has no CONNECT privilege.
+PgDatabaseSizes loadDatabaseSizes(Pgsql::Connection &connection);
+
+#endif // PGDATABASESIZES_H
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index a02ee3c..645cac5 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -3,3 +3,5 @@ find_package(GTest CONFIG REQUIRED)
add_subdirectory(mygtestutils)
add_subdirectory(PgsqlTests)
add_subdirectory(pglabtests)
+add_subdirectory(pgintegration)
+add_subdirectory(integration)
diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt
new file mode 100644
index 0000000..1284cdb
--- /dev/null
+++ b/tests/integration/CMakeLists.txt
@@ -0,0 +1,46 @@
+add_executable(IntegrationTests
+ main.cpp
+ tst_Smoke.cpp
+ tst_CatalogLoad.cpp
+ tst_DatabaseSizes.cpp
+ )
+
+target_link_libraries(IntegrationTests PRIVATE
+ pgintegration
+ pglablib
+ GTest::gtest
+ Qt6::Core
+ )
+
+target_compile_definitions(IntegrationTests PRIVATE
+ PGTEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
+ )
+
+set_target_properties(IntegrationTests PROPERTIES
+ CXX_STANDARD 20
+ CXX_STANDARD_REQUIRED ON
+ )
+
+option(PGLAB_INTEGRATION_TESTS "Register Docker based PostgreSQL integration tests" OFF)
+
+if (PGLAB_INTEGRATION_TESTS)
+ set(PGTEST_COMPOSE_FILE ${CMAKE_CURRENT_SOURCE_DIR}/docker-compose.yml)
+
+ add_test(NAME db_up
+ COMMAND docker compose -f ${PGTEST_COMPOSE_FILE} up -d --wait)
+ add_test(NAME db_down
+ COMMAND docker compose -f ${PGTEST_COMPOSE_FILE} down -v)
+ set_tests_properties(db_up PROPERTIES FIXTURES_SETUP pgcluster LABELS integration)
+ set_tests_properties(db_down PROPERTIES FIXTURES_CLEANUP pgcluster LABELS integration)
+
+ foreach (v 14 15 16 17 18)
+ add_test(NAME integration_pg${v} COMMAND IntegrationTests)
+ set_tests_properties(integration_pg${v} PROPERTIES
+ FIXTURES_REQUIRED pgcluster
+ LABELS integration
+ ENVIRONMENT "TEST_DATABASE_URL=postgresql://postgres:test@localhost:543${v}/postgres")
+ endforeach ()
+else ()
+ add_test(NAME IntegrationTests COMMAND IntegrationTests)
+ set_tests_properties(IntegrationTests PROPERTIES LABELS integration)
+endif ()
diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml
new file mode 100644
index 0000000..b2749e2
--- /dev/null
+++ b/tests/integration/docker-compose.yml
@@ -0,0 +1,17 @@
+x-pg: &pg
+ environment: { POSTGRES_PASSWORD: test }
+ tmpfs: [/var/lib/postgresql]
+ command: >
+ postgres -c fsync=off -c synchronous_commit=off
+ -c full_page_writes=off -c max_connections=200
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 1s
+ retries: 30
+
+services:
+ pg14: { <<: *pg, image: "postgres:14", ports: ["54314:5432"] }
+ pg15: { <<: *pg, image: "postgres:15", ports: ["54315:5432"] }
+ pg16: { <<: *pg, image: "postgres:16", ports: ["54316:5432"] }
+ pg17: { <<: *pg, image: "postgres:17", ports: ["54317:5432"] }
+ pg18: { <<: *pg, image: "postgres:18", ports: ["54318:5432"] }
diff --git a/tests/integration/fixtures/schema.sql b/tests/integration/fixtures/schema.sql
new file mode 100644
index 0000000..ee8554f
--- /dev/null
+++ b/tests/integration/fixtures/schema.sql
@@ -0,0 +1,6 @@
+CREATE TABLE fixture_item (
+ id integer PRIMARY KEY,
+ name text NOT NULL
+);
+
+INSERT INTO fixture_item (id, name) VALUES (1, 'one'), (2, 'two');
diff --git a/tests/integration/main.cpp b/tests/integration/main.cpp
new file mode 100644
index 0000000..60bed8d
--- /dev/null
+++ b/tests/integration/main.cpp
@@ -0,0 +1,10 @@
+#include
+
+#include "PgTestEnvironment.h"
+
+int main(int argc, char *argv[])
+{
+ ::testing::InitGoogleTest(&argc, argv);
+ ::testing::AddGlobalTestEnvironment(new pgtest::PgEnv(PGTEST_FIXTURE_DIR));
+ return RUN_ALL_TESTS();
+}
diff --git a/tests/integration/tst_CatalogLoad.cpp b/tests/integration/tst_CatalogLoad.cpp
new file mode 100644
index 0000000..5993337
--- /dev/null
+++ b/tests/integration/tst_CatalogLoad.cpp
@@ -0,0 +1,36 @@
+#include "DbTest.h"
+#include "PgTestEnvironment.h"
+#include "catalog/PgClassContainer.h"
+#include "catalog/PgDatabaseCatalog.h"
+#include "Pgsql_Result.h"
+
+using namespace pgtest;
+
+class CatalogLoad : public DbTest {};
+
+TEST_F(CatalogLoad, SuperuserLoadsCatalog)
+{
+ PgDatabaseCatalog catalog;
+ catalog.loadAll(conn(), {});
+
+ EXPECT_EQ(catalog.serverVersion(), serverVersionNum());
+ EXPECT_EQ(catalog.getDBName(), dbName());
+ ASSERT_NE(catalog.classes(), nullptr);
+ EXPECT_NE(catalog.classes()->getByObjectNsAndName("public", "fixture_item"), nullptr);
+}
+
+TEST_F(CatalogLoad, PlainRoleLoadsCatalog)
+{
+ auto plain = plainConn();
+ Pgsql::Result r = plain->query("SELECT rolsuper FROM pg_roles WHERE rolname = current_user");
+ ASSERT_EQ(r.rows(), 1);
+ EXPECT_EQ(QString::fromUtf8(r.val(0, 0)), QString("f"));
+
+ PgDatabaseCatalog catalog;
+ catalog.loadAll(*plain, {});
+
+ EXPECT_EQ(catalog.serverVersion(), serverVersionNum());
+ EXPECT_EQ(catalog.getDBName(), dbName());
+ ASSERT_NE(catalog.classes(), nullptr);
+ EXPECT_NE(catalog.classes()->getByObjectNsAndName("public", "fixture_item"), nullptr);
+}
diff --git a/tests/integration/tst_DatabaseSizes.cpp b/tests/integration/tst_DatabaseSizes.cpp
new file mode 100644
index 0000000..7ed8345
--- /dev/null
+++ b/tests/integration/tst_DatabaseSizes.cpp
@@ -0,0 +1,68 @@
+#include "DbTest.h"
+#include "PgTestEnvironment.h"
+#include "catalog/PgDatabaseSizes.h"
+#include "Pgsql_Result.h"
+
+using namespace pgtest;
+
+namespace {
+
+Oid oidOfDatabase(Pgsql::Connection &c, const QString &db)
+{
+ Pgsql::Result r = c.query(QString("SELECT oid FROM pg_database WHERE datname = '%1'").arg(db));
+ if (!r || r.rows() != 1)
+ return InvalidOid;
+ return QString::fromUtf8(r.val(0, 0)).toUInt();
+}
+
+const PgDatabaseSize *findSize(const PgDatabaseSizes &sizes, Oid oid)
+{
+ for (const auto &s : sizes) {
+ if (s.oid == oid)
+ return &s;
+ }
+ return nullptr;
+}
+
+} // namespace
+
+class DatabaseSizes : public DbTest {};
+
+TEST_F(DatabaseSizes, ConnectableDatabaseReturnsSize)
+{
+ auto plain = plainConn();
+ Oid oid = oidOfDatabase(*plain, dbName());
+ ASSERT_NE(oid, InvalidOid);
+
+ PgDatabaseSizes sizes = loadDatabaseSizes(*plain);
+ const PgDatabaseSize *s = findSize(sizes, oid);
+ ASSERT_NE(s, nullptr);
+ EXPECT_GE(s->totalBytes, 0);
+}
+
+TEST_F(DatabaseSizes, RevokedConnectReturnsMinusOne)
+{
+ Oid oid = oidOfDatabase(conn(), dbName());
+ ASSERT_NE(oid, InvalidOid);
+ conn().query(QString("REVOKE CONNECT ON DATABASE %1 FROM PUBLIC").arg(quoteIdent(dbName())));
+
+ auto plain = std::make_unique();
+ plain->connect(urlForDbAsPlainRole("postgres"));
+
+ PgDatabaseSizes sizes = loadDatabaseSizes(*plain);
+ const PgDatabaseSize *s = findSize(sizes, oid);
+ ASSERT_NE(s, nullptr);
+ EXPECT_EQ(s->totalBytes, -1);
+}
+
+TEST_F(DatabaseSizes, SuperuserSeesAllDatabases)
+{
+ PgDatabaseSizes sizes = loadDatabaseSizes(conn());
+
+ Pgsql::Result r = conn().query("SELECT count(*) FROM pg_database");
+ ASSERT_EQ(r.rows(), 1);
+ EXPECT_EQ(static_cast(QString::fromUtf8(r.val(0, 0)).toULongLong()), sizes.size());
+
+ for (const auto &s : sizes)
+ EXPECT_GE(s.totalBytes, 0) << "database oid " << s.oid;
+}
diff --git a/tests/integration/tst_Smoke.cpp b/tests/integration/tst_Smoke.cpp
new file mode 100644
index 0000000..900a278
--- /dev/null
+++ b/tests/integration/tst_Smoke.cpp
@@ -0,0 +1,63 @@
+#include "DbTest.h"
+#include "PgTestEnvironment.h"
+#include "Pgsql_Result.h"
+
+using namespace pgtest;
+
+namespace {
+
+int scalarInt(Pgsql::Connection &c, const char *sql)
+{
+ Pgsql::Result r = c.query(sql);
+ EXPECT_EQ(r.rows(), 1);
+ return QString::fromUtf8(r.val(0, 0)).toInt();
+}
+
+} // namespace
+
+class Smoke : public DbTest {};
+
+TEST_F(Smoke, SelectOne)
+{
+ EXPECT_EQ(scalarInt(conn(), "SELECT 1"), 1);
+}
+
+TEST_F(Smoke, RunsOnClonedDatabase)
+{
+ Pgsql::Result r = conn().query("SELECT current_database()");
+ ASSERT_EQ(r.rows(), 1);
+ EXPECT_EQ(QString::fromUtf8(r.val(0, 0)), dbName());
+ EXPECT_TRUE(dbName().startsWith("t_"));
+}
+
+TEST_F(Smoke, ServerVersionAtLeast14)
+{
+ EXPECT_GE(serverVersionNum(), 140000);
+}
+
+TEST_F(Smoke, FixtureSchemaPresent)
+{
+ EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
+}
+
+// The two tests below each modify the database and expect the pristine
+// fixture state, in whichever order they run.
+TEST_F(Smoke, IsolationA)
+{
+ EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
+ conn().query("INSERT INTO fixture_item VALUES (100, 'a')");
+ EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 3);
+}
+
+TEST_F(Smoke, IsolationB)
+{
+ EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 2);
+ conn().query("DELETE FROM fixture_item");
+ EXPECT_EQ(scalarInt(conn(), "SELECT count(*) FROM fixture_item"), 0);
+}
+
+TEST_F(Smoke, VersionGate)
+{
+ REQUIRE_PG_AT_LEAST(15);
+ EXPECT_GE(serverVersionNum(), 150000);
+}
diff --git a/tests/pgintegration/CMakeLists.txt b/tests/pgintegration/CMakeLists.txt
new file mode 100644
index 0000000..96f623a
--- /dev/null
+++ b/tests/pgintegration/CMakeLists.txt
@@ -0,0 +1,21 @@
+add_library(pgintegration STATIC
+ PgTestEnvironment.h
+ PgTestEnvironment.cpp
+ DbTest.h
+ DbTest.cpp
+ )
+
+target_link_libraries(pgintegration PUBLIC
+ pgsql
+ GTest::gtest
+ Qt6::Core
+ )
+
+target_include_directories(pgintegration PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+
+set_target_properties(pgintegration PROPERTIES
+ CXX_STANDARD 20
+ CXX_STANDARD_REQUIRED ON
+ )
diff --git a/tests/pgintegration/DbTest.cpp b/tests/pgintegration/DbTest.cpp
new file mode 100644
index 0000000..c6caaf2
--- /dev/null
+++ b/tests/pgintegration/DbTest.cpp
@@ -0,0 +1,62 @@
+#include "DbTest.h"
+#include "PgTestEnvironment.h"
+
+#include
+#include
+
+namespace pgtest {
+
+namespace {
+std::atomic g_counter{0};
+}
+
+QString DbTest::dbUrl() const
+{
+ return urlForDb(dbName_);
+}
+
+std::unique_ptr DbTest::plainConn() const
+{
+ auto c = std::make_unique();
+ c->connect(urlForDbAsPlainRole(dbName_));
+ return c;
+}
+
+void DbTest::SetUp()
+{
+ if (!available())
+ GTEST_SKIP() << "TEST_DATABASE_URL not set or template database unavailable";
+
+ dbName_ = QString("t_%1_%2").arg(g_counter++).arg(processId());
+ try {
+ {
+ Pgsql::Connection admin;
+ admin.connect(*baseUrl());
+ admin.query(QString("CREATE DATABASE %1 TEMPLATE %2")
+ .arg(quoteIdent(dbName_), quoteIdent(templateDbName())));
+ }
+ conn_ = std::make_unique();
+ conn_->connect(dbUrl());
+ }
+ catch (const std::exception &e) {
+ FAIL() << "Creating test database failed: " << e.what();
+ }
+}
+
+void DbTest::TearDown()
+{
+ conn_.reset();
+ if (dbName_.isEmpty())
+ return;
+ try {
+ Pgsql::Connection admin;
+ admin.connect(*baseUrl());
+ admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(dbName_)));
+ }
+ catch (const std::exception &e) {
+ ADD_FAILURE() << "Dropping test database failed: " << e.what();
+ }
+ dbName_.clear();
+}
+
+} // namespace pgtest
diff --git a/tests/pgintegration/DbTest.h b/tests/pgintegration/DbTest.h
new file mode 100644
index 0000000..37477a5
--- /dev/null
+++ b/tests/pgintegration/DbTest.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "Pgsql_Connection.h"
+
+namespace pgtest {
+
+/// Fixture giving every test its own database cloned from the template.
+class DbTest : public ::testing::Test {
+protected:
+ void SetUp() override;
+ void TearDown() override;
+
+ Pgsql::Connection& conn() { return *conn_; }
+ QString dbName() const { return dbName_; }
+ QString dbUrl() const;
+ /// Opens a new connection to this test's database as the plain (non-superuser) role.
+ std::unique_ptr plainConn() const;
+
+private:
+ QString dbName_;
+ std::unique_ptr conn_;
+};
+
+} // namespace pgtest
diff --git a/tests/pgintegration/PgTestEnvironment.cpp b/tests/pgintegration/PgTestEnvironment.cpp
new file mode 100644
index 0000000..e2557b2
--- /dev/null
+++ b/tests/pgintegration/PgTestEnvironment.cpp
@@ -0,0 +1,149 @@
+#include "PgTestEnvironment.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include "Pgsql_Connection.h"
+#include "Pgsql_Result.h"
+
+namespace pgtest {
+
+namespace {
+
+bool g_available = false;
+int g_versionNum = 0;
+
+const char *kPlainRolePassword = "pglab_plain_test";
+
+} // namespace
+
+std::optional baseUrl()
+{
+ QByteArray v = qgetenv("TEST_DATABASE_URL");
+ if (v.isEmpty())
+ return std::nullopt;
+ return QString::fromUtf8(v);
+}
+
+QString urlForDb(const QString &db)
+{
+ auto base = baseUrl();
+ if (!base)
+ throw std::runtime_error("TEST_DATABASE_URL is not set");
+ QUrl url(*base);
+ url.setPath("/" + db);
+ return url.toString();
+}
+
+bool available()
+{
+ return g_available;
+}
+
+int serverVersionNum()
+{
+ return g_versionNum;
+}
+
+qint64 processId()
+{
+ return QCoreApplication::applicationPid();
+}
+
+QString templateDbName()
+{
+ return QString("tmpl_%1").arg(processId());
+}
+
+QString plainRoleName()
+{
+ return QString("tst_%1").arg(processId());
+}
+
+QString urlForDbAsPlainRole(const QString &db)
+{
+ auto base = baseUrl();
+ if (!base)
+ throw std::runtime_error("TEST_DATABASE_URL is not set");
+ QUrl url(*base);
+ url.setPath("/" + db);
+ url.setUserName(plainRoleName());
+ url.setPassword(QString::fromUtf8(kPlainRolePassword));
+ return url.toString();
+}
+
+QString quoteIdent(const QString &name)
+{
+ QString r = name;
+ r.replace('"', "\"\"");
+ return "\"" + r + "\"";
+}
+
+void PgEnv::SetUp()
+{
+ auto base = baseUrl();
+ if (!base) {
+ std::cerr << "TEST_DATABASE_URL not set, integration tests will be skipped\n";
+ return;
+ }
+
+ const QString tmpl = templateDbName();
+ try {
+ {
+ Pgsql::Connection admin;
+ admin.connect(*base);
+ Pgsql::Result r = admin.query("SHOW server_version_num");
+ if (!r || r.rows() != 1)
+ throw std::runtime_error("could not query server_version_num");
+ g_versionNum = QString::fromUtf8(r.val(0, 0)).toInt();
+ admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(tmpl)));
+ admin.query(QString("CREATE DATABASE %1").arg(quoteIdent(tmpl)));
+ admin.query(QString("DROP ROLE IF EXISTS %1").arg(quoteIdent(plainRoleName())));
+ admin.query(QString("CREATE ROLE %1 LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '%2'")
+ .arg(quoteIdent(plainRoleName()), QString::fromUtf8(kPlainRolePassword)));
+ }
+ {
+ QFile f(fixtureDir_ + "/schema.sql");
+ if (!f.open(QIODevice::ReadOnly))
+ throw std::runtime_error("cannot open " + f.fileName().toStdString());
+ QByteArray sql = f.readAll();
+
+ Pgsql::Connection c;
+ c.connect(urlForDb(tmpl));
+ c.query(sql.constData());
+ }
+ g_available = true;
+ }
+ catch (const std::exception &e) {
+ ADD_FAILURE() << "Setting up template database failed: " << e.what();
+ try {
+ Pgsql::Connection admin;
+ admin.connect(*base);
+ admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(tmpl)));
+ admin.query(QString("DROP ROLE IF EXISTS %1").arg(quoteIdent(plainRoleName())));
+ }
+ catch (const std::exception &) {
+ }
+ }
+}
+
+void PgEnv::TearDown()
+{
+ if (!g_available)
+ return;
+ try {
+ Pgsql::Connection admin;
+ admin.connect(*baseUrl());
+ admin.query(QString("DROP DATABASE IF EXISTS %1 WITH (FORCE)").arg(quoteIdent(templateDbName())));
+ admin.query(QString("DROP ROLE IF EXISTS %1").arg(quoteIdent(plainRoleName())));
+ }
+ catch (const std::exception &e) {
+ ADD_FAILURE() << "Dropping template database or plain role failed: " << e.what();
+ }
+ g_available = false;
+}
+
+} // namespace pgtest
diff --git a/tests/pgintegration/PgTestEnvironment.h b/tests/pgintegration/PgTestEnvironment.h
new file mode 100644
index 0000000..0bf3f7a
--- /dev/null
+++ b/tests/pgintegration/PgTestEnvironment.h
@@ -0,0 +1,52 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace pgtest {
+
+/// Value of the TEST_DATABASE_URL environment variable, if set and non-empty.
+std::optional baseUrl();
+
+/// Same as baseUrl() but with the database name replaced.
+QString urlForDb(const QString &db);
+
+/// True when a server is configured and the template database was created.
+bool available();
+
+/// server_version_num of the server under test (0 when unavailable).
+int serverVersionNum();
+
+/// Portable process id.
+qint64 processId();
+
+/// Name of the template database for this process.
+QString templateDbName();
+
+/// Name of the login role without superuser rights, unique per process.
+QString plainRoleName();
+
+/// Same as urlForDb() but with the plain role as user.
+QString urlForDbAsPlainRole(const QString &db);
+
+/// Quotes an identifier for use in SQL.
+QString quoteIdent(const QString &name);
+
+/// Global environment, register it in main():
+/// ::testing::AddGlobalTestEnvironment(new pgtest::PgEnv(PGTEST_FIXTURE_DIR));
+/// Creates a template database from /schema.sql once per process.
+class PgEnv : public ::testing::Environment {
+public:
+ explicit PgEnv(QString fixtureDir) : fixtureDir_(std::move(fixtureDir)) {}
+ void SetUp() override;
+ void TearDown() override;
+
+private:
+ QString fixtureDir_;
+};
+
+} // namespace pgtest
+
+#define REQUIRE_PG_AT_LEAST(v) \
+ if (pgtest::serverVersionNum() < (v) * 10000) GTEST_SKIP() << "needs PG " #v