From a6768fbad99380fbf20bc8e033af82800932c3e5 Mon Sep 17 00:00:00 2001 From: Jan Vorel Date: Thu, 6 Aug 2026 07:55:27 +0200 Subject: [PATCH] Implement built-in SQL create, update and delete --- .../sql/base/AbstractGroovySqlConnector.java | 24 +- .../base/build/api/SqlAttributeMapping.java | 53 ++- .../sql/base/groovy/SqlHandlerBuilder.java | 9 +- .../sql/base/schema/SqlSchemaDetector.java | 36 +- .../sql/base/write/SqlCreateOperation.java | 75 ++++ .../sql/base/write/SqlDeleteOperation.java | 43 ++ .../sql/base/write/SqlUpdateOperation.java | 60 +++ .../base/write/SqlWriteOperationSupport.java | 392 ++++++++++++++++++ .../FrameworkConnectorLoadingTest.java | 53 ++- .../SqlWriteOperationIntegrationTest.java | 280 +++++++++++++ .../write/SqlWriteOperationPostgresTest.java | 235 +++++++++++ docs/sql-connector-reference.adoc | 17 +- docs/sql-connector-tutorial.adoc | 21 +- 13 files changed, 1273 insertions(+), 25 deletions(-) create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/write/SqlDeleteOperation.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/write/SqlUpdateOperation.java create mode 100644 base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationIntegrationTest.java create mode 100644 base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationPostgresTest.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 d9b3642..cd8331b 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 @@ -10,8 +10,11 @@ import com.evolveum.polygon.conndev.dev.ConnDevSchema; import com.evolveum.polygon.conndev.spi.ClassHandlerConnectorBase; import com.evolveum.polygon.conndev.spi.ObjectClassHandler; +import com.evolveum.polygon.conndev.spi.ObjectCreateOperation; +import com.evolveum.polygon.conndev.spi.ObjectDeleteOperation; import com.evolveum.polygon.conndev.spi.ObjectSearchOperation; import com.evolveum.polygon.conndev.spi.ObjectSyncOperation; +import com.evolveum.polygon.conndev.spi.ObjectUpdateOperation; 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; @@ -26,6 +29,9 @@ import com.evolveum.polygon.sql.base.search.SqlSearchOperation; import com.evolveum.polygon.sql.base.sync.SqlSyncOperation; import com.evolveum.polygon.sql.base.sync.SyncConfig; +import com.evolveum.polygon.sql.base.write.SqlCreateOperation; +import com.evolveum.polygon.sql.base.write.SqlDeleteOperation; +import com.evolveum.polygon.sql.base.write.SqlUpdateOperation; import com.querydsl.sql.SQLTemplates; import org.identityconnectors.framework.common.exceptions.ConnectionFailedException; import org.identityconnectors.framework.common.exceptions.InvalidCredentialException; @@ -206,15 +212,25 @@ private void initialize0(boolean allowConnection) { ObjectSearchOperation.class, new SqlObjectClassDevHandler(context)); } - // Register QueryDSL-based search and sync operations for all application object classes (tables) + // Register QueryDSL-based operations for all application object classes (tables). + // Explicit Groovy handlers take precedence over these defaults. if (context.schema() != null) { for (SqlObjectClassDefinition def : context.schema().objectClasses()) { var oc = def.objectClass(); var mapping = def.sql(); if (mapping != null) { - handlerBuilder.register(oc, ObjectSearchOperation.class, new SqlSearchOperation(context, def)); - handlerBuilder.register(oc, ObjectSyncOperation.class, - new SqlSyncOperation(context, def, SyncConfig.defaultFor(def))); + handlerBuilder.registerIfAbsent( + oc, ObjectSearchOperation.class, new SqlSearchOperation(context, def)); + handlerBuilder.registerIfAbsent(oc, ObjectSyncOperation.class, + new SqlSyncOperation(context, def, SyncConfig.defaultFor(def))); + if (!Boolean.TRUE.equals(def.getReadOnly())) { + handlerBuilder.registerIfAbsent( + oc, ObjectCreateOperation.class, new SqlCreateOperation(context, def)); + handlerBuilder.registerIfAbsent( + oc, ObjectUpdateOperation.class, new SqlUpdateOperation(context, def)); + handlerBuilder.registerIfAbsent( + oc, ObjectDeleteOperation.class, new SqlDeleteOperation(context, def)); + } } } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlAttributeMapping.java b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlAttributeMapping.java index 56460d9..6a99f7e 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlAttributeMapping.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlAttributeMapping.java @@ -65,8 +65,17 @@ default FilterSupport sqlFilter() { Collection> selectPaths(Path table); + /** + * Converts a ConnId attribute value into the physical column assignments used by SQL DML. + * A regular attribute produces one assignment; a composite UID produces one per key column. + */ + List columnValues(Path table, Object connIdValue); + DefinitionValue column(); + record ColumnValue(Path path, Object value) { + } + interface FilterSupport { BooleanExpression eq(RelationalPathBase tablePath, Object connIdValue); @@ -231,6 +240,11 @@ public Collection> selectPaths(Path table) { return List.of(dslPath(table)); } + @Override + public List columnValues(Path table, Object connIdValue) { + return List.of(new ColumnValue(dslPath(table), toSqlValue(connIdValue))); + } + public Object toSqlValue(Object connIdValue) { return valueMapping().toWireValue(connIdValue); } @@ -301,13 +315,16 @@ public BooleanExpression predicateFor(RelationalPathBase tp, AttributeFilter @Override public BooleanExpression eq(RelationalPathBase tp, Object connIdValue) { if (connIdValue == null) { - var r = (BooleanExpression) self.mainColumn.dslPath(tp); - for (SingleColumn ac : self.additionalColumns) { r = r.and((BooleanExpression) ac.dslPath(tp)); } - return r.isNull(); + var result = self.mainColumn.sqlFilter().eq(tp, null); + for (var additionalColumn : self.additionalColumns) { + result = result.and(additionalColumn.sqlFilter().eq(tp, null)); + } + return result; } // Split composite UID by delimiter. var uidValue = connIdValue.toString(); - var parts = uidValue.split(delimiter, self.additionalColumns.size() + 1); + var parts = uidValue.split( + Pattern.quote(delimiter), self.additionalColumns.size() + 1); if (parts.length != self.additionalColumns.size() + 1) { throw new IllegalArgumentException( "UID has wrong number of parts: expected " + (self.additionalColumns.size() + 1) + @@ -332,6 +349,34 @@ public BooleanExpression eq(RelationalPathBase tp, Object connIdValue) { return paths; } + @Override + public List columnValues(Path table, Object connIdValue) { + var columns = new ArrayList(); + if (connIdValue == null) { + columns.add(new ColumnValue(mainColumn.dslPath(table), null)); + for (var additionalColumn : additionalColumns) { + columns.add(new ColumnValue(additionalColumn.dslPath(table), null)); + } + return columns; + } + + var parts = connIdValue.toString().split( + Pattern.quote(delimiter), additionalColumns.size() + 1); + if (parts.length != additionalColumns.size() + 1) { + throw new IllegalArgumentException( + "UID has wrong number of parts: expected " + (additionalColumns.size() + 1) + + ", got " + parts.length); + } + + columns.add(new ColumnValue(mainColumn.dslPath(table), mainColumn.toSqlValue(parts[0]))); + for (int i = 0; i < additionalColumns.size(); i++) { + var additionalColumn = additionalColumns.get(i); + columns.add(new ColumnValue( + additionalColumn.dslPath(table), additionalColumn.toSqlValue(parts[i + 1]))); + } + return columns; + } + public Object toSqlValue(Object value) { return mainColumn.toSqlValue(value); } } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/groovy/SqlHandlerBuilder.java b/base/src/main/java/com/evolveum/polygon/sql/base/groovy/SqlHandlerBuilder.java index f38800f..8ad1c6f 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/groovy/SqlHandlerBuilder.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/groovy/SqlHandlerBuilder.java @@ -43,6 +43,13 @@ public SqlHandlerBuilder register(ObjectClass objectClass, Class operationTyp return this; } + /** Registers a built-in handler without replacing an explicitly configured one. */ + public SqlHandlerBuilder registerIfAbsent( + ObjectClass objectClass, Class operationType, Object handler) { + handlers.computeIfAbsent(objectClass, k -> new HashMap<>()).putIfAbsent(operationType, handler); + return this; + } + /** * Evaluates a Groovy script from a classpath resource as handler definitions. * Scripts can call objectClass("name") { search(...) } to register handlers. @@ -220,4 +227,4 @@ public GroovyHandlerFacade sync(Map config) { return this; } } -} \ 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 2482efa..8b8a734 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 @@ -45,15 +45,16 @@ public SqlSchemaDetector(SqlBaseContext context) throws SQLException { try (var wrapper = context.getConnection()) { var meta = wrapper.getConnection().getMetaData(); - var templatesFromRegistry = new SQLTemplatesRegistry().getTemplates(meta); - if (templatesFromRegistry == null) { - templatesFromRegistry = SQLTemplates.DEFAULT; - } - // For H2, use H2Templates with no quoting - unqualified column paths avoid table.column issues var productName = meta.getDatabaseProductName(); + SQLTemplates templatesFromRegistry; if (productName != null && productName.toUpperCase().contains("H2")) { templatesFromRegistry = new H2Templates(false); + } else { + var templatesBuilder = new SQLTemplatesRegistry().getBuilder(meta); + templatesFromRegistry = templatesBuilder != null + ? templatesBuilder.printSchema().quote().build() + : SQLTemplates.DEFAULT; } templates = templatesFromRegistry; querydslConfig = new Configuration(templates); @@ -260,10 +261,13 @@ private List getColumnMetas(Connection conn, Table table) throws var rawAutoInc = resolveColumn(colsRs, meta, "IS_AUTOINCREMENT"); var colLower = colName.toLowerCase(); + var mappedColumnName = mappedColumnName(conn.getMetaData(), colName); boolean isPk = pkList.contains(colLower); // Use QueryDSL for Java type resolution (dialect-aware) - var javaType = resolveJavaType(dataType, typeName, columnSize, decimalDigits, table.table(), colLower); + var javaType = resolveJavaType( + dataType, typeName, columnSize, decimalDigits, + table.table(), mappedColumnName); // Normalize type name using driver-typical TYPE_NAME (with fallback normalization) var normalizedTypeName = normalizeTypeName(typeName); @@ -272,7 +276,7 @@ private List getColumnMetas(Connection conn, Table table) throws var valueMapping = resolveValueMapping(javaType, dataType); cols.add(SqlColumnMeta.builder() - .name(colLower) + .name(mappedColumnName) .typeName(normalizedTypeName) .typeCode(dataType) .size(columnSize) @@ -310,6 +314,22 @@ private List getColumnMetas(Connection conn, Table table) throws return cols; } + /** + * Keeps explicitly quoted, case-sensitive column names while retaining the connector's + * historical lowercase names for ordinary unquoted identifiers. + */ + private String mappedColumnName(DatabaseMetaData metadata, String columnName) throws SQLException { + if (metadata.storesUpperCaseIdentifiers() + && columnName.equals(columnName.toUpperCase(Locale.ROOT))) { + return columnName.toLowerCase(Locale.ROOT); + } + if (metadata.storesLowerCaseIdentifiers() + && columnName.equals(columnName.toLowerCase(Locale.ROOT))) { + return columnName; + } + return columnName; + } + /** * Collects column names that have unique constraints by scanning index metadata. */ @@ -471,4 +491,4 @@ public void setTableFilter(TableFilter tableFilter) { } record Table(String schema, String table, String tableType, String catalog) {} -} \ No newline at end of file +} 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 new file mode 100644 index 0000000..62f2ad3 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlCreateOperation.java @@ -0,0 +1,75 @@ +/* + * 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.write; + +import com.evolveum.polygon.conndev.spi.ObjectCreateOperation; +import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.build.api.SqlAttributeMapping; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.querydsl.core.types.Path; +import com.querydsl.sql.dml.SQLInsertClause; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.OperationOptions; + +import java.util.Set; + +/** QueryDSL-based create operation for a writable SQL table. */ +public class SqlCreateOperation extends SqlWriteOperationSupport implements ObjectCreateOperation { + + public SqlCreateOperation(SqlBaseContext context, SqlObjectClassDefinition objectClass) { + super(context, objectClass); + } + + @Override + public ConnectorObject create(Set createAttributes, OperationOptions options) { + requireWritable(); + return inTransaction("Create " + objectClass.name(), connection -> { + var table = tablePath(); + var uidDefinition = uidDefinition(); + var suppliedUid = suppliedUid(createAttributes); + var assignments = createAssignments(table, createAttributes); + var insert = new SQLInsertClause( + connection.getConnection(), context.getSqlTemplates(), table); + setAssignments(insert, assignments); + + final org.identityconnectors.framework.common.objects.Uid uid; + if (suppliedUid != null) { + var affected = insert.execute(); + if (affected != 1) { + throw new ConnectorException( + "Create affected " + affected + " rows instead of one"); + } + uid = suppliedUid; + } else { + if (uidDefinition.connId().isCreateable()) { + throw invalid("Required attribute " + uidDefinition.connId().getName() + " is missing"); + } + var generatedPath = generatedKeyPath(uidDefinition.sql(), table); + uid = generatedUid( + uidDefinition.sql(), generatedKey(insert, generatedPath), table, assignments); + } + + var created = findByUid(connection, uid, options, false); + if (created == null) { + throw new ConnectorException("Created object " + uid + " could not be read back"); + } + return created; + }); + } + + private Path generatedKeyPath(SqlAttributeMapping mapping, Path table) { + if (mapping instanceof SqlAttributeMapping.SingleColumn singleColumn) { + return singleColumn.dslPath(table); + } + if (mapping instanceof SqlAttributeMapping.MultiColumn multiColumn) { + return multiColumn.mainColumn().dslPath(table); + } + throw new ConnectorException("Unsupported UID mapping " + mapping.getClass().getName()); + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlDeleteOperation.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlDeleteOperation.java new file mode 100644 index 0000000..628e597 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlDeleteOperation.java @@ -0,0 +1,43 @@ +/* + * 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.write; + +import com.evolveum.polygon.conndev.spi.ObjectDeleteOperation; +import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.querydsl.sql.dml.SQLDeleteClause; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; + +/** QueryDSL-based delete operation for a writable SQL table. */ +public class SqlDeleteOperation extends SqlWriteOperationSupport implements ObjectDeleteOperation { + + public SqlDeleteOperation(SqlBaseContext context, SqlObjectClassDefinition objectClass) { + super(context, objectClass); + } + + @Override + public void delete(Uid uid, OperationOptions options) { + requireWritable(); + inTransaction("Delete " + objectClass.name(), connection -> { + var table = tablePath(); + var delete = new SQLDeleteClause( + connection.getConnection(), context.getSqlTemplates(), table); + var affected = delete.where(uidPredicate(table, uid)).execute(); + if (affected == 0) { + throw new UnknownUidException(uid, objectClass.objectClass()); + } + if (affected != 1) { + throw new ConnectorException( + "Delete affected " + affected + " rows instead of one"); + } + return null; + }); + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlUpdateOperation.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlUpdateOperation.java new file mode 100644 index 0000000..afd863c --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlUpdateOperation.java @@ -0,0 +1,60 @@ +/* + * 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.write; + +import com.evolveum.polygon.conndev.spi.ObjectUpdateOperation; +import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.querydsl.sql.dml.SQLUpdateClause; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.AttributeDelta; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; + +import java.util.Collections; +import java.util.Set; + +/** QueryDSL-based update-delta operation for a writable SQL table. */ +public class SqlUpdateOperation extends SqlWriteOperationSupport implements ObjectUpdateOperation { + + public SqlUpdateOperation(SqlBaseContext context, SqlObjectClassDefinition objectClass) { + super(context, objectClass); + } + + @Override + public Set updateDelta( + Uid uid, Set modifications, OperationOptions options) { + requireWritable(); + var requested = modifications != null ? Set.copyOf(modifications) : Collections.emptySet(); + if (requested.isEmpty()) { + return requested; + } + + return inTransaction("Update " + objectClass.name(), connection -> { + var current = requireByUid(connection, uid, options, true); + var table = tablePath(); + var assignments = updateAssignments(table, current, requested); + if (assignments.isEmpty()) { + return requested; + } + + var update = new SQLUpdateClause( + connection.getConnection(), context.getSqlTemplates(), table); + setAssignments(update, assignments); + var affected = update.where(uidPredicate(table, uid)).execute(); + if (affected == 0) { + throw new UnknownUidException(uid, objectClass.objectClass()); + } + if (affected != 1) { + throw new ConnectorException( + "Update affected " + affected + " rows instead of one"); + } + return requested; + }); + } +} 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 new file mode 100644 index 0000000..783bd08 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationSupport.java @@ -0,0 +1,392 @@ +/* + * 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.write; + +import com.evolveum.polygon.sql.base.SqlBaseContext; +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.connection.SqlConnection; +import com.evolveum.polygon.sql.base.search.SqlSearchExecutor; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.sql.RelationalPathBase; +import com.querydsl.sql.dml.SQLInsertClause; +import com.querydsl.sql.dml.SQLUpdateClause; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.ConnectionFailedException; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.AttributeDelta; +import org.identityconnectors.framework.common.objects.ConnectorObject; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.OperationOptions; +import org.identityconnectors.framework.common.objects.Uid; + +import java.sql.SQLException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Shared mapping, transaction, lookup, and exception support for SQL write operations. + */ +abstract class SqlWriteOperationSupport extends SqlSearchExecutor { + + protected SqlWriteOperationSupport(SqlBaseContext context, SqlObjectClassDefinition objectClass) { + super(context, objectClass); + } + + protected void requireWritable() { + if (Boolean.TRUE.equals(objectClass.getReadOnly())) { + throw new UnsupportedOperationException( + "Object class " + objectClass.name() + " is read-only"); + } + } + + protected RelationalPathBase tablePath() { + return objectClass.sql().pathAlias("o"); + } + + protected SqlAttributeDefinition uidDefinition() { + var definition = objectClass.attributeFromConnIdName(Uid.NAME); + if (definition == null || definition.sql() == null) { + throw new ConnectorException( + "Object class " + objectClass.name() + " does not define a SQL UID mapping"); + } + return definition; + } + + protected Uid suppliedUid(Collection attributes) { + if (attributes == null) { + return null; + } + for (var attribute : attributes) { + if (Uid.NAME.equals(attribute.getName())) { + var value = singleValue(attribute.getName(), attribute.getValue()); + if (value == null) { + throw invalid("UID must not be null"); + } + return new Uid(value.toString()); + } + } + + var uidDefinition = uidDefinition(); + var nameDefinition = objectClass.attributeFromConnIdName(Name.NAME); + if (uidDefinition.connId().isCreateable() + && nameDefinition != null + && mapsSameColumns(nameDefinition, uidDefinition)) { + for (var attribute : attributes) { + if (Name.NAME.equals(attribute.getName())) { + var value = singleValue(attribute.getName(), attribute.getValue()); + if (value == null) { + throw invalid("Name used as UID must not be null"); + } + return new Uid(value.toString()); + } + } + } + return null; + } + + protected Map, Object> createAssignments( + RelationalPathBase table, Collection attributes) { + var assignments = new LinkedHashMap, Object>(); + + if (attributes != null) { + for (var attribute : attributes) { + var definition = requireAttribute(attribute.getName()); + + if (definition.emulated()) { + // ConnId does not permit __UID__ in facade create requests. For a natural + // (non-generated) SQL key, the auto-emulated __NAME__ carries that key value. + var uidDefinition = uidDefinition(); + if (Name.NAME.equals(attribute.getName()) + && uidDefinition.connId().isCreateable() + && mapsSameColumns(definition, uidDefinition)) { + addAssignments(assignments, uidDefinition, + singleValue(attribute.getName(), attribute.getValue()), table); + } + continue; + } + if (!definition.connId().isCreateable()) { + throw invalid("Attribute " + attribute.getName() + " is not creatable"); + } + addAssignments(assignments, definition, + singleValue(attribute.getName(), attribute.getValue()), table); + } + } + + return assignments; + } + + protected Map, Object> updateAssignments( + RelationalPathBase table, ConnectorObject current, + Collection modifications) { + var assignments = new LinkedHashMap, Object>(); + if (modifications == null) { + return assignments; + } + + for (var modification : modifications) { + var definition = requireAttribute(modification.getName()); + if (definition.emulated() || !definition.connId().isUpdateable()) { + throw invalid("Attribute " + modification.getName() + " is not updatable"); + } + + var before = current.getAttributeByName(modification.getName()); + if (before == null) { + before = AttributeBuilder.build(modification.getName()); + } + var after = modification.applyTo(before); + addAssignments(assignments, definition, + singleValue(modification.getName(), after.getValue()), table); + } + return assignments; + } + + protected BooleanExpression uidPredicate(RelationalPathBase table, Uid uid) { + if (uid == null || uid.getUidValue() == null) { + throw invalid("UID must not be null"); + } + var filter = uidDefinition().sql().sqlFilter(); + if (filter == null) { + throw new ConnectorException( + "UID mapping for " + objectClass.name() + " does not support equality"); + } + return filter.eq(table, uid.getUidValue()); + } + + protected ConnectorObject findByUid( + SqlConnection connection, Uid uid, OperationOptions options, boolean includeAllAttributes) { + var table = tablePath(); + Map>> selectedAttributes; + if (includeAllAttributes) { + selectedAttributes = new LinkedHashMap<>(); + for (var definition : objectClass.attributes()) { + if (definition.sql() != null) { + selectedAttributes.put(definition, definition.sql().selectPaths(table)); + } + } + } else { + selectedAttributes = selectColumns(table, options); + } + + var columns = selectedAttributes.values().stream() + .flatMap(Collection::stream) + .distinct() + .toArray(Path[]::new); + if (columns.length == 0) { + throw new ConnectorException("No SQL columns are mapped for " + objectClass.name()); + } + + var row = connection.newQuery() + .select(columns) + .from(table) + .where(uidPredicate(table, uid)) + .fetchOne(); + return row != null ? buildConnectorObject(row, selectedAttributes) : null; + } + + protected ConnectorObject requireByUid( + SqlConnection connection, Uid uid, OperationOptions options, boolean includeAllAttributes) { + var object = findByUid(connection, uid, options, includeAllAttributes); + if (object == null) { + throw new UnknownUidException(uid, objectClass.objectClass()); + } + return object; + } + + protected Uid generatedUid(SqlAttributeMapping mapping, Object generatedKey, + Path table, Map, Object> assignments) { + if (generatedKey == null) { + throw new ConnectorException("Database did not return a generated UID"); + } + if (mapping instanceof SqlAttributeMapping.SingleColumn singleColumn) { + var connIdValue = singleColumn.singleValueFromAttribute(generatedKey); + if (connIdValue == null) { + throw new ConnectorException("Database returned a null generated UID"); + } + return new Uid(connIdValue.toString()); + } + if (mapping instanceof SqlAttributeMapping.MultiColumn multiColumn) { + var uid = new StringBuilder(connIdPart(multiColumn.mainColumn(), generatedKey)); + for (var additionalColumn : multiColumn.additionalColumns()) { + var path = additionalColumn.dslPath(table); + if (!assignments.containsKey(path)) { + throw new ConnectorException( + "Generated composite UID requires a value for column " + path); + } + uid.append(multiColumn.delimiter()) + .append(connIdPart(additionalColumn, assignments.get(path))); + } + return new Uid(uid.toString()); + } + throw new ConnectorException("Unsupported UID mapping " + mapping.getClass().getName()); + } + + protected Object generatedKey(SQLInsertClause insert, Path path) { + return executeWithKey(insert, path); + } + + protected void setAssignments(SQLInsertClause insert, Map, Object> assignments) { + assignments.forEach((path, value) -> set(insert, path, value)); + } + + protected void setAssignments(SQLUpdateClause update, Map, Object> assignments) { + assignments.forEach((path, value) -> set(update, path, value)); + } + + protected T inTransaction(String action, TransactionWork work) { + try (var connection = context.getConnection()) { + try { + connection.setAutoCommit(false); + var result = work.execute(connection); + connection.commit(); + return result; + } catch (Exception e) { + try { + connection.rollback(); + } catch (SQLException rollbackException) { + e.addSuppressed(rollbackException); + } + throw translate(action, e); + } + } catch (RuntimeException e) { + throw translate(action, e); + } + } + + protected InvalidAttributeValueException invalid(String message) { + return new InvalidAttributeValueException(message); + } + + private String connIdPart(SqlAttributeMapping.SingleColumn column, Object sqlValue) { + var value = column.singleValueFromAttribute(sqlValue); + if (value == null) { + throw new ConnectorException("Database returned a null UID component"); + } + return value.toString(); + } + + private SqlAttributeDefinition requireAttribute(String name) { + var definition = objectClass.attributeFromConnIdName(name); + if (definition == null || definition.sql() == null) { + throw invalid("Unknown or unmapped attribute " + name); + } + return definition; + } + + private boolean mapsSameColumns( + SqlAttributeDefinition first, SqlAttributeDefinition second) { + if (first.sql() == null || second.sql() == null) { + return false; + } + var table = tablePath(); + return new LinkedHashSet<>(first.sql().selectPaths(table)) + .equals(new LinkedHashSet<>(second.sql().selectPaths(table))); + } + + private void addAssignments(Map, Object> assignments, SqlAttributeDefinition definition, + Object value, RelationalPathBase table) { + for (var columnValue : definition.sql().columnValues(table, value)) { + var path = columnValue.path(); + if (assignments.containsKey(path) + && !Objects.deepEquals(assignments.get(path), columnValue.value())) { + throw invalid("Conflicting values for SQL column " + path); + } + assignments.put(path, columnValue.value()); + } + } + + private Object singleValue(String attributeName, List values) { + if (values == null || values.isEmpty()) { + return null; + } + if (values.size() > 1) { + throw invalid("SQL attribute " + attributeName + " must have at most one value"); + } + return values.getFirst(); + } + + private RuntimeException translate(String action, Throwable failure) { + if (failure instanceof ConnectorException connectorException) { + return connectorException; + } + if (failure instanceof IllegalArgumentException illegalArgumentException) { + return new InvalidAttributeValueException( + action + " failed: " + illegalArgumentException.getMessage(), illegalArgumentException); + } + + var sqlException = findSqlException(failure); + if (sqlException != null) { + var sqlState = sqlException.getSQLState(); + var message = action + " failed: " + sqlException.getMessage(); + if (isDuplicate(sqlException)) { + return new AlreadyExistsException(message, failure); + } + if (sqlState != null && (sqlState.startsWith("22") || sqlState.startsWith("23"))) { + return new InvalidAttributeValueException(message, failure); + } + if (sqlState != null && sqlState.startsWith("08")) { + return new ConnectionFailedException(message, failure); + } + } + return new ConnectorException(action + " failed: " + failure.getMessage(), failure); + } + + private SQLException findSqlException(Throwable failure) { + for (var current = failure; current != null; current = current.getCause()) { + if (current instanceof SQLException sqlException) { + return sqlException; + } + } + return null; + } + + private boolean isDuplicate(SQLException exception) { + return "23505".equals(exception.getSQLState()) + || exception.getErrorCode() == 1 + || exception.getErrorCode() == 1062 + || exception.getErrorCode() == 2601 + || exception.getErrorCode() == 2627; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static void set(SQLInsertClause insert, Path path, Object value) { + if (value == null) { + insert.setNull((Path) path); + } else { + insert.set((Path) path, value); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static void set(SQLUpdateClause update, Path path, Object value) { + if (value == null) { + update.setNull((Path) path); + } else { + update.set((Path) path, value); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static Object executeWithKey(SQLInsertClause insert, Path path) { + return insert.executeWithKey((Path) path); + } + + @FunctionalInterface + protected interface TransactionWork { + T execute(SqlConnection connection) throws Exception; + } +} 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 55106aa..e90ad32 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 @@ -13,13 +13,16 @@ import org.identityconnectors.framework.api.ConnectorFacade; import org.identityconnectors.framework.api.ConnectorFacadeFactory; import org.identityconnectors.framework.api.operations.*; -import org.identityconnectors.framework.common.objects.ObjectClassInfo; +import org.identityconnectors.framework.common.objects.*; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; import org.identityconnectors.test.common.TestHelpers; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -53,9 +56,10 @@ public void setUp() throws Exception { var s = c.createStatement()) { s.execute("DROP TABLE IF EXISTS app_user CASCADE"); s.execute("DROP TABLE IF EXISTS app_group CASCADE"); + s.execute("DROP TABLE IF EXISTS external_account CASCADE"); s.execute(""" CREATE TABLE app_user ( - user_id INT PRIMARY KEY, + user_id INT GENERATED BY DEFAULT AS IDENTITY (START WITH 100) PRIMARY KEY, user_name VARCHAR(50) NOT NULL, user_email VARCHAR(100), user_created_at TIMESTAMP, @@ -65,6 +69,10 @@ CREATE TABLE app_group ( group_id INT PRIMARY KEY, group_name VARCHAR(50) NOT NULL, group_description VARCHAR(200))"""); + s.execute(""" + CREATE TABLE external_account ( + account_id VARCHAR(64) PRIMARY KEY, + display_name VARCHAR(100) NOT NULL)"""); s.execute("INSERT INTO app_user VALUES (1, 'alice', 'alice@test.com', CURRENT_TIMESTAMP(), 'active')"); s.execute("INSERT INTO app_user VALUES (2, 'bob', 'bob@test.com', CURRENT_TIMESTAMP(), 'active')"); s.execute("INSERT INTO app_group VALUES (10, 'Admins', 'System administrators')"); @@ -156,4 +164,43 @@ public void configurationPropertiesAreExposed() { assertThat(props.getProperty("poolSize")).isNotNull(); assertThat(props.getProperty("scanTables")).isNotNull(); } -} \ No newline at end of file + + @Test + public void writeOperationsWorkThroughConnectorFacade() { + var person = new ObjectClass("APP_USER"); + var options = new OperationOptions(Collections.emptyMap()); + var uid = facade.create(person, Set.of( + AttributeBuilder.build(Name.NAME, "carol"), + AttributeBuilder.build("user_name", "carol"), + AttributeBuilder.build("user_email", "carol@test.com")), options); + + assertThat(uid.getUidValue()).isEqualTo("100"); + + var replacement = AttributeDeltaBuilder.build( + "user_email", List.of("carol.updated@test.com")); + assertThat(facade.updateDelta(person, uid, Set.of(replacement), options)) + .containsExactly(replacement); + + var results = new ArrayList(); + facade.search(person, + FilterBuilder.equalTo(AttributeBuilder.build(Uid.NAME, uid.getUidValue())), + results::add, options); + assertThat(results).hasSize(1); + assertThat(results.getFirst().getAttributeByName("user_email").getValue()) + .containsExactly("carol.updated@test.com"); + + facade.delete(person, uid, options); + results.clear(); + facade.search(person, + FilterBuilder.equalTo(AttributeBuilder.build(Uid.NAME, uid.getUidValue())), + results::add, options); + assertThat(results).isEmpty(); + + var externalAccount = new ObjectClass("EXTERNAL_ACCOUNT"); + var naturalUid = facade.create(externalAccount, Set.of( + AttributeBuilder.build(Name.NAME, "ext-3"), + AttributeBuilder.build("display_name", "External Three")), options); + assertThat(naturalUid.getUidValue()).isEqualTo("ext-3"); + facade.delete(externalAccount, naturalUid, options); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationIntegrationTest.java new file mode 100644 index 0000000..07e0f5a --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationIntegrationTest.java @@ -0,0 +1,280 @@ +/* + * 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.write; + +import com.evolveum.polygon.conndev.spi.ObjectCreateOperation; +import com.evolveum.polygon.sql.base.AbstractGroovySqlConnector; +import com.evolveum.polygon.sql.base.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.groovy.SqlGroovySchemaLoader; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerBuilder; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.exceptions.UnknownUidException; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.AttributeDeltaBuilder; +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.Uid; +import org.identityconnectors.framework.common.objects.filter.Filter; +import org.identityconnectors.framework.common.objects.filter.FilterBuilder; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** End-to-end H2 tests for the built-in SQL create, update, and delete handlers. */ +@Test(singleThreaded = true) +public class SqlWriteOperationIntegrationTest { + + private static final ObjectClass USER = new ObjectClass("app_user"); + private static final ObjectClass EXTERNAL_ACCOUNT = new ObjectClass("external_account"); + private static final ObjectClass MEMBERSHIP = new ObjectClass("membership"); + private static final ObjectClass GENERATED_MEMBERSHIP = new ObjectClass("generated_membership"); + private static final ObjectClass USER_VIEW = new ObjectClass("app_user_view"); + + private String jdbcUrl; + private TestSqlConnector connector; + + private static class TestSqlConnector + extends AbstractGroovySqlConnector { + + TestSqlConnector() { + super(false); + } + + @Override + protected void initializeSchema(SqlGroovySchemaLoader loader) { + // Schema is discovered from the test database. + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerBuilder builder) { + // Built-in handlers are registered after explicit handlers. + } + } + + private static class CustomCreateConnector extends TestSqlConnector { + + @Override + protected void initializeObjectClassHandler(SqlHandlerBuilder builder) { + ObjectCreateOperation custom = (attributes, options) -> new ConnectorObjectBuilder() + .setObjectClass(USER) + .setUid("custom-uid") + .setName("custom-name") + .build(); + builder.register(USER, ObjectCreateOperation.class, custom); + } + } + + @BeforeMethod + public void setUp() throws Exception { + jdbcUrl = "jdbc:h2:mem:write_" + System.nanoTime() + + ";DB_CLOSE_DELAY=-1;MODE=MySQL"; + try (var connection = DriverManager.getConnection(jdbcUrl, "sa", ""); + var statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE app_user ( + id INT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(255) NOT NULL UNIQUE, + email VARCHAR(255) UNIQUE, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' + ); + CREATE TABLE external_account ( + account_id VARCHAR(64) PRIMARY KEY, + display_name VARCHAR(255) NOT NULL + ); + CREATE TABLE membership ( + tenant_id INT NOT NULL, + account_id INT NOT NULL, + role_name VARCHAR(64) NOT NULL, + PRIMARY KEY (tenant_id, account_id) + ); + CREATE TABLE generated_membership ( + id INT AUTO_INCREMENT, + tenant_id INT NOT NULL, + role_name VARCHAR(64) NOT NULL, + PRIMARY KEY (id, tenant_id) + ); + CREATE VIEW app_user_view AS + SELECT id, username, email, status FROM app_user; + """); + } + + connector = new TestSqlConnector(); + connector.init(configuration()); + connector.schema(); + } + + @AfterMethod + public void tearDown() { + if (connector != null) { + connector.dispose(); + connector = null; + } + } + + @Test + public void testGeneratedUidCreateUpdateAndDelete() { + var createdUid = connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "new.user"), + AttributeBuilder.build("username", "new.user"), + AttributeBuilder.build("email", "new.user@example.com")), options()); + + assertThat(createdUid.getUidValue()).isNotBlank(); + var created = get(USER, createdUid); + assertThat(value(created, "username")).isEqualTo("new.user"); + assertThat(value(created, "status")).isEqualTo("ACTIVE"); + + var emailReplacement = AttributeDeltaBuilder.build( + "email", List.of("updated@example.com")); + assertThat(connector.updateDelta( + USER, createdUid, Set.of(emailReplacement), options())) + .containsExactly(emailReplacement); + assertThat(value(get(USER, createdUid), "email")).isEqualTo("updated@example.com"); + + var removeEmail = new AttributeDeltaBuilder() + .setName("email") + .addValueToRemove("updated@example.com") + .build(); + connector.updateDelta(USER, createdUid, Set.of(removeEmail), options()); + assertThat(value(get(USER, createdUid), "email")).isNull(); + + connector.delete(USER, createdUid, options()); + assertThat(search(USER, uidFilter(createdUid))).isEmpty(); + assertThatThrownBy(() -> connector.delete(USER, createdUid, options())) + .isInstanceOf(UnknownUidException.class); + } + + @Test + public void testSuppliedAndCompositeUids() { + var externalUid = connector.create(EXTERNAL_ACCOUNT, Set.of( + AttributeBuilder.build(Uid.NAME, "ext-100"), + AttributeBuilder.build(Name.NAME, "ext-100"), + AttributeBuilder.build("display_name", "External account")), options()); + assertThat(externalUid.getUidValue()).isEqualTo("ext-100"); + assertThat(value(get(EXTERNAL_ACCOUNT, externalUid), "display_name")) + .isEqualTo("External account"); + + var membershipUid = connector.create(MEMBERSHIP, Set.of( + AttributeBuilder.build(Uid.NAME, "10.20"), + AttributeBuilder.build(Name.NAME, "10.20"), + AttributeBuilder.build("role_name", "owner")), options()); + assertThat(membershipUid.getUidValue()).isEqualTo("10.20"); + + var replacement = AttributeDeltaBuilder.build("role_name", List.of("reviewer")); + connector.updateDelta(MEMBERSHIP, membershipUid, Set.of(replacement), options()); + assertThat(value(get(MEMBERSHIP, membershipUid), "role_name")).isEqualTo("reviewer"); + + connector.delete(MEMBERSHIP, membershipUid, options()); + assertThat(search(MEMBERSHIP, uidFilter(membershipUid))).isEmpty(); + + var generatedMembershipUid = connector.create(GENERATED_MEMBERSHIP, Set.of( + AttributeBuilder.build(Name.NAME, "generated-membership"), + AttributeBuilder.build("tenant_id", 42), + AttributeBuilder.build("role_name", "member")), options()); + assertThat(generatedMembershipUid.getUidValue()).endsWith(".42"); + assertThat(value(get(GENERATED_MEMBERSHIP, generatedMembershipUid), "role_name")) + .isEqualTo("member"); + connector.delete(GENERATED_MEMBERSHIP, generatedMembershipUid, options()); + } + + @Test + public void testValidationConstraintMappingRollbackAndReadOnlyView() { + var existingUid = connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "duplicate"), + AttributeBuilder.build("username", "duplicate"), + AttributeBuilder.build("email", "first@example.com")), options()); + + assertThatThrownBy(() -> connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "duplicate"), + AttributeBuilder.build("username", "duplicate"), + AttributeBuilder.build("email", "second@example.com")), options())) + .isInstanceOf(AlreadyExistsException.class); + assertThat(search(USER, null)).hasSize(1); + + var uidReplacement = AttributeDeltaBuilder.build(Uid.NAME, List.of("other-uid")); + assertThatThrownBy(() -> connector.updateDelta( + USER, existingUid, Set.of(uidReplacement), options())) + .isInstanceOf(InvalidAttributeValueException.class); + + var missingUpdate = AttributeDeltaBuilder.build("email", List.of("missing@example.com")); + assertThatThrownBy(() -> connector.updateDelta( + USER, new Uid("999999"), Set.of(missingUpdate), options())) + .isInstanceOf(UnknownUidException.class); + + assertThatThrownBy(() -> connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "missing-username"), + AttributeBuilder.build("email", "missing@example.com")), options())) + .isInstanceOf(InvalidAttributeValueException.class) + .hasMessageContaining("USERNAME"); + + assertThatThrownBy(() -> connector.create(USER_VIEW, Collections.emptySet(), options())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + public void testExplicitCreateHandlerTakesPrecedence() { + connector.dispose(); + connector = new CustomCreateConnector(); + connector.init(configuration()); + connector.schema(); + + var uid = connector.create(USER, Collections.emptySet(), options()); + + assertThat(uid.getUidValue()).isEqualTo("custom-uid"); + assertThat(search(USER, null)).isEmpty(); + } + + private SqlConnectorConfiguration configuration() { + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(jdbcUrl); + configuration.setUsername("sa"); + configuration.setPassword(new GuardedString("".toCharArray())); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setScanTables(true); + configuration.setScanViews(true); + return configuration; + } + + private OperationOptions options() { + return new OperationOptions(Collections.emptyMap()); + } + + private ConnectorObject get(ObjectClass objectClass, Uid uid) { + var result = search(objectClass, uidFilter(uid)); + assertThat(result).hasSize(1); + return result.getFirst(); + } + + private List search(ObjectClass objectClass, Filter filter) { + var result = new ArrayList(); + connector.executeQuery(objectClass, filter, result::add, options()); + return result; + } + + private Filter uidFilter(Uid uid) { + return FilterBuilder.equalTo(AttributeBuilder.build(Uid.NAME, uid.getUidValue())); + } + + private Object value(ConnectorObject object, String attributeName) { + var values = object.getAttributeByName(attributeName).getValue(); + return values.isEmpty() ? null : values.getFirst(); + } +} diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationPostgresTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationPostgresTest.java new file mode 100644 index 0000000..94b80c2 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlWriteOperationPostgresTest.java @@ -0,0 +1,235 @@ +/* + * 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.write; + +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.groovy.SqlGroovySchemaLoader; +import com.evolveum.polygon.sql.base.groovy.SqlHandlerBuilder; +import com.evolveum.polygon.sql.base.test.PostgresDatabaseInitializer; +import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.AttributeDeltaBuilder; +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.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.Test; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** PostgreSQL 16 coverage for SQL write operations and dialect-specific generated keys. */ +@Test(singleThreaded = true) +public class SqlWriteOperationPostgresTest { + + private static final ObjectClass USER = new ObjectClass("app_user"); + private static final ObjectClass MEMBERSHIP = new ObjectClass("tenant_membership"); + private static final ObjectClass ACCOUNT = new ObjectClass("account"); + private static final ObjectClass QUOTED_ACCOUNT = new ObjectClass("QuotedAccount"); + private static final ObjectClass PROJECT_MEMBERSHIP = new ObjectClass("projectmembership"); + + private PostgresDatabaseInitializer postgres; + private TestSqlConnector connector; + + private static class TestSqlConnector + extends AbstractGroovySqlConnector { + + TestSqlConnector() { + super(false); + } + + @Override + protected void initializeSchema(SqlGroovySchemaLoader loader) { + // Schema is discovered from the test database. + } + + @Override + protected void initializeObjectClassHandler(SqlHandlerBuilder builder) { + // Use built-in operation handlers. + } + } + + @BeforeClass + public void setUp() throws Exception { + postgres = PostgresDatabaseInitializer.create(); + var password = new GuardedStringAccessor(); + postgres.getPassword().access(password); + try (Connection connection = DriverManager.getConnection( + postgres.getJdbcUrl(), postgres.getUsername(), password.getClearString())) { + executeSql(connection, "postgresql/basic/schema.sql"); + executeSql(connection, "postgresql/basic/data.sql"); + try (var statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE tenant_membership ( + tenant_id INT NOT NULL, + account_id INT NOT NULL, + role_name VARCHAR(64) NOT NULL, + PRIMARY KEY (tenant_id, account_id) + ); + CREATE SCHEMA provisioning; + CREATE TABLE provisioning.account ( + id BIGSERIAL PRIMARY KEY, + login VARCHAR(255) NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL, + quota NUMERIC(10, 2) + ); + CREATE SCHEMA "ProvisioningCase"; + CREATE TABLE "ProvisioningCase"."QuotedAccount" ( + "Id" BIGSERIAL PRIMARY KEY, + "Login" VARCHAR(255) NOT NULL UNIQUE, + "Enabled" BOOLEAN NOT NULL + ); + """); + } + } + + var configuration = new SqlConnectorConfiguration(); + configuration.setJdbcUrl(postgres.getJdbcUrl()); + configuration.setUsername(postgres.getUsername()); + configuration.setPassword(postgres.getPassword()); + configuration.setPoolSize(5); + configuration.setConnectionTimeout(10000); + configuration.setScanTables(true); + configuration.setScanViews(true); + + connector = new TestSqlConnector(); + connector.init(configuration); + connector.schema(); + } + + @AfterClass + public void tearDown() { + if (connector != null) { + connector.dispose(); + connector = null; + } + if (postgres != null) { + postgres.close(); + postgres = null; + } + } + + @Test + public void testPostgresCreateUpdateDeleteConstraintsAndCompositeUid() { + var createdAt = ZonedDateTime.now().withNano(0); + var userUid = connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "postgres.user"), + AttributeBuilder.build("username", "postgres.user"), + AttributeBuilder.build("email", "postgres.user@example.com"), + AttributeBuilder.build("created_at", createdAt)), options()); + + assertThat(userUid.getUidValue()).isNotBlank(); + assertThat(value(get(USER, userUid), "username")).isEqualTo("postgres.user"); + assertThat(value(get(USER, userUid), "created_at")).isNotNull(); + + var email = AttributeDeltaBuilder.build("email", List.of("changed@example.com")); + connector.updateDelta(USER, userUid, Set.of(email), options()); + assertThat(value(get(USER, userUid), "email")).isEqualTo("changed@example.com"); + + assertThatThrownBy(() -> connector.create(USER, Set.of( + AttributeBuilder.build(Name.NAME, "postgres.user"), + AttributeBuilder.build("username", "postgres.user")), options())) + .isInstanceOf(AlreadyExistsException.class); + + var beforeFailedForeignKeyInsert = search(PROJECT_MEMBERSHIP, null).size(); + assertThatThrownBy(() -> connector.create(PROJECT_MEMBERSHIP, Set.of( + AttributeBuilder.build(Name.NAME, "invalid-membership"), + AttributeBuilder.build("user_id", 99999), + AttributeBuilder.build("project_id", 1), + AttributeBuilder.build("role_id", 1)), options())) + .isInstanceOf(InvalidAttributeValueException.class); + assertThat(search(PROJECT_MEMBERSHIP, null)).hasSize(beforeFailedForeignKeyInsert); + + var membershipUid = connector.create(MEMBERSHIP, Set.of( + AttributeBuilder.build(Uid.NAME, "7.8"), + AttributeBuilder.build(Name.NAME, "7.8"), + AttributeBuilder.build("role_name", "member")), options()); + assertThat(membershipUid.getUidValue()).isEqualTo("7.8"); + connector.delete(MEMBERSHIP, membershipUid, options()); + assertThat(search(MEMBERSHIP, uidFilter(membershipUid))).isEmpty(); + + var accountUid = connector.create(ACCOUNT, Set.of( + AttributeBuilder.build(Name.NAME, "qualified.account"), + AttributeBuilder.build("login", "qualified.account"), + AttributeBuilder.build("enabled", true), + AttributeBuilder.build("quota", new BigDecimal("125.50"))), options()); + var account = get(ACCOUNT, accountUid); + assertThat(value(account, "enabled")).isEqualTo(true); + assertThat(value(account, "quota")).isEqualTo(new BigDecimal("125.50")); + + connector.delete(ACCOUNT, accountUid, options()); + + var quotedAccountUid = connector.create(QUOTED_ACCOUNT, Set.of( + AttributeBuilder.build(Name.NAME, "quoted.account"), + AttributeBuilder.build("Login", "quoted.account"), + AttributeBuilder.build("Enabled", true)), options()); + var quotedAccount = get(QUOTED_ACCOUNT, quotedAccountUid); + assertThat(value(quotedAccount, "Login")).isEqualTo("quoted.account"); + assertThat(value(quotedAccount, "Enabled")).isEqualTo(true); + connector.delete(QUOTED_ACCOUNT, quotedAccountUid, options()); + + connector.delete(USER, userUid, options()); + assertThat(search(USER, uidFilter(userUid))).isEmpty(); + } + + private OperationOptions options() { + return new OperationOptions(Collections.emptyMap()); + } + + private ConnectorObject get(ObjectClass objectClass, Uid uid) { + var result = search(objectClass, uidFilter(uid)); + assertThat(result).hasSize(1); + return result.getFirst(); + } + + private List search(ObjectClass objectClass, Filter filter) { + var result = new ArrayList(); + connector.executeQuery(objectClass, filter, result::add, options()); + return result; + } + + private Filter uidFilter(Uid uid) { + return FilterBuilder.equalTo(AttributeBuilder.build(Uid.NAME, uid.getUidValue())); + } + + private Object value(ConnectorObject object, String attributeName) { + var values = object.getAttributeByName(attributeName).getValue(); + return values.isEmpty() ? null : values.getFirst(); + } + + private static void executeSql(Connection connection, String resourcePath) throws Exception { + try (var statement = connection.createStatement()) { + statement.execute(readResource(resourcePath)); + } + } + + private static String readResource(String path) throws IOException { + var stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(path); + return new String(Objects.requireNonNull(stream, "Resource not found: " + path).readAllBytes(), + StandardCharsets.UTF_8); + } +} diff --git a/docs/sql-connector-reference.adoc b/docs/sql-connector-reference.adoc index 83f6958..94f9cb8 100644 --- a/docs/sql-connector-reference.adoc +++ b/docs/sql-connector-reference.adoc @@ -337,8 +337,21 @@ By default, the connector binds QueryDSL-based handlers: * `ObjectSearchOperation` -> `SqlSearchOperation` -- paginated SELECT with WHERE, ORDER BY * `ObjectSyncOperation` -> `SqlSyncOperation` -- incremental sync using a sync column +* `ObjectCreateOperation` -> `SqlCreateOperation` -- transactional INSERT and read-back +* `ObjectUpdateOperation` -> `SqlUpdateOperation` -- transactional update-delta by exact UID +* `ObjectDeleteOperation` -> `SqlDeleteOperation` -- transactional DELETE by exact UID -Create, update, and delete operations have stub handlers (`SqlHandlerBuilder.create()` returns `this` without actual implementation). Override them in operation scripts for custom SQL. +Write handlers are registered only for writable table object classes. JDBC-discovered views and object classes configured with `readOnly true` expose search and sync, but not create, update, or delete. A handler explicitly registered by an operation script takes precedence over the corresponding built-in handler. + +=== Built-in Write Semantics + +Create converts each creatable ConnId attribute through its SQL value mapping and omits absent columns, allowing database defaults to apply. For identity/auto-increment primary keys, it obtains the generated key and reads the inserted row back in the same transaction. ConnId does not allow `__UID__` in facade create requests; for a non-generated primary key, the connector therefore uses the auto-emulated `__NAME__` value as the UID. Composite UIDs join their physical key components with `.`. + +Update first reads the current row, applies each `AttributeDelta`, and writes the resulting scalar column values. This supports replace, add, and remove delta forms while preserving ConnId delta semantics. UID columns, auto-increment columns, read-only attributes, and emulated attributes are not updatable. + +Delete uses the UID mapping as an exact predicate. Update and delete require exactly one affected row; zero rows produce `UnknownUidException`. + +Each built-in write runs in a JDBC transaction. Constraint and data failures are translated to ConnId exceptions: duplicate keys to `AlreadyExistsException`, invalid values/not-null/foreign-key/check failures to `InvalidAttributeValueException`, and SQL connection failures to `ConnectionFailedException`. The transaction is rolled back before the exception is returned. === Sync Configuration @@ -684,4 +697,4 @@ When `developmentMode` is true, the connector registers additional ConnId object |Groovy script compiles but has a runtime error |Review connector logs. Groovy errors are logged at ERROR level with full stack traces. |Schema detection breaks on H2 |Ensure `SqlSchemaDetector.getTables()` is called with `null` as the first argument (not the JDBC URL). -|=== \ No newline at end of file +|=== diff --git a/docs/sql-connector-tutorial.adoc b/docs/sql-connector-tutorial.adoc index eccc263..847e3d5 100644 --- a/docs/sql-connector-tutorial.adoc +++ b/docs/sql-connector-tutorial.adoc @@ -383,8 +383,23 @@ By default, the connector binds QueryDSL-based handlers: * `ObjectSearchOperation` -> `SqlSearchOperation` -- paginated SELECT with WHERE, ORDER BY * `ObjectSyncOperation` -> `SqlSyncOperation` -- incremental sync using a sync column +* `ObjectCreateOperation` -> `SqlCreateOperation` -- transactional INSERT and read-back +* `ObjectUpdateOperation` -> `SqlUpdateOperation` -- transactional update-delta by UID +* `ObjectDeleteOperation` -> `SqlDeleteOperation` -- transactional DELETE by UID -Create, update, and delete operations have stub handlers (`SqlHandlerBuilder.create()`). Override them in operation scripts for custom SQL. +The write handlers are available automatically for writable tables. Views and object classes declared with `readOnly true` do not receive create, update, or delete handlers. An explicitly registered handler in an operation script overrides the corresponding default. + +=== Using the Built-in Write Operations + +No operation script is required for ordinary single-table provisioning. Keep the manifest's `operation` array empty and define a SQL-backed `__UID__` mapping. + +For an auto-generated primary key, send `__NAME__` and the creatable data attributes. The connector omits the UID column, retrieves the generated key, and reads the created row back in one transaction. + +For a natural or composite primary key, ConnId still does not permit `__UID__` in a facade create request. If no separate `__NAME__` mapping is configured, the connector auto-emulates `__NAME__` from the UID mapping; provide the natural/composite key in `__NAME__`. Composite key parts use `.` as the delimiter, for example `tenant-1.account-42`. + +Update accepts ConnId `AttributeDelta` values. Replace, add, and remove forms are applied to the current row before one SQL UPDATE is executed. Delete and update use the full UID mapping and fail with `UnknownUidException` if no row matches. + +All writes are transactional. Duplicate keys become `AlreadyExistsException`; not-null, foreign-key, check, and value-conversion failures become `InvalidAttributeValueException`; failed transactions are rolled back. === Sync Configuration @@ -423,7 +438,7 @@ objectClass("User") { === Custom Search -The handler closure's top-level function must be defined at the script's outer scope, and the script's delegate receives it. Here's a simplified pattern you can follow -- the delegate is a `GroovyHandlerFacade` that only accepts handlers (not closure-based overrides in the current codebase `create()` method is a stub): +The handler closure's top-level function must be defined at the script's outer scope, and the script's delegate receives it. The delegate is a `GroovyHandlerFacade` that accepts complete operation-handler objects: [source,groovy] // In your handler script, define a Groovy function at top level: @@ -607,4 +622,4 @@ Read the xref:sql-connector-reference.adoc[SQL Connector Reference] for: * Sync strategy detail * Filter support matrices * JDBC driver compatibility -* Troubleshooting guide \ No newline at end of file +* Troubleshooting guide