From 81b5c3080b38fdb11218052a09c21f6cbee67ed8 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Tue, 18 Aug 2026 08:55:31 +0200 Subject: [PATCH 01/13] Add SQL development metadata export Expose detected tables and views through the read-only conndev_SqlTable object class. Include schema-aware identifiers and complete column, key, and type metadata as formatted JSON. Cover development-mode behavior with H2 and PostgreSQL integration tests. --- .../sql/base/AbstractGroovySqlConnector.java | 9 ++ .../polygon/sql/base/SqlBaseContext.java | 13 +- .../sql/base/dev/SqlDevelopmentMode.java | 50 +++++++ .../sql/base/dev/SqlTableDevHandler.java | 140 ++++++++++++++++++ .../base/SqlDevConnectorIntegrationTest.java | 106 ++++++++++++- .../SqlSearchOperationPostgresTest.java | 50 +++++++ 6 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java b/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java index af94d73..a956769 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java @@ -18,7 +18,9 @@ import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; import com.evolveum.polygon.sql.base.build.api.SqlSchemaBuilder; import com.evolveum.polygon.sql.base.build.api.SqlSchemaBuilderImpl; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.dev.SqlObjectClassDevHandler; +import com.evolveum.polygon.sql.base.dev.SqlTableDevHandler; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; import com.evolveum.polygon.sql.base.groovy.impl.SqlOperationSupportBuilderImpl; @@ -175,6 +177,7 @@ private void initialize0(boolean allowConnection) { additional.addAll(ConnDevSchema.objectClassInfos( List.of(ConnDevSchema.embeddedBlock(SQL_BLOCK, SQL_BLOCK_TYPE)), List.of())); additional.add(sqlObjectClassBlock()); + additional.add(SqlDevelopmentMode.tableObjectClassInfo()); } List tables; @@ -233,12 +236,18 @@ private void initialize0(boolean allowConnection) { var name = new ObjectClass(ConnDevObjectClass.OBJECT_CLASS_NAME); var handler = CompositeObjectClassHandler.of(name,ObjectSearchOperation.class, new SqlObjectClassDevHandler(context)); handlers.put(name, handler); + + var tableName = new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME); + var tableHandler = CompositeObjectClassHandler.of( + tableName, ObjectSearchOperation.class, new SqlTableDevHandler(context)); + handlers.put(tableName, tableHandler); } context.handlers(handlers); } private static void populateTableInfo(SqlBaseContext context, List tables) { + context.setDetectedTables(tables); var tableMap = new LinkedHashMap(); for (SqlTableInfo table : tables) { // Use lowercase table name as key for case-insensitive lookup diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlBaseContext.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlBaseContext.java index f9dea03..6b4147c 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlBaseContext.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlBaseContext.java @@ -19,6 +19,7 @@ import org.identityconnectors.framework.common.objects.ObjectClass; import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -37,6 +38,7 @@ public class SqlBaseContext implements ConnectorContext, ContextLookup, Retrieva private volatile HikariConnectionPool connectionPool; private SQLTemplates templates; private Map tableInfos; + private List detectedTables = List.of(); public SqlBaseContext(SqlConnectorConfiguration configuration) { this.configuration = configuration; @@ -180,6 +182,15 @@ void setTableInfos(Map tableInfos) { this.tableInfos = tableInfos != null ? Map.copyOf(tableInfos) : null; } + void setDetectedTables(List detectedTables) { + this.detectedTables = detectedTables != null ? List.copyOf(detectedTables) : List.of(); + } + + /** Returns every detected table, including equal table names from different schemas. */ + public List getDetectedTables() { + return detectedTables; + } + /** * Returns the table info map for custom query building. */ @@ -199,4 +210,4 @@ public SqlTableInfo findTableInfo(String tableName) { } return null; } -} \ No newline at end of file +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java new file mode 100644 index 0000000..8807fa8 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.dev; + +import org.identityconnectors.framework.common.objects.AttributeInfo; +import org.identityconnectors.framework.common.objects.AttributeInfoBuilder; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.ObjectClassInfo; +import org.identityconnectors.framework.common.objects.ObjectClassInfoBuilder; +import org.identityconnectors.framework.common.objects.Uid; + +/** Development-only SQL metadata object classes. */ +public final class SqlDevelopmentMode { + + public static final String TABLE_OC_NAME = "conndev_SqlTable"; + public static final String CATALOG_ATTRIBUTE = "catalog"; + public static final String SCHEMA_ATTRIBUTE = "schema"; + public static final String TABLE_TYPE_ATTRIBUTE = "tableType"; + public static final String REMARKS_ATTRIBUTE = "remarks"; + public static final String TABLE_CONTENT_ATTRIBUTE = "tableContent"; + + private SqlDevelopmentMode() { + } + + /** Raw detected table/view metadata, analogous to the SCIM development metadata exports. */ + public static ObjectClassInfo tableObjectClassInfo() { + var builder = new ObjectClassInfoBuilder(); + builder.setType(TABLE_OC_NAME); + builder.addAttributeInfo(readOnly(Uid.NAME)); + builder.addAttributeInfo(readOnly(Name.NAME)); + builder.addAttributeInfo(readOnly(CATALOG_ATTRIBUTE)); + builder.addAttributeInfo(readOnly(SCHEMA_ATTRIBUTE)); + builder.addAttributeInfo(readOnly(TABLE_TYPE_ATTRIBUTE)); + builder.addAttributeInfo(readOnly(REMARKS_ATTRIBUTE)); + builder.addAttributeInfo(readOnly(TABLE_CONTENT_ATTRIBUTE)); + return builder.build(); + } + + private static AttributeInfo readOnly(String name) { + return AttributeInfoBuilder.define(name) + .setType(String.class) + .setCreateable(false) + .setUpdateable(false) + .build(); + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java new file mode 100644 index 0000000..515afe0 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.dev; + +import com.evolveum.polygon.conndev.api.ContextLookup; +import com.evolveum.polygon.conndev.spi.ObjectSearchOperation; +import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.schema.SqlColumnMeta; +import com.evolveum.polygon.sql.base.schema.SqlTableInfo; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.objects.AttributeUtil; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.ConnectorObjectBuilder; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.ResultsHandler; +import org.identityconnectors.framework.common.objects.Uid; +import org.identityconnectors.framework.common.objects.filter.EqualsFilter; +import org.identityconnectors.framework.common.objects.filter.Filter; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** Search handler that exposes raw JDBC table and column metadata in development mode. */ +public class SqlTableDevHandler implements ObjectSearchOperation { + + private static final ObjectClass OBJECT_CLASS = new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME); + private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + private final SqlBaseContext context; + + public SqlTableDevHandler(SqlBaseContext context) { + this.context = context; + } + + @Override + public void executeQuery(ContextLookup contextLookup, Filter filter, ResultsHandler resultsHandler, + OperationOptions operationOptions) { + for (var table : context.getDetectedTables()) { + var uid = tableUid(table); + if (!matchesFilter(filter, table, uid)) { + continue; + } + if (!resultsHandler.handle(createTableObject(table, uid))) { + return; + } + } + } + + private static ConnectorObject createTableObject(SqlTableInfo table, String uid) { + var builder = new ConnectorObjectBuilder(); + builder.setObjectClass(OBJECT_CLASS); + builder.setUid(uid); + builder.setName(table.getName()); + addIfPresent(builder, SqlDevelopmentMode.CATALOG_ATTRIBUTE, table.getCatalog()); + addIfPresent(builder, SqlDevelopmentMode.SCHEMA_ATTRIBUTE, table.getSchema()); + addIfPresent(builder, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE, table.getTableType()); + addIfPresent(builder, SqlDevelopmentMode.REMARKS_ATTRIBUTE, table.getRemarks()); + builder.addAttribute(SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE, tableToJson(table)); + return builder.build(); + } + + private static void addIfPresent(ConnectorObjectBuilder builder, String name, String value) { + if (value != null) { + builder.addAttribute(name, value); + } + } + + private static boolean matchesFilter(Filter filter, SqlTableInfo table, String uid) { + if (filter == null) { + return true; + } + if (filter instanceof EqualsFilter equalsFilter) { + var attributeName = equalsFilter.getAttribute().getName(); + var filterValue = AttributeUtil.getSingleValue(equalsFilter.getAttribute()); + if (Uid.NAME.equals(attributeName)) { + return Objects.equals(uid, filterValue); + } + if (Name.NAME.equals(attributeName)) { + return Objects.equals(table.getName(), filterValue); + } + if (SqlDevelopmentMode.SCHEMA_ATTRIBUTE.equals(attributeName)) { + return Objects.equals(table.getSchema(), filterValue); + } + } + return false; + } + + private static String tableUid(SqlTableInfo table) { + return Stream.of(table.getCatalog(), table.getSchema(), table.getName()) + .filter(value -> value != null && !value.isBlank()) + .collect(Collectors.joining(".")); + } + + private static String tableToJson(SqlTableInfo table) { + var content = new LinkedHashMap(); + content.put("catalog", table.getCatalog()); + content.put("schema", table.getSchema()); + content.put("name", table.getName()); + content.put("tableType", table.getTableType()); + content.put("remarks", table.getRemarks()); + content.put("columns", table.getColumns().stream().map(SqlTableDevHandler::columnContent).toList()); + try { + return OBJECT_MAPPER.writeValueAsString(content); + } catch (Exception e) { + throw new ConnectorException("Failed to serialize SQL table metadata", e); + } + } + + private static Map columnContent(SqlColumnMeta column) { + var content = new LinkedHashMap(); + content.put("name", column.getName()); + content.put("typeName", column.getTypeName()); + content.put("typeCode", column.getTypeCode()); + content.put("size", column.getSize()); + content.put("javaType", column.getJavaType() != null ? column.getJavaType().getTypeName() : null); + content.put("nullable", column.isNullable()); + content.put("primaryKey", column.isPrimaryKey()); + content.put("unique", column.isUnique()); + content.put("defaultValue", column.getDefaultValue()); + content.put("autoIncrement", column.isAutoIncrement()); + content.put("referencedTable", column.getReferencedTable()); + content.put("referencedColumn", column.getReferencedColumn()); + content.put("foreignKeyName", column.getForeignKeyName()); + return content; + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java index 219515f..a0a6e00 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java @@ -6,19 +6,25 @@ */ package com.evolveum.polygon.sql.base; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; import com.evolveum.polygon.sql.base.test.SqlIntegrationTestBase; import com.evolveum.polygon.sql.base.test.SqlSchemaAssertions; import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.Name; import org.identityconnectors.framework.common.objects.ObjectClassInfo; import org.identityconnectors.framework.common.objects.Uid; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; import org.testng.annotations.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; /** - * End-to-end (H2) test of the SQL development-mode export: the connector exposes - * {@code conndev_ObjectClass} in its schema. + * End-to-end (H2) tests of the SQL development-mode schema and raw table metadata exports. */ @Test(singleThreaded = true) public class SqlDevConnectorIntegrationTest @@ -36,6 +42,11 @@ protected String schemaSql() { CREATE TABLE app_group (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50)); CREATE TABLE membership (id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, CONSTRAINT fk_m_user FOREIGN KEY (user_id) REFERENCES app_user(id)); + CREATE VIEW app_user_view AS SELECT id, username FROM app_user; + CREATE SCHEMA tenant_a; + CREATE SCHEMA tenant_b; + CREATE TABLE tenant_a.shared_entry (id INT PRIMARY KEY, entry_value VARCHAR(50)); + CREATE TABLE tenant_b.shared_entry (id INT PRIMARY KEY, entry_value VARCHAR(50)); """; } @@ -58,7 +69,96 @@ protected void initConnector() { @Test public void exposesConnDevObjectClassInSchema() { assertThat(schemaNames()).contains( - "conndev_objectclass", "conndev_attribute", "conndev_connidattribute", "conndev_sql"); + "conndev_objectclass", "conndev_attribute", "conndev_connidattribute", "conndev_sql", + "conndev_sqltable"); + } + + @Test + public void exportsDetectedTableMetadata() throws Exception { + var tables = search(SqlDevelopmentMode.TABLE_OC_NAME, null); + + var membership = tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase("membership")) + .findFirst() + .orElseThrow(); + var content = (String) getAttr(membership, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(getAttr(membership, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("PUBLIC"); + assertThat(content) + .contains("\"columns\"") + .contains("\"primaryKey\" : true") + .contains("\"referencedTable\" : \"APP_USER\"") + .contains("\"foreignKeyName\" : \"FK_M_USER\""); + } + + @Test + public void doesNotExposeTableMetadataOutsideDevelopmentMode() { + var config = new SqlConnectorConfiguration(); + config.setJdbcUrl(url); + config.setUsername("sa"); + config.setPassword(new GuardedString("".toCharArray())); + config.setDevelopmentMode(false); + + var nonDevConnector = new TestSqlConnector(); + nonDevConnector.init(config); + try { + assertThat(nonDevConnector.schema().getObjectClassInfo().stream() + .map(ObjectClassInfo::getType)) + .doesNotContain(SqlDevelopmentMode.TABLE_OC_NAME); + } finally { + nonDevConnector.dispose(); + } + } + + @Test + public void filtersTableMetadataByUidNameAndSchema() throws Exception { + var appUser = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME, null), "app_user"); + + assertThat(search(SqlDevelopmentMode.TABLE_OC_NAME, FilterBuilder.equalTo( + AttributeBuilder.build(Uid.NAME, appUser.getUid().getUidValue())))) + .extracting(object -> object.getUid().getUidValue()) + .containsExactly(appUser.getUid().getUidValue()); + assertThat(search(SqlDevelopmentMode.TABLE_OC_NAME, FilterBuilder.equalTo( + AttributeBuilder.build(Name.NAME, appUser.getName().getNameValue())))) + .extracting(object -> object.getName().getNameValue()) + .containsExactly(appUser.getName().getNameValue()); + assertThat(search(SqlDevelopmentMode.TABLE_OC_NAME, FilterBuilder.equalTo( + AttributeBuilder.build(SqlDevelopmentMode.SCHEMA_ATTRIBUTE, "PUBLIC")))) + .isNotEmpty() + .allSatisfy(object -> assertThat(getAttr(object, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)) + .isEqualTo("PUBLIC")); + } + + @Test + public void keepsSameNamedTablesFromDifferentSchemas() throws Exception { + var sharedTables = search(SqlDevelopmentMode.TABLE_OC_NAME, null).stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase("shared_entry")) + .toList(); + + assertThat(sharedTables) + .hasSize(2) + .extracting(table -> getAttr(table, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)) + .containsExactlyInAnyOrder("TENANT_A", "TENANT_B"); + assertThat(sharedTables) + .extracting(table -> table.getUid().getUidValue()) + .doesNotHaveDuplicates(); + } + + @Test + public void exportsViewMetadata() throws Exception { + var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME, null), "app_user_view"); + + assertThat(getAttr(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) getAttr(view, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) + .contains("\"tableType\" : \"VIEW\"") + .contains("\"name\" : \"USERNAME\""); + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(); } // ─── Scan-disabled with Groovy-defined object classes ─── diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java index 1864de3..f71dd38 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java @@ -9,6 +9,7 @@ import com.evolveum.polygon.common.GuardedStringAccessor; import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; import com.evolveum.polygon.sql.base.test.PostgresDatabaseInitializer; @@ -116,4 +117,53 @@ public void testSearchAllTables() throws Exception { assertThat(search("project")).isNotEmpty(); assertThat(search("projectmembership")).isNotEmpty(); } + + @Test + public void testDevelopmentMetadataUsesPostgresValues() throws Exception { + var schemaNames = connector.schema().getObjectClassInfo().stream() + .map(ObjectClassInfo::getType) + .toList(); + assertThat(schemaNames).contains(SqlDevelopmentMode.TABLE_OC_NAME); + + var tables = search(SqlDevelopmentMode.TABLE_OC_NAME); + var appUser = tableNamed(tables, "app_user"); + var userAddress = tableNamed(tables, "useraddress"); + + assertThat(appUser.getUid().getUidValue()).isEqualTo("postgres.public.app_user"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.CATALOG_ATTRIBUTE)).isEqualTo("postgres"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("public"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("TABLE"); + assertThat((String) attributeValue(appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) + .contains("\"name\" : \"id\"") + .contains("\"typeName\" : \"SERIAL\"") + .contains("\"primaryKey\" : true") + .contains("\"autoIncrement\" : true") + .contains("\"defaultValue\" : null"); + assertThat((String) attributeValue(userAddress, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) + .contains("\"referencedTable\" : \"app_user\"") + .contains("\"foreignKeyName\" : \"fk_user_address_user\""); + } + + @Test + public void testDevelopmentMetadataExportsPostgresView() throws Exception { + var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME), "user_overview"); + + assertThat(view.getName().getNameValue()).isEqualTo("user_overview"); + assertThat(attributeValue(view, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("public"); + assertThat(attributeValue(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) attributeValue(view, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) + .contains("\"tableType\" : \"VIEW\"") + .contains("\"name\" : \"username\""); + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static Object attributeValue(ConnectorObject object, String name) { + return AttributeUtil.getSingleValue(object.getAttributeByName(name)); + } } From 5c092308617b23528b79f906e6f3ac3ba93f8777 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Mon, 24 Aug 2026 09:21:35 +0200 Subject: [PATCH 02/13] Document SQL development schema views --- .../polygon/sql/base/AbstractGroovySqlConnector.java | 2 ++ .../polygon/sql/base/dev/SqlObjectClassDevHandler.java | 3 +++ .../evolveum/polygon/sql/base/dev/SqlTableDevHandler.java | 8 +++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java b/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java index a956769..af84f06 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/AbstractGroovySqlConnector.java @@ -180,6 +180,8 @@ private void initialize0(boolean allowConnection) { additional.add(SqlDevelopmentMode.tableObjectClassInfo()); } + // Detect the database structure once. The same snapshot is translated into the normal + // connector schema and, in development mode, exposed as raw conndev_SqlTable metadata. List tables; try { if (tableFilter.isDiscoveryEnabled()) { diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlObjectClassDevHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlObjectClassDevHandler.java index 355bc72..207cad2 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlObjectClassDevHandler.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlObjectClassDevHandler.java @@ -18,6 +18,9 @@ * Search handler for the {@code conndev_ObjectClass} object class of the SQL connector. Serializes the * framework schema model (conndev {@code BaseSchema}, translated from the detected tables) via the * shared {@link ConnDevObjectClassSerializer}. Supports equals filters on {@code __UID__} / {@code __NAME__}. + * + *

This is the connector-oriented view of the schema. {@link SqlTableDevHandler} exposes the raw + * JDBC-oriented view, but both views originate from the same schema-discovery snapshot.

*/ public class SqlObjectClassDevHandler implements ObjectSearchOperation { diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java index 515afe0..c0807c5 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java @@ -32,7 +32,13 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -/** Search handler that exposes raw JDBC table and column metadata in development mode. */ +/** + * Search handler that exposes raw JDBC table and column metadata in development mode. + * + *

Unlike {@link SqlObjectClassDevHandler}, which exposes the translated connector model, this + * handler preserves the database-oriented structure. It reads the stored discovery snapshot from + * {@link SqlBaseContext}; searching this object class does not perform another JDBC schema scan.

+ */ public class SqlTableDevHandler implements ObjectSearchOperation { private static final ObjectClass OBJECT_CLASS = new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME); From 0d42c40779e87044fcc5ca5a2fcc34081fa3fe0d Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Tue, 25 Aug 2026 13:08:18 +0200 Subject: [PATCH 03/13] Add JDBC descriptions to SQL development metadata Read table and column remarks and column default values from JDBC metadata, expose them through conndev_SqlTable, and cover the behavior with H2 and PostgreSQL tests. --- .../sql/base/dev/SqlTableDevHandler.java | 1 + .../sql/base/schema/SqlColumnMeta.java | 20 ++++++++- .../sql/base/schema/SqlSchemaDetector.java | 44 +++++++++++++------ .../base/SqlDevConnectorIntegrationTest.java | 17 ++++++- .../SqlSearchOperationPostgresTest.java | 5 ++- .../resources/postgresql/basic/schema.sql | 7 ++- 6 files changed, 74 insertions(+), 20 deletions(-) diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java index c0807c5..0dedb97 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java @@ -137,6 +137,7 @@ private static Map columnContent(SqlColumnMeta column) { content.put("primaryKey", column.isPrimaryKey()); content.put("unique", column.isUnique()); content.put("defaultValue", column.getDefaultValue()); + content.put("remarks", column.getRemarks()); content.put("autoIncrement", column.isAutoIncrement()); content.put("referencedTable", column.getReferencedTable()); content.put("referencedColumn", column.getReferencedColumn()); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlColumnMeta.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlColumnMeta.java index 69f6deb..04e154a 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlColumnMeta.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlColumnMeta.java @@ -26,6 +26,7 @@ public class SqlColumnMeta { final boolean primaryKey; final boolean unique; final Object defaultValue; + final String remarks; final boolean autoIncrement; private String referencedTable; @@ -37,6 +38,14 @@ public SqlColumnMeta(String name, String typeName, int typeCode, int size, java.lang.reflect.Type javaType, boolean nullable, boolean primaryKey, boolean unique, Object defaultValue, boolean autoIncrement) { + this(name, typeName, typeCode, size, javaType, nullable, primaryKey, unique, + defaultValue, autoIncrement, null); + } + + public SqlColumnMeta(String name, String typeName, int typeCode, int size, + java.lang.reflect.Type javaType, + boolean nullable, boolean primaryKey, boolean unique, + Object defaultValue, boolean autoIncrement, String remarks) { this.name = name; this.typeName = typeName; this.typeCode = typeCode; @@ -46,6 +55,7 @@ public SqlColumnMeta(String name, String typeName, int typeCode, int size, this.primaryKey = primaryKey; this.unique = unique; this.defaultValue = defaultValue; + this.remarks = remarks; this.autoIncrement = autoIncrement; } @@ -85,6 +95,10 @@ public Object getDefaultValue() { return defaultValue; } + public String getRemarks() { + return remarks; + } + public boolean isAutoIncrement() { return autoIncrement; } @@ -142,6 +156,7 @@ public static class Builder { private boolean primaryKey; private boolean unique; private Object defaultValue; + private String remarks; private boolean autoIncrement; private String referencedTable; private String referencedColumn; @@ -158,6 +173,7 @@ public static class Builder { public Builder primaryKey(boolean primaryKey) { this.primaryKey = primaryKey; return this; } public Builder unique(boolean unique) { this.unique = unique; return this; } public Builder defaultValue(Object defaultValue) { this.defaultValue = defaultValue; return this; } + public Builder remarks(String remarks) { this.remarks = remarks; return this; } public Builder autoIncrement(boolean autoIncrement) { this.autoIncrement = autoIncrement; return this; } public Builder setForeignKey(String referencedTable, String referencedColumn, String foreignKeyName) { this.referencedTable = referencedTable; @@ -172,7 +188,7 @@ public Builder setForeignKey(String referencedTable, String referencedColumn, St public SqlColumnMeta build() { var col = new SqlColumnMeta(name, typeName, typeCode, size, javaType, - nullable, primaryKey, unique, defaultValue, autoIncrement); + nullable, primaryKey, unique, defaultValue, autoIncrement, remarks); if (referencedTable != null) { col.setForeignKey(referencedTable, referencedColumn, foreignKeyName); } @@ -182,4 +198,4 @@ public SqlColumnMeta build() { return col; } } -} \ No newline at end of file +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java index e626884..9c9c0f5 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java @@ -108,6 +108,7 @@ public List discover() throws SQLException { .name(entry.getKey().table()) .tableType(entry.getKey().tableType() != null ? entry.getKey().tableType() : "TABLE") .catalog(entry.getKey().catalog()) + .remarks(entry.getKey().remarks()) .columns(entry.getValue()) .build()); } @@ -134,27 +135,33 @@ public List discover(Collection tableRefs) throws SQLExc // Build a lookup of actual table schemas from JDBC metadata // (needed because user may provide empty schema but actual schema is e.g. "PUBLIC") - Map tableToSchema = new HashMap<>(); + Map tablesByName = new HashMap<>(); + Map tablesByQualifiedName = new HashMap<>(); try (var rs = conn.getMetaData().getTables(null, null, "%", new String[]{"TABLE", "VIEW"})) { var meta = rs.getMetaData(); while (rs.next()) { var schema = resolveColumn(rs, meta, "TABLE_SCHEM"); var name = resolveColumn(rs, meta, "TABLE_NAME"); if (name != null) { - tableToSchema.put(name, schema); + var table = new Table( + schema, + name, + resolveColumn(rs, meta, "TABLE_TYPE"), + resolveColumn(rs, meta, "TABLE_CAT"), + resolveColumn(rs, meta, "REMARKS")); + tablesByName.put(name, table); + tablesByQualifiedName.put(tableKey(schema, name), table); } } } for (TableRef ref : refs) { - var actualSchema = ref.schema(); - if (actualSchema == null || actualSchema.isEmpty()) { - var resolved = tableToSchema.get(ref.table()); - if (resolved != null) { - actualSchema = resolved; - } + var table = ref.schema() == null || ref.schema().isEmpty() + ? tablesByName.get(ref.table()) + : tablesByQualifiedName.get(tableKey(ref.schema(), ref.table())); + if (table == null) { + table = new Table(ref.schema(), ref.table(), "TABLE", null, null); } - var table = new Table(actualSchema, ref.table(), "TABLE", null); List cols = getColumnMetas(conn, table); if (!cols.isEmpty()) { colMap.put(table, cols); @@ -166,8 +173,9 @@ public List discover(Collection tableRefs) throws SQLExc tables.add(SqlTableInfo.builder() .schema(entry.getKey().schema()) .name(entry.getKey().table()) - .tableType("TABLE") - .catalog(null) + .tableType(entry.getKey().tableType() != null ? entry.getKey().tableType() : "TABLE") + .catalog(entry.getKey().catalog()) + .remarks(entry.getKey().remarks()) .columns(entry.getValue()) .build()); } @@ -199,7 +207,8 @@ private List getTableList(Connection conn, String schemaName) throws SQLE if (name != null) { var tableType = resolveColumn(rs, meta, "TABLE_TYPE"); var catalog = resolveColumn(rs, meta, "TABLE_CAT"); - names.add(new Table(schema, name, tableType, catalog)); + var remarks = resolveColumn(rs, meta, "REMARKS"); + names.add(new Table(schema, name, tableType, catalog, remarks)); } } } @@ -259,6 +268,8 @@ private List getColumnMetas(Connection conn, Table table) throws int decimalDigits = resolveColumnDigits(colsRs); var rawNullable = resolveColumn(colsRs, meta, "IS_NULLABLE"); var rawAutoInc = resolveColumn(colsRs, meta, "IS_AUTOINCREMENT"); + var defaultValue = resolveColumn(colsRs, meta, "COLUMN_DEF"); + var remarks = resolveColumn(colsRs, meta, "REMARKS"); boolean isPk = pkList.contains(colName); @@ -282,7 +293,8 @@ private List getColumnMetas(Connection conn, Table table) throws .primaryKey(isPk) .autoIncrement(isAutoInc(rawAutoInc)) .unique(isPk || uniqueCols.contains(colName)) - .defaultValue(null) + .defaultValue(defaultValue) + .remarks(remarks) .build()); } } @@ -470,5 +482,9 @@ public void setTableFilter(TableFilter tableFilter) { this.tableFilter = tableFilter; } - record Table(String schema, String table, String tableType, String catalog) {} + private static String tableKey(String schema, String table) { + return (schema != null ? schema : "") + '\0' + table; + } + + record Table(String schema, String table, String tableType, String catalog, String remarks) {} } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java index a0a6e00..c1b1e5e 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java @@ -38,7 +38,10 @@ protected static class TestSqlConnector extends DefaultTestConnector { protected String schemaSql() { return """ DROP ALL OBJECTS; - CREATE TABLE app_user (id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL); + CREATE TABLE app_user (id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) DEFAULT 'anonymous' NOT NULL); + COMMENT ON TABLE app_user IS 'Application users'; + COMMENT ON COLUMN app_user.username IS 'Application login name'; CREATE TABLE app_group (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50)); CREATE TABLE membership (id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, CONSTRAINT fk_m_user FOREIGN KEY (user_id) REFERENCES app_user(id)); @@ -91,6 +94,18 @@ public void exportsDetectedTableMetadata() throws Exception { .contains("\"foreignKeyName\" : \"FK_M_USER\""); } + @Test + public void exportsTableAndColumnDescriptionsAndDefaults() throws Exception { + var appUser = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME, null), "app_user"); + var content = (String) getAttr(appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(getAttr(appUser, SqlDevelopmentMode.REMARKS_ATTRIBUTE)).isEqualTo("Application users"); + assertThat(content) + .contains("\"remarks\" : \"Application users\"") + .contains("\"defaultValue\" : \"'anonymous'\"") + .contains("\"remarks\" : \"Application login name\""); + } + @Test public void doesNotExposeTableMetadataOutsideDevelopmentMode() { var config = new SqlConnectorConfiguration(); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java index f71dd38..9f5276f 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java @@ -133,12 +133,15 @@ public void testDevelopmentMetadataUsesPostgresValues() throws Exception { assertThat(attributeValue(appUser, SqlDevelopmentMode.CATALOG_ATTRIBUTE)).isEqualTo("postgres"); assertThat(attributeValue(appUser, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("public"); assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("TABLE"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.REMARKS_ATTRIBUTE)).isEqualTo("Application users"); assertThat((String) attributeValue(appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) .contains("\"name\" : \"id\"") .contains("\"typeName\" : \"SERIAL\"") .contains("\"primaryKey\" : true") .contains("\"autoIncrement\" : true") - .contains("\"defaultValue\" : null"); + .contains("\"defaultValue\" : \"nextval('app_user_id_seq'::regclass)\"") + .contains("unknown@example.com") + .contains("\"remarks\" : \"Primary email address\""); assertThat((String) attributeValue(userAddress, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) .contains("\"referencedTable\" : \"app_user\"") .contains("\"foreignKeyName\" : \"fk_user_address_user\""); diff --git a/base/src/test/resources/postgresql/basic/schema.sql b/base/src/test/resources/postgresql/basic/schema.sql index 502072f..c0cf8b1 100644 --- a/base/src/test/resources/postgresql/basic/schema.sql +++ b/base/src/test/resources/postgresql/basic/schema.sql @@ -11,10 +11,13 @@ DROP TABLE IF EXISTS app_user CASCADE; CREATE TABLE app_user ( id SERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE, - email VARCHAR(255) UNIQUE, + email VARCHAR(255) DEFAULT 'unknown@example.com' UNIQUE, created_at TIMESTAMP ); +COMMENT ON TABLE app_user IS 'Application users'; +COMMENT ON COLUMN app_user.email IS 'Primary email address'; + CREATE TABLE app_group ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -68,4 +71,4 @@ ALTER TABLE projectmembership ADD CONSTRAINT fk_membership_role DROP VIEW IF EXISTS user_overview CASCADE; CREATE VIEW user_overview AS SELECT id, username, email, created_at - FROM app_user; \ No newline at end of file + FROM app_user; From ee46f2b790231bb992434c6e315bf165ed6c73f6 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Tue, 25 Aug 2026 14:41:52 +0200 Subject: [PATCH 04/13] Export H2 table definitions in development metadata Introduce a pluggable table-definition provider, implement H2 extraction with SCRIPT NODATA TABLE, and expose sanitized table and view definitions through conndev_SqlTable. Add H2 integration coverage for DDL export and setup-statement filtering. --- .../sql/base/dev/SqlDevelopmentMode.java | 2 + .../sql/base/dev/SqlTableDevHandler.java | 2 + .../sql/base/schema/SqlSchemaDetector.java | 22 +++++++ .../polygon/sql/base/schema/SqlTableInfo.java | 22 ++++++- .../H2SqlTableDefinitionProvider.java | 64 +++++++++++++++++++ .../SqlTableDefinitionProvider.java | 20 ++++++ .../SqlTableDefinitionProviders.java | 26 ++++++++ .../base/SqlDevConnectorIntegrationTest.java | 17 +++++ 8 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java index 8807fa8..0910708 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlDevelopmentMode.java @@ -21,6 +21,7 @@ public final class SqlDevelopmentMode { public static final String SCHEMA_ATTRIBUTE = "schema"; public static final String TABLE_TYPE_ATTRIBUTE = "tableType"; public static final String REMARKS_ATTRIBUTE = "remarks"; + public static final String DEFINITION_ATTRIBUTE = "definition"; public static final String TABLE_CONTENT_ATTRIBUTE = "tableContent"; private SqlDevelopmentMode() { @@ -36,6 +37,7 @@ public static ObjectClassInfo tableObjectClassInfo() { builder.addAttributeInfo(readOnly(SCHEMA_ATTRIBUTE)); builder.addAttributeInfo(readOnly(TABLE_TYPE_ATTRIBUTE)); builder.addAttributeInfo(readOnly(REMARKS_ATTRIBUTE)); + builder.addAttributeInfo(readOnly(DEFINITION_ATTRIBUTE)); builder.addAttributeInfo(readOnly(TABLE_CONTENT_ATTRIBUTE)); return builder.build(); } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java index 0dedb97..e9a3c14 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/dev/SqlTableDevHandler.java @@ -75,6 +75,7 @@ private static ConnectorObject createTableObject(SqlTableInfo table, String uid) addIfPresent(builder, SqlDevelopmentMode.SCHEMA_ATTRIBUTE, table.getSchema()); addIfPresent(builder, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE, table.getTableType()); addIfPresent(builder, SqlDevelopmentMode.REMARKS_ATTRIBUTE, table.getRemarks()); + addIfPresent(builder, SqlDevelopmentMode.DEFINITION_ATTRIBUTE, table.getDefinition()); builder.addAttribute(SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE, tableToJson(table)); return builder.build(); } @@ -118,6 +119,7 @@ private static String tableToJson(SqlTableInfo table) { content.put("name", table.getName()); content.put("tableType", table.getTableType()); content.put("remarks", table.getRemarks()); + content.put("definition", table.getDefinition()); content.put("columns", table.getColumns().stream().map(SqlTableDevHandler::columnContent).toList()); try { return OBJECT_MAPPER.writeValueAsString(content); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java index 9c9c0f5..6d18080 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java @@ -8,6 +8,8 @@ import com.evolveum.polygon.sql.base.SqlBaseContext; import com.evolveum.polygon.sql.base.connection.SqlSchemaValueMapping; +import com.evolveum.polygon.sql.base.schema.definition.SqlTableDefinitionProvider; +import com.evolveum.polygon.sql.base.schema.definition.SqlTableDefinitionProviders; import com.querydsl.sql.Configuration; import com.querydsl.sql.H2Templates; import com.querydsl.sql.SQLTemplates; @@ -40,6 +42,8 @@ public class SqlSchemaDetector { private final SQLTemplates templates; + private final SqlTableDefinitionProvider tableDefinitionProvider; + public SqlSchemaDetector(SqlBaseContext context) throws SQLException { this.context = context; @@ -58,6 +62,9 @@ public SqlSchemaDetector(SqlBaseContext context) throws SQLException { } templates = templatesFromRegistry; querydslConfig = new Configuration(templates); + tableDefinitionProvider = context.getDevelopmentMode() + ? SqlTableDefinitionProviders.find(productName).orElse(null) + : null; } } @@ -109,6 +116,7 @@ public List discover() throws SQLException { .tableType(entry.getKey().tableType() != null ? entry.getKey().tableType() : "TABLE") .catalog(entry.getKey().catalog()) .remarks(entry.getKey().remarks()) + .definition(readTableDefinition(conn, entry.getKey())) .columns(entry.getValue()) .build()); } @@ -176,6 +184,7 @@ public List discover(Collection tableRefs) throws SQLExc .tableType(entry.getKey().tableType() != null ? entry.getKey().tableType() : "TABLE") .catalog(entry.getKey().catalog()) .remarks(entry.getKey().remarks()) + .definition(readTableDefinition(conn, entry.getKey())) .columns(entry.getValue()) .build()); } @@ -184,6 +193,19 @@ public List discover(Collection tableRefs) throws SQLExc } } + /** Definition export is optional and must not prevent normal schema discovery. */ + private String readTableDefinition(Connection connection, Table table) { + if (tableDefinitionProvider == null) { + return null; + } + try { + return tableDefinitionProvider.readDefinition(connection, table.catalog(), table.schema(), + table.table(), table.tableType()).orElse(null); + } catch (SQLException e) { + return null; + } + } + /** * Minimal table reference for targeted discovery (schema + table name). */ diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlTableInfo.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlTableInfo.java index e003905..131af4c 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlTableInfo.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlTableInfo.java @@ -20,15 +20,22 @@ public class SqlTableInfo { private final String catalog; private final String tableType; private final String remarks; + private final String definition; private final List columns; public SqlTableInfo(String name, String schema, String catalog, String tableType, String remarks, List columns) { + this(name, schema, catalog, tableType, remarks, null, columns); + } + + public SqlTableInfo(String name, String schema, String catalog, String tableType, + String remarks, String definition, List columns) { this.name = name; this.schema = schema; this.catalog = catalog; this.tableType = tableType; this.remarks = remarks; + this.definition = definition; this.columns = columns != null ? new ArrayList<>(columns) : new ArrayList<>(); } @@ -52,6 +59,10 @@ public String getRemarks() { return remarks; } + public String getDefinition() { + return definition; + } + public List getColumns() { return new ArrayList<>(columns); } @@ -67,6 +78,7 @@ public static class Builder { private String catalog; private String tableType; private String remarks; + private String definition; final List columns = new ArrayList<>(); public Builder name(String name) { @@ -94,13 +106,19 @@ public Builder remarks(String remarks) { return this; } + public Builder definition(String definition) { + this.definition = definition; + return this; + } + public Builder addColumn(SqlColumnMeta column) { columns.add(column); return this; } public SqlTableInfo build() { - return new SqlTableInfo(name, schema, catalog, tableType, remarks, new ArrayList<>(columns)); + return new SqlTableInfo( + name, schema, catalog, tableType, remarks, definition, new ArrayList<>(columns)); } public Builder columns(Collection columns) { @@ -108,4 +126,4 @@ public Builder columns(Collection columns) { return this; } } -} \ No newline at end of file +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java new file mode 100644 index 0000000..711f3b6 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +/** Reads H2 table and view definitions using {@code SCRIPT NODATA TABLE}. */ +public final class H2SqlTableDefinitionProvider implements SqlTableDefinitionProvider { + + @Override + public boolean supports(String databaseProductName) { + return "H2".equalsIgnoreCase(databaseProductName); + } + + @Override + public Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException { + var qualifiedName = schema == null || schema.isBlank() + ? quote(connection, name) + : quote(connection, schema) + "." + quote(connection, name); + var sql = "SCRIPT NODATA TABLE " + qualifiedName; + var definition = new StringBuilder(); + boolean objectDefinitionStarted = false; + + try (var statement = connection.createStatement(); + var result = statement.executeQuery(sql)) { + while (result.next()) { + var scriptLine = result.getString(1); + if (scriptLine == null || scriptLine.isBlank()) { + continue; + } + var trimmed = scriptLine.strip(); + if (!objectDefinitionStarted) { + objectDefinitionStarted = trimmed.regionMatches(true, 0, "CREATE ", 0, 7) + && trimmed.contains(qualifiedName); + } + if (objectDefinitionStarted && !trimmed.startsWith("--")) { + if (!definition.isEmpty()) { + definition.append(System.lineSeparator()); + } + definition.append(trimmed); + } + } + } + + return definition.isEmpty() + ? Optional.empty() + : Optional.of(definition.toString()); + } + + private static String quote(Connection connection, String identifier) throws SQLException { + var quote = connection.getMetaData().getIdentifierQuoteString(); + if (quote == null || quote.isBlank()) { + return identifier; + } + return quote + identifier.replace(quote, quote + quote) + quote; + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java new file mode 100644 index 0000000..f129ddb --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +/** Database-specific reader of a table or view SQL definition. */ +public interface SqlTableDefinitionProvider { + + boolean supports(String databaseProductName); + + Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException; +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java new file mode 100644 index 0000000..99f976a --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import java.util.List; +import java.util.Optional; + +/** Registry of database-specific table definition providers. */ +public final class SqlTableDefinitionProviders { + + private static final List PROVIDERS = List.of( + new H2SqlTableDefinitionProvider()); + + private SqlTableDefinitionProviders() { + } + + public static Optional find(String databaseProductName) { + return PROVIDERS.stream() + .filter(provider -> provider.supports(databaseProductName)) + .findFirst(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java index c1b1e5e..4c1b27e 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDevConnectorIntegrationTest.java @@ -106,6 +106,20 @@ public void exportsTableAndColumnDescriptionsAndDefaults() throws Exception { .contains("\"remarks\" : \"Application login name\""); } + @Test + public void exportsH2TableDefinitionWithoutDatabaseSetupStatements() throws Exception { + var appUser = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME, null), "app_user"); + var definition = (String) getAttr(appUser, SqlDevelopmentMode.DEFINITION_ATTRIBUTE); + var content = (String) getAttr(appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(definition) + .contains("CREATE MEMORY TABLE \"PUBLIC\".\"APP_USER\"") + .contains("DEFAULT 'anonymous'") + .contains("PRIMARY KEY") + .doesNotContain("CREATE USER", "HASH", "SET DB_CLOSE_DELAY"); + assertThat(content).contains("\"definition\" : \"CREATE MEMORY TABLE"); + } + @Test public void doesNotExposeTableMetadataOutsideDevelopmentMode() { var config = new SqlConnectorConfiguration(); @@ -164,6 +178,9 @@ public void exportsViewMetadata() throws Exception { var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME, null), "app_user_view"); assertThat(getAttr(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) getAttr(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("CREATE FORCE VIEW \"PUBLIC\".\"APP_USER_VIEW\"") + .contains("FROM \"PUBLIC\".\"APP_USER\""); assertThat((String) getAttr(view, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) .contains("\"tableType\" : \"VIEW\"") .contains("\"name\" : \"USERNAME\""); From ece981393f3ca388d806b07e26bed0c43afb360a Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Tue, 25 Aug 2026 20:08:28 +0200 Subject: [PATCH 05/13] Add PostgreSQL native table definition export Invoke a configurable pg_dump executable to expose native table and view definitions in development metadata. Keep definition failures isolated per object and cover invocation, table output, view output, and connector configuration with tests. --- .../sql/base/SqlConnectorConfiguration.java | 16 +- .../sql/base/schema/SqlSchemaDetector.java | 2 +- .../PostgreSqlTableDefinitionProvider.java | 139 ++++++++++++++++++ .../SqlTableDefinitionProviders.java | 13 +- .../FrameworkConnectorLoadingTest.java | 1 + .../SqlSearchOperationPostgresTest.java | 82 +++++++++++ .../test/PostgresDatabaseInitializer.java | 2 +- .../sql/base/test/SqlIntegrationTestBase.java | 1 + .../src/main/resources/Messages.properties | 3 +- 9 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlConnectorConfiguration.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlConnectorConfiguration.java index a9235ab..06ccff3 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlConnectorConfiguration.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlConnectorConfiguration.java @@ -30,6 +30,7 @@ public class SqlConnectorConfiguration extends BaseGroovyConnectorConfiguration private String scanExcludeTables; private String scanExcludeViews; private String testConnectionQuery; + private String pgDumpPath = "pg_dump"; @ConfigurationProperty(required = true, order = 0) public String getJdbcUrl() { @@ -182,4 +183,17 @@ public String getTestConnectionQuery() { public void setTestConnectionQuery(String testConnectionQuery) { this.testConnectionQuery = testConnectionQuery; } -} \ No newline at end of file + + /** + * Executable used to read native PostgreSQL table and view definitions in development mode. + * May be an absolute path or a command available on {@code PATH}. A blank value disables it. + */ + @ConfigurationProperty(order = 110) + public String getPgDumpPath() { + return pgDumpPath; + } + + public void setPgDumpPath(String pgDumpPath) { + this.pgDumpPath = pgDumpPath; + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java index 6d18080..db065b8 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java @@ -63,7 +63,7 @@ public SqlSchemaDetector(SqlBaseContext context) throws SQLException { templates = templatesFromRegistry; querydslConfig = new Configuration(templates); tableDefinitionProvider = context.getDevelopmentMode() - ? SqlTableDefinitionProviders.find(productName).orElse(null) + ? SqlTableDefinitionProviders.find(productName, context.configuration()).orElse(null) : null; } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java new file mode 100644 index 0000000..96ebe7b --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import com.evolveum.polygon.common.GuardedStringAccessor; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +/** Reads native PostgreSQL table and view definitions with {@code pg_dump}. */ +public final class PostgreSqlTableDefinitionProvider implements SqlTableDefinitionProvider { + + private static final Duration PROCESS_TIMEOUT = Duration.ofSeconds(30); + + private final SqlConnectorConfiguration configuration; + + public PostgreSqlTableDefinitionProvider(SqlConnectorConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public boolean supports(String databaseProductName) { + return "PostgreSQL".equalsIgnoreCase(databaseProductName); + } + + @Override + public Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException { + var executable = configuration.getPgDumpPath(); + if (executable == null || executable.isBlank()) { + return Optional.empty(); + } + + var command = new ArrayList(); + command.add(executable); + command.add("--schema-only"); + command.add("--no-owner"); + command.add("--no-privileges"); + command.add("--no-password"); + command.add("--quote-all-identifiers"); + command.add("--encoding=UTF8"); + command.add("--dbname=" + postgresUrl(connection)); + command.add("--username=" + configuration.getUsername()); + command.add("--table=" + tablePattern(schema, name)); + + var processBuilder = new ProcessBuilder(command); + processBuilder.environment().put("LC_ALL", "C"); + addPassword(processBuilder); + + final Process process; + try { + process = processBuilder.start(); + } catch (IOException e) { + throw new SQLException("Could not start pg_dump", e); + } + + try { + var standardOutput = readAsync(process.getInputStream()); + var standardError = readAsync(process.getErrorStream()); + if (!process.waitFor(PROCESS_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + process.waitFor(); + throw new SQLException("pg_dump timed out after " + PROCESS_TIMEOUT.toSeconds() + " seconds"); + } + + var output = await(standardOutput); + var error = await(standardError); + if (process.exitValue() != 0) { + throw new SQLException("pg_dump failed with exit code " + process.exitValue() + + (error.isBlank() ? "" : ": " + error.strip())); + } + return output.isBlank() ? Optional.empty() : Optional.of(output.strip()); + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + throw new SQLException("Interrupted while waiting for pg_dump", e); + } + } + + private void addPassword(ProcessBuilder processBuilder) { + if (configuration.getPassword() == null) { + return; + } + var password = new GuardedStringAccessor(); + configuration.getPassword().access(password); + processBuilder.environment().put("PGPASSWORD", password.getClearString()); + } + + private static String postgresUrl(Connection connection) throws SQLException { + var jdbcUrl = connection.getMetaData().getURL(); + var prefix = "jdbc:postgresql:"; + if (jdbcUrl == null || !jdbcUrl.startsWith(prefix)) { + throw new SQLException("Unsupported PostgreSQL JDBC URL"); + } + return "postgresql:" + jdbcUrl.substring(prefix.length()); + } + + private static String tablePattern(String schema, String name) { + return schema == null || schema.isBlank() + ? quotePatternIdentifier(name) + : quotePatternIdentifier(schema) + "." + quotePatternIdentifier(name); + } + + private static String quotePatternIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + private static CompletableFuture readAsync(java.io.InputStream stream) { + return CompletableFuture.supplyAsync(() -> { + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + private static String await(CompletableFuture output) throws SQLException { + try { + return output.join(); + } catch (CompletionException e) { + throw new SQLException("Could not read pg_dump output", e.getCause()); + } + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index 99f976a..1326212 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -6,20 +6,23 @@ */ package com.evolveum.polygon.sql.base.schema.definition; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; + import java.util.List; import java.util.Optional; /** Registry of database-specific table definition providers. */ public final class SqlTableDefinitionProviders { - private static final List PROVIDERS = List.of( - new H2SqlTableDefinitionProvider()); - private SqlTableDefinitionProviders() { } - public static Optional find(String databaseProductName) { - return PROVIDERS.stream() + public static Optional find( + String databaseProductName, SqlConnectorConfiguration configuration) { + return List.of( + new H2SqlTableDefinitionProvider(), + new PostgreSqlTableDefinitionProvider(configuration)) + .stream() .filter(provider -> provider.supports(databaseProductName)) .findFirst(); } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/integration/FrameworkConnectorLoadingTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/integration/FrameworkConnectorLoadingTest.java index e90ad32..fb4e19a 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/integration/FrameworkConnectorLoadingTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/integration/FrameworkConnectorLoadingTest.java @@ -163,6 +163,7 @@ public void configurationPropertiesAreExposed() { assertThat(props.getProperty("password")).isNotNull(); assertThat(props.getProperty("poolSize")).isNotNull(); assertThat(props.getProperty("scanTables")).isNotNull(); + assertThat(props.getProperty("pgDumpPath")).isNotNull(); } @Test diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java index 9f5276f..8562c08 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationPostgresTest.java @@ -20,6 +20,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.util.ArrayList; @@ -41,6 +43,7 @@ public class SqlSearchOperationPostgresTest { private PostgresDatabaseInitializer postgres; private TestSqlConnector connector; + private Path pgDumpStub; private static class TestSqlConnector extends AbstractGroovySqlConnector { TestSqlConnector() { super(false); } @@ -72,6 +75,8 @@ public void setUp() throws Exception { config.setScanTables(true); config.setScanViews(true); config.setDevelopmentMode(true); + pgDumpStub = createPgDumpStub(); + config.setPgDumpPath(pgDumpStub.toString()); connector = new TestSqlConnector(); connector.init(config); @@ -81,6 +86,15 @@ public void setUp() throws Exception { public void tearDown() { if (connector != null) { connector.dispose(); connector = null; } if (postgres != null) { postgres.close(); postgres = null; } + if (pgDumpStub != null) { + try { + Files.deleteIfExists(pgDumpStub); + } catch (IOException e) { + throw new RuntimeException("Failed to remove pg_dump test stub", e); + } finally { + pgDumpStub = null; + } + } } private static String readResource(String path) throws IOException { @@ -88,6 +102,65 @@ private static String readResource(String path) throws IOException { return new String(Objects.requireNonNull(is, "Resource not found: " + path).readAllBytes(), StandardCharsets.UTF_8); } + private static Path createPgDumpStub() throws IOException { + var stub = Files.createTempFile("connector-sql-pg-dump-", ".sh"); + Files.writeString(stub, """ + #!/bin/sh + table="" + schema_only=0 + native_url=0 + no_password=0 + for argument in "$@"; do + case "$argument" in + --table=*) table="${argument#--table=}" ;; + --schema-only) schema_only=1 ;; + --dbname=postgresql://*) native_url=1 ;; + --no-password) no_password=1 ;; + esac + done + if [ "$PGPASSWORD" != "postgres" ] || [ "$schema_only" -ne 1 ] \ + || [ "$native_url" -ne 1 ] || [ "$no_password" -ne 1 ] || [ -z "$table" ]; then + echo "Invalid pg_dump invocation" >&2 + exit 9 + fi + case "$table" in + *user_overview*) + cat <<'SQL' + CREATE VIEW "public"."user_overview" AS + SELECT "app_user"."id", + "app_user"."username", + "app_user"."email", + "app_user"."created_at" + FROM "public"."app_user"; + SQL + ;; + *app_user*) + cat <<'SQL' + CREATE TABLE "public"."app_user" ( + "id" integer NOT NULL, + "username" character varying(255) NOT NULL, + "email" character varying(255) DEFAULT 'unknown@example.com'::character varying, + "created_at" timestamp without time zone + ); + ALTER TABLE ONLY "public"."app_user" + ADD CONSTRAINT "app_user_pkey" PRIMARY KEY ("id"); + SQL + ;; + *app_group*) + echo "Simulated pg_dump failure" >&2 + exit 12 + ;; + *) + echo "-- native pg_dump definition for $table" + ;; + esac + """, StandardCharsets.UTF_8); + if (!stub.toFile().setExecutable(true)) { + throw new IOException("Cannot make pg_dump test stub executable"); + } + return stub; + } + private OperationOptions opts() { return new OperationOptions(Collections.emptyMap()); } @@ -127,6 +200,7 @@ public void testDevelopmentMetadataUsesPostgresValues() throws Exception { var tables = search(SqlDevelopmentMode.TABLE_OC_NAME); var appUser = tableNamed(tables, "app_user"); + var appGroup = tableNamed(tables, "app_group"); var userAddress = tableNamed(tables, "useraddress"); assertThat(appUser.getUid().getUidValue()).isEqualTo("postgres.public.app_user"); @@ -134,6 +208,11 @@ public void testDevelopmentMetadataUsesPostgresValues() throws Exception { assertThat(attributeValue(appUser, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("public"); assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("TABLE"); assertThat(attributeValue(appUser, SqlDevelopmentMode.REMARKS_ATTRIBUTE)).isEqualTo("Application users"); + assertThat((String) attributeValue(appUser, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("CREATE TABLE \"public\".\"app_user\"") + .contains("ADD CONSTRAINT \"app_user_pkey\"") + .contains("DEFAULT 'unknown@example.com'::character varying"); + assertThat(appGroup.getAttributeByName(SqlDevelopmentMode.DEFINITION_ATTRIBUTE)).isNull(); assertThat((String) attributeValue(appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) .contains("\"name\" : \"id\"") .contains("\"typeName\" : \"SERIAL\"") @@ -154,6 +233,9 @@ public void testDevelopmentMetadataExportsPostgresView() throws Exception { assertThat(view.getName().getNameValue()).isEqualTo("user_overview"); assertThat(attributeValue(view, SqlDevelopmentMode.SCHEMA_ATTRIBUTE)).isEqualTo("public"); assertThat(attributeValue(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) attributeValue(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("CREATE VIEW \"public\".\"user_overview\" AS") + .contains("FROM \"public\".\"app_user\""); assertThat((String) attributeValue(view, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)) .contains("\"tableType\" : \"VIEW\"") .contains("\"name\" : \"username\""); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/PostgresDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/PostgresDatabaseInitializer.java index 7b853c2..c853174 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/PostgresDatabaseInitializer.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/PostgresDatabaseInitializer.java @@ -201,4 +201,4 @@ private static void executeSql(Connection conn, String resourcePath) throws SQLE } } } -} \ No newline at end of file +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/SqlIntegrationTestBase.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqlIntegrationTestBase.java index c7aa122..1e7ff66 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/SqlIntegrationTestBase.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqlIntegrationTestBase.java @@ -234,6 +234,7 @@ private SqlConnectorConfiguration copyConfig(SqlConnectorConfiguration src) { config.setScanTables(src.getScanTables()); config.setScanViews(src.getScanViews()); config.setDevelopmentMode(src.getDevelopmentMode()); + config.setPgDumpPath(src.getPgDumpPath()); return config; } diff --git a/connector/generic/src/main/resources/Messages.properties b/connector/generic/src/main/resources/Messages.properties index aa5b3be..7281d57 100644 --- a/connector/generic/src/main/resources/Messages.properties +++ b/connector/generic/src/main/resources/Messages.properties @@ -12,4 +12,5 @@ scanViews.display=Scan Views During Schema Discovery scanTableFilter.display=Table Name Filter (Regex) scanViewFilter.display=View Name Filter (Regex) scanExcludeTables.display=Exclude Tables (Regex) -scanExcludeViews.display=Exclude Views (Regex) \ No newline at end of file +scanExcludeViews.display=Exclude Views (Regex) +pgDumpPath.display=PostgreSQL pg_dump Executable From 02d28d6f913d1f70d31f22cf5ce6e85b58395c11 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 11:09:09 +0200 Subject: [PATCH 06/13] Centralize JDBC database product mapping Introduce SqlDatabase to map JDBC product names once and let table-definition providers declare their supported database by enum value. Cover supported, case-insensitive, unknown, and null product-name mappings with unit tests. --- .../polygon/sql/base/SqlDatabase.java | 36 +++++++++++++++++++ .../sql/base/schema/SqlSchemaDetector.java | 7 ++-- .../H2SqlTableDefinitionProvider.java | 6 ++-- .../PostgreSqlTableDefinitionProvider.java | 5 +-- .../SqlTableDefinitionProvider.java | 4 ++- .../SqlTableDefinitionProviders.java | 5 +-- .../polygon/sql/base/SqlDatabaseTest.java | 32 +++++++++++++++++ 7 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java new file mode 100644 index 0000000..d05323a --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base; + +import java.util.List; + +/** Supported database products identified from JDBC metadata. */ +public enum SqlDatabase { + + H2("H2"), + POSTGRESQL("PostgreSQL"), + UNKNOWN(); + + private final List jdbcProductNames; + + SqlDatabase(String... jdbcProductNames) { + this.jdbcProductNames = List.of(jdbcProductNames); + } + + public static SqlDatabase fromJdbcProductName(String productName) { + if (productName == null) { + return UNKNOWN; + } + for (var database : values()) { + if (database.jdbcProductNames.stream() + .anyMatch(productName::equalsIgnoreCase)) { + return database; + } + } + return UNKNOWN; + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java index db065b8..13b0eb4 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java @@ -7,6 +7,7 @@ package com.evolveum.polygon.sql.base.schema; import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.SqlDatabase; import com.evolveum.polygon.sql.base.connection.SqlSchemaValueMapping; import com.evolveum.polygon.sql.base.schema.definition.SqlTableDefinitionProvider; import com.evolveum.polygon.sql.base.schema.definition.SqlTableDefinitionProviders; @@ -56,14 +57,14 @@ public SqlSchemaDetector(SqlBaseContext context) throws SQLException { : SQLTemplates.DEFAULT; // For H2, use H2Templates with no quoting - unqualified column paths avoid table.column issues - var productName = meta.getDatabaseProductName(); - if (productName != null && productName.toUpperCase().contains("H2")) { + var database = SqlDatabase.fromJdbcProductName(meta.getDatabaseProductName()); + if (database == SqlDatabase.H2) { templatesFromRegistry = new H2Templates(false); } templates = templatesFromRegistry; querydslConfig = new Configuration(templates); tableDefinitionProvider = context.getDevelopmentMode() - ? SqlTableDefinitionProviders.find(productName, context.configuration()).orElse(null) + ? SqlTableDefinitionProviders.find(database, context.configuration()).orElse(null) : null; } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java index 711f3b6..43c2181 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/H2SqlTableDefinitionProvider.java @@ -6,6 +6,8 @@ */ package com.evolveum.polygon.sql.base.schema.definition; +import com.evolveum.polygon.sql.base.SqlDatabase; + import java.sql.Connection; import java.sql.SQLException; import java.util.Optional; @@ -14,8 +16,8 @@ public final class H2SqlTableDefinitionProvider implements SqlTableDefinitionProvider { @Override - public boolean supports(String databaseProductName) { - return "H2".equalsIgnoreCase(databaseProductName); + public SqlDatabase database() { + return SqlDatabase.H2; } @Override diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java index 96ebe7b..e4b08ce 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/PostgreSqlTableDefinitionProvider.java @@ -8,6 +8,7 @@ import com.evolveum.polygon.common.GuardedStringAccessor; import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.SqlDatabase; import java.io.IOException; import java.io.UncheckedIOException; @@ -33,8 +34,8 @@ public PostgreSqlTableDefinitionProvider(SqlConnectorConfiguration configuration } @Override - public boolean supports(String databaseProductName) { - return "PostgreSQL".equalsIgnoreCase(databaseProductName); + public SqlDatabase database() { + return SqlDatabase.POSTGRESQL; } @Override diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java index f129ddb..d33a906 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProvider.java @@ -6,6 +6,8 @@ */ package com.evolveum.polygon.sql.base.schema.definition; +import com.evolveum.polygon.sql.base.SqlDatabase; + import java.sql.Connection; import java.sql.SQLException; import java.util.Optional; @@ -13,7 +15,7 @@ /** Database-specific reader of a table or view SQL definition. */ public interface SqlTableDefinitionProvider { - boolean supports(String databaseProductName); + SqlDatabase database(); Optional readDefinition(Connection connection, String catalog, String schema, String name, String tableType) throws SQLException; diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index 1326212..a04ea7c 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -7,6 +7,7 @@ package com.evolveum.polygon.sql.base.schema.definition; import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.SqlDatabase; import java.util.List; import java.util.Optional; @@ -18,12 +19,12 @@ private SqlTableDefinitionProviders() { } public static Optional find( - String databaseProductName, SqlConnectorConfiguration configuration) { + SqlDatabase database, SqlConnectorConfiguration configuration) { return List.of( new H2SqlTableDefinitionProvider(), new PostgreSqlTableDefinitionProvider(configuration)) .stream() - .filter(provider -> provider.supports(databaseProductName)) + .filter(provider -> provider.database() == database) .findFirst(); } } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java new file mode 100644 index 0000000..f717af9 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SqlDatabaseTest { + + @DataProvider + public static Object[][] jdbcProductNames() { + return new Object[][] { + { "H2", SqlDatabase.H2 }, + { "h2", SqlDatabase.H2 }, + { "PostgreSQL", SqlDatabase.POSTGRESQL }, + { "postgresql", SqlDatabase.POSTGRESQL }, + { "Unsupported database", SqlDatabase.UNKNOWN }, + { null, SqlDatabase.UNKNOWN } + }; + } + + @Test(dataProvider = "jdbcProductNames") + public void mapsJdbcProductName(String productName, SqlDatabase expected) { + assertThat(SqlDatabase.fromJdbcProductName(productName)).isEqualTo(expected); + } +} From 6168d1ae6262f44d908fff5cdbcc5a9bb4123fbc Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 11:30:20 +0200 Subject: [PATCH 07/13] Fix Oracle schema mapping and batched searches Enrich explicit object classes with detected JDBC schema and table metadata when those values were omitted. Flush batch-aware search handlers after every page, add regression coverage, and align Oracle integration tests with embedded object-class search semantics. --- .../sql/base/schema/SqlSchemaTranslator.java | 8 ++++- .../sql/base/search/SqlSearchExecutor.java | 9 +++++- .../sql/base/SqlSchemaTranslatorTest.java | 20 +++++++++++++ .../SqlSearchOperationIntegrationTest.java | 29 +++++++++++++++++++ .../test/OracleConnectorIntegrationTest.java | 4 +-- 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaTranslator.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaTranslator.java index e2fb1c0..3afc54b 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaTranslator.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaTranslator.java @@ -392,7 +392,7 @@ private void translateTable(SqlTableInfo table) { @SuppressWarnings("unchecked") private SqlObjectClassSchemaBuilderImpl correlateBuilder(SqlTableInfo table) { var maybeClassName = detected(table.getName()); - return (SqlObjectClassSchemaBuilderImpl) builder.correlateObjectClass( + var objectClass = (SqlObjectClassSchemaBuilderImpl) builder.correlateObjectClass( o -> { var sqlSchema = o.sql().schema(); var sqlTable = o.sql().table(); @@ -404,6 +404,12 @@ private SqlObjectClassSchemaBuilderImpl correlateBuilder(SqlTableInfo table) { maybeClassName, o -> o.sql().schema(detected(table.getSchema())).table(detected(table.getName())) ); + // Enrich existing explicit definitions as well as newly created ones. Declared values + // retain precedence, while omitted schema/table values receive their JDBC metadata value. + objectClass.sql() + .schema(detected(table.getSchema())) + .table(detected(table.getName())); + return objectClass; } private List collectTableActions(SqlTableInfo table) { diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlSearchExecutor.java b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlSearchExecutor.java index 8b066d5..e89fac6 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlSearchExecutor.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlSearchExecutor.java @@ -6,6 +6,7 @@ */ package com.evolveum.polygon.sql.base.search; +import com.evolveum.polygon.conndev.spi.BatchAwareResultHandler; import com.evolveum.polygon.sql.base.SqlBaseContext; import com.evolveum.polygon.sql.base.SqlObjectMapper; import com.evolveum.polygon.sql.base.build.api.SqlAttributeDefinition; @@ -65,12 +66,18 @@ public void execute(Filter filter, ResultsHandler resultsHandler, OperationOptio query.where(predicate); } var rows = query.fetch(); + boolean stopped = false; for (var row : rows) { var object = buildConnectorObject(row, selectedAttributes); if (!resultsHandler.handle(object)) { - return; + stopped = true; + break; } } + BatchAwareResultHandler.batchFinished(resultsHandler); + if (stopped) { + return; + } if (rows.isEmpty() || rows.size() < pageSize) { return; diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlSchemaTranslatorTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlSchemaTranslatorTest.java index 0d9541b..1acaa60 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlSchemaTranslatorTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlSchemaTranslatorTest.java @@ -7,6 +7,7 @@ package com.evolveum.polygon.sql.base; import com.evolveum.polygon.sql.base.build.api.SqlSchema; +import com.evolveum.polygon.sql.base.build.api.SqlSchemaBuilderImpl; import com.evolveum.polygon.sql.base.schema.SqlColumnMeta; import com.evolveum.polygon.sql.base.schema.SqlSchemaDetector; import com.evolveum.polygon.sql.base.schema.SqlSchemaTranslator; @@ -131,6 +132,25 @@ public void testTranslatedModelKeepsNativeSide() throws Exception { assertThat(id.connId().isUpdateable()).isFalse(); } + @Test + public void testDetectedSchemaEnrichesExistingObjectClass() throws Exception { + var builder = new SqlSchemaBuilderImpl(SqlSchemaDetectorIntegrationTest.StubConnector.class, context); + builder.objectClass("account").sql().table("ACCOUNT"); + var table = SqlTableInfo.builder() + .schema("ORACLE") + .name("ACCOUNT") + .addColumn(SqlColumnMeta.builder() + .name("ID") + .typeName("NUMBER") + .primaryKey(true) + .build()) + .build(); + + var schema = translated(new SqlSchemaTranslator(builder, List.of(table))); + + assertThat(schema.objectClass("account").sql().schema().value()).isEqualTo("ORACLE"); + } + @Test public void testForeignKeyBecomesReference() throws Exception { var schema = translated(new SqlSchemaTranslator(discovered())); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationIntegrationTest.java index 16a1846..706d01b 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/search/SqlSearchOperationIntegrationTest.java @@ -6,10 +6,13 @@ */ package com.evolveum.polygon.sql.base.search; +import com.evolveum.polygon.conndev.spi.BatchAwareResultHandler; import com.evolveum.polygon.sql.base.test.SqlIntegrationTestBase; import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.ObjectClass; import org.testng.annotations.Test; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -75,4 +78,30 @@ public void testAllObjectClassesWork() throws Exception { .isNotEmpty().withFailMessage("No results for " + name); } } + + @Test + public void testSearchFlushesBatchAwareResultHandler() { + var objectClass = connector.context().schema().objectClass(new ObjectClass("app_user")); + var delivered = new ArrayList(); + var pending = new ArrayList(); + var handler = new BatchAwareResultHandler() { + @Override + public boolean handle(ConnectorObject object) { + pending.add(object); + return true; + } + + @Override + public void batchFinished() { + delivered.addAll(pending); + pending.clear(); + } + }; + + new SqlSearchExecutor(connector.context(), objectClass) + .execute(null, handler, opts()); + + assertThat(delivered).hasSize(2); + assertThat(pending).isEmpty(); + } } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java index 8723f24..980fcf1 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java @@ -152,11 +152,11 @@ public void testSearchDirMembership() throws Exception { } @Test - public void testSearchAllObjectClassesWork() throws Exception { + public void testSearchAllStandaloneObjectClassesWork() throws Exception { + // orgchart_label is embedded in orgchart_node and therefore has no standalone search handler. for (String name : List.of( "orgchart_type_ref", "orgchart_node", - "orgchart_label", "dir_status_ref", "dir_account", "dir_service", From 844c76a706ddcda1437e6e5290cc5823d3c67a3a Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 11:41:51 +0200 Subject: [PATCH 08/13] Add Oracle native table definition export Read Oracle table and view DDL through DBMS_METADATA.GET_DDL and register Oracle in the database-specific definition provider registry. Cover database detection and development metadata export against both Oracle integration modes. --- .../polygon/sql/base/SqlDatabase.java | 1 + .../OracleSqlTableDefinitionProvider.java | 48 +++++++++++++++++++ .../SqlTableDefinitionProviders.java | 1 + .../polygon/sql/base/SqlDatabaseTest.java | 2 + .../test/OracleConnectorIntegrationTest.java | 40 ++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/OracleSqlTableDefinitionProvider.java diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java index d05323a..77fffda 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java @@ -12,6 +12,7 @@ public enum SqlDatabase { H2("H2"), + ORACLE("Oracle"), POSTGRESQL("PostgreSQL"), UNKNOWN(); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/OracleSqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/OracleSqlTableDefinitionProvider.java new file mode 100644 index 0000000..f3f5479 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/OracleSqlTableDefinitionProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import com.evolveum.polygon.sql.base.SqlDatabase; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +/** Reads native Oracle table and view definitions with {@code DBMS_METADATA.GET_DDL}. */ +public final class OracleSqlTableDefinitionProvider implements SqlTableDefinitionProvider { + + private static final String GET_DDL = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + + @Override + public SqlDatabase database() { + return SqlDatabase.ORACLE; + } + + @Override + public Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException { + var objectType = "VIEW".equalsIgnoreCase(tableType) ? "VIEW" : "TABLE"; + var owner = schema == null || schema.isBlank() + ? connection.getMetaData().getUserName() + : schema; + + try (var statement = connection.prepareStatement(GET_DDL)) { + statement.setString(1, objectType); + statement.setString(2, name); + statement.setString(3, owner); + try (var result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + var definition = result.getString(1); + return definition == null || definition.isBlank() + ? Optional.empty() + : Optional.of(definition.strip()); + } + } + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index a04ea7c..b523a22 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -22,6 +22,7 @@ public static Optional find( SqlDatabase database, SqlConnectorConfiguration configuration) { return List.of( new H2SqlTableDefinitionProvider(), + new OracleSqlTableDefinitionProvider(), new PostgreSqlTableDefinitionProvider(configuration)) .stream() .filter(provider -> provider.database() == database) diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java index f717af9..dcc55b6 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java @@ -18,6 +18,8 @@ public static Object[][] jdbcProductNames() { return new Object[][] { { "H2", SqlDatabase.H2 }, { "h2", SqlDatabase.H2 }, + { "Oracle", SqlDatabase.ORACLE }, + { "oracle", SqlDatabase.ORACLE }, { "PostgreSQL", SqlDatabase.POSTGRESQL }, { "postgresql", SqlDatabase.POSTGRESQL }, { "Unsupported database", SqlDatabase.UNKNOWN }, diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java index 980fcf1..cc55158 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java @@ -8,6 +8,7 @@ import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; import org.identityconnectors.common.security.GuardedString; @@ -167,6 +168,45 @@ public void testSearchAllStandaloneObjectClassesWork() throws Exception { } } + @Test + public void testDevelopmentTableMetadataExport() throws Exception { + assertThat(connector.schema().getObjectClassInfo().stream() + .map(ObjectClassInfo::getType)) + .contains(SqlDevelopmentMode.TABLE_OC_NAME); + + List tables = new ArrayList<>(); + connector.executeQuery( + new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME), null, tables::add, opts()); + + var dirAccount = tables.stream() + .filter(table -> "DIR_ACCOUNT".equalsIgnoreCase(table.getName().getNameValue())) + .findFirst() + .orElseThrow(); + var content = (String) AttributeUtil.getSingleValue( + dirAccount.getAttributeByName(SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE)); + var definition = (String) AttributeUtil.getSingleValue( + dirAccount.getAttributeByName(SqlDevelopmentMode.DEFINITION_ATTRIBUTE)); + + assertThat(AttributeUtil.getSingleValue( + dirAccount.getAttributeByName(SqlDevelopmentMode.SCHEMA_ATTRIBUTE))) + .isEqualTo("ORACLE"); + assertThat(AttributeUtil.getSingleValue( + dirAccount.getAttributeByName(SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE))) + .isEqualTo("TABLE"); + assertThat(definition) + .contains("CREATE TABLE \"ORACLE\".\"DIR_ACCOUNT\"") + .contains("\"ACCOUNT_ID\" VARCHAR2(8)") + .contains("DEFAULT SYSTIMESTAMP") + .contains("CONSTRAINT \"FK_ACCT_STATUS\" FOREIGN KEY"); + assertThat(content) + .contains("\"name\" : \"ACCOUNT_ID\"") + .contains("\"primaryKey\" : true") + .contains("CREATE TABLE \\\"ORACLE\\\".\\\"DIR_ACCOUNT\\\"") + .contains("\"referencedTable\" : \"DIR_STATUS_REF\"") + .contains("\"referencedColumn\" : \"STATUS_CODE\"") + .contains("\"foreignKeyName\" : \"FK_ACCT_STATUS\""); + } + // ── concrete test classes ── /** From 0e2e1de847ff477b71b5a74671b777799adaf3e7 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 11:52:03 +0200 Subject: [PATCH 09/13] Add MariaDB native table definition export Read MariaDB table and view DDL through SHOW CREATE and register MariaDB in the database-specific provider registry. Add an opt-in MariaDB test profile with Docker-backed coverage for columns, defaults, comments, keys, and native definitions. --- base/pom.xml | 7 + .../polygon/sql/base/SqlDatabase.java | 1 + .../MariaDbSqlTableDefinitionProvider.java | 51 ++++++ .../SqlTableDefinitionProviders.java | 1 + .../polygon/sql/base/SqlDatabaseTest.java | 2 + .../test/MariaDbConnectorIntegrationTest.java | 149 ++++++++++++++++++ .../base/test/MariaDbDatabaseInitializer.java | 56 +++++++ .../test/resources/mariadb/basic/schema.sql | 29 ++++ pom.xml | 37 ++++- 9 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java create mode 100644 base/src/test/resources/mariadb/basic/schema.sql diff --git a/base/pom.xml b/base/pom.xml index da446a1..bbc9400 100644 --- a/base/pom.xml +++ b/base/pom.xml @@ -75,6 +75,13 @@ test + + org.mariadb.jdbc + mariadb-java-client + 3.5.7 + test + + com.oracle.database.jdbc ojdbc11 diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java index 77fffda..b653f57 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java @@ -12,6 +12,7 @@ public enum SqlDatabase { H2("H2"), + MARIADB("MariaDB"), ORACLE("Oracle"), POSTGRESQL("PostgreSQL"), UNKNOWN(); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java new file mode 100644 index 0000000..52303ed --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import com.evolveum.polygon.sql.base.SqlDatabase; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +/** Reads native MariaDB table and view definitions with {@code SHOW CREATE}. */ +public final class MariaDbSqlTableDefinitionProvider implements SqlTableDefinitionProvider { + + @Override + public SqlDatabase database() { + return SqlDatabase.MARIADB; + } + + @Override + public Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException { + var objectType = "VIEW".equalsIgnoreCase(tableType) ? "VIEW" : "TABLE"; + var namespace = schema == null || schema.isBlank() ? catalog : schema; + var qualifiedName = namespace == null || namespace.isBlank() + ? quote(connection, name) + : quote(connection, namespace) + "." + quote(connection, name); + + try (var statement = connection.createStatement(); + var result = statement.executeQuery("SHOW CREATE " + objectType + " " + qualifiedName)) { + if (!result.next()) { + return Optional.empty(); + } + var definition = result.getString(2); + return definition == null || definition.isBlank() + ? Optional.empty() + : Optional.of(definition.strip()); + } + } + + private static String quote(Connection connection, String identifier) throws SQLException { + var quote = connection.getMetaData().getIdentifierQuoteString(); + if (quote == null || quote.isBlank()) { + return identifier; + } + return quote + identifier.replace(quote, quote + quote) + quote; + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index b523a22..c53377a 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -22,6 +22,7 @@ public static Optional find( SqlDatabase database, SqlConnectorConfiguration configuration) { return List.of( new H2SqlTableDefinitionProvider(), + new MariaDbSqlTableDefinitionProvider(), new OracleSqlTableDefinitionProvider(), new PostgreSqlTableDefinitionProvider(configuration)) .stream() diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java index dcc55b6..0a65efd 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java @@ -18,6 +18,8 @@ public static Object[][] jdbcProductNames() { return new Object[][] { { "H2", SqlDatabase.H2 }, { "h2", SqlDatabase.H2 }, + { "MariaDB", SqlDatabase.MARIADB }, + { "mariadb", SqlDatabase.MARIADB }, { "Oracle", SqlDatabase.ORACLE }, { "oracle", SqlDatabase.ORACLE }, { "PostgreSQL", SqlDatabase.POSTGRESQL }, diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java new file mode 100644 index 0000000..b9f719e --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; +import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.objects.AttributeUtil; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Integration tests for MariaDB development metadata and native SQL definitions. */ +@Test(singleThreaded = true) +public class MariaDbConnectorIntegrationTest { + + private MariaDbDatabaseInitializer database; + private TestMariaDbConnector connector; + + private static final class TestMariaDbConnector + extends AbstractGroovySqlConnector { + + private TestMariaDbConnector() { + super(false); + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerLoader builder) { + } + + @Override + protected void initializeSchema(SqlSchemaDefinitionLoader loader) { + } + } + + @BeforeMethod + public void setUp() throws Exception { + database = MariaDbDatabaseInitializer.create(); + database.init(); + + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(MariaDbDatabaseInitializer.JDBC_URL); + configuration.setUsername(MariaDbDatabaseInitializer.USERNAME); + configuration.setPassword(new GuardedString( + MariaDbDatabaseInitializer.PASSWORD.toCharArray())); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setValidateConnectionOnBorrow(true); + configuration.setScanTables(true); + configuration.setScanViews(true); + configuration.setDevelopmentMode(true); + + connector = new TestMariaDbConnector(); + connector.init(configuration); + } + + @AfterMethod + public void tearDown() throws Exception { + if (connector != null) { + connector.dispose(); + connector = null; + } + if (database != null) { + database.close(); + database = null; + } + } + + @Test + public void exportsMariaDbTableMetadataAndNativeDefinition() throws Exception { + assertThat(connector.schema().getObjectClassInfo().stream() + .map(info -> info.getType())) + .contains(SqlDevelopmentMode.TABLE_OC_NAME); + + var tables = search(SqlDevelopmentMode.TABLE_OC_NAME); + var appUser = tableNamed(tables, "app_user"); + var membership = tableNamed(tables, "membership"); + var definition = (String) attributeValue(appUser, SqlDevelopmentMode.DEFINITION_ATTRIBUTE); + var appUserContent = (String) attributeValue( + appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + var membershipContent = (String) attributeValue( + membership, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(attributeValue(appUser, SqlDevelopmentMode.CATALOG_ATTRIBUTE)) + .isEqualTo("connector_sql"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)) + .isEqualTo("TABLE"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.REMARKS_ATTRIBUTE)) + .isEqualTo("Application users"); + assertThat(definition) + .contains("CREATE TABLE `app_user`") + .contains("`username` varchar(50) NOT NULL DEFAULT 'anonymous'") + .contains("PRIMARY KEY (`id`)"); + assertThat(appUserContent) + .contains("\"name\" : \"id\"") + .contains("\"primaryKey\" : true") + .contains("\"autoIncrement\" : true") + .contains("\"defaultValue\" : \"'anonymous'\"") + .contains("\"remarks\" : \"Application login name\""); + assertThat(membershipContent) + .contains("\"referencedTable\" : \"app_user\"") + .contains("\"referencedColumn\" : \"id\"") + .contains("\"foreignKeyName\" : \"fk_membership_user\""); + } + + @Test + public void exportsMariaDbViewDefinition() throws Exception { + var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME), "app_user_view"); + + assertThat(attributeValue(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) attributeValue(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("VIEW `app_user_view` AS") + .containsIgnoringCase("FROM `app_user`"); + } + + private List search(String objectClass) throws Exception { + List results = new ArrayList<>(); + connector.executeQuery(new ObjectClass(objectClass), null, results::add, + new OperationOptions(Collections.emptyMap())); + return results; + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(); + } + + private static Object attributeValue(ConnectorObject object, String name) { + return AttributeUtil.getSingleValue(object.getAttributeByName(name)); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java new file mode 100644 index 0000000..0e26fa7 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Objects; + +/** Initializes the external MariaDB instance used by opt-in integration tests. */ +public final class MariaDbDatabaseInitializer implements AutoCloseable { + + public static final String JDBC_URL = "jdbc:mariadb://localhost:3307/connector_sql"; + public static final String USERNAME = "connector"; + public static final String PASSWORD = "connector123"; + + private static final String SCHEMA_RESOURCE = "mariadb/basic/schema.sql"; + + private final Connection connection; + + private MariaDbDatabaseInitializer() throws SQLException { + connection = DriverManager.getConnection(JDBC_URL, USERNAME, PASSWORD); + } + + public static MariaDbDatabaseInitializer create() throws SQLException { + return new MariaDbDatabaseInitializer(); + } + + public void init() throws IOException, SQLException { + var stream = Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream(SCHEMA_RESOURCE), + "Resource not found: " + SCHEMA_RESOURCE); + try (stream) { + var sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + for (var statementSql : sql.split(";")) { + if (statementSql.isBlank()) { + continue; + } + try (var statement = connection.createStatement()) { + statement.execute(statementSql); + } + } + } + } + + @Override + public void close() throws SQLException { + connection.close(); + } +} diff --git a/base/src/test/resources/mariadb/basic/schema.sql b/base/src/test/resources/mariadb/basic/schema.sql new file mode 100644 index 0000000..05b8fc2 --- /dev/null +++ b/base/src/test/resources/mariadb/basic/schema.sql @@ -0,0 +1,29 @@ +DROP VIEW IF EXISTS app_user_view; +DROP TABLE IF EXISTS membership; +DROP TABLE IF EXISTS app_group; +DROP TABLE IF EXISTS app_user; + +CREATE TABLE app_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL DEFAULT 'anonymous' COMMENT 'Application login name', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_app_user PRIMARY KEY (id) +) COMMENT = 'Application users'; + +CREATE TABLE app_group ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + CONSTRAINT pk_app_group PRIMARY KEY (id), + CONSTRAINT uq_app_group_name UNIQUE (name) +); + +CREATE TABLE membership ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_membership PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES app_user (id), + CONSTRAINT fk_membership_group FOREIGN KEY (group_id) REFERENCES app_group (id) +); + +CREATE VIEW app_user_view AS +SELECT id, username, created_at FROM app_user; diff --git a/pom.xml b/pom.xml index 852836f..42e542d 100644 --- a/pom.xml +++ b/pom.xml @@ -160,11 +160,10 @@ **/*Tests.java **/*TestCase.java - - - + **/*Oracle*Test.java + **/*MariaDb*Test.java @@ -249,8 +248,8 @@ **/*Tests.java **/*TestCase.java - + **/*MariaDb*Test.java @@ -262,6 +261,36 @@ connector + + mariadb + + false + + mariadb.tests + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/Test*.java + **/*Test.java + **/*Tests.java + **/*TestCase.java + + + **/*Oracle*Test.java + + + + + + + sbom From e676963e8973865184766df106c3ec8ad396cb3e Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 12:00:38 +0200 Subject: [PATCH 10/13] Add MySQL native table definition export Share SHOW CREATE table and view definition handling between MySQL and MariaDB while preserving separate database detection. Add an opt-in MySQL profile with Docker-backed metadata coverage and keep external database suites isolated. --- .../polygon/sql/base/SqlDatabase.java | 1 + ...> MySqlFamilyTableDefinitionProvider.java} | 15 +- .../SqlTableDefinitionProviders.java | 3 +- .../polygon/sql/base/SqlDatabaseTest.java | 2 + .../test/MySqlConnectorIntegrationTest.java | 149 ++++++++++++++++++ .../base/test/MySqlDatabaseInitializer.java | 57 +++++++ .../src/test/resources/mysql/basic/schema.sql | 29 ++++ pom.xml | 35 +++- 8 files changed, 286 insertions(+), 5 deletions(-) rename base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/{MariaDbSqlTableDefinitionProvider.java => MySqlFamilyTableDefinitionProvider.java} (76%) create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java create mode 100644 base/src/test/resources/mysql/basic/schema.sql diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java index b653f57..c34c197 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java @@ -13,6 +13,7 @@ public enum SqlDatabase { H2("H2"), MARIADB("MariaDB"), + MYSQL("MySQL"), ORACLE("Oracle"), POSTGRESQL("PostgreSQL"), UNKNOWN(); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MySqlFamilyTableDefinitionProvider.java similarity index 76% rename from base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java rename to base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MySqlFamilyTableDefinitionProvider.java index 52303ed..590d62c 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MariaDbSqlTableDefinitionProvider.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/MySqlFamilyTableDefinitionProvider.java @@ -12,12 +12,21 @@ import java.sql.SQLException; import java.util.Optional; -/** Reads native MariaDB table and view definitions with {@code SHOW CREATE}. */ -public final class MariaDbSqlTableDefinitionProvider implements SqlTableDefinitionProvider { +/** Reads native MySQL and MariaDB table and view definitions with {@code SHOW CREATE}. */ +public final class MySqlFamilyTableDefinitionProvider implements SqlTableDefinitionProvider { + + private final SqlDatabase database; + + public MySqlFamilyTableDefinitionProvider(SqlDatabase database) { + if (database != SqlDatabase.MYSQL && database != SqlDatabase.MARIADB) { + throw new IllegalArgumentException("Unsupported MySQL-family database: " + database); + } + this.database = database; + } @Override public SqlDatabase database() { - return SqlDatabase.MARIADB; + return database; } @Override diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index c53377a..07b992f 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -22,7 +22,8 @@ public static Optional find( SqlDatabase database, SqlConnectorConfiguration configuration) { return List.of( new H2SqlTableDefinitionProvider(), - new MariaDbSqlTableDefinitionProvider(), + new MySqlFamilyTableDefinitionProvider(SqlDatabase.MARIADB), + new MySqlFamilyTableDefinitionProvider(SqlDatabase.MYSQL), new OracleSqlTableDefinitionProvider(), new PostgreSqlTableDefinitionProvider(configuration)) .stream() diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java index 0a65efd..a25bebe 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java @@ -20,6 +20,8 @@ public static Object[][] jdbcProductNames() { { "h2", SqlDatabase.H2 }, { "MariaDB", SqlDatabase.MARIADB }, { "mariadb", SqlDatabase.MARIADB }, + { "MySQL", SqlDatabase.MYSQL }, + { "mysql", SqlDatabase.MYSQL }, { "Oracle", SqlDatabase.ORACLE }, { "oracle", SqlDatabase.ORACLE }, { "PostgreSQL", SqlDatabase.POSTGRESQL }, diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java new file mode 100644 index 0000000..cff446e --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; +import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.objects.AttributeUtil; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Integration tests for MySQL development metadata and native SQL definitions. */ +@Test(singleThreaded = true) +public class MySqlConnectorIntegrationTest { + + private MySqlDatabaseInitializer database; + private TestMySqlConnector connector; + + private static final class TestMySqlConnector + extends AbstractGroovySqlConnector { + + private TestMySqlConnector() { + super(false); + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerLoader builder) { + } + + @Override + protected void initializeSchema(SqlSchemaDefinitionLoader loader) { + } + } + + @BeforeMethod + public void setUp() throws Exception { + database = MySqlDatabaseInitializer.create(); + database.init(); + + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(MySqlDatabaseInitializer.JDBC_URL); + configuration.setUsername(MySqlDatabaseInitializer.USERNAME); + configuration.setPassword(new GuardedString( + MySqlDatabaseInitializer.PASSWORD.toCharArray())); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setValidateConnectionOnBorrow(true); + configuration.setScanTables(true); + configuration.setScanViews(true); + configuration.setDevelopmentMode(true); + + connector = new TestMySqlConnector(); + connector.init(configuration); + } + + @AfterMethod + public void tearDown() throws Exception { + if (connector != null) { + connector.dispose(); + connector = null; + } + if (database != null) { + database.close(); + database = null; + } + } + + @Test + public void exportsMySqlTableMetadataAndNativeDefinition() throws Exception { + assertThat(connector.schema().getObjectClassInfo().stream() + .map(info -> info.getType())) + .contains(SqlDevelopmentMode.TABLE_OC_NAME); + + var tables = search(SqlDevelopmentMode.TABLE_OC_NAME); + var appUser = tableNamed(tables, "app_user"); + var membership = tableNamed(tables, "membership"); + var definition = (String) attributeValue(appUser, SqlDevelopmentMode.DEFINITION_ATTRIBUTE); + var appUserContent = (String) attributeValue( + appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + var membershipContent = (String) attributeValue( + membership, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(attributeValue(appUser, SqlDevelopmentMode.CATALOG_ATTRIBUTE)) + .isEqualTo("connector_sql"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)) + .isEqualTo("TABLE"); + assertThat(attributeValue(appUser, SqlDevelopmentMode.REMARKS_ATTRIBUTE)) + .isEqualTo("Application users"); + assertThat(definition) + .contains("CREATE TABLE `app_user`") + .contains("`username` varchar(50) NOT NULL DEFAULT 'anonymous'") + .contains("PRIMARY KEY (`id`)"); + assertThat(appUserContent) + .contains("\"name\" : \"id\"") + .contains("\"primaryKey\" : true") + .contains("\"autoIncrement\" : true") + .contains("\"defaultValue\" : \"anonymous\"") + .contains("\"remarks\" : \"Application login name\""); + assertThat(membershipContent) + .contains("\"referencedTable\" : \"app_user\"") + .contains("\"referencedColumn\" : \"id\"") + .contains("\"foreignKeyName\" : \"fk_membership_user\""); + } + + @Test + public void exportsMySqlViewDefinition() throws Exception { + var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME), "app_user_view"); + + assertThat(attributeValue(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) attributeValue(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("VIEW `app_user_view` AS") + .containsIgnoringCase("FROM `app_user`"); + } + + private List search(String objectClass) throws Exception { + List results = new ArrayList<>(); + connector.executeQuery(new ObjectClass(objectClass), null, results::add, + new OperationOptions(Collections.emptyMap())); + return results; + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(); + } + + private static Object attributeValue(ConnectorObject object, String name) { + return AttributeUtil.getSingleValue(object.getAttributeByName(name)); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java new file mode 100644 index 0000000..f47f954 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Objects; + +/** Initializes the external MySQL instance used by opt-in integration tests. */ +public final class MySqlDatabaseInitializer implements AutoCloseable { + + public static final String JDBC_URL = + "jdbc:mysql://localhost:3308/connector_sql?allowPublicKeyRetrieval=true&useSSL=false"; + public static final String USERNAME = "connector"; + public static final String PASSWORD = "connector123"; + + private static final String SCHEMA_RESOURCE = "mysql/basic/schema.sql"; + + private final Connection connection; + + private MySqlDatabaseInitializer() throws SQLException { + connection = DriverManager.getConnection(JDBC_URL, USERNAME, PASSWORD); + } + + public static MySqlDatabaseInitializer create() throws SQLException { + return new MySqlDatabaseInitializer(); + } + + public void init() throws IOException, SQLException { + var stream = Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream(SCHEMA_RESOURCE), + "Resource not found: " + SCHEMA_RESOURCE); + try (stream) { + var sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + for (var statementSql : sql.split(";")) { + if (statementSql.isBlank()) { + continue; + } + try (var statement = connection.createStatement()) { + statement.execute(statementSql); + } + } + } + } + + @Override + public void close() throws SQLException { + connection.close(); + } +} diff --git a/base/src/test/resources/mysql/basic/schema.sql b/base/src/test/resources/mysql/basic/schema.sql new file mode 100644 index 0000000..05b8fc2 --- /dev/null +++ b/base/src/test/resources/mysql/basic/schema.sql @@ -0,0 +1,29 @@ +DROP VIEW IF EXISTS app_user_view; +DROP TABLE IF EXISTS membership; +DROP TABLE IF EXISTS app_group; +DROP TABLE IF EXISTS app_user; + +CREATE TABLE app_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL DEFAULT 'anonymous' COMMENT 'Application login name', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_app_user PRIMARY KEY (id) +) COMMENT = 'Application users'; + +CREATE TABLE app_group ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + CONSTRAINT pk_app_group PRIMARY KEY (id), + CONSTRAINT uq_app_group_name UNIQUE (name) +); + +CREATE TABLE membership ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_membership PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES app_user (id), + CONSTRAINT fk_membership_group FOREIGN KEY (group_id) REFERENCES app_group (id) +); + +CREATE VIEW app_user_view AS +SELECT id, username, created_at FROM app_user; diff --git a/pom.xml b/pom.xml index 42e542d..523b06b 100644 --- a/pom.xml +++ b/pom.xml @@ -164,6 +164,7 @@ **/*Oracle*Test.java **/*MariaDb*Test.java + **/*MySql*Test.java @@ -241,7 +242,6 @@ org.apache.maven.plugins maven-surefire-plugin - **/Test*.java **/*Test.java @@ -250,6 +250,7 @@ **/*MariaDb*Test.java + **/*MySql*Test.java @@ -284,6 +285,38 @@ **/*Oracle*Test.java + **/*MySql*Test.java + + + + + + + + + mysql + + false + + mysql.tests + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/Test*.java + **/*Test.java + **/*Tests.java + **/*TestCase.java + + + **/*Oracle*Test.java + **/*MariaDb*Test.java From 234b029ea328d689238933e02fe377a63a01d2ae Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 12:30:20 +0200 Subject: [PATCH 11/13] Add SQLite native table definition export Read SQLite table and view definitions from sqlite_schema and register SQLite in the database-specific provider registry. Add embedded file-backed integration coverage for defaults, auto-increment columns, keys, and native definitions. --- .../polygon/sql/base/SqlDatabase.java | 1 + .../SqlTableDefinitionProviders.java | 3 +- .../SqliteTableDefinitionProvider.java | 44 ++++++ .../polygon/sql/base/SqlDatabaseTest.java | 2 + .../test/SqliteConnectorIntegrationTest.java | 142 ++++++++++++++++++ .../base/test/SqliteDatabaseInitializer.java | 63 ++++++++ .../test/resources/sqlite/basic/schema.sql | 26 ++++ 7 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqliteTableDefinitionProvider.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteConnectorIntegrationTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteDatabaseInitializer.java create mode 100644 base/src/test/resources/sqlite/basic/schema.sql diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java index c34c197..4704fa3 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlDatabase.java @@ -16,6 +16,7 @@ public enum SqlDatabase { MYSQL("MySQL"), ORACLE("Oracle"), POSTGRESQL("PostgreSQL"), + SQLITE("SQLite"), UNKNOWN(); private final List jdbcProductNames; diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java index 07b992f..5585eb0 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqlTableDefinitionProviders.java @@ -25,7 +25,8 @@ public static Optional find( new MySqlFamilyTableDefinitionProvider(SqlDatabase.MARIADB), new MySqlFamilyTableDefinitionProvider(SqlDatabase.MYSQL), new OracleSqlTableDefinitionProvider(), - new PostgreSqlTableDefinitionProvider(configuration)) + new PostgreSqlTableDefinitionProvider(configuration), + new SqliteTableDefinitionProvider()) .stream() .filter(provider -> provider.database() == database) .findFirst(); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqliteTableDefinitionProvider.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqliteTableDefinitionProvider.java new file mode 100644 index 0000000..b2c7005 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/definition/SqliteTableDefinitionProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.schema.definition; + +import com.evolveum.polygon.sql.base.SqlDatabase; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +/** Reads native SQLite table and view definitions from {@code sqlite_schema}. */ +public final class SqliteTableDefinitionProvider implements SqlTableDefinitionProvider { + + private static final String SELECT_DEFINITION = + "SELECT sql FROM sqlite_schema WHERE type = ? AND name = ?"; + + @Override + public SqlDatabase database() { + return SqlDatabase.SQLITE; + } + + @Override + public Optional readDefinition(Connection connection, String catalog, String schema, + String name, String tableType) throws SQLException { + var objectType = "VIEW".equalsIgnoreCase(tableType) ? "view" : "table"; + try (var statement = connection.prepareStatement(SELECT_DEFINITION)) { + statement.setString(1, objectType); + statement.setString(2, name); + try (var result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + var definition = result.getString(1); + return definition == null || definition.isBlank() + ? Optional.empty() + : Optional.of(definition.strip()); + } + } + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java index a25bebe..9828a2f 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/SqlDatabaseTest.java @@ -26,6 +26,8 @@ public static Object[][] jdbcProductNames() { { "oracle", SqlDatabase.ORACLE }, { "PostgreSQL", SqlDatabase.POSTGRESQL }, { "postgresql", SqlDatabase.POSTGRESQL }, + { "SQLite", SqlDatabase.SQLITE }, + { "sqlite", SqlDatabase.SQLITE }, { "Unsupported database", SqlDatabase.UNKNOWN }, { null, SqlDatabase.UNKNOWN } }; diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteConnectorIntegrationTest.java new file mode 100644 index 0000000..9bfcdb7 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteConnectorIntegrationTest.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; +import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.objects.AttributeUtil; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Integration tests for SQLite development metadata and native SQL definitions. */ +@Test(singleThreaded = true) +public class SqliteConnectorIntegrationTest { + + private SqliteDatabaseInitializer database; + private TestSqliteConnector connector; + + private static final class TestSqliteConnector + extends AbstractGroovySqlConnector { + + private TestSqliteConnector() { + super(false); + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerLoader builder) { + } + + @Override + protected void initializeSchema(SqlSchemaDefinitionLoader loader) { + } + } + + @BeforeMethod + public void setUp() throws Exception { + database = SqliteDatabaseInitializer.create(); + database.init(); + + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(database.jdbcUrl()); + configuration.setUsername("sqlite"); + configuration.setPassword(new GuardedString(new char[0])); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setValidateConnectionOnBorrow(true); + configuration.setScanTables(true); + configuration.setScanViews(true); + configuration.setDevelopmentMode(true); + + connector = new TestSqliteConnector(); + connector.init(configuration); + } + + @AfterMethod + public void tearDown() throws Exception { + if (connector != null) { + connector.dispose(); + connector = null; + } + if (database != null) { + database.close(); + database = null; + } + } + + @Test + public void exportsSqliteTableMetadataAndNativeDefinition() throws Exception { + assertThat(connector.schema().getObjectClassInfo().stream() + .map(info -> info.getType())) + .contains(SqlDevelopmentMode.TABLE_OC_NAME); + + var tables = search(SqlDevelopmentMode.TABLE_OC_NAME); + var appUser = tableNamed(tables, "app_user"); + var membership = tableNamed(tables, "membership"); + var definition = (String) attributeValue(appUser, SqlDevelopmentMode.DEFINITION_ATTRIBUTE); + var appUserContent = (String) attributeValue( + appUser, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + var membershipContent = (String) attributeValue( + membership, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE); + + assertThat(attributeValue(appUser, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)) + .isEqualTo("TABLE"); + assertThat(definition) + .contains("CREATE TABLE app_user") + .contains("id INTEGER PRIMARY KEY AUTOINCREMENT") + .contains("username TEXT NOT NULL DEFAULT 'anonymous'"); + assertThat(appUserContent) + .contains("\"name\" : \"id\"") + .contains("\"primaryKey\" : true") + .contains("\"autoIncrement\" : true") + .contains("\"defaultValue\" : \"'anonymous'\""); + assertThat(membershipContent) + .contains("\"referencedTable\" : \"app_user\"") + .contains("\"referencedColumn\" : \"id\""); + } + + @Test + public void exportsSqliteViewDefinition() throws Exception { + var view = tableNamed(search(SqlDevelopmentMode.TABLE_OC_NAME), "app_user_view"); + + assertThat(attributeValue(view, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE)).isEqualTo("VIEW"); + assertThat((String) attributeValue(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .contains("CREATE VIEW app_user_view AS") + .contains("FROM app_user"); + } + + private List search(String objectClass) throws Exception { + List results = new ArrayList<>(); + connector.executeQuery(new ObjectClass(objectClass), null, results::add, + new OperationOptions(Collections.emptyMap())); + return results; + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(); + } + + private static Object attributeValue(ConnectorObject object, String name) { + return AttributeUtil.getSingleValue(object.getAttributeByName(name)); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteDatabaseInitializer.java new file mode 100644 index 0000000..24f8862 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/SqliteDatabaseInitializer.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + * + */ +package com.evolveum.polygon.sql.base.test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Objects; + +/** Initializes an embedded file-backed SQLite database for integration tests. */ +public final class SqliteDatabaseInitializer implements AutoCloseable { + + private static final String SCHEMA_RESOURCE = "sqlite/basic/schema.sql"; + + private final Path databaseFile; + private final String jdbcUrl; + private final Connection connection; + + private SqliteDatabaseInitializer() throws IOException, SQLException { + databaseFile = Files.createTempFile("connector-sql-sqlite-", ".db"); + jdbcUrl = "jdbc:sqlite:" + databaseFile.toAbsolutePath(); + connection = DriverManager.getConnection(jdbcUrl); + } + + public static SqliteDatabaseInitializer create() throws IOException, SQLException { + return new SqliteDatabaseInitializer(); + } + + public void init() throws IOException, SQLException { + var stream = Objects.requireNonNull( + Thread.currentThread().getContextClassLoader().getResourceAsStream(SCHEMA_RESOURCE), + "Resource not found: " + SCHEMA_RESOURCE); + try (stream) { + var sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + for (var statementSql : sql.split(";")) { + if (statementSql.isBlank()) { + continue; + } + try (var statement = connection.createStatement()) { + statement.execute(statementSql); + } + } + } + } + + public String jdbcUrl() { + return jdbcUrl; + } + + @Override + public void close() throws IOException, SQLException { + connection.close(); + Files.deleteIfExists(databaseFile); + } +} diff --git a/base/src/test/resources/sqlite/basic/schema.sql b/base/src/test/resources/sqlite/basic/schema.sql new file mode 100644 index 0000000..34e9af3 --- /dev/null +++ b/base/src/test/resources/sqlite/basic/schema.sql @@ -0,0 +1,26 @@ +DROP VIEW IF EXISTS app_user_view; +DROP TABLE IF EXISTS membership; +DROP TABLE IF EXISTS app_group; +DROP TABLE IF EXISTS app_user; + +CREATE TABLE app_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL DEFAULT 'anonymous', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE app_group ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE membership ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + CONSTRAINT pk_membership PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES app_user (id), + CONSTRAINT fk_membership_group FOREIGN KEY (group_id) REFERENCES app_group (id) +); + +CREATE VIEW app_user_view AS +SELECT id, username, created_at FROM app_user; From 95af93d44e85576876932f7a347338871b5e0fc4 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Wed, 26 Aug 2026 13:27:42 +0200 Subject: [PATCH 12/13] Add shared SQL database contract tests Run the same CRUD, schema, UID, view, constraint, and development metadata checks across H2, PostgreSQL, SQLite, Oracle, MariaDB, and MySQL. Add optional and strict external-database modes, configurable connection settings, and portability fixes exposed by the shared coverage. --- .../base/build/api/SqlSchemaBuilderImpl.java | 22 +- .../sql/base/schema/SqlSchemaDetector.java | 26 +- .../sql/base/write/SqlCreateOperation.java | 2 +- .../base/write/SqlWriteOperationSupport.java | 10 +- .../test/MariaDbConnectorIntegrationTest.java | 4 +- .../base/test/MariaDbDatabaseInitializer.java | 19 +- .../test/MySqlConnectorIntegrationTest.java | 4 +- .../base/test/MySqlDatabaseInitializer.java | 20 +- .../test/OracleConnectorIntegrationTest.java | 4 +- .../base/test/OracleDatabaseInitializer.java | 29 +- .../AbstractSqlConnectorContractTest.java | 427 ++++++++++++++++++ .../test/contract/DatabaseCapabilities.java | 15 + .../contract/ExternalDatabaseTestSupport.java | 40 ++ .../contract/H2ConnectorContractTest.java | 17 + .../test/contract/JdbcSqlTestDatabase.java | 130 ++++++ .../MariaDbConnectorContractTest.java | 17 + .../contract/MySqlConnectorContractTest.java | 17 + .../contract/OracleConnectorContractTest.java | 17 + .../PostgresConnectorContractTest.java | 17 + .../base/test/contract/SqlTestDatabase.java | 24 + .../base/test/contract/SqlTestDatabases.java | 138 ++++++ .../contract/SqliteConnectorContractTest.java | 17 + .../resources/database/h2/contract-schema.sql | 46 ++ .../database/mariadb/contract-schema.sql | 47 ++ .../database/mysql/contract-schema.sql | 47 ++ .../database/oracle/contract-schema.sql | 49 ++ .../database/postgresql/contract-schema.sql | 46 ++ .../database/sqlite/contract-schema.sql | 44 ++ pom.xml | 48 ++ 29 files changed, 1317 insertions(+), 26 deletions(-) create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/AbstractSqlConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/DatabaseCapabilities.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/ExternalDatabaseTestSupport.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/H2ConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/JdbcSqlTestDatabase.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MariaDbConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MySqlConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/OracleConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/PostgresConnectorContractTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabase.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabases.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqliteConnectorContractTest.java create mode 100644 base/src/test/resources/database/h2/contract-schema.sql create mode 100644 base/src/test/resources/database/mariadb/contract-schema.sql create mode 100644 base/src/test/resources/database/mysql/contract-schema.sql create mode 100644 base/src/test/resources/database/oracle/contract-schema.sql create mode 100644 base/src/test/resources/database/postgresql/contract-schema.sql create mode 100644 base/src/test/resources/database/sqlite/contract-schema.sql diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlSchemaBuilderImpl.java b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlSchemaBuilderImpl.java index e8797e4..0262e24 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlSchemaBuilderImpl.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlSchemaBuilderImpl.java @@ -12,6 +12,7 @@ import com.evolveum.polygon.conndev.schema.BaseSchemaBuilder; import com.evolveum.polygon.sql.base.schema.SqlSchemaDetector; import com.querydsl.core.types.PathMetadataFactory; +import com.querydsl.core.types.Path; import com.querydsl.sql.RelationalPathBase; import groovy.lang.Closure; import org.identityconnectors.framework.common.objects.Name; @@ -105,7 +106,26 @@ public String getTableName() { public RelationalPathBase pathAlias(String alias) { - return new RelationalPathBase<>(Object.class, PathMetadataFactory.forVariable(alias), schema.value(), table.value()); + var schemaName = schema.value(); + if (schemaName == null || schemaName.isBlank() || "null".equalsIgnoreCase(schemaName)) { + schemaName = null; + } + return new DynamicRelationalPath( + Object.class, PathMetadataFactory.forVariable(alias), schemaName, table.value()); + } + } + + /** Dynamic QueryDSL path that can expose a detected generated key to JDBC drivers. */ + public static final class DynamicRelationalPath extends RelationalPathBase { + + private DynamicRelationalPath( + Class type, com.querydsl.core.types.PathMetadata metadata, + String schema, String table) { + super(type, metadata, schema, table); + } + + public void registerPrimaryKey(Path path) { + createPrimaryKey(path); } } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java index 13b0eb4..b4e68bb 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlSchemaDetector.java @@ -13,6 +13,7 @@ import com.evolveum.polygon.sql.base.schema.definition.SqlTableDefinitionProviders; import com.querydsl.sql.Configuration; import com.querydsl.sql.H2Templates; +import com.querydsl.sql.MySQLTemplates; import com.querydsl.sql.SQLTemplates; import com.querydsl.sql.SQLTemplatesRegistry; @@ -50,16 +51,27 @@ public SqlSchemaDetector(SqlBaseContext context) throws SQLException { try (var wrapper = context.getConnection()) { var meta = wrapper.getConnection().getMetaData(); + var database = SqlDatabase.fromJdbcProductName(meta.getDatabaseProductName()); var templatesBuilder = new SQLTemplatesRegistry().getBuilder(meta); - // Preserve discovered schemas and case-sensitive identifiers in generated SQL. - var templatesFromRegistry = templatesBuilder != null - ? templatesBuilder.printSchema().quote().build() - : SQLTemplates.DEFAULT; + SQLTemplates templatesFromRegistry = SQLTemplates.DEFAULT; + if (templatesBuilder != null) { + // SQLite and the MySQL family report no JDBC schema for ordinary tables. + // Printing a missing schema makes QueryDSL generate "null"."table". + if (database != SqlDatabase.SQLITE + && database != SqlDatabase.MYSQL + && database != SqlDatabase.MARIADB) { + templatesBuilder.printSchema(); + } + templatesFromRegistry = templatesBuilder.quote().build(); + } // For H2, use H2Templates with no quoting - unqualified column paths avoid table.column issues - var database = SqlDatabase.fromJdbcProductName(meta.getDatabaseProductName()); if (database == SqlDatabase.H2) { templatesFromRegistry = new H2Templates(false); + } else if (database == SqlDatabase.MARIADB) { + // QueryDSL's registry does not recognize every MariaDB driver product name and + // can fall back to ANSI double quotes, which MariaDB does not accept by default. + templatesFromRegistry = MySQLTemplates.builder().quote().build(); } templates = templatesFromRegistry; querydslConfig = new Configuration(templates); @@ -312,7 +324,9 @@ private List getColumnMetas(Connection conn, Table table) throws .size(columnSize) .javaType(javaType) .valueMapping(valueMapping) - .nullable(isNullable(rawNullable)) + // Some drivers (notably SQLite) report an INTEGER PRIMARY KEY as + // nullable even though a primary-key value can never be null. + .nullable(!isPk && isNullable(rawNullable)) .primaryKey(isPk) .autoIncrement(isAutoInc(rawAutoInc)) .unique(isPk || uniqueCols.contains(colName)) diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java index 6508245..00e2b5d 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java @@ -59,7 +59,7 @@ public ConnectorObject create(Set createAttributes, OperationOptions } var generatedPath = generatedKeyPath(uidDefinition.sql(), table); uid = support.generatedUid(uidDefinition.sql(), - support.generatedKey(insert, generatedPath), table, columnValues); + support.generatedKey(insert, table, generatedPath), table, columnValues); } var created = support.findByUid(connection, uid, false); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java index 4ae4b9a..9c50f84 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java @@ -11,10 +11,12 @@ import com.evolveum.polygon.sql.base.build.api.SqlAttributeDefinition; import com.evolveum.polygon.sql.base.build.api.SqlAttributeMapping; import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.evolveum.polygon.sql.base.build.api.SqlSchemaBuilderImpl; import com.evolveum.polygon.sql.base.connection.SqlConnection; import com.querydsl.core.types.Path; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.sql.RelationalPathBase; +import com.querydsl.sql.OracleTemplates; import com.querydsl.sql.dml.SQLInsertClause; import com.querydsl.sql.dml.SQLUpdateClause; import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; @@ -256,7 +258,13 @@ Uid generatedUid(SqlAttributeMapping mapping, Object generatedKey, throw new ConnectorException("Unsupported UID mapping " + mapping.getClass().getName()); } - Object generatedKey(SQLInsertClause insert, Path path) { + Object generatedKey(SQLInsertClause insert, RelationalPathBase table, Path path) { + if (context.getSqlTemplates() instanceof OracleTemplates + && table instanceof SqlSchemaBuilderImpl.DynamicRelationalPath dynamicPath) { + // Oracle otherwise returns ROWID from RETURN_GENERATED_KEYS. Supplying the detected + // primary-key column makes the driver return the generated identity value instead. + dynamicPath.registerPrimaryKey(path); + } return executeWithKey(insert, path); } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java index b9f719e..783cb8e 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbConnectorIntegrationTest.java @@ -11,6 +11,7 @@ import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import com.evolveum.polygon.sql.base.test.contract.ExternalDatabaseTestSupport; import org.identityconnectors.common.security.GuardedString; import org.identityconnectors.framework.common.objects.AttributeUtil; import org.identityconnectors.framework.common.objects.ConnectorObject; @@ -51,7 +52,8 @@ protected void initializeSchema(SqlSchemaDefinitionLoader loader) { @BeforeMethod public void setUp() throws Exception { - database = MariaDbDatabaseInitializer.create(); + database = ExternalDatabaseTestSupport.connect( + "MariaDB", MariaDbDatabaseInitializer::create); database.init(); var configuration = new SqlConnectorConfiguration(); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java index 0e26fa7..6c2823f 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MariaDbDatabaseInitializer.java @@ -16,9 +16,13 @@ /** Initializes the external MariaDB instance used by opt-in integration tests. */ public final class MariaDbDatabaseInitializer implements AutoCloseable { - public static final String JDBC_URL = "jdbc:mariadb://localhost:3307/connector_sql"; - public static final String USERNAME = "connector"; - public static final String PASSWORD = "connector123"; + public static final String JDBC_URL = setting( + "sql.test.mariadb.url", "SQL_TEST_MARIADB_URL", + "jdbc:mariadb://localhost:3307/connector_sql"); + public static final String USERNAME = setting( + "sql.test.mariadb.username", "SQL_TEST_MARIADB_USERNAME", "connector"); + public static final String PASSWORD = setting( + "sql.test.mariadb.password", "SQL_TEST_MARIADB_PASSWORD", "connector123"); private static final String SCHEMA_RESOURCE = "mariadb/basic/schema.sql"; @@ -53,4 +57,13 @@ public void init() throws IOException, SQLException { public void close() throws SQLException { connection.close(); } + + private static String setting(String property, String environment, String defaultValue) { + var configured = System.getProperty(property); + if (configured != null && !configured.isBlank()) { + return configured; + } + configured = System.getenv(environment); + return configured != null && !configured.isBlank() ? configured : defaultValue; + } } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java index cff446e..5e598d1 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlConnectorIntegrationTest.java @@ -11,6 +11,7 @@ import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import com.evolveum.polygon.sql.base.test.contract.ExternalDatabaseTestSupport; import org.identityconnectors.common.security.GuardedString; import org.identityconnectors.framework.common.objects.AttributeUtil; import org.identityconnectors.framework.common.objects.ConnectorObject; @@ -51,7 +52,8 @@ protected void initializeSchema(SqlSchemaDefinitionLoader loader) { @BeforeMethod public void setUp() throws Exception { - database = MySqlDatabaseInitializer.create(); + database = ExternalDatabaseTestSupport.connect( + "MySQL", MySqlDatabaseInitializer::create); database.init(); var configuration = new SqlConnectorConfiguration(); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java index f47f954..83dd264 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/MySqlDatabaseInitializer.java @@ -16,10 +16,13 @@ /** Initializes the external MySQL instance used by opt-in integration tests. */ public final class MySqlDatabaseInitializer implements AutoCloseable { - public static final String JDBC_URL = - "jdbc:mysql://localhost:3308/connector_sql?allowPublicKeyRetrieval=true&useSSL=false"; - public static final String USERNAME = "connector"; - public static final String PASSWORD = "connector123"; + public static final String JDBC_URL = setting( + "sql.test.mysql.url", "SQL_TEST_MYSQL_URL", + "jdbc:mysql://localhost:3308/connector_sql?allowPublicKeyRetrieval=true&useSSL=false"); + public static final String USERNAME = setting( + "sql.test.mysql.username", "SQL_TEST_MYSQL_USERNAME", "connector"); + public static final String PASSWORD = setting( + "sql.test.mysql.password", "SQL_TEST_MYSQL_PASSWORD", "connector123"); private static final String SCHEMA_RESOURCE = "mysql/basic/schema.sql"; @@ -54,4 +57,13 @@ public void init() throws IOException, SQLException { public void close() throws SQLException { connection.close(); } + + private static String setting(String property, String environment, String defaultValue) { + var configured = System.getProperty(property); + if (configured != null && !configured.isBlank()) { + return configured; + } + configured = System.getenv(environment); + return configured != null && !configured.isBlank() ? configured : defaultValue; + } } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java index cc55158..26e6afa 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java @@ -11,6 +11,7 @@ import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import com.evolveum.polygon.sql.base.test.contract.ExternalDatabaseTestSupport; import org.identityconnectors.common.security.GuardedString; import org.identityconnectors.framework.common.objects.*; import org.testng.annotations.AfterMethod; @@ -64,7 +65,8 @@ protected void initializeSchema(SqlSchemaDefinitionLoader loader) { @BeforeMethod public void setUp() throws Exception { - oracle = OracleDatabaseInitializer.create(); + oracle = ExternalDatabaseTestSupport.connect( + "Oracle", OracleDatabaseInitializer::create); oracle.init(); var config = new SqlConnectorConfiguration(); diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleDatabaseInitializer.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleDatabaseInitializer.java index 80bb5ef..113c570 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleDatabaseInitializer.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleDatabaseInitializer.java @@ -34,14 +34,20 @@ */ public final class OracleDatabaseInitializer implements AutoCloseable { + public static final String JDBC_URL = setting( + "sql.test.oracle.url", "SQL_TEST_ORACLE_URL", + "jdbc:oracle:thin:@//localhost:1521/FREEPDB1"); + public static final String USERNAME = setting( + "sql.test.oracle.username", "SQL_TEST_ORACLE_USERNAME", "oracle"); + public static final String PASSWORD = setting( + "sql.test.oracle.password", "SQL_TEST_ORACLE_PASSWORD", "oracle123"); + private static final String RESOURCE_SCHEMA = "oracle/basic/schema.sql"; private final Connection conn; private OracleDatabaseInitializer() throws SQLException { - this.conn = DriverManager.getConnection( - "jdbc:oracle:thin:@//localhost:1521/FREEPDB1", - "oracle", "oracle123"); + this.conn = DriverManager.getConnection(JDBC_URL, USERNAME, PASSWORD); this.conn.setAutoCommit(true); } @@ -162,9 +168,9 @@ private List splitStatements(String sql) { */ public SqlBaseContext createContext(boolean scanTables) throws Exception { var config = new SqlConnectorConfiguration(); - config.setJdbcUrl("jdbc:oracle:thin:@//localhost:1521/FREEPDB1"); - config.setUsername("oracle"); - config.setPassword(new GuardedString("oracle123".toCharArray())); + config.setJdbcUrl(JDBC_URL); + config.setUsername(USERNAME); + config.setPassword(new GuardedString(PASSWORD.toCharArray())); config.setPoolSize(5); config.setConnectionTimeout(10000); config.setValidateConnectionOnBorrow(true); @@ -196,4 +202,13 @@ private static String toString(InputStreamReader ir) throws IOException { } return sb.toString(); } -} \ No newline at end of file + + private static String setting(String property, String environment, String defaultValue) { + var configured = System.getProperty(property); + if (configured != null && !configured.isBlank()) { + return configured; + } + configured = System.getenv(environment); + return configured != null && !configured.isBlank() ? configured : defaultValue; + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/AbstractSqlConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/AbstractSqlConnectorContractTest.java new file mode 100644 index 0000000..47e0073 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/AbstractSqlConnectorContractTest.java @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.dev.SqlDevelopmentMode; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerLoader; +import com.evolveum.polygon.sql.base.groovy.SqlSchemaDefinitionLoader; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.AttributeDeltaBuilder; +import org.identityconnectors.framework.common.objects.AttributeInfo; +import org.identityconnectors.framework.common.objects.AttributeUtil; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.identityconnectors.framework.common.objects.ObjectClassInfo; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; +import org.identityconnectors.framework.common.objects.filter.Filter; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import tools.jackson.databind.json.JsonMapper; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Common behavior contract executed against every supported real database configuration. + * Database-specific tests remain responsible for exact dialect syntax and vendor-only types. + */ +public abstract class AbstractSqlConnectorContractTest { + + private static final String USER = "contract_user"; + private static final String GROUP = "contract_group"; + private static final String EXTERNAL = "contract_external"; + private static final String ADDRESS = "contract_address"; + private static final String COMPOSITE = "contract_composite"; + private static final String USER_VIEW = "contract_user_view"; + + private static final OperationOptions OPTIONS = new OperationOptions(Collections.emptyMap()); + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private SqlTestDatabase database; + private ContractConnector connector; + + protected abstract SqlTestDatabase createDatabase() throws Exception; + + @BeforeClass(alwaysRun = true) + public final void setUpDatabaseContract() throws Exception { + var candidate = createDatabase(); + try { + if (candidate.capabilities().external()) { + database = ExternalDatabaseTestSupport.connect( + candidate.database().name(), () -> initialize(candidate)); + } else { + database = initialize(candidate); + } + } catch (Exception e) { + closeQuietly(candidate); + throw e; + } + + connector = new ContractConnector(); + connector.init(database.configuration(true)); + connector.schema(); + } + + @BeforeMethod(alwaysRun = true) + public final void resetDatabaseContractData() throws Exception { + database.initializeSchema(); + } + + @AfterClass(alwaysRun = true) + public final void tearDownDatabaseContract() throws Exception { + if (connector != null) { + connector.dispose(); + connector = null; + } + if (database != null) { + database.close(); + database = null; + } + } + + @Test + public final void discoversSharedSchemaContract() { + assertThat(schemaNames()).contains( + objectClassInfo(USER).getType(), + objectClassInfo(GROUP).getType(), + objectClassInfo(EXTERNAL).getType(), + objectClassInfo(COMPOSITE).getType(), + objectClassInfo(USER_VIEW).getType()); + + var user = objectClassInfo(USER); + assertThat(attributeInfo(user, "id").isRequired()).isTrue(); + assertThat(attributeInfo(user, "username").isRequired()).isTrue(); + assertThat(attributeInfo(user, "email").isRequired()).isFalse(); + assertThat(attributeInfo(user, "active")).isNotNull(); + assertThat(attributeInfo(user, "quota")).isNotNull(); + + var composite = objectClassInfo(COMPOSITE); + assertThat(attributeInfo(composite, "tenant_id").isRequired()).isTrue(); + assertThat(attributeInfo(composite, "record_id").isRequired()).isTrue(); + } + + @Test + public final void searchesByUidAndAttributeAcrossDatabase() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var users = search(userClass, null); + + assertThat(users).hasSize(2); + var alice = users.stream() + .filter(object -> "alice".equals(value(object, username))) + .findFirst() + .orElseThrow(); + + assertThat(search(userClass, FilterBuilder.equalTo( + AttributeBuilder.build(Uid.NAME, alice.getUid().getUidValue())))) + .extracting(ConnectorObject::getUid) + .extracting(Uid::getUidValue) + .containsExactly(alice.getUid().getUidValue()); + assertThat(search(userClass, FilterBuilder.equalTo( + AttributeBuilder.build(username, "alice")))) + .hasSize(1) + .first() + .extracting(object -> value(object, username)) + .isEqualTo("alice"); + } + + @Test + public final void createsUpdatesAndDeletesGeneratedUid() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var email = attributeName(USER, "email"); + var active = attributeName(USER, "active"); + var quota = attributeName(USER, "quota"); + + var uid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-created-user"), + AttributeBuilder.build(username, "charlie"), + AttributeBuilder.build(email, "charlie@example.com"), + AttributeBuilder.build(active, mappedNumber(USER, "active", "1")), + AttributeBuilder.build(quota, mappedNumber(USER, "quota", "30.25"))), OPTIONS); + + assertThat(uid.getUidValue()).isNotBlank(); + assertThat(value(get(userClass, uid), username)).isEqualTo("charlie"); + + connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(email, List.of("changed@example.com"))), OPTIONS); + assertThat(value(get(userClass, uid), email)).isEqualTo("changed@example.com"); + + connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(email, List.of())), OPTIONS); + assertThat(value(get(userClass, uid), email)).isNull(); + + connector.delete(userClass, uid, OPTIONS); + assertThat(search(userClass, uidFilter(uid))).isEmpty(); + } + + @Test + public final void supportsDatabaseDefaultsAndConstraintErrors() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var email = attributeName(USER, "email"); + + var defaultedUid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-default-user"), + AttributeBuilder.build(email)), OPTIONS); + assertThat(value(get(userClass, defaultedUid), username)).isEqualTo("anonymous"); + + assertThatThrownBy(() -> connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-duplicate-user"), + AttributeBuilder.build(username, "alice")), OPTIONS)) + .isInstanceOf(AlreadyExistsException.class); + + } + + @Test + public final void supportsNaturalAndCompositeUids() { + var external = objectClass(EXTERNAL); + var displayName = attributeName(EXTERNAL, "display_name"); + var externalUid = connector.create(external, Set.of( + AttributeBuilder.build(Uid.NAME, "external-100"), + AttributeBuilder.build(Name.NAME, "external-100"), + AttributeBuilder.build(displayName, "External 100")), OPTIONS); + + assertThat(externalUid.getUidValue()).isEqualTo("external-100"); + assertThat(value(get(external, externalUid), displayName)).isEqualTo("External 100"); + connector.delete(external, externalUid, OPTIONS); + + var composite = objectClass(COMPOSITE); + var roleName = attributeName(COMPOSITE, "role_name"); + var compositeUid = connector.create(composite, Set.of( + AttributeBuilder.build(Uid.NAME, "2.10"), + AttributeBuilder.build(Name.NAME, "2.10"), + AttributeBuilder.build(roleName, "member")), OPTIONS); + + assertThat(compositeUid.getUidValue()).isEqualTo("2.10"); + assertThat(value(get(composite, compositeUid), roleName)).isEqualTo("member"); + connector.delete(composite, compositeUid, OPTIONS); + assertThat(search(composite, uidFilter(compositeUid))).isEmpty(); + } + + @Test + public final void exportsStructuredDevelopmentMetadata() throws Exception { + assertThat(schemaNames()).contains(SqlDevelopmentMode.TABLE_OC_NAME); + + var tables = search(new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME), null); + var user = tableNamed(tables, USER); + var address = tableNamed(tables, ADDRESS); + var view = tableNamed(tables, USER_VIEW); + + assertThat(attributeValue(user, SqlDevelopmentMode.TABLE_TYPE_ATTRIBUTE).toString()) + .containsIgnoringCase("TABLE"); + if (database.capabilities().supportsNativeDefinitions()) { + assertThat((String) attributeValue(user, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .isNotBlank() + .containsIgnoringCase(USER); + assertThat((String) attributeValue(view, SqlDevelopmentMode.DEFINITION_ATTRIBUTE)) + .isNotBlank() + .containsIgnoringCase(USER_VIEW) + .containsIgnoringCase(USER); + } else { + assertThat(user.getAttributeByName(SqlDevelopmentMode.DEFINITION_ATTRIBUTE)).isNull(); + assertThat(view.getAttributeByName(SqlDevelopmentMode.DEFINITION_ATTRIBUTE)).isNull(); + } + + var userContent = json(user); + var id = column(userContent, "id"); + var username = column(userContent, "username"); + assertThat(id.get("primaryKey")).isEqualTo(true); + assertThat(id.get("autoIncrement")).isEqualTo(true); + assertThat(username.get("nullable")).isEqualTo(false); + if (database.capabilities().supportsJdbcDefaults()) { + assertThat(String.valueOf(username.get("defaultValue"))).contains("anonymous"); + } else { + assertThat(username.get("defaultValue")).isNull(); + } + + var addressContent = json(address); + var userId = column(addressContent, "user_id"); + assertThat(String.valueOf(userId.get("referencedTable"))).isEqualToIgnoringCase(USER); + assertThat(String.valueOf(userId.get("referencedColumn"))).isEqualToIgnoringCase("id"); + assertThat(String.valueOf(userId.get("foreignKeyName"))) + .containsIgnoringCase("contract_address_user"); + + if (database.capabilities().supportsRemarks()) { + assertThat(attributeValue(user, SqlDevelopmentMode.REMARKS_ATTRIBUTE)) + .isEqualTo("Contract users"); + assertThat(username.get("remarks")).isEqualTo("Contract login name"); + } + } + + @Test + public final void filtersMetadataAndHidesItOutsideDevelopmentMode() throws Exception { + var metadataClass = new ObjectClass(SqlDevelopmentMode.TABLE_OC_NAME); + var user = tableNamed(search(metadataClass, null), USER); + + assertThat(search(metadataClass, FilterBuilder.equalTo( + AttributeBuilder.build(Uid.NAME, user.getUid().getUidValue())))) + .extracting(object -> object.getUid().getUidValue()) + .containsExactly(user.getUid().getUidValue()); + assertThat(search(metadataClass, FilterBuilder.equalTo( + AttributeBuilder.build(Name.NAME, user.getName().getNameValue())))) + .hasSize(1); + + var nonDevelopmentConnector = new ContractConnector(); + nonDevelopmentConnector.init(database.configuration(false)); + try { + assertThat(nonDevelopmentConnector.schema().getObjectClassInfo().stream() + .map(ObjectClassInfo::getType)) + .doesNotContain(SqlDevelopmentMode.TABLE_OC_NAME); + } finally { + nonDevelopmentConnector.dispose(); + } + } + + @Test + public final void rejectsWritesToViews() { + var view = objectClass(USER_VIEW); + assertThatThrownBy(() -> connector.create(view, Set.of( + AttributeBuilder.build(Name.NAME, "read-only")), OPTIONS)) + .isInstanceOf(UnsupportedOperationException.class); + } + + private SqlTestDatabase initialize(SqlTestDatabase candidate) throws Exception { + candidate.initializeSchema(); + return candidate; + } + + private List schemaNames() { + return connector.schema().getObjectClassInfo().stream() + .map(ObjectClassInfo::getType) + .toList(); + } + + private ObjectClassInfo objectClassInfo(String expectedName) { + return connector.schema().getObjectClassInfo().stream() + .filter(info -> info.getType().equalsIgnoreCase(expectedName)) + .findFirst() + .orElseThrow(() -> new AssertionError("Object class not found: " + expectedName)); + } + + private ObjectClass objectClass(String expectedName) { + return new ObjectClass(objectClassInfo(expectedName).getType()); + } + + private AttributeInfo attributeInfo(ObjectClassInfo objectClass, String expectedName) { + return objectClass.getAttributeInfo().stream() + .filter(attribute -> expectedName.equalsIgnoreCase(attribute.getName()) + || expectedName.equalsIgnoreCase(attribute.getNativeName())) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Attribute not found: " + objectClass.getType() + "." + expectedName)); + } + + private String attributeName(String objectClass, String expectedName) { + return attributeInfo(objectClassInfo(objectClass), expectedName).getName(); + } + + private Object mappedNumber(String objectClass, String attribute, String value) { + var type = attributeInfo(objectClassInfo(objectClass), attribute).getType(); + if (type == Boolean.class || type == boolean.class) { + return !"0".equals(value); + } + if (type == Integer.class || type == int.class) { + return Integer.valueOf(value.split("\\.")[0]); + } + if (type == Long.class || type == long.class) { + return Long.valueOf(value.split("\\.")[0]); + } + if (type == Float.class || type == float.class) { + return Float.valueOf(value); + } + if (type == Double.class || type == double.class) { + return Double.valueOf(value); + } + return new BigDecimal(value); + } + + private List search(ObjectClass objectClass, Filter filter) { + var result = new ArrayList(); + connector.executeQuery(objectClass, filter, result::add, OPTIONS); + return result; + } + + private ConnectorObject get(ObjectClass objectClass, Uid uid) { + var result = search(objectClass, uidFilter(uid)); + assertThat(result).hasSize(1); + return result.getFirst(); + } + + private static Filter uidFilter(Uid uid) { + return FilterBuilder.equalTo(AttributeBuilder.build(Uid.NAME, uid.getUidValue())); + } + + private static Object value(ConnectorObject object, String name) { + var attribute = object.getAttributeByName(name); + return attribute == null ? null : AttributeUtil.getSingleValue(attribute); + } + + private static ConnectorObject tableNamed(List tables, String name) { + return tables.stream() + .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new AssertionError("Table metadata not found: " + name)); + } + + private static Object attributeValue(ConnectorObject object, String name) { + return AttributeUtil.getSingleValue(object.getAttributeByName(name)); + } + + @SuppressWarnings("unchecked") + private static Map json(ConnectorObject object) throws Exception { + return JSON.readValue( + (String) attributeValue(object, SqlDevelopmentMode.TABLE_CONTENT_ATTRIBUTE), Map.class); + } + + @SuppressWarnings("unchecked") + private static Map column(Map table, String name) { + var columns = (List>) table.get("columns"); + return columns.stream() + .filter(column -> name.equalsIgnoreCase(String.valueOf(column.get("name")))) + .findFirst() + .orElseThrow(() -> new AssertionError("Column metadata not found: " + name)); + } + + private static void closeQuietly(SqlTestDatabase database) { + try { + database.close(); + } catch (Exception ignored) { + // Keep the original setup failure. + } + } + + private static final class ContractConnector + extends AbstractGroovySqlConnector { + + private ContractConnector() { + super(false); + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerLoader builder) { + } + + @Override + protected void initializeSchema(SqlSchemaDefinitionLoader loader) { + } + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/DatabaseCapabilities.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/DatabaseCapabilities.java new file mode 100644 index 0000000..64baa5a --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/DatabaseCapabilities.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +/** Genuine database differences that affect shared connector assertions. */ +public record DatabaseCapabilities( + boolean external, + boolean supportsSchemas, + boolean supportsRemarks, + boolean supportsNativeDefinitions, + boolean supportsJdbcDefaults) { +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/ExternalDatabaseTestSupport.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/ExternalDatabaseTestSupport.java new file mode 100644 index 0000000..8da655a --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/ExternalDatabaseTestSupport.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.SkipException; + +/** Availability policy for the opt-in external-database test modes. */ +public final class ExternalDatabaseTestSupport { + + private ExternalDatabaseTestSupport() { + } + + public static T connect(String database, ThrowingSupplier supplier) throws Exception { + try { + return supplier.get(); + } catch (Exception e) { + if (Boolean.getBoolean("available.database.tests") + && !Boolean.getBoolean("all.database.tests")) { + throw new SkipException(database + " is unavailable: " + rootMessage(e), e); + } + throw e; + } + } + + private static String rootMessage(Throwable throwable) { + var current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage() != null ? current.getMessage() : current.getClass().getSimpleName(); + } + + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/H2ConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/H2ConnectorContractTest.java new file mode 100644 index 0000000..6e0258c --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/H2ConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class H2ConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() { + return SqlTestDatabases.h2(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/JdbcSqlTestDatabase.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/JdbcSqlTestDatabase.java new file mode 100644 index 0000000..e475803 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/JdbcSqlTestDatabase.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.SqlDatabase; +import org.identityconnectors.common.security.GuardedString; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; +import java.util.Objects; + +/** JDBC-backed implementation shared by all database fixtures. */ +final class JdbcSqlTestDatabase implements SqlTestDatabase { + + private static final String CONTRACT_FILTER = "(?i)^contract_.*$"; + + private final SqlDatabase database; + private final DatabaseCapabilities capabilities; + private final String jdbcUrl; + private final String username; + private final String password; + private final String schemaResource; + private final List beforeDrop; + private final List dropStatements; + private final List afterDrop; + private final AutoCloseable closeAction; + + JdbcSqlTestDatabase( + SqlDatabase database, + DatabaseCapabilities capabilities, + String jdbcUrl, + String username, + String password, + String schemaResource, + List beforeDrop, + List dropStatements, + List afterDrop, + AutoCloseable closeAction) { + this.database = database; + this.capabilities = capabilities; + this.jdbcUrl = jdbcUrl; + this.username = username; + this.password = password; + this.schemaResource = schemaResource; + this.beforeDrop = beforeDrop; + this.dropStatements = dropStatements; + this.afterDrop = afterDrop; + this.closeAction = closeAction; + } + + @Override + public SqlDatabase database() { + return database; + } + + @Override + public DatabaseCapabilities capabilities() { + return capabilities; + } + + @Override + public SqlConnectorConfiguration configuration(boolean developmentMode) { + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(jdbcUrl); + configuration.setUsername(username); + configuration.setPassword(new GuardedString(password.toCharArray())); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setValidateConnectionOnBorrow(true); + configuration.setScanTables(true); + configuration.setScanViews(true); + configuration.setScanTableFilter(CONTRACT_FILTER); + configuration.setScanViewFilter(CONTRACT_FILTER); + configuration.setDevelopmentMode(developmentMode); + if (database == SqlDatabase.POSTGRESQL) { + configuration.setPgDumpPath(System.getProperty("sql.test.postgresql.pgDumpPath", "")); + } + return configuration; + } + + @Override + public void initializeSchema() throws Exception { + try (var connection = DriverManager.getConnection(jdbcUrl, username, password)) { + executeAll(connection, beforeDrop, false); + executeAll(connection, dropStatements, true); + executeAll(connection, afterDrop, false); + executeScript(connection, schemaResource); + } + } + + @Override + public void close() throws Exception { + closeAction.close(); + } + + private static void executeAll(Connection connection, List statements, boolean ignoreFailure) + throws Exception { + for (var sql : statements) { + try (var statement = connection.createStatement()) { + statement.execute(sql); + } catch (Exception e) { + if (!ignoreFailure) { + throw e; + } + } + } + } + + private static void executeScript(Connection connection, String resourcePath) throws Exception { + var classLoader = Thread.currentThread().getContextClassLoader(); + try (var stream = Objects.requireNonNull( + classLoader.getResourceAsStream(resourcePath), "Resource not found: " + resourcePath)) { + var script = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + for (var sql : script.split(";")) { + if (sql.isBlank()) { + continue; + } + try (var statement = connection.createStatement()) { + statement.execute(sql); + } + } + } + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MariaDbConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MariaDbConnectorContractTest.java new file mode 100644 index 0000000..166a500 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MariaDbConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class MariaDbConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() { + return SqlTestDatabases.mariadb(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MySqlConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MySqlConnectorContractTest.java new file mode 100644 index 0000000..407ce49 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/MySqlConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class MySqlConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() { + return SqlTestDatabases.mysql(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/OracleConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/OracleConnectorContractTest.java new file mode 100644 index 0000000..5235721 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/OracleConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class OracleConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() { + return SqlTestDatabases.oracle(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/PostgresConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/PostgresConnectorContractTest.java new file mode 100644 index 0000000..9d87279 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/PostgresConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class PostgresConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() { + return SqlTestDatabases.postgresql(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabase.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabase.java new file mode 100644 index 0000000..ae4e92d --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabase.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.SqlDatabase; + +/** Database lifecycle used by the cross-database connector contract. */ +public interface SqlTestDatabase extends AutoCloseable { + + SqlDatabase database(); + + DatabaseCapabilities capabilities(); + + SqlConnectorConfiguration configuration(boolean developmentMode); + + void initializeSchema() throws Exception; + + @Override + void close() throws Exception; +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabases.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabases.java new file mode 100644 index 0000000..0aa7fd9 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqlTestDatabases.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import com.evolveum.polygon.sql.base.SqlDatabase; +import com.evolveum.polygon.sql.base.test.PostgresDatabaseInitializer; +import com.evolveum.polygon.sql.base.test.SqliteDatabaseInitializer; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** Factory for the six database configurations supported by the contract suite. */ +public final class SqlTestDatabases { + + private static final AutoCloseable NOOP_CLOSE = () -> { }; + + private SqlTestDatabases() { + } + + public static SqlTestDatabase h2() { + var id = ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE); + return database( + SqlDatabase.H2, false, true, true, true, true, + "jdbc:h2:mem:contract_" + id + ";DB_CLOSE_DELAY=-1", + "sa", "", "database/h2/contract-schema.sql", + List.of(), standardDrops(" CASCADE"), List.of(), NOOP_CLOSE); + } + + public static SqlTestDatabase postgresql() { + var postgres = PostgresDatabaseInitializer.create(); + return database( + SqlDatabase.POSTGRESQL, false, true, true, + !System.getProperty("sql.test.postgresql.pgDumpPath", "").isBlank(), + true, + postgres.getJdbcUrl(), postgres.getUsername(), "postgres", + "database/postgresql/contract-schema.sql", + List.of(), standardDrops(" CASCADE"), List.of(), postgres); + } + + public static SqlTestDatabase sqlite() throws Exception { + var sqlite = SqliteDatabaseInitializer.create(); + return database( + SqlDatabase.SQLITE, false, false, false, true, true, + sqlite.jdbcUrl() + "?foreign_keys=on", "unused", "", + "database/sqlite/contract-schema.sql", + List.of("PRAGMA foreign_keys = OFF"), standardDrops(""), + List.of("PRAGMA foreign_keys = ON"), sqlite); + } + + public static SqlTestDatabase oracle() { + return database( + SqlDatabase.ORACLE, true, true, false, true, false, + setting("sql.test.oracle.url", "SQL_TEST_ORACLE_URL", + "jdbc:oracle:thin:@//localhost:1521/FREEPDB1"), + setting("sql.test.oracle.username", "SQL_TEST_ORACLE_USERNAME", "oracle"), + setting("sql.test.oracle.password", "SQL_TEST_ORACLE_PASSWORD", "oracle123"), + "database/oracle/contract-schema.sql", + List.of(), oracleDrops(), List.of(), NOOP_CLOSE); + } + + public static SqlTestDatabase mariadb() { + return database( + SqlDatabase.MARIADB, true, false, true, true, true, + setting("sql.test.mariadb.url", "SQL_TEST_MARIADB_URL", + "jdbc:mariadb://localhost:3307/connector_sql"), + setting("sql.test.mariadb.username", "SQL_TEST_MARIADB_USERNAME", "connector"), + setting("sql.test.mariadb.password", "SQL_TEST_MARIADB_PASSWORD", "connector123"), + "database/mariadb/contract-schema.sql", + List.of(), standardDrops(""), List.of(), NOOP_CLOSE); + } + + public static SqlTestDatabase mysql() { + return database( + SqlDatabase.MYSQL, true, false, true, true, true, + setting("sql.test.mysql.url", "SQL_TEST_MYSQL_URL", + "jdbc:mysql://localhost:3308/connector_sql?allowPublicKeyRetrieval=true&useSSL=false"), + setting("sql.test.mysql.username", "SQL_TEST_MYSQL_USERNAME", "connector"), + setting("sql.test.mysql.password", "SQL_TEST_MYSQL_PASSWORD", "connector123"), + "database/mysql/contract-schema.sql", + List.of(), standardDrops(""), List.of(), NOOP_CLOSE); + } + + private static JdbcSqlTestDatabase database( + SqlDatabase database, + boolean external, + boolean supportsSchemas, + boolean supportsRemarks, + boolean supportsNativeDefinitions, + boolean supportsJdbcDefaults, + String jdbcUrl, + String username, + String password, + String resource, + List beforeDrop, + List drops, + List afterDrop, + AutoCloseable closeAction) { + return new JdbcSqlTestDatabase( + database, + new DatabaseCapabilities( + external, supportsSchemas, supportsRemarks, + supportsNativeDefinitions, supportsJdbcDefaults), + jdbcUrl, username, password, resource, + beforeDrop, drops, afterDrop, closeAction); + } + + private static List standardDrops(String tableSuffix) { + return List.of( + "DROP VIEW IF EXISTS contract_user_view", + "DROP TABLE IF EXISTS contract_address" + tableSuffix, + "DROP TABLE IF EXISTS contract_composite" + tableSuffix, + "DROP TABLE IF EXISTS contract_external" + tableSuffix, + "DROP TABLE IF EXISTS contract_group" + tableSuffix, + "DROP TABLE IF EXISTS contract_user" + tableSuffix); + } + + private static List oracleDrops() { + return List.of( + "DROP VIEW contract_user_view", + "DROP TABLE contract_address CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_composite CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_external CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_group CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_user CASCADE CONSTRAINTS PURGE"); + } + + private static String setting(String property, String environment, String defaultValue) { + var configured = System.getProperty(property); + if (configured != null && !configured.isBlank()) { + return configured; + } + configured = System.getenv(environment); + return configured != null && !configured.isBlank() ? configured : defaultValue; + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqliteConnectorContractTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqliteConnectorContractTest.java new file mode 100644 index 0000000..f84b9c8 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/contract/SqliteConnectorContractTest.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 Evolveum and contributors + * + * This work is licensed under European Union Public License v1.2. See LICENSE file for details. + */ +package com.evolveum.polygon.sql.base.test.contract; + +import org.testng.annotations.Test; + +@Test(singleThreaded = true) +public class SqliteConnectorContractTest extends AbstractSqlConnectorContractTest { + + @Override + protected SqlTestDatabase createDatabase() throws Exception { + return SqlTestDatabases.sqlite(); + } +} diff --git a/base/src/test/resources/database/h2/contract-schema.sql b/base/src/test/resources/database/h2/contract-schema.sql new file mode 100644 index 0000000..633a46a --- /dev/null +++ b/base/src/test/resources/database/h2/contract-schema.sql @@ -0,0 +1,46 @@ +CREATE TABLE contract_user ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(50) DEFAULT 'anonymous' NOT NULL UNIQUE, + email VARCHAR(100), + active BOOLEAN DEFAULT TRUE NOT NULL, + quota DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); +COMMENT ON TABLE contract_user IS 'Contract users'; +COMMENT ON COLUMN contract_user.username IS 'Contract login name'; + +CREATE TABLE contract_group ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +); + +CREATE TABLE contract_external ( + account_id VARCHAR(40) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL +); + +CREATE TABLE contract_address ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + city VARCHAR(50), + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + role_name VARCHAR(50), + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', TRUE, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, FALSE, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/base/src/test/resources/database/mariadb/contract-schema.sql b/base/src/test/resources/database/mariadb/contract-schema.sql new file mode 100644 index 0000000..5880fe0 --- /dev/null +++ b/base/src/test/resources/database/mariadb/contract-schema.sql @@ -0,0 +1,47 @@ +CREATE TABLE contract_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) DEFAULT 'anonymous' NOT NULL UNIQUE COMMENT 'Contract login name', + email VARCHAR(100), + active BOOLEAN DEFAULT TRUE NOT NULL, + quota DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT pk_contract_user PRIMARY KEY (id) +) COMMENT = 'Contract users'; + +CREATE TABLE contract_group ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL UNIQUE, + CONSTRAINT pk_contract_group PRIMARY KEY (id) +); + +CREATE TABLE contract_external ( + account_id VARCHAR(40) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL +); + +CREATE TABLE contract_address ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + city VARCHAR(50), + CONSTRAINT pk_contract_address PRIMARY KEY (id), + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + role_name VARCHAR(50), + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', TRUE, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, FALSE, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/base/src/test/resources/database/mysql/contract-schema.sql b/base/src/test/resources/database/mysql/contract-schema.sql new file mode 100644 index 0000000..5880fe0 --- /dev/null +++ b/base/src/test/resources/database/mysql/contract-schema.sql @@ -0,0 +1,47 @@ +CREATE TABLE contract_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) DEFAULT 'anonymous' NOT NULL UNIQUE COMMENT 'Contract login name', + email VARCHAR(100), + active BOOLEAN DEFAULT TRUE NOT NULL, + quota DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT pk_contract_user PRIMARY KEY (id) +) COMMENT = 'Contract users'; + +CREATE TABLE contract_group ( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(50) NOT NULL UNIQUE, + CONSTRAINT pk_contract_group PRIMARY KEY (id) +); + +CREATE TABLE contract_external ( + account_id VARCHAR(40) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL +); + +CREATE TABLE contract_address ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + city VARCHAR(50), + CONSTRAINT pk_contract_address PRIMARY KEY (id), + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + role_name VARCHAR(50), + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', TRUE, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, FALSE, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/base/src/test/resources/database/oracle/contract-schema.sql b/base/src/test/resources/database/oracle/contract-schema.sql new file mode 100644 index 0000000..389c28f --- /dev/null +++ b/base/src/test/resources/database/oracle/contract-schema.sql @@ -0,0 +1,49 @@ +CREATE TABLE contract_user ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY, + username VARCHAR2(50) DEFAULT 'anonymous' NOT NULL UNIQUE, + email VARCHAR2(100), + active NUMBER(1) DEFAULT 1 NOT NULL, + quota NUMBER(10, 2), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + CONSTRAINT pk_contract_user PRIMARY KEY (id) +); +COMMENT ON TABLE contract_user IS 'Contract users'; +COMMENT ON COLUMN contract_user.username IS 'Contract login name'; + +CREATE TABLE contract_group ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY, + name VARCHAR2(50) NOT NULL UNIQUE, + CONSTRAINT pk_contract_group PRIMARY KEY (id) +); + +CREATE TABLE contract_external ( + account_id VARCHAR2(40) PRIMARY KEY, + display_name VARCHAR2(100) NOT NULL +); + +CREATE TABLE contract_address ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY, + user_id NUMBER NOT NULL, + city VARCHAR2(50), + CONSTRAINT pk_contract_address PRIMARY KEY (id), + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id NUMBER NOT NULL, + record_id NUMBER NOT NULL, + role_name VARCHAR2(50), + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', 1, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, 0, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/base/src/test/resources/database/postgresql/contract-schema.sql b/base/src/test/resources/database/postgresql/contract-schema.sql new file mode 100644 index 0000000..f17385b --- /dev/null +++ b/base/src/test/resources/database/postgresql/contract-schema.sql @@ -0,0 +1,46 @@ +CREATE TABLE contract_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) DEFAULT 'anonymous' NOT NULL UNIQUE, + email VARCHAR(100), + active BOOLEAN DEFAULT TRUE NOT NULL, + quota DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); +COMMENT ON TABLE contract_user IS 'Contract users'; +COMMENT ON COLUMN contract_user.username IS 'Contract login name'; + +CREATE TABLE contract_group ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE +); + +CREATE TABLE contract_external ( + account_id VARCHAR(40) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL +); + +CREATE TABLE contract_address ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + city VARCHAR(50), + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + role_name VARCHAR(50), + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', TRUE, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, FALSE, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/base/src/test/resources/database/sqlite/contract-schema.sql b/base/src/test/resources/database/sqlite/contract-schema.sql new file mode 100644 index 0000000..f09c132 --- /dev/null +++ b/base/src/test/resources/database/sqlite/contract-schema.sql @@ -0,0 +1,44 @@ +CREATE TABLE contract_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT DEFAULT 'anonymous' NOT NULL UNIQUE, + email TEXT, + active INTEGER DEFAULT 1 NOT NULL, + quota NUMERIC, + created_at TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE contract_group ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE contract_external ( + account_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL +); + +CREATE TABLE contract_address ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + city TEXT, + CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_composite ( + tenant_id INTEGER NOT NULL, + record_id INTEGER NOT NULL, + role_name TEXT, + CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) +); + +CREATE VIEW contract_user_view AS +SELECT id, username, email FROM contract_user; + +INSERT INTO contract_user (username, email, active, quota) +VALUES ('alice', 'alice@example.com', 1, 10.50); +INSERT INTO contract_user (username, email, active, quota) +VALUES ('bob', NULL, 0, 20.00); +INSERT INTO contract_group (name) VALUES ('developers'); +INSERT INTO contract_address (user_id, city) VALUES (1, 'Bratislava'); +INSERT INTO contract_composite (tenant_id, record_id, role_name) VALUES (1, 1, 'owner'); +INSERT INTO contract_external (account_id, display_name) VALUES ('existing', 'Existing account'); diff --git a/pom.xml b/pom.xml index 523b06b..0585084 100644 --- a/pom.xml +++ b/pom.xml @@ -324,6 +324,54 @@ + + available-databases + + false + + available.database.tests + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/__NoDatabaseTestIsExcluded__.java + + + + + + + + + all-databases + + false + + all.database.tests + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/__NoDatabaseTestIsExcluded__.java + + + + + + + sbom From f6607ed44e5aa34e94ea063c14bb973471891a22 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Thu, 27 Aug 2026 09:38:15 +0200 Subject: [PATCH 13/13] Use configurable Oracle test connection settings Reuse the Oracle database initializer URL, username, and password so connector tests honor system-property and environment overrides. --- .../sql/base/test/OracleConnectorIntegrationTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java index 26e6afa..9bfca76 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/test/OracleConnectorIntegrationTest.java @@ -70,9 +70,9 @@ public void setUp() throws Exception { oracle.init(); var config = new SqlConnectorConfiguration(); - config.setJdbcUrl("jdbc:oracle:thin:@//localhost:1521/FREEPDB1"); - config.setUsername("oracle"); - config.setPassword(new GuardedString("oracle123".toCharArray())); + config.setJdbcUrl(OracleDatabaseInitializer.JDBC_URL); + config.setUsername(OracleDatabaseInitializer.USERNAME); + config.setPassword(new GuardedString(OracleDatabaseInitializer.PASSWORD.toCharArray())); config.setPoolSize(5); config.setConnectionTimeout(10000); config.setValidateConnectionOnBorrow(true);