From c3bf9e88e465af9f29a675ad6e9e7dfcb51a07f6 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 18 Oct 2026 12:09:54 +0200 Subject: [PATCH 1/4] Add framework for executing tests against multiple postgresql versions using docker compose. --- .junie/skills/pg-integration-tests/SKILL.md | 43 +++++++ AGENTS.md | 1 + tests/CMakeLists.txt | 2 + tests/integration/CMakeLists.txt | 43 +++++++ tests/integration/docker-compose.yml | 16 +++ tests/integration/fixtures/schema.sql | 6 + tests/integration/main.cpp | 10 ++ tests/integration/tst_Smoke.cpp | 63 ++++++++++ tests/pgintegration/CMakeLists.txt | 21 ++++ tests/pgintegration/DbTest.cpp | 55 +++++++++ tests/pgintegration/DbTest.h | 26 ++++ tests/pgintegration/PgTestEnvironment.cpp | 125 ++++++++++++++++++++ tests/pgintegration/PgTestEnvironment.h | 46 +++++++ 13 files changed, 457 insertions(+) create mode 100644 .junie/skills/pg-integration-tests/SKILL.md create mode 100644 tests/integration/CMakeLists.txt create mode 100644 tests/integration/docker-compose.yml create mode 100644 tests/integration/fixtures/schema.sql create mode 100644 tests/integration/main.cpp create mode 100644 tests/integration/tst_Smoke.cpp create mode 100644 tests/pgintegration/CMakeLists.txt create mode 100644 tests/pgintegration/DbTest.cpp create mode 100644 tests/pgintegration/DbTest.h create mode 100644 tests/pgintegration/PgTestEnvironment.cpp create mode 100644 tests/pgintegration/PgTestEnvironment.h 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/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..42c908b --- /dev/null +++ b/tests/integration/CMakeLists.txt @@ -0,0 +1,43 @@ +add_executable(IntegrationTests + main.cpp + tst_Smoke.cpp + ) + +target_link_libraries(IntegrationTests PRIVATE + pgintegration + 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) + 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..e90e8dc --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,16 @@ +x-pg: &pg + environment: { POSTGRES_PASSWORD: test } + tmpfs: [/var/lib/postgresql/data] + 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"] } 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_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..5e80dc4 --- /dev/null +++ b/tests/pgintegration/DbTest.cpp @@ -0,0 +1,55 @@ +#include "DbTest.h" +#include "PgTestEnvironment.h" + +#include +#include + +namespace pgtest { + +namespace { +std::atomic g_counter{0}; +} + +QString DbTest::dbUrl() const +{ + return urlForDb(dbName_); +} + +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..26fc457 --- /dev/null +++ b/tests/pgintegration/DbTest.h @@ -0,0 +1,26 @@ +#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; + +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..9a82dd2 --- /dev/null +++ b/tests/pgintegration/PgTestEnvironment.cpp @@ -0,0 +1,125 @@ +#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; + +} // 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 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))); + } + { + 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))); + } + 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()))); + } + catch (const std::exception &e) { + ADD_FAILURE() << "Dropping template database 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..b8782f0 --- /dev/null +++ b/tests/pgintegration/PgTestEnvironment.h @@ -0,0 +1,46 @@ +#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(); + +/// 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 -- 2.47.2 From 591573b6b8f01913961cd140a540562cc725f925 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 18 Oct 2026 12:12:32 +0200 Subject: [PATCH 2/4] Ignore temp folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 -- 2.47.2 From a1ac5450860ce018f9b996cc6df5b40a3fa13d1d Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 18 Oct 2026 12:32:16 +0200 Subject: [PATCH 3/4] Add tests for loading the catalog --- .idea/pgLab.iml | 2 - pglab/catalog/models/DatabasesTableModel.cpp | 19 +----- pglab/catalog/models/DatabasesTableModel.h | 14 +--- pglablib/CMakeLists.txt | 2 + pglablib/catalog/PgDatabaseSizes.cpp | 25 +++++++ pglablib/catalog/PgDatabaseSizes.h | 25 +++++++ tests/integration/CMakeLists.txt | 3 + tests/integration/tst_CatalogLoad.cpp | 36 +++++++++++ tests/integration/tst_DatabaseSizes.cpp | 68 ++++++++++++++++++++ tests/pgintegration/DbTest.cpp | 7 ++ tests/pgintegration/DbTest.h | 2 + tests/pgintegration/PgTestEnvironment.cpp | 26 +++++++- tests/pgintegration/PgTestEnvironment.h | 6 ++ 13 files changed, 203 insertions(+), 32 deletions(-) delete mode 100644 .idea/pgLab.iml create mode 100644 pglablib/catalog/PgDatabaseSizes.cpp create mode 100644 pglablib/catalog/PgDatabaseSizes.h create mode 100644 tests/integration/tst_CatalogLoad.cpp create mode 100644 tests/integration/tst_DatabaseSizes.cpp 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/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/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index 42c908b..10a87d0 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -1,10 +1,13 @@ 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 ) 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/pgintegration/DbTest.cpp b/tests/pgintegration/DbTest.cpp index 5e80dc4..c6caaf2 100644 --- a/tests/pgintegration/DbTest.cpp +++ b/tests/pgintegration/DbTest.cpp @@ -15,6 +15,13 @@ 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()) diff --git a/tests/pgintegration/DbTest.h b/tests/pgintegration/DbTest.h index 26fc457..37477a5 100644 --- a/tests/pgintegration/DbTest.h +++ b/tests/pgintegration/DbTest.h @@ -17,6 +17,8 @@ protected: 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_; diff --git a/tests/pgintegration/PgTestEnvironment.cpp b/tests/pgintegration/PgTestEnvironment.cpp index 9a82dd2..e2557b2 100644 --- a/tests/pgintegration/PgTestEnvironment.cpp +++ b/tests/pgintegration/PgTestEnvironment.cpp @@ -16,6 +16,8 @@ namespace { bool g_available = false; int g_versionNum = 0; +const char *kPlainRolePassword = "pglab_plain_test"; + } // namespace std::optional baseUrl() @@ -56,6 +58,23 @@ 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; @@ -82,6 +101,9 @@ void PgEnv::SetUp() 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"); @@ -101,6 +123,7 @@ void PgEnv::SetUp() 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 &) { } @@ -115,9 +138,10 @@ void PgEnv::TearDown() 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 failed: " << e.what(); + ADD_FAILURE() << "Dropping template database or plain role failed: " << e.what(); } g_available = false; } diff --git a/tests/pgintegration/PgTestEnvironment.h b/tests/pgintegration/PgTestEnvironment.h index b8782f0..0bf3f7a 100644 --- a/tests/pgintegration/PgTestEnvironment.h +++ b/tests/pgintegration/PgTestEnvironment.h @@ -24,6 +24,12 @@ 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); -- 2.47.2 From 6683e8644b1da473207380b43a6c7681a5ba3457 Mon Sep 17 00:00:00 2001 From: eelke Date: Sun, 18 Oct 2026 12:46:38 +0200 Subject: [PATCH 4/4] Add postgres 18 --- tests/integration/CMakeLists.txt | 2 +- tests/integration/docker-compose.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index 10a87d0..1284cdb 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -33,7 +33,7 @@ if (PGLAB_INTEGRATION_TESTS) 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) + 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 diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index e90e8dc..b2749e2 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -1,6 +1,6 @@ x-pg: &pg environment: { POSTGRES_PASSWORD: test } - tmpfs: [/var/lib/postgresql/data] + tmpfs: [/var/lib/postgresql] command: > postgres -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_connections=200 @@ -14,3 +14,4 @@ services: 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"] } -- 2.47.2