diff --git a/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt b/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt index 85f8084a58..3f124cbd72 100644 --- a/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt +++ b/build-extensions/src/main/kotlin/eu/cloudnetservice/cloudnet/gradle/util/Files.kt @@ -34,6 +34,7 @@ object Files { const val dockerizedServices = "cloudnet-dockerized-services.jar" const val databaseMongo = "cloudnet-database-mongodb.jar" const val databaseMysql = "cloudnet-database-mysql.jar" + const val databasePostgres = "cloudnet-database-postgres.jar" const val labymod = "cloudnet-labymod.jar" const val npcs = "cloudnet-npcs.jar" const val rest = "cloudnet-rest.jar" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0a185950f..785183287a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ xodus = "2.0.1" mongodb = "5.6.1" hikariCp = "7.0.2" mysqlConnector = "9.5.0" +postgresql = "42.7.8" # general oshi = "6.9.1" @@ -162,6 +163,7 @@ hikariCp = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikariCp" } mongodb = { group = "org.mongodb", name = "mongodb-driver-sync", version.ref = "mongodb" } xodus = { group = "org.jetbrains.xodus", name = "xodus-environment", version.ref = "xodus" } mysqlConnector = { group = "com.mysql", name = "mysql-connector-j", version.ref = "mysqlConnector" } +postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" } # platform api nukkitX = { group = "cn.nukkit", name = "nukkit", version.ref = "nukkitX" } @@ -202,6 +204,7 @@ aerogelApi = ["aerogel", "aerogelAuto"] npcLib = ["npcLib", "npcLibLabymod"] unirest = ["unirest", "unirestGson"] mysql = ["mysqlConnector", "hikariCp"] +postgresql = ["postgresql", "hikariCp"] jline = ["jlineReader", "jlineTerminal"] cloud = ["cloudCore", "cloudAnnotations", "cloudConfirmationProcessor"] cloudApi = ["cloudCoreApi", "cloudAnnotationsApi", "cloudConfirmationProcessor"] diff --git a/modules/database-postgres/api/build.gradle.kts b/modules/database-postgres/api/build.gradle.kts new file mode 100644 index 0000000000..5036bcbf44 --- /dev/null +++ b/modules/database-postgres/api/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright 2019-2025 CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("cloudnet-publish") + id("cloudnet-modules-api") +} + +dependencies { + compileOnlyApi(projects.driver.driverApi) +} diff --git a/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConfiguration.java b/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConfiguration.java new file mode 100644 index 0000000000..cbcb6116d6 --- /dev/null +++ b/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.config; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.NonNull; + +public record PostgresConfiguration( + @NonNull String username, + @NonNull String password, + @NonNull String databaseServiceName, + @NonNull List endpoints +) { + + public @NonNull PostgresConnectionEndpoint randomEndpoint() { + if (this.endpoints.isEmpty()) { + throw new IllegalStateException("No postgres connection endpoints available"); + } + return this.endpoints.get(ThreadLocalRandom.current().nextInt(0, this.endpoints.size())); + } +} diff --git a/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConnectionEndpoint.java b/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConnectionEndpoint.java new file mode 100644 index 0000000000..401deab8f2 --- /dev/null +++ b/modules/database-postgres/api/src/main/java/eu/cloudnetservice/modules/postgres/config/PostgresConnectionEndpoint.java @@ -0,0 +1,23 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.config; + +import eu.cloudnetservice.driver.network.HostAndPort; +import lombok.NonNull; + +public record PostgresConnectionEndpoint(@NonNull String database, @NonNull HostAndPort address) { +} diff --git a/modules/database-postgres/impl/build.gradle.kts b/modules/database-postgres/impl/build.gradle.kts new file mode 100644 index 0000000000..f614d37d9c --- /dev/null +++ b/modules/database-postgres/impl/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2019-2025 CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import eu.cloudnetservice.cloudnet.gradle.util.Files + +plugins { + id("cloudnet-modules") + id("cloudnet-publish") + alias(libs.plugins.shadow) +} + +dependencies { + moduleLibrary(libs.bundles.postgresql) + + compileOnly(libs.caffeine) + compileOnlyApi(projects.node.nodeImpl) + api(projects.modules.databasePostgres.databasePostgresApi) +} + +tasks.shadowJar.configure { + archiveFileName = Files.databasePostgres +} + +moduleJson { + author = "CloudNetService" + name = "CloudNet-Database-PostgreSQL" + main = "eu.cloudnetservice.modules.postgres.impl.CloudNetPostgresDatabaseModule" + description = "CloudNet extension, which includes the database support for PostgreSQL" + minJavaVersionId = JavaVersion.VERSION_11 + runtimeModule = true + storesSensitiveData = true +} diff --git a/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/CloudNetPostgresDatabaseModule.java b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/CloudNetPostgresDatabaseModule.java new file mode 100644 index 0000000000..958fd5cc37 --- /dev/null +++ b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/CloudNetPostgresDatabaseModule.java @@ -0,0 +1,76 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl; + +import eu.cloudnetservice.driver.document.Document; +import eu.cloudnetservice.driver.document.DocumentFactory; +import eu.cloudnetservice.driver.module.ModuleLifeCycle; +import eu.cloudnetservice.driver.module.ModuleTask; +import eu.cloudnetservice.driver.module.driver.DriverModule; +import eu.cloudnetservice.driver.network.HostAndPort; +import eu.cloudnetservice.driver.registry.ServiceRegistry; +import eu.cloudnetservice.modules.postgres.config.PostgresConfiguration; +import eu.cloudnetservice.modules.postgres.config.PostgresConnectionEndpoint; +import eu.cloudnetservice.node.impl.database.NodeDatabaseProvider; +import io.leangen.geantyref.TypeFactory; +import jakarta.inject.Singleton; +import java.util.List; +import lombok.NonNull; + +@Singleton +public final class CloudNetPostgresDatabaseModule extends DriverModule { + + private volatile PostgresConfiguration configuration; + + @ModuleTask(order = 127, lifecycle = ModuleLifeCycle.LOADED) + public void convertConfig() { + var config = this.readConfig(DocumentFactory.json()); + if (config.contains("addresses")) { + this.writeConfig(Document.newJsonDocument().appendTree(new PostgresConfiguration( + config.getString("username"), + config.getString("password"), + config.getString("database"), + config.readObject("addresses", TypeFactory.parameterizedClass(List.class, PostgresConnectionEndpoint.class)) + ))); + } + } + + @ModuleTask(order = 125, lifecycle = ModuleLifeCycle.LOADED) + public void registerDatabaseProvider(@NonNull ServiceRegistry serviceRegistry) { + this.configuration = this.readConfig( + PostgresConfiguration.class, + () -> new PostgresConfiguration( + "postgres", + "postgres", + "postgres", + List.of(new PostgresConnectionEndpoint("cloudnet", new HostAndPort("127.0.0.1", 5432)))), + DocumentFactory.json()); + + serviceRegistry.registerProvider( + NodeDatabaseProvider.class, + this.configuration.databaseServiceName(), + new PostgresDatabaseProvider(this.configuration)); + } + + @ModuleTask(order = 127, lifecycle = ModuleLifeCycle.STOPPED) + public void unregisterDatabaseProvider(@NonNull ServiceRegistry serviceRegistry) { + var service = serviceRegistry.registration(NodeDatabaseProvider.class, this.configuration.databaseServiceName()); + if (service != null) { + service.unregister(); + } + } +} diff --git a/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabase.java b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabase.java new file mode 100644 index 0000000000..2d6cb2f6e1 --- /dev/null +++ b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabase.java @@ -0,0 +1,227 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl; + +import eu.cloudnetservice.driver.document.Document; +import eu.cloudnetservice.driver.document.DocumentFactory; +import eu.cloudnetservice.node.impl.database.sql.SQLDatabase; +import eu.cloudnetservice.node.impl.database.sql.SQLDatabaseProvider; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; + +public final class PostgresDatabase extends SQLDatabase { + + public PostgresDatabase(@NonNull SQLDatabaseProvider provider, @NonNull String name) { + super(provider, name); + + // create the table with jsonb column + provider.executeUpdate(String.format( + "CREATE TABLE IF NOT EXISTS \"%s\" (\"%s\" VARCHAR(512) PRIMARY KEY, \"%s\" JSONB NOT NULL);", + name, + TABLE_COLUMN_KEY, + TABLE_COLUMN_VAL)); + } + + @Override + public boolean insert(@NonNull String key, @NonNull Document document) { + var json = this.serializeDocumentToJsonString(document); + // upsert using ON CONFLICT + return this.databaseProvider.executeUpdate( + String.format( + "INSERT INTO \"%s\" (\"%s\", \"%s\") VALUES (?, ?::jsonb) ON CONFLICT (\"%s\") DO UPDATE SET \"%s\" = EXCLUDED.\"%s\";", + this.name, + TABLE_COLUMN_KEY, + TABLE_COLUMN_VAL, + TABLE_COLUMN_KEY, + TABLE_COLUMN_VAL, + TABLE_COLUMN_VAL), + key, json) > 0; + } + + @Override + public boolean contains(@NonNull String key) { + return this.databaseProvider.executeQuery( + String.format("SELECT 1 FROM \"%s\" WHERE \"%s\" = ?;", this.name, TABLE_COLUMN_KEY), + ResultSet::next, + false, + key); + } + + @Override + public boolean delete(@NonNull String key) { + return this.databaseProvider.executeUpdate( + String.format("DELETE FROM \"%s\" WHERE \"%s\" = ?;", this.name, TABLE_COLUMN_KEY), + key) > 0; + } + + @Override + public @Nullable Document get(@NonNull String key) { + return this.databaseProvider.executeQuery( + String.format("SELECT \"%s\" FROM \"%s\" WHERE \"%s\" = ?;", TABLE_COLUMN_VAL, this.name, TABLE_COLUMN_KEY), + resultSet -> { + if (resultSet.next()) { + return DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL)); + } + return null; + }, null, key); + } + + @Override + public @NonNull Collection find(@NonNull String fieldName, @Nullable String fieldValue) { + return this.databaseProvider.executeQuery( + String.format( + "SELECT \"%s\" FROM \"%s\" WHERE \"%s\" @> ?::jsonb;", + TABLE_COLUMN_VAL, + this.name, + TABLE_COLUMN_VAL), + resultSet -> { + List results = new ArrayList<>(); + while (resultSet.next()) { + results.add(DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return results; + }, + List.of(), + DocumentFactory.json().newDocument(fieldName, Objects.toString(fieldValue)).serializeToString()); + } + + @Override + public @NonNull Collection find(@NonNull Map filters) { + if (filters.isEmpty()) { + return List.of(); + } + var whereDoc = DocumentFactory.json().newDocument(); + for (var entry : filters.entrySet()) { + whereDoc.append(entry.getKey(), entry.getValue()); + } + var whereJson = whereDoc.serializeToString(); + + return this.databaseProvider.executeQuery( + String.format("SELECT \"%s\" FROM \"%s\" WHERE \"%s\" @> ?::jsonb;", TABLE_COLUMN_VAL, this.name, TABLE_COLUMN_VAL), + resultSet -> { + List results = new ArrayList<>(); + while (resultSet.next()) { + results.add(DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return results; + }, + List.of(), + whereJson); + } + + @Override + public @NonNull Collection keys() { + return this.databaseProvider.executeQuery( + String.format("SELECT \"%s\" FROM \"%s\";", TABLE_COLUMN_KEY, this.name), + resultSet -> { + List results = new ArrayList<>(); + while (resultSet.next()) { + results.add(resultSet.getString(TABLE_COLUMN_KEY)); + } + return results; + }, Set.of()); + } + + @Override + public @NonNull Collection documents() { + return this.databaseProvider.executeQuery( + String.format("SELECT \"%s\" FROM \"%s\";", TABLE_COLUMN_VAL, this.name), + resultSet -> { + List results = new ArrayList<>(); + while (resultSet.next()) { + results.add(DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return results; + }, Set.of()); + } + + @Override + public @NonNull Map entries() { + return this.databaseProvider.executeQuery( + String.format("SELECT \"%s\", \"%s\" FROM \"%s\";", TABLE_COLUMN_KEY, TABLE_COLUMN_VAL, this.name), + resultSet -> { + Map results = new HashMap<>(); + while (resultSet.next()) { + results.put(resultSet.getString(TABLE_COLUMN_KEY), DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return results; + }, Map.of()); + } + + @Override + public void clear() { + this.databaseProvider.executeUpdate(String.format("TRUNCATE TABLE \"%s\";", this.name)); + } + + @Override + public long documentCount() { + return this.databaseProvider.executeQuery("SELECT COUNT(*) FROM \"" + this.name + "\";", resultSet -> { + if (resultSet.next()) { + return resultSet.getLong(1); + } + return -1L; + }, -1L); + } + + @Override + public boolean synced() { + return true; + } + + @Override + public void iterate(@NonNull BiConsumer consumer) { + this.databaseProvider.executeQuery( + String.format("SELECT \"%s\", \"%s\" FROM \"%s\";", TABLE_COLUMN_KEY, TABLE_COLUMN_VAL, this.name), + resultSet -> { + while (resultSet.next()) { + consumer.accept(resultSet.getString(TABLE_COLUMN_KEY), DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return null; + }, null); + } + + @Override + public @Nullable Map readChunk(long beginIndex, int chunkSize) { + return this.databaseProvider.executeQuery( + String.format( + "SELECT \"%s\", \"%s\" FROM \"%s\" ORDER BY \"%s\" LIMIT ? OFFSET ?;", + TABLE_COLUMN_KEY, + TABLE_COLUMN_VAL, + this.name, + TABLE_COLUMN_KEY), + resultSet -> { + Map result = new HashMap<>(); + while (resultSet.next()) { + result.put(resultSet.getString(TABLE_COLUMN_KEY), DocumentFactory.json().parse(resultSet.getString(TABLE_COLUMN_VAL))); + } + return result.isEmpty() ? null : result; + }, null, chunkSize, beginIndex); + } + + @Override + public void close() { + } +} diff --git a/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseProvider.java b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseProvider.java new file mode 100644 index 0000000000..bfc60f54d2 --- /dev/null +++ b/modules/database-postgres/impl/src/main/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseProvider.java @@ -0,0 +1,148 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import eu.cloudnetservice.modules.postgres.config.PostgresConfiguration; +import eu.cloudnetservice.node.database.LocalDatabase; +import eu.cloudnetservice.node.impl.database.sql.SQLDatabaseProvider; +import io.vavr.CheckedFunction1; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Set; +import lombok.NonNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +public final class PostgresDatabaseProvider extends SQLDatabaseProvider { + + private static final String CONNECT_URL_FORMAT = "jdbc:postgresql://%s:%d/%s"; + + private final PostgresConfiguration config; + private volatile HikariDataSource hikariDataSource; + + public PostgresDatabaseProvider( + @NonNull PostgresConfiguration config + ) { + super(DEFAULT_REMOVAL_LISTENER); + this.config = config; + } + + @Override + public boolean init() { + var hikariConfig = new HikariConfig(); + var endpoint = this.config.randomEndpoint(); + + hikariConfig.setJdbcUrl(String.format( + CONNECT_URL_FORMAT, + endpoint.address().host(), endpoint.address().port(), endpoint.database())); + hikariConfig.setDriverClassName("org.postgresql.Driver"); + hikariConfig.setUsername(this.config.username()); + hikariConfig.setPassword(this.config.password()); + + // reasonable defaults + hikariConfig.setMinimumIdle(2); + hikariConfig.setMaximumPoolSize(100); + hikariConfig.setConnectionTimeout(10_000); + hikariConfig.setValidationTimeout(10_000); + + this.hikariDataSource = new HikariDataSource(hikariConfig); + return true; + } + + @Override + public @NonNull LocalDatabase database(@NonNull String name) { + return this.databaseCache.get(name, _ -> new PostgresDatabase(this, name)); + } + + @Override + public boolean deleteDatabase(@NonNull String name) { + return this.executeUpdate("DROP TABLE IF EXISTS \"" + name + "\";") != -1; + } + + @Override + public @NonNull Collection databaseNames() { + try (var connection = this.hikariDataSource.getConnection(); + var meta = connection.getMetaData().getTables(null, null, null, TABLE_TYPE)) { + Collection names = new ArrayList<>(); + while (meta.next()) { + names.add(meta.getString("table_name")); + } + return names; + } catch (SQLException exception) { + LOGGER.error("Exception listing tables", exception); + return Set.of(); + } + } + + @Override + public @NonNull String name() { + return this.config.databaseServiceName(); + } + + @Override + public void close() throws Exception { + super.close(); + this.hikariDataSource.close(); + } + + @Override + public @NonNull Connection connection() { + try { + return this.hikariDataSource.getConnection(); + } catch (SQLException exception) { + throw new IllegalStateException("Unable to retrieve connection from pool", exception); + } + } + + @Override + public int executeUpdate(@NonNull String query, @NonNull Object... objects) { + try (var con = this.connection(); var statement = con.prepareStatement(query)) { + for (var i = 0; i < objects.length; i++) { + statement.setObject(i + 1, objects[i]); + } + return statement.executeUpdate(); + } catch (SQLException exception) { + LOGGER.error("Exception while executing database update", exception); + return -1; + } + } + + @Override + public @UnknownNullability T executeQuery( + @NonNull String query, + @NonNull CheckedFunction1 callback, + @Nullable T def, + @NonNull Object... objects + ) { + try (var con = this.connection(); var statement = con.prepareStatement(query)) { + for (var i = 0; i < objects.length; i++) { + statement.setObject(i + 1, objects[i]); + } + try (var resultSet = statement.executeQuery()) { + return callback.apply(resultSet); + } + } catch (Throwable throwable) { + LOGGER.error("Exception while executing database query", throwable); + } + return def; + } +} diff --git a/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseTest.java b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseTest.java new file mode 100644 index 0000000000..98e46c6eac --- /dev/null +++ b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/PostgresDatabaseTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl; + +import eu.cloudnetservice.driver.document.Document; +import eu.cloudnetservice.driver.network.HostAndPort; +import eu.cloudnetservice.modules.postgres.config.PostgresConfiguration; +import eu.cloudnetservice.modules.postgres.config.PostgresConnectionEndpoint; +import eu.cloudnetservice.modules.postgres.impl.junit.EnableServicesInject; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@EnableServicesInject +@Testcontainers(disabledWithoutDocker = true) +class PostgresDatabaseTest { + + @Container + private final GenericContainer postgresContainer = new GenericContainer<>("postgres:latest") + .withExposedPorts(5432) + .withEnv("POSTGRES_USER", "test") + .withEnv("POSTGRES_PASSWORD", "test") + .withEnv("POSTGRES_DB", "cn_testing") + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2)) + .withStartupTimeout(Duration.ofMinutes(2)); + + private PostgresDatabaseProvider databaseProvider; + + @BeforeEach + void setup() { + this.databaseProvider = new PostgresDatabaseProvider(new PostgresConfiguration( + "test", + "test", + "postgres", + List.of(new PostgresConnectionEndpoint( + "cn_testing", + new HostAndPort(this.postgresContainer.getHost(), this.postgresContainer.getFirstMappedPort())))) + ); + this.databaseProvider.init(); + } + + @Test + void testAccessCreatesDatabase() { + Assertions.assertNotNull(this.databaseProvider.database("hello_world")); + Assertions.assertNotNull(this.databaseProvider.database("hello2_world")); + + var names = this.databaseProvider.databaseNames(); + Assertions.assertTrue(names.contains("hello_world")); + Assertions.assertTrue(names.contains("hello2_world")); + } + + @Test + void testDatabaseDeletion() { + Assertions.assertNotNull(this.databaseProvider.database("hello_world")); + Assertions.assertNotNull(this.databaseProvider.database("hello2_world")); + + var names = this.databaseProvider.databaseNames(); + Assertions.assertTrue(names.contains("hello_world")); + Assertions.assertTrue(names.contains("hello2_world")); + + Assertions.assertTrue(this.databaseProvider.deleteDatabase("hello_world")); + Assertions.assertTrue(this.databaseProvider.deleteDatabase("hello2_world")); + + Assertions.assertTrue(this.databaseProvider.databaseNames().isEmpty()); + } + + @Test + void testBasicDatabaseOperations() { + var database = this.databaseProvider.database("test"); + Assertions.assertNotNull(database); + + Assertions.assertTrue(database.insert("1234", Document.newJsonDocument().append("hello", "world"))); + Assertions.assertTrue(database.insert("12234", Document.newJsonDocument().append("hello", "world2"))); + Assertions.assertTrue(database.insert("122234", Document.newJsonDocument().append("hello", "world_123"))); + + Assertions.assertTrue(database.contains("1234")); + Assertions.assertTrue(database.contains("12234")); + Assertions.assertTrue(database.contains("122234")); + + Assertions.assertEquals(3, database.documentCount()); + + var keys = database.keys(); + Assertions.assertEquals(3, keys.size()); + Assertions.assertTrue(keys.contains("1234")); + Assertions.assertTrue(keys.contains("12234")); + Assertions.assertTrue(keys.contains("122234")); + + var entry = database.get("1234"); + Assertions.assertNotNull(entry); + Assertions.assertEquals("world", entry.getString("hello")); + + var entry2 = database.get("12234"); + Assertions.assertNotNull(entry2); + Assertions.assertEquals("world2", entry2.getString("hello")); + + var entry3 = database.get("122334"); + Assertions.assertNull(entry3); + + var entry4 = database.find("hello", "world"); + Assertions.assertEquals(1, entry4.size()); + Assertions.assertEquals("world", entry4.iterator().next().getString("hello")); + + var entry5 = database.find(Map.of("hello", "world2")); + Assertions.assertEquals(1, entry5.size()); + Assertions.assertEquals("world2", entry5.iterator().next().getString("hello")); + + var entry6 = database.find("hello", "world_123"); + Assertions.assertEquals(1, entry6.size()); + Assertions.assertEquals("world_123", entry6.iterator().next().getString("hello")); + + var entries = database.entries(); + Assertions.assertEquals(3, entries.size()); + Assertions.assertEquals("world", entries.get("1234").getString("hello")); + Assertions.assertEquals("world2", entries.get("12234").getString("hello")); + + var documents = database.documents(); + Assertions.assertEquals(3, documents.size()); + + Assertions.assertTrue(database.delete("12234")); + Assertions.assertEquals(2, database.documentCount()); + + database.clear(); + Assertions.assertEquals(0, database.documentCount()); + + Assertions.assertFalse(database.delete("1234")); + } + + @Test + void testChunkedDataRead() { + var database = this.databaseProvider.database("test"); + Assertions.assertNotNull(database); + + // fill in some data + var entries = 1235; + List keys = new ArrayList<>(); + var expectedReadCounts = (int) Math.ceil(entries / 50D); + + for (var i = 0; i < entries; i++) { + var key = UUID.randomUUID().toString(); + + keys.add(key); + database.insert(key, Document.newJsonDocument().append("this_is", "a_world_test")); + } + + Assertions.assertEquals(entries, database.documentCount()); + + var index = 0; + var readsCalled = 0; + + Map currentChunk; + while ((currentChunk = database.readChunk(index, 50)) != null) { + index += 50; + readsCalled++; + + Assertions.assertFalse(currentChunk.size() > 50); + Assertions.assertTrue(keys.removeAll(currentChunk.keySet())); + } + + Assertions.assertEquals(expectedReadCounts, readsCalled); + Assertions.assertTrue(keys.isEmpty()); + } +} diff --git a/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInject.java b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInject.java new file mode 100644 index 0000000000..c6957ac805 --- /dev/null +++ b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInject.java @@ -0,0 +1,31 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl.junit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +@Inherited +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(EnableServicesInjectExtension.class) +public @interface EnableServicesInject { +} diff --git a/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInjectExtension.java b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInjectExtension.java new file mode 100644 index 0000000000..fd7fc32f1f --- /dev/null +++ b/modules/database-postgres/impl/src/test/java/eu/cloudnetservice/modules/postgres/impl/junit/EnableServicesInjectExtension.java @@ -0,0 +1,94 @@ +/* + * Copyright 2019-present CloudNetService team & contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.cloudnetservice.modules.postgres.impl.junit; + +import eu.cloudnetservice.driver.DriverEnvironment; +import eu.cloudnetservice.driver.impl.registry.DefaultServiceRegistry; +import eu.cloudnetservice.driver.inject.InjectionLayer; +import eu.cloudnetservice.driver.registry.ServiceRegistry; +import eu.cloudnetservice.node.impl.Node; +import java.lang.reflect.Field; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; + +public final class EnableServicesInjectExtension + implements BeforeAllCallback, AfterAllCallback, AfterTestExecutionCallback { + + private static final Field EXT_LAYER_FIELD; + private static final Field BOOT_LAYER_FIELD; + private static final Field SERVICE_REGISTRY_FIELD; + + private static final Logger LOGGER = LoggerFactory.getLogger(EnableServicesInjectExtension.class); + + static { + try { + var injectProviderClass = Class.forName("eu.cloudnetservice.driver.inject.InjectionLayerProvider"); + EXT_LAYER_FIELD = injectProviderClass.getDeclaredField("ext"); + EXT_LAYER_FIELD.setAccessible(true); + BOOT_LAYER_FIELD = injectProviderClass.getDeclaredField("boot"); + BOOT_LAYER_FIELD.setAccessible(true); + + var serviceHolderClass = Class.forName("eu.cloudnetservice.driver.registry.ServiceRegistryHolder"); + SERVICE_REGISTRY_FIELD = serviceHolderClass.getDeclaredField("instance"); + SERVICE_REGISTRY_FIELD.setAccessible(true); + } catch (Throwable exception) { + throw new ExceptionInInitializerError(exception); + } + } + + @Override + public void beforeAll(ExtensionContext context) { + this.setupInjectionAndServicesState(); + LOGGER.debug(() -> "Setup initial test injection and services state"); + } + + @Override + public void afterAll(ExtensionContext context) throws Exception { + this.resetInjectionAndServicesState(); + LOGGER.debug(() -> "Cleaned up test injection and services state after final test"); + } + + @Override + public void afterTestExecution(ExtensionContext context) throws Exception { + this.resetInjectionAndServicesState(); + this.setupInjectionAndServicesState(); + LOGGER.debug(() -> "Reset test injection and services state"); + } + + private void resetInjectionAndServicesState() throws Exception { + EXT_LAYER_FIELD.set(null, null); + BOOT_LAYER_FIELD.set(null, null); + SERVICE_REGISTRY_FIELD.set(null, null); + } + + private void setupInjectionAndServicesState() { + var bootInjectionLayer = InjectionLayer.boot(); + bootInjectionLayer.installAutoConfigureBindings(EnableServicesInjectExtension.class.getClassLoader(), "driver"); + bootInjectionLayer.installAutoConfigureBindings(EnableServicesInjectExtension.class.getClassLoader(), "node"); + + var serviceRegistry = bootInjectionLayer.instance(ServiceRegistry.class); + serviceRegistry.discoverServices(Node.class); + serviceRegistry.discoverServices(DefaultServiceRegistry.class); + + var bindingBuilder = bootInjectionLayer.injector().createBindingBuilder(); + bootInjectionLayer.install(bindingBuilder.bind(DriverEnvironment.class).toInstance(DriverEnvironment.NODE)); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e26cbaf916..962ebde944 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -90,6 +90,11 @@ registerSubProjects( prefix = "database-mysql", subProjects = arrayOf("api", "impl"), ) +registerSubProjects( + root = "modules:database-postgres", + prefix = "database-postgres", + subProjects = arrayOf("api", "impl"), +) registerSubProjects( root = "modules:dockerized-services", prefix = "dockerized-services",