diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/SqlTableAccess.java b/base/src/main/java/com/evolveum/polygon/sql/base/SqlTableAccess.java new file mode 100644 index 0000000..278b25c --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/SqlTableAccess.java @@ -0,0 +1,242 @@ +/* + * 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 com.evolveum.polygon.sql.base.connection.SqlConnection; +import com.evolveum.polygon.sql.base.schema.SqlColumnMeta; +import com.evolveum.polygon.sql.base.schema.SqlTableInfo; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.PathMetadataFactory; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.SimpleExpression; +import com.querydsl.sql.RelationalPathBase; +import com.querydsl.sql.dml.SQLDeleteClause; +import com.querydsl.sql.dml.SQLInsertClause; +import org.identityconnectors.framework.common.exceptions.ConnectorException; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Metadata-aware access to a dynamically detected SQL table. + * + *

Centralizes case-insensitive column lookup, typed QueryDSL paths, value conversion, + * schema-qualified DML, and predicates used by related-table readers and writers.

+ */ +public final class SqlTableAccess { + + private final SqlBaseContext context; + private final SqlTableInfo table; + private final RelationalPathBase path; + private final Map> columnPaths = new LinkedHashMap<>(); + + public SqlTableAccess(SqlBaseContext context, String tableName, String alias) { + this(context, requireTable(context, tableName), alias); + } + + public SqlTableAccess( + SqlBaseContext context, String tableName, RelationalPathBase path) { + this.context = context; + this.table = requireTable(context, tableName); + this.path = path; + } + + private SqlTableAccess(SqlBaseContext context, SqlTableInfo table, String alias) { + this.context = context; + this.table = table; + var schema = table.getSchema(); + if (schema != null && (schema.isBlank() || "null".equalsIgnoreCase(schema))) { + schema = null; + } + this.path = new RelationalPathBase<>( + Object.class, PathMetadataFactory.forVariable(alias), schema, table.getName()); + } + + public SqlTableInfo metadata() { + return table; + } + + public RelationalPathBase path() { + return path; + } + + public String actualColumn(String columnName) { + var column = column(columnName); + if (column == null) { + throw new ConnectorException( + "Column " + columnName + " was not detected in " + table.getName()); + } + return column.getName(); + } + + public SqlColumnMeta column(String columnName) { + return table.getColumns().stream() + .filter(candidate -> candidate.getName().equalsIgnoreCase(columnName)) + .findFirst() + .orElse(null); + } + + public Path columnPath(String columnName) { + var key = columnName.toLowerCase(Locale.ROOT); + var existing = columnPaths.get(key); + if (existing != null) { + return existing; + } + var column = column(columnName); + Path result; + if (column != null && column.getValueMapping() != null) { + result = column.getValueMapping().pathFor(path, column.getName()); + } else { + result = Expressions.path(Object.class, path, actualColumn(columnName)); + } + columnPaths.put(key, result); + return result; + } + + public Object value(Tuple row, String columnName) { + return row.get(columnPath(columnName)); + } + + public Object toWireValue(String columnName, Object value) { + var column = column(columnName); + if (column == null || column.getValueMapping() == null || value == null) { + return value; + } + var mapping = column.getValueMapping(); + if (mapping.primaryWireType().isInstance(value)) { + return value; + } + if (value instanceof String stringValue) { + return parse(stringValue, mapping.primaryWireType()); + } + return mapping.toWireValue(value); + } + + public Object toConnIdValue(String columnName, Object value) { + var column = column(columnName); + if (value == null || column == null || column.getValueMapping() == null) { + return value; + } + var mapping = column.getValueMapping(); + var wireValue = value; + if (!mapping.primaryWireType().isInstance(value) && value instanceof Number) { + wireValue = parse(value.toString(), mapping.primaryWireType()); + } + return mapping.toConnIdValue(wireValue); + } + + public BooleanExpression predicate(Map criteria) { + if (criteria.isEmpty()) { + throw new IllegalArgumentException("A SQL predicate requires at least one criterion"); + } + BooleanExpression result = null; + for (var entry : criteria.entrySet()) { + var current = equal(columnPath(entry.getKey()), entry.getValue()); + result = result == null ? current : result.and(current); + } + return result; + } + + public BooleanExpression matchingAny(Collection> alternatives) { + if (alternatives.isEmpty()) { + throw new IllegalArgumentException("A SQL predicate requires at least one alternative"); + } + BooleanExpression result = null; + for (var alternative : alternatives) { + var current = predicate(alternative); + result = result == null ? current : result.or(current); + } + return result; + } + + public void insert(SqlConnection connection, Map assignments) { + if (assignments.isEmpty()) { + throw new IllegalArgumentException("No values supplied for table " + table.getName()); + } + var insert = new SQLInsertClause( + connection.getConnection(), context.getSqlTemplates(), path); + assignments.forEach((column, value) -> set(insert, columnPath(column), value)); + insert.execute(); + } + + public long delete(SqlConnection connection, Map criteria) { + return new SQLDeleteClause(connection.getConnection(), context.getSqlTemplates(), path) + .where(predicate(criteria)) + .execute(); + } + + public boolean exists(SqlConnection connection, Map criteria) { + return connection.newQuery() + .select(Expressions.ONE) + .from(path) + .where(predicate(criteria)) + .fetchFirst() != null; + } + + private static SqlTableInfo requireTable(SqlBaseContext context, String tableName) { + var table = context.findTableInfo(tableName); + if (table == null) { + throw new ConnectorException("No detected metadata for related table " + tableName); + } + return table; + } + + private static Object parse(String value, Class targetType) { + if (targetType == String.class) { + return value; + } + if (targetType == BigInteger.class) { + return new BigInteger(value); + } + if (targetType == BigDecimal.class) { + return new BigDecimal(value); + } + if (targetType == Integer.class) { + return Integer.valueOf(value); + } + if (targetType == Long.class) { + return Long.valueOf(value); + } + if (targetType == Short.class) { + return Short.valueOf(value); + } + if (targetType == Byte.class) { + return Byte.valueOf(value); + } + if (targetType == Double.class) { + return Double.valueOf(value); + } + if (targetType == Float.class) { + return Float.valueOf(value); + } + if (targetType == Boolean.class) { + return Boolean.valueOf(value); + } + return value; + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static BooleanExpression equal(Path path, Object value) { + var expression = (SimpleExpression) path; + return value == null ? expression.isNull() : expression.eq(value); + } + + @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); + } + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassDefinition.java b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassDefinition.java index 8b254b6..df5fc29 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassDefinition.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassDefinition.java @@ -8,11 +8,14 @@ import com.evolveum.polygon.conndev.dev.ConnDevObjectClass; import com.evolveum.polygon.conndev.schema.BaseObjectClassDefinition; +import com.evolveum.polygon.sql.base.schema.SqlChildJoinConfig; +import com.evolveum.polygon.sql.base.schema.SqlJunctionJoinConfig; import org.identityconnectors.framework.common.objects.Attribute; import org.identityconnectors.framework.common.objects.AttributeBuilder; import org.identityconnectors.framework.common.objects.ObjectClassInfo; import java.util.ArrayList; +import java.util.List; import java.util.Map; @@ -20,15 +23,21 @@ public class SqlObjectClassDefinition extends BaseObjectClassDefinition relatedAttributeJoinConfigs; + private final List junctionJoinConfigs; public SqlObjectClassDefinition(ObjectClassInfo connId, Map nativeAttrs, Map connIdAttrs, SqlSchemaBuilderImpl.SqlObjectClassMapping sql, - Boolean readOnly) { + Boolean readOnly, + List relatedAttributeJoinConfigs, + List junctionJoinConfigs) { super(connId, nativeAttrs, connIdAttrs); this.sql = sql; this.readOnly = readOnly; + this.relatedAttributeJoinConfigs = List.copyOf(relatedAttributeJoinConfigs); + this.junctionJoinConfigs = List.copyOf(junctionJoinConfigs); } /** @@ -51,6 +60,16 @@ public SqlSchemaBuilderImpl.SqlObjectClassMapping sql() { return this.sql; } + /** Child-table joins exposed as scalar or embedded attributes on this object class. */ + public List relatedAttributeJoinConfigs() { + return relatedAttributeJoinConfigs; + } + + /** Junction-table joins exposed as reference attributes on this object class. */ + public List junctionJoinConfigs() { + return junctionJoinConfigs; + } + @Override public void contribute(ConnDevObjectClass target) { if (sql == null) { diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassSchemaBuilderImpl.java b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassSchemaBuilderImpl.java index b2daf56..f956f2a 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassSchemaBuilderImpl.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/build/api/SqlObjectClassSchemaBuilderImpl.java @@ -34,7 +34,7 @@ public class SqlObjectClassSchemaBuilderImpl extends BaseObjectClassDefinitionBu private Boolean onlyExplicitlyListed = false; private DefinitionValue readOnly = DefinitionValue.DEFAULT_FALSE; private final Set explicitRemoteNames = new LinkedHashSet<>(); - private final List embeddedJoinConfigs = new ArrayList<>(); + private final List relatedAttributeJoinConfigs = new ArrayList<>(); private final List junctionJoinConfigs = new ArrayList<>(); public SqlObjectClassSchemaBuilderImpl(SqlSchemaBuilderImpl restSchemaBuilder, DefinitionValue name) { @@ -90,9 +90,9 @@ public Set getExplicitRemoteNames() { return explicitRemoteNames; } - /** Adds a join config for an embedded child table. */ - public void addEmbeddedJoinConfig(SqlChildJoinConfig config) { - embeddedJoinConfigs.add(config); + /** Adds a join config for a scalar or embedded attribute stored in a child table. */ + public void addRelatedAttributeJoinConfig(SqlChildJoinConfig config) { + relatedAttributeJoinConfigs.add(config); } /** Adds a join config for a junction table reference. */ @@ -100,16 +100,6 @@ public void addJunctionJoinConfig(SqlJunctionJoinConfig config) { junctionJoinConfigs.add(config); } - /** Returns the embedded join configurations. */ - public List getEmbeddedJoinConfigs() { - return Collections.unmodifiableList(embeddedJoinConfigs); - } - - /** Returns the junction join configurations. */ - public List getJunctionJoinConfigs() { - return Collections.unmodifiableList(junctionJoinConfigs); - } - /** * Finds an attribute by name using case-insensitive matching. * Used for Oracle compatibility where column names may differ in case. @@ -201,7 +191,9 @@ protected SqlObjectClassDefinition buildImpl(ObjectClassInfo connIdInfo, var sql = new SqlSchemaBuilderImpl.SqlObjectClassMapping(schema, table); - return new SqlObjectClassDefinition(connIdInfo, nativeAttrs, connIdAttrs, sql, readOnly.value()); + return new SqlObjectClassDefinition( + connIdInfo, nativeAttrs, connIdAttrs, sql, readOnly.value(), + relatedAttributeJoinConfigs, junctionJoinConfigs); } @Override diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlChildJoinConfig.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlChildJoinConfig.java index 4a96f8c..018825d 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlChildJoinConfig.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlChildJoinConfig.java @@ -6,21 +6,33 @@ */ package com.evolveum.polygon.sql.base.schema; +import java.util.List; + +import com.evolveum.polygon.sql.base.schema.ChildTableRelationship.JoinKey; + /** - * Configuration for an embedded child table join. + * Configuration for an attribute stored in an owned child table. * For simple attribute joins (FK + one value column), {@code valueColumn} specifies * the column to extract as a scalar value instead of building an EmbeddedObject. */ public record SqlChildJoinConfig( + String parentTable, String childTable, - String parentJoinColumn, - String childJoinColumn, + List joinKeys, boolean multiValued, String targetAttributeName, String valueColumn ) { - public SqlChildJoinConfig(String childTable, String parentJoinColumn, String childJoinColumn, - boolean multiValued, String targetAttributeName) { - this(childTable, parentJoinColumn, childJoinColumn, multiValued, targetAttributeName, null); + public SqlChildJoinConfig { + joinKeys = List.copyOf(joinKeys); + if (joinKeys.isEmpty()) { + throw new IllegalArgumentException("A child-table relationship requires at least one join key"); + } + } + + public SqlChildJoinConfig( + String parentTable, String childTable, List joinKeys, + boolean multiValued, String targetAttributeName) { + this(parentTable, childTable, joinKeys, multiValued, targetAttributeName, null); } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlJunctionJoinConfig.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlJunctionJoinConfig.java index 3ee5805..0ced378 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlJunctionJoinConfig.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/SqlJunctionJoinConfig.java @@ -6,13 +6,25 @@ */ package com.evolveum.polygon.sql.base.schema; +import java.util.List; + +import com.evolveum.polygon.sql.base.schema.ChildTableRelationship.JoinKey; + /** * Configuration for a junction table join. */ public record SqlJunctionJoinConfig( + String parentTable, String junctionTable, - String parentJoinColumn, - String junctionParentKey, - String junctionTargetKey, + List parentJoinKeys, + List targetJoinKeys, String targetObjectClass -) {} +) { + public SqlJunctionJoinConfig { + parentJoinKeys = List.copyOf(parentJoinKeys); + targetJoinKeys = List.copyOf(targetJoinKeys); + if (parentJoinKeys.isEmpty() || targetJoinKeys.isEmpty()) { + throw new IllegalArgumentException("A junction relationship requires parent and target join keys"); + } + } +} 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 240dc3e..eb3de51 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 @@ -235,14 +235,18 @@ private void detectRelationships() { List parentKeys = new ArrayList<>(); for (var fk : fkColumns.values()) { if (fk.targetTable().toUpperCase().equals(otherTarget)) { - parentKeys.add(new JoinKey("id", fk.childColumn())); + var referencedColumn = fk.referencedColumn() != null + ? fk.referencedColumn() : "id"; + parentKeys.add(new JoinKey(referencedColumn, fk.childColumn())); } } List targetKeys = new ArrayList<>(); for (var fk : fkColumns.values()) { if (fk.targetTable().toUpperCase().equals(targetKey)) { - targetKeys.add(new JoinKey(fk.childColumn(), "id")); + var referencedColumn = fk.referencedColumn() != null + ? fk.referencedColumn() : "id"; + targetKeys.add(new JoinKey(referencedColumn, fk.childColumn())); } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/schema/strategy/ChildTableRelationshipDetectionRule.java b/base/src/main/java/com/evolveum/polygon/sql/base/schema/strategy/ChildTableRelationshipDetectionRule.java index a57bf85..9c9ee0d 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/schema/strategy/ChildTableRelationshipDetectionRule.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/schema/strategy/ChildTableRelationshipDetectionRule.java @@ -103,7 +103,8 @@ private void addSimpleAttribute(SqlObjectClassSchemaBuilder objectClass, attr.connId().type(String.class); } attr.connId().multiValued(detected(true)); - ((SqlObjectClassSchemaBuilderImpl) objectClass).addEmbeddedJoinConfig(createSimpleAttributeJoinConfig(rel)); + ((SqlObjectClassSchemaBuilderImpl) objectClass).addRelatedAttributeJoinConfig( + createSimpleAttributeJoinConfig(rel)); } private void registerSimpleAttributeResolver(SqlObjectOperationBuilderImpl hBuilder, @@ -124,7 +125,8 @@ private void addEmbeddedAttribute(SqlObjectClassSchemaBuilder objectClass, var attr = (SqlAttributeBuilderImpl) objectClass.attribute(attrName); attr.complexType(detected(rel.childTable())); attr.connId().multiValued(detected(multiValued)); - ((SqlObjectClassSchemaBuilderImpl) objectClass).addEmbeddedJoinConfig(createSqlJoinConfig(rel)); + ((SqlObjectClassSchemaBuilderImpl) objectClass).addRelatedAttributeJoinConfig( + createSqlJoinConfig(rel)); } private void addReferenceAttribute(SqlObjectClassSchemaBuilder objectClass, @@ -133,6 +135,9 @@ private void addReferenceAttribute(SqlObjectClassSchemaBuilder objectClass, var ref = (SqlAttributeBuilderImpl) ((SqlObjectClassSchemaBuilderImpl) objectClass).reference(detected(targetTable)); ref.objectClass(targetTable); ref.connId().multiValued(detected(true)); + // Built-in writes currently support owned child rows, not links to independent objects. + ref.connId().creatable(detected(false)); + ref.connId().updatable(detected(false)); ((SqlObjectClassSchemaBuilderImpl) objectClass).addJunctionJoinConfig(createJunctionConfig(jr)); } @@ -163,28 +168,26 @@ private ContextLookup resolveContextLookup(SqlObjectOperationBuilderImpl hBuilde } private SqlChildJoinConfig createSqlJoinConfig(ChildTableRelationship rel) { - var jk = rel.joinKeys().getFirst(); return new SqlChildJoinConfig( - rel.childTable(), jk.parentColumn(), jk.childColumn(), + rel.parentTable(), rel.childTable(), rel.joinKeys(), !rel.type().isSingleValue(), rel.childTable()); } private SqlChildJoinConfig createSimpleAttributeJoinConfig(ChildTableRelationship rel) { - var jk = rel.joinKeys().getFirst(); var sar = (ChildTableRelationship.SimpleAttributeRelationship) rel; String valueCol = sar.valueColumn() != null ? sar.valueColumn().getName() : null; return new SqlChildJoinConfig( - rel.childTable(), jk.parentColumn(), jk.childColumn(), + rel.parentTable(), rel.childTable(), rel.joinKeys(), true, rel.childTable(), valueCol); } private SqlJunctionJoinConfig createJunctionConfig(ChildTableRelationship.JunctionRelationship jr) { return new SqlJunctionJoinConfig( + jr.parentTable(), jr.junctionTable(), - jr.parentJoinKeys().getFirst().parentColumn(), - jr.parentJoinKeys().getFirst().childColumn(), - jr.targetJoinKeys().getFirst().childColumn(), + jr.parentJoinKeys(), + jr.targetJoinKeys(), jr.targetTable() ); } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJoinAttributeResolver.java b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJoinAttributeResolver.java index 271ebd1..6ee2996 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJoinAttributeResolver.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJoinAttributeResolver.java @@ -11,34 +11,36 @@ import com.evolveum.polygon.conndev.schema.BaseAttributeDefinition; import com.evolveum.polygon.conndev.spi.AttributeResolver; import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.SqlTableAccess; import com.evolveum.polygon.sql.base.connection.SqlConnection; import com.evolveum.polygon.sql.base.schema.SqlChildJoinConfig; -import com.querydsl.core.Tuple; -import com.querydsl.core.types.PathMetadataFactory; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.StringPath; -import com.querydsl.sql.RelationalPathBase; -import com.querydsl.sql.SQLQuery; +import com.querydsl.core.types.Path; import org.identityconnectors.framework.common.exceptions.ConnectorException; -import org.identityconnectors.framework.common.objects.*; - -import java.sql.Connection; -import java.util.*; - -/** - * Batch attribute resolver that fetches child table data using parameterized queries. - * Supports both embedded object mode (multiple columns) and simple attribute mode (single column). - */ +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.ConnectorObjectBuilder; +import org.identityconnectors.framework.common.objects.EmbeddedObject; +import org.identityconnectors.framework.common.objects.ObjectClass; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Resolves scalar or embedded attributes stored in an owned child table. */ public class SqlJoinAttributeResolver implements AttributeResolver { private final SqlBaseContext sqlContext; private final SqlChildJoinConfig config; private final String attributeName; - public SqlJoinAttributeResolver(SqlBaseContext ctx, SqlChildJoinConfig config, String attr) { - this.sqlContext = ctx; + public SqlJoinAttributeResolver(SqlBaseContext context, SqlChildJoinConfig config, String attributeName) { + this.sqlContext = context; this.config = config; - this.attributeName = attr; + this.attributeName = attributeName; } @Override @@ -62,145 +64,101 @@ public void resolve(ContextLookup context, Iterable buil return; } - var parentMap = collectParentIds(builders); - if (parentMap.isEmpty()) { + var buildersByUid = SqlRelatedJoinResolverSupport.collectByUid(builders); + if (buildersByUid.isEmpty()) { return; } - boolean simple = config.valueColumn() != null; - - try (var wrapper = sqlContext.getConnection()) { - if (simple) { - resolveSimpleAttribute(wrapper, parentMap); - } else { - resolveEmbeddedObjects(wrapper.getConnection(), parentMap); + try (var connection = sqlContext.getConnection()) { + var parents = SqlRelatedJoinResolverSupport.indexParents( + sqlContext, connection, config.parentTable(), config.joinKeys(), buildersByUid); + if (parents.isEmpty()) { + return; } + var childTable = new SqlTableAccess(sqlContext, config.childTable(), "c"); + var criteria = parents.keySet().stream() + .map(values -> SqlRelatedJoinResolverSupport.relatedCriteria( + config.joinKeys(), values)) + .toList(); + var values = config.valueColumn() != null + ? resolveScalar(connection, childTable, criteria, parents) + : resolveEmbedded(connection, childTable, criteria, parents); + applyResults(values); } catch (Exception e) { throw new ConnectorException( - "Failed to resolve attribute '" + attributeName + - "' from child table '" + config.childTable() + "'", e); + "Failed to resolve attribute '" + attributeName + + "' from child table '" + config.childTable() + "'", e); } } - private Map collectParentIds( - Iterable builders) { - var map = new LinkedHashMap(); - for (var builder : builders) { - var obj = builder.build(); - var uid = obj.getUid(); - if (uid != null) { - map.put(uid.getUidValue(), builder); + private Map> resolveScalar( + SqlConnection connection, + SqlTableAccess childTable, + List> criteria, + Map parents) { + var selected = new LinkedHashSet>(); + selected.add(childTable.columnPath(config.valueColumn())); + config.joinKeys().stream() + .map(key -> childTable.columnPath(key.childColumn())) + .forEach(selected::add); + + var result = new LinkedHashMap>(); + var rows = connection.newQuery() + .select(selected.toArray(Path[]::new)) + .from(childTable.path()) + .where(childTable.matchingAny(criteria)) + .fetch(); + for (var row : rows) { + var parent = parents.get(SqlRelatedJoinResolverSupport.relatedValues( + childTable, row, config.joinKeys())); + var value = childTable.toConnIdValue( + config.valueColumn(), childTable.value(row, config.valueColumn())); + if (parent != null && value != null) { + result.computeIfAbsent(parent, ignored -> new ArrayList<>()).add(value); } } - return map; + return result; } - /** - * QueryDSL-based query for simple-attribute mode. - * Only SELECTs the value column and join column (optimized). - */ - private void resolveSimpleAttribute(SqlConnection conn, - Map parentMap) { - var childTable = config.childTable(); - var childJoinCol = config.childJoinColumn(); - var valueColumn = config.valueColumn(); - - var path = new RelationalPathBase<>(Object.class, - PathMetadataFactory.forVariable("c"), "", childTable); - StringPath valuePath = Expressions.stringPath(path, valueColumn); - StringPath joinPath = Expressions.stringPath(path, childJoinCol); - - var result = new LinkedHashMap>(); - try { - SQLQuery query = conn.newQuery() - .select(valuePath, joinPath) - .from(path) - .where(joinPath.in(new ArrayList<>(parentMap.keySet()))); - for (Tuple row : query.fetch()) { - var parentId = row.get(joinPath); - if (parentId == null) { - continue; - } - var value = row.get(valuePath); - if (value != null) { - result.computeIfAbsent(parentId, k -> new ArrayList<>()).add(value); - } + private Map> resolveEmbedded( + SqlConnection connection, + SqlTableAccess childTable, + List> criteria, + Map parents) { + var selected = childTable.metadata().getColumns().stream() + .map(column -> childTable.columnPath(column.getName())) + .distinct() + .toArray(Path[]::new); + var result = new LinkedHashMap>(); + var rows = connection.newQuery() + .select(selected) + .from(childTable.path()) + .where(childTable.matchingAny(criteria)) + .fetch(); + for (var row : rows) { + var parent = parents.get(SqlRelatedJoinResolverSupport.relatedValues( + childTable, row, config.joinKeys())); + if (parent == null) { + continue; } - } catch (Exception e) { - throw new ConnectorException("Simple attribute query failed for '" + childTable + "'", e); - } - - applyResults(parentMap, result); - } - - /** - * PreparedStatement-based query for embedded-object mode. - * SELECT * is required because we don't know child column names at compile time. - * Parameters are bound via placeholders to avoid SQL injection. - */ - private void resolveEmbeddedObjects(Connection conn, - Map parentMap) { - var childTable = config.childTable(); - var childJoinCol = config.childJoinColumn(); - - List parentIds = new ArrayList<>(parentMap.keySet()); - var sql = new StringBuilder(); - sql.append("SELECT * FROM ").append(childTable) - .append(" WHERE ").append(childJoinCol).append(" IN ("); - for (int i = 0; i < parentIds.size(); i++) { - if (i > 0) sql.append(','); - sql.append('?'); - } - sql.append(')'); - - var result = new LinkedHashMap>(); - try { - try (var stmt = conn.prepareStatement(sql.toString())) { - for (int i = 0; i < parentIds.size(); i++) { - stmt.setString(i + 1, parentIds.get(i)); - } - - try (var rs = stmt.executeQuery()) { - var rsmd = rs.getMetaData(); - int colCount = rsmd.getColumnCount(); - - while (rs.next()) { - var parentId = rs.getString(childJoinCol); - if (parentId == null) { - continue; - } - - var attrs = new LinkedHashSet(); - for (int i = 1; i <= colCount; i++) { - var colName = rsmd.getColumnName(i); - var value = rs.getObject(i); - if (value != null) { - attrs.add(AttributeBuilder.build(colName, value)); - } - } - - var childOcl = new ObjectClass(childTable); - var embedded = new EmbeddedObject(childOcl, attrs); - result.computeIfAbsent(parentId, k -> new ArrayList<>()).add(embedded); - } + var attributes = new LinkedHashSet(); + for (var column : childTable.metadata().getColumns()) { + var value = childTable.toConnIdValue( + column.getName(), childTable.value(row, column.getName())); + if (value != null) { + attributes.add(AttributeBuilder.build(column.getName(), value)); } } - } catch (Exception e) { - throw new ConnectorException("Embedded object query failed for '" + childTable + "'", e); + var embedded = new EmbeddedObject(new ObjectClass(config.childTable()), attributes); + result.computeIfAbsent(parent, ignored -> new ArrayList<>()).add(embedded); } - - applyResults(parentMap, result); + return result; } - private void applyResults(Map parentMap, - Map> parentToValues) { - for (var entry : parentToValues.entrySet()) { - var builder = parentMap.get(entry.getKey()); - if (builder != null) { - List values = entry.getValue(); - if (!values.isEmpty()) { - builder.addAttribute(AttributeBuilder.build(attributeName, values)); - } + private void applyResults(Map> values) { + for (var entry : values.entrySet()) { + if (!entry.getValue().isEmpty()) { + entry.getKey().addAttribute(AttributeBuilder.build(attributeName, entry.getValue())); } } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJunctionAttributeResolver.java b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJunctionAttributeResolver.java index 7971fef..771c1af 100644 --- a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJunctionAttributeResolver.java +++ b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlJunctionAttributeResolver.java @@ -11,32 +11,35 @@ import com.evolveum.polygon.conndev.schema.BaseAttributeDefinition; import com.evolveum.polygon.conndev.spi.AttributeResolver; import com.evolveum.polygon.sql.base.SqlBaseContext; -import com.evolveum.polygon.sql.base.connection.SqlConnection; +import com.evolveum.polygon.sql.base.SqlTableAccess; import com.evolveum.polygon.sql.base.schema.SqlJunctionJoinConfig; -import com.querydsl.core.Tuple; -import com.querydsl.core.types.PathMetadataFactory; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.StringPath; -import com.querydsl.sql.RelationalPathBase; +import com.querydsl.core.types.Path; import org.identityconnectors.framework.common.exceptions.ConnectorException; -import org.identityconnectors.framework.common.objects.*; - -import java.util.*; - -/** - * Batch attribute resolver that fetches junction table data using parameterized QueryDSL queries. - * Builds ConnectorObjectReference instances for bidirectional references. - */ +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.ConnectorObjectBuilder; +import org.identityconnectors.framework.common.objects.ConnectorObjectReference; +import org.identityconnectors.framework.common.objects.ObjectClass; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Resolves read-only object references stored in a junction table. */ public class SqlJunctionAttributeResolver implements AttributeResolver { private final SqlBaseContext sqlContext; private final SqlJunctionJoinConfig config; private final String attributeName; - public SqlJunctionAttributeResolver(SqlBaseContext ctx, SqlJunctionJoinConfig config, String attr) { - this.sqlContext = ctx; + public SqlJunctionAttributeResolver( + SqlBaseContext context, SqlJunctionJoinConfig config, String attributeName) { + this.sqlContext = context; this.config = config; - this.attributeName = attr; + this.attributeName = attributeName; } @Override @@ -60,91 +63,86 @@ public void resolve(ContextLookup context, Iterable buil return; } - Map parentMap = collectParentIds(builders); - if (parentMap.isEmpty()) { + var buildersByUid = SqlRelatedJoinResolverSupport.collectByUid(builders); + if (buildersByUid.isEmpty()) { return; } - try (var wrapper = sqlContext.getConnection()) { - var result = fetchJunctionReferences(wrapper, parentMap.keySet()); - applyResults(parentMap, result); - } catch (Exception e) { - throw new ConnectorException( - "Failed to resolve junction attribute '" + attributeName + - "' from '" + config.junctionTable() + "'", e); - } - } - - private Map collectParentIds( - Iterable builders) { - Map map = new LinkedHashMap<>(); - for (ConnectorObjectBuilder builder : builders) { - var obj = builder.build(); - var uid = obj.getUid(); - if (uid != null) { - map.put(uid.getUidValue(), builder); + try (var connection = sqlContext.getConnection()) { + var parents = SqlRelatedJoinResolverSupport.indexParents( + sqlContext, connection, config.parentTable(), + config.parentJoinKeys(), buildersByUid); + if (parents.isEmpty()) { + return; } - } - return map; - } - /** - * QueryDSL-based query for junction table resolution. - * SELECTs only the two key columns used for references. - */ - private Map> fetchJunctionReferences( - SqlConnection conn, - Set parentIds) { - var junctionTable = config.junctionTable(); - var junctionParentKey = config.junctionParentKey(); - var junctionTargetKey = config.junctionTargetKey(); - var targetObjectClass = config.targetObjectClass(); - - var path = new RelationalPathBase<>(Object.class, - PathMetadataFactory.forVariable("j"), "", junctionTable); - StringPath parentKeyPath = Expressions.stringPath(path, junctionParentKey); - StringPath targetKeyPath = Expressions.stringPath(path, junctionTargetKey); - - var result = new LinkedHashMap>(); - try { - var query = conn.newQuery() - .select(parentKeyPath, targetKeyPath) - .from(path) - .where(parentKeyPath.in(new ArrayList<>(parentIds))); - - for (Tuple row : query.fetch()) { - var parentId = row.get(parentKeyPath); - var targetId = row.get(targetKeyPath); - if (parentId == null || targetId == null) { + var junction = new SqlTableAccess(sqlContext, config.junctionTable(), "j"); + var parentCriteria = parents.keySet().stream() + .map(values -> SqlRelatedJoinResolverSupport.relatedCriteria( + config.parentJoinKeys(), values)) + .toList(); + var selected = new LinkedHashSet>(); + config.parentJoinKeys().stream() + .map(key -> junction.columnPath(key.childColumn())) + .forEach(selected::add); + config.targetJoinKeys().stream() + .map(key -> junction.columnPath(key.childColumn())) + .forEach(selected::add); + + var resolvedRows = new ArrayList(); + var targetValues = new LinkedHashSet(); + var rows = connection.newQuery() + .select(selected.toArray(Path[]::new)) + .from(junction.path()) + .where(junction.matchingAny(parentCriteria)) + .fetch(); + for (var row : rows) { + var parent = parents.get(SqlRelatedJoinResolverSupport.relatedValues( + junction, row, config.parentJoinKeys())); + if (parent == null) { continue; } - - var refBuilder = new ConnectorObjectBuilder(); - refBuilder.setObjectClass(new ObjectClass(targetObjectClass)); - refBuilder.setUid(targetId); - var identification = refBuilder.buildIdentification(); - var ref = new ConnectorObjectReference(identification); - - result.computeIfAbsent(parentId, k -> new ArrayList<>()).add(ref); + var target = SqlRelatedJoinResolverSupport.relatedValues( + junction, row, config.targetJoinKeys()); + resolvedRows.add(new ResolvedJunctionRow(parent, target)); + targetValues.add(target); } + + var targetUids = SqlRelatedJoinResolverSupport.targetUids( + sqlContext, connection, config.targetObjectClass(), + config.targetJoinKeys(), targetValues); + applyResults(resolvedRows, targetUids); } catch (Exception e) { throw new ConnectorException( - "Junction query failed for table '" + junctionTable + "'", e); + "Failed to resolve junction attribute '" + attributeName + + "' from '" + config.junctionTable() + "'", e); } - - return result; } - private void applyResults(Map parentMap, - Map> parentToRefs) { - for (var entry : parentToRefs.entrySet()) { - var builder = parentMap.get(entry.getKey()); - if (builder != null) { - List refs = entry.getValue(); - if (!refs.isEmpty()) { - builder.addAttribute(AttributeBuilder.build(attributeName, refs)); - } + private void applyResults( + List rows, + Map targetUids) { + var references = new LinkedHashMap>(); + for (var row : rows) { + var targetUid = targetUids.get(row.targetValues()); + if (targetUid == null) { + continue; } + var target = new ConnectorObjectBuilder(); + target.setObjectClass(new ObjectClass(config.targetObjectClass())); + target.setUid(targetUid); + references.computeIfAbsent(row.parent(), ignored -> new ArrayList<>()) + .add(new ConnectorObjectReference(target.buildIdentification())); } + for (var entry : references.entrySet()) { + if (!entry.getValue().isEmpty()) { + entry.getKey().addAttribute(AttributeBuilder.build(attributeName, entry.getValue())); + } + } + } + + private record ResolvedJunctionRow( + ConnectorObjectBuilder parent, + SqlRelatedJoinResolverSupport.JoinValues targetValues) { } } diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlRelatedJoinResolverSupport.java b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlRelatedJoinResolverSupport.java new file mode 100644 index 0000000..ca0a995 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/search/SqlRelatedJoinResolverSupport.java @@ -0,0 +1,247 @@ +/* + * 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.search; + +import com.evolveum.polygon.sql.base.SqlBaseContext; +import com.evolveum.polygon.sql.base.SqlTableAccess; +import com.evolveum.polygon.sql.base.SqlTuple; +import com.evolveum.polygon.sql.base.build.api.SqlAttributeDefinition; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.evolveum.polygon.sql.base.connection.SqlConnection; +import com.evolveum.polygon.sql.base.schema.ChildTableRelationship.JoinKey; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.sql.RelationalPathBase; +import org.identityconnectors.framework.common.exceptions.ConnectorException; +import org.identityconnectors.framework.common.objects.ConnectorObjectBuilder; +import org.identityconnectors.framework.common.objects.Uid; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** Shared parent-key and composite-join resolution for related-table attribute resolvers. */ +final class SqlRelatedJoinResolverSupport { + + private SqlRelatedJoinResolverSupport() { + } + + static Map collectByUid( + Iterable builders) { + var result = new LinkedHashMap(); + for (var builder : builders) { + var uid = builder.build().getUid(); + if (uid != null) { + result.put(uid.getUidValue(), builder); + } + } + return result; + } + + static Map indexParents( + SqlBaseContext context, SqlConnection connection, + String parentTable, List joinKeys, + Map buildersByUid) { + if (buildersByUid.isEmpty()) { + return Map.of(); + } + + var definition = objectClass(context, parentTable); + var tablePath = definition.sql().pathAlias("rp"); + var table = new SqlTableAccess(context, definition.sql().getTableName(), tablePath); + var uidDefinition = definition.attributeFromConnIdName(Uid.NAME); + if (uidDefinition == null || uidDefinition.sql() == null + || uidDefinition.sql().sqlFilter() == null) { + throw new ConnectorException( + "Object class " + definition.name() + " has no queryable UID mapping"); + } + + var uidBasedIndex = indexFromUidColumns( + tablePath, uidDefinition, joinKeys, buildersByUid); + if (uidBasedIndex != null) { + return uidBasedIndex; + } + + var selected = new LinkedHashSet>(); + selected.addAll(uidDefinition.sql().selectPaths(tablePath)); + joinKeys.stream() + .map(JoinKey::parentColumn) + .map(table::columnPath) + .forEach(selected::add); + + BooleanExpression uidPredicate = null; + for (var uid : buildersByUid.keySet()) { + var current = uidDefinition.sql().sqlFilter().eq(tablePath, uid); + uidPredicate = uidPredicate == null ? current : uidPredicate.or(current); + } + + var rows = connection.newQuery() + .select(selected.toArray(Path[]::new)) + .from(tablePath) + .where(uidPredicate) + .fetch(); + var result = new LinkedHashMap(); + for (var row : rows) { + var uidValues = uidDefinition.sql().valuesFromObject(new SqlTuple(tablePath, row)); + if (uidValues.isEmpty() || uidValues.getFirst() == null) { + continue; + } + var builder = buildersByUid.get(uidValues.getFirst().toString()); + if (builder != null) { + result.put(parentValues(table, row, joinKeys), builder); + } + } + return result; + } + + static Map targetUids( + SqlBaseContext context, SqlConnection connection, + String targetTable, List targetJoinKeys, + Collection requestedValues) { + if (requestedValues.isEmpty()) { + return Map.of(); + } + + var definition = objectClass(context, targetTable); + var tablePath = definition.sql().pathAlias("rt"); + var table = new SqlTableAccess(context, definition.sql().getTableName(), tablePath); + var uidDefinition = definition.attributeFromConnIdName(Uid.NAME); + if (uidDefinition == null || uidDefinition.sql() == null) { + throw new ConnectorException( + "Object class " + definition.name() + " has no UID mapping"); + } + + var criteria = requestedValues.stream() + .map(values -> parentCriteria(table, targetJoinKeys, values)) + .toList(); + var selected = new LinkedHashSet>(); + selected.addAll(uidDefinition.sql().selectPaths(tablePath)); + targetJoinKeys.stream() + .map(JoinKey::parentColumn) + .map(table::columnPath) + .forEach(selected::add); + + var rows = connection.newQuery() + .select(selected.toArray(Path[]::new)) + .from(tablePath) + .where(table.matchingAny(criteria)) + .fetch(); + var result = new LinkedHashMap(); + for (var row : rows) { + var uidValues = uidDefinition.sql().valuesFromObject(new SqlTuple(tablePath, row)); + if (!uidValues.isEmpty() && uidValues.getFirst() != null) { + result.put(parentValues(table, row, targetJoinKeys), + uidValues.getFirst().toString()); + } + } + return result; + } + + static Map relatedCriteria( + List joinKeys, JoinValues values) { + if (joinKeys.size() != values.values().size()) { + throw new IllegalArgumentException("Join key and value counts differ"); + } + var result = new LinkedHashMap(); + for (int i = 0; i < joinKeys.size(); i++) { + result.put(joinKeys.get(i).childColumn(), values.values().get(i)); + } + return result; + } + + static JoinValues relatedValues( + SqlTableAccess relatedTable, Tuple row, List joinKeys) { + var values = new ArrayList(joinKeys.size()); + for (var joinKey : joinKeys) { + values.add(relatedTable.value(row, joinKey.childColumn())); + } + return new JoinValues(values); + } + + private static Map parentCriteria( + SqlTableAccess parentTable, List joinKeys, JoinValues values) { + if (joinKeys.size() != values.values().size()) { + throw new IllegalArgumentException("Join key and value counts differ"); + } + var result = new LinkedHashMap(); + for (int i = 0; i < joinKeys.size(); i++) { + var column = joinKeys.get(i).parentColumn(); + result.put(column, parentTable.toWireValue(column, values.values().get(i))); + } + return result; + } + + private static Map indexFromUidColumns( + RelationalPathBase tablePath, + SqlAttributeDefinition uidDefinition, + List joinKeys, + Map buildersByUid) { + var result = new LinkedHashMap(); + for (var entry : buildersByUid.entrySet()) { + var uidColumns = new LinkedHashMap(); + for (var columnValue : uidDefinition.sql().columnValues(tablePath, entry.getKey())) { + uidColumns.put(columnValue.path().getMetadata().getName(), columnValue.value()); + } + var joinValues = new ArrayList(joinKeys.size()); + for (var joinKey : joinKeys) { + var value = valueIgnoreCase(uidColumns, joinKey.parentColumn()); + if (value == MissingValue.INSTANCE) { + return null; + } + joinValues.add(value); + } + result.put(new JoinValues(joinValues), entry.getValue()); + } + return result; + } + + private static Object valueIgnoreCase(Map values, String column) { + for (var entry : values.entrySet()) { + if (entry.getKey().equalsIgnoreCase(column)) { + return entry.getValue(); + } + } + return MissingValue.INSTANCE; + } + + private static JoinValues parentValues( + SqlTableAccess parentTable, Tuple row, List joinKeys) { + var values = new ArrayList(joinKeys.size()); + for (var joinKey : joinKeys) { + values.add(parentTable.value(row, joinKey.parentColumn())); + } + return new JoinValues(values); + } + + private static SqlObjectClassDefinition objectClass( + SqlBaseContext context, String tableName) { + var schema = context.schema(); + if (schema == null) { + throw new ConnectorException("SQL schema is not initialized"); + } + return schema.objectClasses().stream() + .filter(candidate -> candidate.sql() != null + && candidate.sql().getTableName().equalsIgnoreCase(tableName)) + .findFirst() + .orElseThrow(() -> new ConnectorException( + "No object class mapping found for table " + tableName)); + } + + record JoinValues(List values) { + JoinValues { + values = List.copyOf(values); + } + } + + private enum MissingValue { + INSTANCE + } +} diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlChildTableWriteHandler.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlChildTableWriteHandler.java new file mode 100644 index 0000000..4382531 --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlChildTableWriteHandler.java @@ -0,0 +1,220 @@ +/* + * 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.SqlTableAccess; +import com.evolveum.polygon.sql.base.build.api.SqlAttributeDefinition; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.evolveum.polygon.sql.base.connection.SqlConnection; +import com.evolveum.polygon.sql.base.schema.SqlChildJoinConfig; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.AttributeDelta; +import org.identityconnectors.framework.common.objects.EmbeddedObject; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Maps and writes one scalar or embedded attribute stored in an owned child table. */ +final class SqlChildTableWriteHandler { + + private final SqlChildJoinConfig config; + private final SqlTableAccess table; + private final SqlObjectClassDefinition childDefinition; + + SqlChildTableWriteHandler(SqlBaseContext context, SqlChildJoinConfig config) { + this.config = config; + this.table = new SqlTableAccess(context, config.childTable(), "cw"); + this.childDefinition = context.schema() != null + ? context.schema().objectClasses().stream() + .filter(candidate -> candidate.sql() != null + && candidate.sql().getTableName().equalsIgnoreCase(config.childTable())) + .findFirst() + .orElse(null) + : null; + } + + SqlChildJoinConfig config() { + return config; + } + + boolean supports(String attributeName) { + return config.targetAttributeName().equalsIgnoreCase(attributeName); + } + + void create( + SqlConnection connection, Map parentValues, + Collection attributes) { + for (var attribute : attributes) { + requireSupported(attribute.getName()); + insertValues(connection, parentValues, attribute.getValue(), false); + } + } + + void update( + SqlConnection connection, Map parentValues, + Collection modifications) { + for (var modification : modifications) { + requireSupported(modification.getName()); + var replacements = modification.getValuesToReplace(); + if (replacements != null) { + table.delete(connection, joinAssignments(parentValues)); + insertValues(connection, parentValues, replacements, false); + continue; + } + deleteValues(connection, parentValues, modification.getValuesToRemove()); + insertValues(connection, parentValues, modification.getValuesToAdd(), true); + } + } + + void delete(SqlConnection connection, Map parentValues) { + table.delete(connection, joinAssignments(parentValues)); + } + + private void insertValues( + SqlConnection connection, Map parentValues, + List values, boolean ignoreExisting) { + if (values == null || values.isEmpty()) { + return; + } + if (!config.multiValued() && values.size() > 1) { + throw invalid("Attribute " + config.targetAttributeName() + + " accepts at most one value"); + } + for (var value : values) { + var assignments = config.valueColumn() != null + ? simpleAssignments(parentValues, value) + : embeddedAssignments(parentValues, value); + if (!ignoreExisting || !table.exists(connection, assignments)) { + table.insert(connection, assignments); + } + } + } + + private void deleteValues( + SqlConnection connection, Map parentValues, List values) { + if (values == null || values.isEmpty()) { + return; + } + for (var value : values) { + var criteria = config.valueColumn() != null + ? simpleAssignments(parentValues, value) + : embeddedAssignments(parentValues, value); + table.delete(connection, criteria); + } + } + + private Map simpleAssignments( + Map parentValues, Object value) { + var assignments = joinAssignments(parentValues); + put(assignments, table.actualColumn(config.valueColumn()), + table.toWireValue(config.valueColumn(), value)); + return assignments; + } + + private Map embeddedAssignments( + Map parentValues, Object value) { + if (!(value instanceof EmbeddedObject embedded)) { + throw invalid("Attribute " + config.targetAttributeName() + + " requires EmbeddedObject values"); + } + + var assignments = new LinkedHashMap(); + for (var attribute : embedded.getAttributes()) { + var definition = childAttribute(attribute.getName()); + if (definition != null && definition.sql() != null) { + var attributeValue = singleValue(attribute); + for (var columnValue : definition.sql().columnValues(table.path(), attributeValue)) { + var column = table.actualColumn(columnValue.path().getMetadata().getName()); + put(assignments, column, columnValue.value()); + } + continue; + } + + var column = table.column(attribute.getName()); + if (column == null) { + throw invalid("Unknown child-table column " + attribute.getName() + + " for " + config.childTable()); + } + put(assignments, column.getName(), + table.toWireValue(column.getName(), singleValue(attribute))); + } + + for (var join : joinAssignments(parentValues).entrySet()) { + if (assignments.containsKey(join.getKey()) + && !Objects.deepEquals(assignments.get(join.getKey()), join.getValue())) { + throw invalid("Embedded attribute " + config.targetAttributeName() + + " cannot change parent join column " + join.getKey()); + } + assignments.put(join.getKey(), join.getValue()); + } + return assignments; + } + + private Map joinAssignments(Map parentValues) { + var assignments = new LinkedHashMap(); + for (var key : config.joinKeys()) { + if (!parentValues.containsKey(key.parentColumn())) { + throw new IllegalArgumentException( + "Missing parent join value for column " + key.parentColumn()); + } + put(assignments, table.actualColumn(key.childColumn()), + table.toWireValue(key.childColumn(), parentValues.get(key.parentColumn()))); + } + return assignments; + } + + private SqlAttributeDefinition childAttribute(String attributeName) { + if (childDefinition == null) { + return null; + } + var attribute = childDefinition.attributeFromConnIdName(attributeName); + if (attribute != null) { + return attribute; + } + return childDefinition.attributes().stream() + .filter(candidate -> candidate.connId().getName().equalsIgnoreCase(attributeName) + || candidate.remoteName().equalsIgnoreCase(attributeName)) + .findFirst() + .orElse(null); + } + + private Object singleValue(Attribute attribute) { + var values = attribute.getValue(); + if (values == null || values.isEmpty()) { + return null; + } + if (values.size() > 1) { + throw invalid("Child-table column " + attribute.getName() + + " accepts at most one value"); + } + return values.getFirst(); + } + + private void put(Map assignments, String column, Object value) { + if (assignments.containsKey(column) + && !Objects.deepEquals(assignments.get(column), value)) { + throw invalid("Conflicting values for child-table column " + column); + } + assignments.put(column, value); + } + + private void requireSupported(String attributeName) { + if (!supports(attributeName)) { + throw invalid("Handler for " + config.targetAttributeName() + + " cannot process attribute " + attributeName); + } + } + + private InvalidAttributeValueException invalid(String message) { + return new InvalidAttributeValueException(message); + } +} 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 00e2b5d..b632374 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 @@ -62,6 +62,8 @@ public ConnectorObject create(Set createAttributes, OperationOptions support.generatedKey(insert, table, generatedPath), table, columnValues); } + support.createRelatedRows(connection, uid, createAttributes); + var created = support.findByUid(connection, uid, false); if (created == null) { throw new ConnectorException("Created object " + uid + " could not be read back"); 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 index b709892..0be52e2 100644 --- 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 @@ -33,6 +33,8 @@ public void delete(Uid uid, OperationOptions options) { support.requireWritable(); support.inTransaction("Delete " + objectClass.name(), connection -> { var table = support.tablePath(); + // Owned child and junction rows must be removed before their parent FK target. + support.deleteRelatedRows(connection, uid); var delete = new SQLDeleteClause( connection.getConnection(), context.getSqlTemplates(), table); var affected = delete.where(support.uidPredicate(table, uid)).execute(); diff --git a/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlRelatedAttributeOperationCoordinator.java b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlRelatedAttributeOperationCoordinator.java new file mode 100644 index 0000000..7667dbe --- /dev/null +++ b/base/src/main/java/com/evolveum/polygon/sql/base/write/SqlRelatedAttributeOperationCoordinator.java @@ -0,0 +1,148 @@ +/* + * 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.SqlTableAccess; +import com.evolveum.polygon.sql.base.build.api.SqlObjectClassDefinition; +import com.evolveum.polygon.sql.base.connection.SqlConnection; +import com.evolveum.polygon.sql.base.schema.SqlJunctionJoinConfig; +import org.identityconnectors.framework.common.objects.Attribute; +import org.identityconnectors.framework.common.objects.AttributeDelta; +import org.identityconnectors.framework.common.objects.Uid; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * Coordinates related-table handlers inside the root object's JDBC transaction. + * + *

Conndev's generic strategies cannot currently sequence parent and child writes on one + * connection, so this coordinator performs only the SQL-specific grouping and ordering.

+ */ +final class SqlRelatedAttributeOperationCoordinator { + + private final SqlBaseContext context; + private final SqlWriteOperationSupport rootSupport; + private final Map childHandlers; + private final List junctions; + + SqlRelatedAttributeOperationCoordinator( + SqlBaseContext context, SqlObjectClassDefinition objectClass, + SqlWriteOperationSupport rootSupport) { + this.context = context; + this.rootSupport = rootSupport; + this.childHandlers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (var config : objectClass.relatedAttributeJoinConfigs()) { + var previous = childHandlers.put( + config.targetAttributeName(), new SqlChildTableWriteHandler(context, config)); + if (previous != null) { + throw new IllegalArgumentException( + "Multiple child-table handlers target attribute " + + config.targetAttributeName()); + } + } + this.junctions = objectClass.junctionJoinConfigs(); + } + + boolean supports(String attributeName) { + return childHandlers.containsKey(attributeName); + } + + void create( + SqlConnection connection, Uid uid, Collection attributes) { + var grouped = groupAttributes(attributes); + if (grouped.isEmpty()) { + return; + } + var parentValues = parentValues(connection, uid, grouped.keySet()); + grouped.forEach((handler, supported) -> + handler.create(connection, parentValues, supported)); + } + + void update( + SqlConnection connection, Uid uid, Collection modifications) { + var grouped = groupModifications(modifications); + if (grouped.isEmpty()) { + return; + } + var parentValues = parentValues(connection, uid, grouped.keySet()); + grouped.forEach((handler, supported) -> + handler.update(connection, parentValues, supported)); + } + + void delete(SqlConnection connection, Uid uid) { + var parentColumns = new LinkedHashSet(); + childHandlers.values().forEach(handler -> handler.config().joinKeys().stream() + .map(key -> key.parentColumn()) + .forEach(parentColumns::add)); + junctions.forEach(config -> config.parentJoinKeys().stream() + .map(key -> key.parentColumn()) + .forEach(parentColumns::add)); + if (parentColumns.isEmpty()) { + return; + } + + var parentValues = rootSupport.parentColumnValues(connection, uid, parentColumns); + childHandlers.values().forEach(handler -> handler.delete(connection, parentValues)); + for (var config : junctions) { + var junction = new SqlTableAccess(context, config.junctionTable(), "jd"); + var criteria = new LinkedHashMap(); + for (var key : config.parentJoinKeys()) { + criteria.put(junction.actualColumn(key.childColumn()), + junction.toWireValue( + key.childColumn(), parentValues.get(key.parentColumn()))); + } + junction.delete(connection, criteria); + } + } + + private Map> groupAttributes( + Collection attributes) { + var result = new LinkedHashMap>(); + if (attributes == null) { + return result; + } + for (var attribute : attributes) { + var handler = childHandlers.get(attribute.getName()); + if (handler != null) { + result.computeIfAbsent(handler, ignored -> new ArrayList<>()).add(attribute); + } + } + return result; + } + + private Map> groupModifications( + Collection modifications) { + var result = new LinkedHashMap>(); + if (modifications == null) { + return result; + } + for (var modification : modifications) { + var handler = childHandlers.get(modification.getName()); + if (handler != null) { + result.computeIfAbsent(handler, ignored -> new ArrayList<>()).add(modification); + } + } + return result; + } + + private Map parentValues( + SqlConnection connection, Uid uid, + Collection handlers) { + var columns = new LinkedHashSet(); + handlers.forEach(handler -> handler.config().joinKeys().stream() + .map(key -> key.parentColumn()) + .forEach(columns::add)); + return rootSupport.parentColumnValues(connection, uid, columns); + } +} 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 index 8d2b0a5..5d8061a 100644 --- 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 @@ -45,21 +45,20 @@ public Set updateDelta( var current = support.requireByUid(connection, uid, true); var table = support.tablePath(); var columnValues = support.updateColumnValues(table, current, requested); - if (columnValues.isEmpty()) { - return requested; - } - - var update = new SQLUpdateClause( - connection.getConnection(), context.getSqlTemplates(), table); - support.applyColumnValues(update, columnValues); - var affected = update.where(support.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"); + if (!columnValues.isEmpty()) { + var update = new SQLUpdateClause( + connection.getConnection(), context.getSqlTemplates(), table); + support.applyColumnValues(update, columnValues); + var affected = update.where(support.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"); + } } + support.updateRelatedRows(connection, uid, requested); 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 index 9c50f84..e2d109a 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 @@ -8,6 +8,7 @@ import com.evolveum.polygon.sql.base.SqlBaseContext; import com.evolveum.polygon.sql.base.SqlObjectMapper; +import com.evolveum.polygon.sql.base.SqlTableAccess; 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; @@ -48,11 +49,13 @@ final class SqlWriteOperationSupport { private final SqlBaseContext context; private final SqlObjectClassDefinition objectClass; private final SqlObjectMapper objectMapper; + private final SqlRelatedAttributeOperationCoordinator relatedAttributes; SqlWriteOperationSupport(SqlBaseContext context, SqlObjectClassDefinition objectClass) { this.context = context; this.objectClass = objectClass; this.objectMapper = new SqlObjectMapper(objectClass); + this.relatedAttributes = new SqlRelatedAttributeOperationCoordinator(context, objectClass, this); } void requireWritable() { @@ -116,6 +119,17 @@ Map, Object> createColumnValues( var definition = requireAttribute(attribute.getName()); var uidDefinition = uidDefinition(); + if (definition.sql() == null) { + if (!relatedAttributes.supports(attribute.getName())) { + throw invalid("Attribute " + attribute.getName() + + " does not have a writable SQL mapping"); + } + if (!definition.connId().isCreateable()) { + throw invalid("Attribute " + attribute.getName() + " is not creatable"); + } + continue; + } + // The schema builder auto-creates __NAME__ from the UID mapping when no // separate name mapping exists. For generated keys it is only a ConnId // identifier placeholder and must not be inserted into the key column. For @@ -164,6 +178,13 @@ Map, Object> updateColumnValues( if (definition.emulated() || !definition.connId().isUpdateable()) { throw invalid("Attribute " + modification.getName() + " is not updatable"); } + if (definition.sql() == null) { + if (!relatedAttributes.supports(modification.getName())) { + throw invalid("Attribute " + modification.getName() + + " does not have a writable SQL mapping"); + } + continue; + } var before = current.getAttributeByName(modification.getName()); if (before == null) { @@ -176,6 +197,46 @@ Map, Object> updateColumnValues( return columnValues; } + void createRelatedRows( + SqlConnection connection, Uid uid, Collection attributes) { + relatedAttributes.create(connection, uid, attributes); + } + + void updateRelatedRows( + SqlConnection connection, Uid uid, Collection modifications) { + relatedAttributes.update(connection, uid, modifications); + } + + void deleteRelatedRows(SqlConnection connection, Uid uid) { + relatedAttributes.delete(connection, uid); + } + + Map parentColumnValues( + SqlConnection connection, Uid uid, Collection columnNames) { + var table = tablePath(); + var access = new SqlTableAccess(context, objectClass.sql().getTableName(), table); + var columns = columnNames.stream() + .distinct() + .map(access::columnPath) + .toArray(Path[]::new); + if (columns.length == 0) { + return Map.of(); + } + var row = connection.newQuery() + .select(columns) + .from(table) + .where(uidPredicate(table, uid)) + .fetchOne(); + if (row == null) { + throw new UnknownUidException(uid, objectClass.objectClass()); + } + var result = new LinkedHashMap(); + for (var column : columnNames) { + result.put(column, access.value(row, column)); + } + return result; + } + BooleanExpression uidPredicate(RelationalPathBase table, Uid uid) { if (uid == null || uid.getUidValue() == null) { throw invalid("UID must not be null"); @@ -316,8 +377,8 @@ private SqlAttributeDefinition requireAttribute(String name) { .findFirst() .orElse(null); } - if (definition == null || definition.sql() == null) { - throw invalid("Unknown or unmapped attribute " + name); + if (definition == null) { + throw invalid("Unknown attribute " + name); } return definition; } diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/schema/ChildTableRelationshipTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/schema/ChildTableRelationshipTest.java index 6f0a2d3..73e5c3a 100644 --- a/base/src/test/java/com/evolveum/polygon/sql/base/schema/ChildTableRelationshipTest.java +++ b/base/src/test/java/com/evolveum/polygon/sql/base/schema/ChildTableRelationshipTest.java @@ -62,7 +62,7 @@ public void junctionTableMultipleFks() { var rel = new ChildTableRelationship.JunctionRelationship( "users", "user_group_membership", List.of(new ChildTableRelationship.JoinKey("id", "user_id")), - List.of(new ChildTableRelationship.JoinKey("group_id", "id")), + List.of(new ChildTableRelationship.JoinKey("id", "group_id")), "groups", ChildTableType.JUNCTION_TABLE, false); assertThat(rel.type()).isEqualTo(ChildTableType.JUNCTION_TABLE); @@ -144,10 +144,14 @@ public void childTableTypeHelpers() { @Test public void sqlChildJoinConfigFields() { - var config = new SqlChildJoinConfig("childTable", "parentId", "childParentId", true, "childAttr"); + var joinKeys = List.of( + new ChildTableRelationship.JoinKey("parentId", "childParentId"), + new ChildTableRelationship.JoinKey("tenantId", "childTenantId")); + var config = new SqlChildJoinConfig( + "parentTable", "childTable", joinKeys, true, "childAttr"); + assertThat(config.parentTable()).isEqualTo("parentTable"); assertThat(config.childTable()).isEqualTo("childTable"); - assertThat(config.parentJoinColumn()).isEqualTo("parentId"); - assertThat(config.childJoinColumn()).isEqualTo("childParentId"); + assertThat(config.joinKeys()).containsExactlyElementsOf(joinKeys); assertThat(config.multiValued()).isTrue(); assertThat(config.targetAttributeName()).isEqualTo("childAttr"); assertThat(config.valueColumn()).isNull(); @@ -155,18 +159,23 @@ public void sqlChildJoinConfigFields() { @Test public void sqlChildJoinConfigWithValueColumn() { - var config = new SqlChildJoinConfig("childTable", "parentId", "childParentId", true, - "childAttr", "email"); + var config = new SqlChildJoinConfig( + "parentTable", "childTable", + List.of(new ChildTableRelationship.JoinKey("parentId", "childParentId")), + true, "childAttr", "email"); assertThat(config.valueColumn()).isEqualTo("email"); } @Test public void sqlJunctionJoinConfigFields() { - var config = new SqlJunctionJoinConfig("membership", "id", "user_id", "group_id", "groups"); + var parentKeys = List.of(new ChildTableRelationship.JoinKey("id", "user_id")); + var targetKeys = List.of(new ChildTableRelationship.JoinKey("id", "group_id")); + var config = new SqlJunctionJoinConfig( + "users", "membership", parentKeys, targetKeys, "groups"); + assertThat(config.parentTable()).isEqualTo("users"); assertThat(config.junctionTable()).isEqualTo("membership"); - assertThat(config.parentJoinColumn()).isEqualTo("id"); - assertThat(config.junctionParentKey()).isEqualTo("user_id"); - assertThat(config.junctionTargetKey()).isEqualTo("group_id"); + assertThat(config.parentJoinKeys()).containsExactlyElementsOf(parentKeys); + assertThat(config.targetJoinKeys()).containsExactlyElementsOf(targetKeys); assertThat(config.targetObjectClass()).isEqualTo("groups"); } } 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 index 47e0073..62e6ef1 100644 --- 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 @@ -11,11 +11,16 @@ 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.exceptions.ConnectorException; +import org.identityconnectors.framework.common.exceptions.InvalidAttributeValueException; +import org.identityconnectors.framework.common.objects.Attribute; 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.ConnectorObjectReference; +import org.identityconnectors.framework.common.objects.EmbeddedObject; import org.identityconnectors.framework.common.objects.Name; import org.identityconnectors.framework.common.objects.ObjectClass; import org.identityconnectors.framework.common.objects.ObjectClassInfo; @@ -49,7 +54,12 @@ public abstract class AbstractSqlConnectorContractTest { 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 PROFILE = "contract_user_profile"; + private static final String EMAILS = "contract_user_email"; + private static final String PHONES = "contract_user_phone"; + private static final String USER_ALIASES = "contract_user_alias"; private static final String COMPOSITE = "contract_composite"; + private static final String COMPOSITE_TAGS = "contract_composite_tag"; private static final String USER_VIEW = "contract_user_view"; private static final OperationOptions OPTIONS = new OperationOptions(Collections.emptyMap()); @@ -112,10 +122,19 @@ public final void discoversSharedSchemaContract() { assertThat(attributeInfo(user, "email").isRequired()).isFalse(); assertThat(attributeInfo(user, "active")).isNotNull(); assertThat(attributeInfo(user, "quota")).isNotNull(); + assertThat(attributeInfo(user, PROFILE).isMultiValued()).isFalse(); + assertThat(attributeInfo(user, EMAILS).isMultiValued()).isTrue(); + assertThat(attributeInfo(user, PHONES).isMultiValued()).isTrue(); + if (database.capabilities().supportsNonPrimaryForeignKeyMetadata()) { + assertThat(attributeInfo(user, USER_ALIASES).isMultiValued()).isTrue(); + } + assertThat(objectClassInfo(PROFILE).isEmbedded()).isTrue(); + assertThat(objectClassInfo(PHONES).isEmbedded()).isTrue(); var composite = objectClassInfo(COMPOSITE); assertThat(attributeInfo(composite, "tenant_id").isRequired()).isTrue(); assertThat(attributeInfo(composite, "record_id").isRequired()).isTrue(); + assertThat(attributeInfo(composite, COMPOSITE_TAGS).isMultiValued()).isTrue(); } @Test @@ -173,6 +192,292 @@ public final void createsUpdatesAndDeletesGeneratedUid() { assertThat(search(userClass, uidFilter(uid))).isEmpty(); } + @Test + public final void createsUpdatesAndDeletesChildTableAttributes() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var profile = attributeName(USER, PROFILE); + var emails = attributeName(USER, EMAILS); + var phones = attributeName(USER, PHONES); + var bio = attributeName(PROFILE, "bio"); + var phoneNumber = attributeName(PHONES, "phone_number"); + var phoneType = attributeName(PHONES, "phone_type"); + var priority = attributeName(PHONES, "priority"); + + var uid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-child-user"), + AttributeBuilder.build(username, "child-user"), + AttributeBuilder.build(profile, + embedded(PROFILE, AttributeBuilder.build(bio, "Original bio"))), + AttributeBuilder.build(emails, + List.of("first@example.com", "keep@example.com")), + AttributeBuilder.build(phones, List.of( + embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-1001"), + AttributeBuilder.build(phoneType, "mobile"), + AttributeBuilder.build(priority, mappedNumber(PHONES, "priority", "1"))), + embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-1002"), + AttributeBuilder.build(phoneType, "home"), + AttributeBuilder.build(priority, mappedNumber(PHONES, "priority", "2")))))), OPTIONS); + + var created = get(userClass, uid); + assertThat(values(created, emails)) + .containsExactlyInAnyOrder("first@example.com", "keep@example.com"); + assertThat(embeddedValue(onlyEmbedded(created, profile), "bio")) + .isEqualTo("Original bio"); + assertThat(embeddedValues(created, phones).stream() + .map(phone -> embeddedValue(phone, "phone_number"))) + .containsExactlyInAnyOrder("555-1001", "555-1002"); + assertThat(embeddedValues(created, phones).stream() + .map(phone -> ((Number) embeddedValue(phone, "priority")).intValue())) + .containsExactlyInAnyOrder(1, 2); + + connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(profile, List.of( + embedded(PROFILE, AttributeBuilder.build(bio, "Updated bio")))), + AttributeDeltaBuilder.build(phones, List.of( + embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-1003"), + AttributeBuilder.build(phoneType, "work"), + AttributeBuilder.build(priority, mappedNumber(PHONES, "priority", "3")))))), OPTIONS); + connector.updateDelta(userClass, uid, Set.of(new AttributeDeltaBuilder() + .setName(emails) + .addValueToRemove("first@example.com") + .addValueToAdd("new@example.com") + .build()), OPTIONS); + connector.updateDelta(userClass, uid, Set.of(new AttributeDeltaBuilder() + .setName(phones) + .addValueToRemove(embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-1003"))) + .addValueToAdd(embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-1004"), + AttributeBuilder.build(phoneType, "other"), + AttributeBuilder.build(priority, mappedNumber(PHONES, "priority", "4")))) + .build()), OPTIONS); + + var updated = get(userClass, uid); + assertThat(embeddedValue(onlyEmbedded(updated, profile), "bio")) + .isEqualTo("Updated bio"); + assertThat(values(updated, emails)) + .containsExactlyInAnyOrder("keep@example.com", "new@example.com"); + assertThat(embeddedValues(updated, phones).stream() + .map(phone -> embeddedValue(phone, "phone_number"))) + .containsExactly("555-1004"); + assertThat(((Number) embeddedValue(onlyEmbedded(updated, phones), "priority")).intValue()) + .isEqualTo(4); + + connector.delete(userClass, uid, OPTIONS); + assertThat(search(userClass, uidFilter(uid))).isEmpty(); + } + + @Test + public final void rollsBackParentWhenChildCreateFails() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var phones = attributeName(USER, PHONES); + var phoneType = attributeName(PHONES, "phone_type"); + + assertThatThrownBy(() -> connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-broken-child"), + AttributeBuilder.build(username, "broken-child"), + AttributeBuilder.build(phones, + embedded(PHONES, AttributeBuilder.build(phoneType, "invalid")))), OPTIONS)) + .isInstanceOf(ConnectorException.class); + + assertThat(search(userClass, FilterBuilder.equalTo( + AttributeBuilder.build(username, "broken-child")))) + .isEmpty(); + } + + @Test + public final void rollsBackChildReplacementWhenInsertFails() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var phones = attributeName(USER, PHONES); + var phoneNumber = attributeName(PHONES, "phone_number"); + var phoneType = attributeName(PHONES, "phone_type"); + + var uid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-rollback-child"), + AttributeBuilder.build(username, "rollback-child"), + AttributeBuilder.build(phones, + embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-2001"), + AttributeBuilder.build(phoneType, "mobile")))), OPTIONS); + + assertThatThrownBy(() -> connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(username, List.of("should-rollback")), + AttributeDeltaBuilder.build(phones, List.of( + embedded(PHONES, AttributeBuilder.build(phoneType, "invalid"))))), OPTIONS)) + .isInstanceOf(ConnectorException.class); + + var afterFailedUpdate = get(userClass, uid); + assertThat(value(afterFailedUpdate, username)).isEqualTo("rollback-child"); + assertThat(embeddedValues(afterFailedUpdate, phones).stream() + .map(phone -> embeddedValue(phone, "phone_number"))) + .containsExactly("555-2001"); + } + + @Test + public final void clearsAllRelatedAttributeShapes() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var profile = attributeName(USER, PROFILE); + var emails = attributeName(USER, EMAILS); + var phones = attributeName(USER, PHONES); + var bio = attributeName(PROFILE, "bio"); + var phoneNumber = attributeName(PHONES, "phone_number"); + + var uid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-clear-related"), + AttributeBuilder.build(username, "clear-related"), + AttributeBuilder.build(profile, + embedded(PROFILE, AttributeBuilder.build(bio, "Temporary bio"))), + AttributeBuilder.build(emails, "temporary@example.com"), + AttributeBuilder.build(phones, + embedded(PHONES, + AttributeBuilder.build(phoneNumber, "555-3001")))), OPTIONS); + + connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(profile, List.of()), + AttributeDeltaBuilder.build(emails, List.of()), + AttributeDeltaBuilder.build(phones, List.of())), OPTIONS); + + var cleared = get(userClass, uid); + assertThat(values(cleared, profile)).isEmpty(); + assertThat(values(cleared, emails)).isEmpty(); + assertThat(values(cleared, phones)).isEmpty(); + } + + @Test + public final void rejectsMultipleValuesForSingleValuedRelatedAttribute() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var profile = attributeName(USER, PROFILE); + var bio = attributeName(PROFILE, "bio"); + + assertThatThrownBy(() -> connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-invalid-profile"), + AttributeBuilder.build(username, "invalid-profile"), + AttributeBuilder.build(profile, List.of( + embedded(PROFILE, AttributeBuilder.build(bio, "First")), + embedded(PROFILE, AttributeBuilder.build(bio, "Second"))))), OPTIONS)) + .isInstanceOf(InvalidAttributeValueException.class); + + assertThat(search(userClass, FilterBuilder.equalTo( + AttributeBuilder.build(username, "invalid-profile")))) + .isEmpty(); + } + + @Test + public final void rejectsUnknownEmbeddedColumnsAndRollsBackCreate() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var phones = attributeName(USER, PHONES); + + assertThatThrownBy(() -> connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-unknown-child-column"), + AttributeBuilder.build(username, "unknown-child-column"), + AttributeBuilder.build(phones, + embedded(PHONES, + AttributeBuilder.build("does_not_exist", "invalid")))), OPTIONS)) + .isInstanceOf(InvalidAttributeValueException.class); + + assertThat(search(userClass, FilterBuilder.equalTo( + AttributeBuilder.build(username, "unknown-child-column")))) + .isEmpty(); + } + + @Test + public final void addingAnExistingRelatedValueIsIdempotent() { + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var emails = attributeName(USER, EMAILS); + + var uid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-idempotent-related"), + AttributeBuilder.build(username, "idempotent-related"), + AttributeBuilder.build(emails, "existing@example.com")), OPTIONS); + + connector.updateDelta(userClass, uid, Set.of( + AttributeDeltaBuilder.build(username, List.of("idempotent-related-updated")), + new AttributeDeltaBuilder() + .setName(emails) + .addValueToAdd("existing@example.com") + .build()), OPTIONS); + + var updated = get(userClass, uid); + assertThat(value(updated, username)).isEqualTo("idempotent-related-updated"); + assertThat(values(updated, emails)).containsExactly("existing@example.com"); + } + + @Test + public final void supportsNonUidRelatedTableJoinsWhenReportedByJdbc() { + if (!database.capabilities().supportsNonPrimaryForeignKeyMetadata()) { + return; + } + var userClass = objectClass(USER); + var username = attributeName(USER, "username"); + var aliases = attributeName(USER, USER_ALIASES); + var userUid = connector.create(userClass, Set.of( + AttributeBuilder.build(Name.NAME, "contract-non-uid-join"), + AttributeBuilder.build(username, "non-uid-parent-key"), + AttributeBuilder.build(aliases, List.of("first-alias", "second-alias"))), OPTIONS); + + assertThat(values(get(userClass, userUid), aliases)) + .containsExactlyInAnyOrder("first-alias", "second-alias"); + connector.updateDelta(userClass, userUid, Set.of(new AttributeDeltaBuilder() + .setName(aliases) + .addValueToRemove("first-alias") + .addValueToAdd("third-alias") + .build()), OPTIONS); + assertThat(values(get(userClass, userUid), aliases)) + .containsExactlyInAnyOrder("second-alias", "third-alias"); + connector.delete(userClass, userUid, OPTIONS); + } + + @Test + public final void supportsCompositeRelatedTableJoins() { + var compositeClass = objectClass(COMPOSITE); + var roleName = attributeName(COMPOSITE, "role_name"); + var tags = attributeName(COMPOSITE, COMPOSITE_TAGS); + var compositeUid = connector.create(compositeClass, Set.of( + AttributeBuilder.build(Uid.NAME, "3.20"), + AttributeBuilder.build(Name.NAME, "3.20"), + AttributeBuilder.build(roleName, "composite-child"), + AttributeBuilder.build(tags, List.of("first-tag", "second-tag"))), OPTIONS); + + assertThat(values(get(compositeClass, compositeUid), tags)) + .containsExactlyInAnyOrder("first-tag", "second-tag"); + connector.updateDelta(compositeClass, compositeUid, Set.of(new AttributeDeltaBuilder() + .setName(tags) + .addValueToRemove("first-tag") + .addValueToAdd("third-tag") + .build()), OPTIONS); + assertThat(values(get(compositeClass, compositeUid), tags)) + .containsExactlyInAnyOrder("second-tag", "third-tag"); + connector.delete(compositeClass, compositeUid, OPTIONS); + assertThat(search(compositeClass, uidFilter(compositeUid))).isEmpty(); + } + + @Test + public final void resolvesJunctionTableReferences() { + var alice = search(objectClass(USER), FilterBuilder.equalTo( + AttributeBuilder.build(attributeName(USER, "username"), "alice"))) + .getFirst(); + var developers = search(objectClass(GROUP), FilterBuilder.equalTo( + AttributeBuilder.build(attributeName(GROUP, "name"), "developers"))) + .getFirst(); + + var reference = (ConnectorObjectReference) values( + alice, attributeName(USER, GROUP)).getFirst(); + assertThat(reference.getValue().getObjectClass().getObjectClassValue()) + .isEqualToIgnoringCase(objectClass(GROUP).getObjectClassValue()); + assertThat(AttributeUtil.getUidAttribute(reference.getValue().getAttributes()).getUidValue()) + .isEqualTo(developers.getUid().getUidValue()); + } + @Test public final void supportsDatabaseDefaultsAndConstraintErrors() { var userClass = objectClass(USER); @@ -327,7 +632,10 @@ private AttributeInfo attributeInfo(ObjectClassInfo objectClass, String expected || expectedName.equalsIgnoreCase(attribute.getNativeName())) .findFirst() .orElseThrow(() -> new AssertionError( - "Attribute not found: " + objectClass.getType() + "." + expectedName)); + "Attribute not found: " + objectClass.getType() + "." + expectedName + + "; available: " + objectClass.getAttributeInfo().stream() + .map(AttributeInfo::getName) + .toList())); } private String attributeName(String objectClass, String expectedName) { @@ -375,6 +683,38 @@ private static Object value(ConnectorObject object, String name) { return attribute == null ? null : AttributeUtil.getSingleValue(attribute); } + private static List values(ConnectorObject object, String name) { + var attribute = object.getAttributeByName(name); + return attribute == null || attribute.getValue() == null + ? List.of() + : attribute.getValue(); + } + + private EmbeddedObject embedded(String objectClass, Attribute... attributes) { + return new EmbeddedObject(objectClass(objectClass), Set.of(attributes)); + } + + private static EmbeddedObject onlyEmbedded(ConnectorObject object, String attributeName) { + assertThat(values(object, attributeName)).hasSize(1); + return (EmbeddedObject) values(object, attributeName).getFirst(); + } + + private static List embeddedValues( + ConnectorObject object, String attributeName) { + return values(object, attributeName).stream() + .map(EmbeddedObject.class::cast) + .toList(); + } + + private static Object embeddedValue(EmbeddedObject object, String expectedName) { + var attribute = object.getAttributes().stream() + .filter(candidate -> candidate.getName().equalsIgnoreCase(expectedName)) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Embedded attribute not found: " + expectedName)); + return AttributeUtil.getSingleValue(attribute); + } + private static ConnectorObject tableNamed(List tables, String name) { return tables.stream() .filter(table -> table.getName().getNameValue().equalsIgnoreCase(name)) 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 index 64baa5a..f3b3fc2 100644 --- 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 @@ -11,5 +11,6 @@ public record DatabaseCapabilities( boolean supportsSchemas, boolean supportsRemarks, boolean supportsNativeDefinitions, - boolean supportsJdbcDefaults) { + boolean supportsJdbcDefaults, + boolean supportsNonPrimaryForeignKeyMetadata) { } 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 index 0aa7fd9..64003c0 100644 --- 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 @@ -23,7 +23,7 @@ private SqlTestDatabases() { public static SqlTestDatabase h2() { var id = ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE); return database( - SqlDatabase.H2, false, true, true, true, true, + SqlDatabase.H2, false, true, 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); @@ -34,7 +34,7 @@ public static SqlTestDatabase postgresql() { return database( SqlDatabase.POSTGRESQL, false, true, true, !System.getProperty("sql.test.postgresql.pgDumpPath", "").isBlank(), - true, + true, true, postgres.getJdbcUrl(), postgres.getUsername(), "postgres", "database/postgresql/contract-schema.sql", List.of(), standardDrops(" CASCADE"), List.of(), postgres); @@ -43,7 +43,7 @@ public static SqlTestDatabase postgresql() { public static SqlTestDatabase sqlite() throws Exception { var sqlite = SqliteDatabaseInitializer.create(); return database( - SqlDatabase.SQLITE, false, false, false, true, true, + SqlDatabase.SQLITE, false, false, false, true, true, true, sqlite.jdbcUrl() + "?foreign_keys=on", "unused", "", "database/sqlite/contract-schema.sql", List.of("PRAGMA foreign_keys = OFF"), standardDrops(""), @@ -52,7 +52,7 @@ public static SqlTestDatabase sqlite() throws Exception { public static SqlTestDatabase oracle() { return database( - SqlDatabase.ORACLE, true, true, false, true, false, + SqlDatabase.ORACLE, true, true, false, true, false, 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"), @@ -63,7 +63,7 @@ public static SqlTestDatabase oracle() { public static SqlTestDatabase mariadb() { return database( - SqlDatabase.MARIADB, true, false, true, true, true, + SqlDatabase.MARIADB, true, false, true, 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"), @@ -74,7 +74,7 @@ public static SqlTestDatabase mariadb() { public static SqlTestDatabase mysql() { return database( - SqlDatabase.MYSQL, true, false, true, true, true, + SqlDatabase.MYSQL, true, false, true, 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"), @@ -90,6 +90,7 @@ private static JdbcSqlTestDatabase database( boolean supportsRemarks, boolean supportsNativeDefinitions, boolean supportsJdbcDefaults, + boolean supportsNonPrimaryForeignKeyMetadata, String jdbcUrl, String username, String password, @@ -102,7 +103,8 @@ private static JdbcSqlTestDatabase database( database, new DatabaseCapabilities( external, supportsSchemas, supportsRemarks, - supportsNativeDefinitions, supportsJdbcDefaults), + supportsNativeDefinitions, supportsJdbcDefaults, + supportsNonPrimaryForeignKeyMetadata), jdbcUrl, username, password, resource, beforeDrop, drops, afterDrop, closeAction); } @@ -110,7 +112,13 @@ private static JdbcSqlTestDatabase database( private static List standardDrops(String tableSuffix) { return List.of( "DROP VIEW IF EXISTS contract_user_view", + "DROP TABLE IF EXISTS contract_user_phone" + tableSuffix, + "DROP TABLE IF EXISTS contract_user_email" + tableSuffix, + "DROP TABLE IF EXISTS contract_user_profile" + tableSuffix, + "DROP TABLE IF EXISTS contract_user_alias" + tableSuffix, "DROP TABLE IF EXISTS contract_address" + tableSuffix, + "DROP TABLE IF EXISTS contract_user_group" + tableSuffix, + "DROP TABLE IF EXISTS contract_composite_tag" + tableSuffix, "DROP TABLE IF EXISTS contract_composite" + tableSuffix, "DROP TABLE IF EXISTS contract_external" + tableSuffix, "DROP TABLE IF EXISTS contract_group" + tableSuffix, @@ -120,7 +128,13 @@ private static List standardDrops(String tableSuffix) { private static List oracleDrops() { return List.of( "DROP VIEW contract_user_view", + "DROP TABLE contract_user_phone CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_user_email CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_user_profile CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_user_alias CASCADE CONSTRAINTS PURGE", "DROP TABLE contract_address CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_user_group CASCADE CONSTRAINTS PURGE", + "DROP TABLE contract_composite_tag CASCADE CONSTRAINTS PURGE", "DROP TABLE contract_composite CASCADE CONSTRAINTS PURGE", "DROP TABLE contract_external CASCADE CONSTRAINTS PURGE", "DROP TABLE contract_group CASCADE CONSTRAINTS PURGE", diff --git a/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlRelatedTableCleanupIntegrationTest.java b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlRelatedTableCleanupIntegrationTest.java new file mode 100644 index 0000000..9d10dc1 --- /dev/null +++ b/base/src/test/java/com/evolveum/polygon/sql/base/write/SqlRelatedTableCleanupIntegrationTest.java @@ -0,0 +1,104 @@ +/* + * 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.SqlConnectorConfiguration; +import com.evolveum.polygon.sql.base.test.SqlIntegrationTestBase; +import org.identityconnectors.common.security.GuardedString; +import org.identityconnectors.framework.common.objects.AttributeBuilder; +import org.identityconnectors.framework.common.objects.Name; +import org.identityconnectors.framework.common.objects.ObjectClass; +import org.testng.annotations.Test; + +import java.sql.DriverManager; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Direct database verification that parent deletion does not leave related rows behind. */ +@Test(singleThreaded = true) +public class SqlRelatedTableCleanupIntegrationTest + extends SqlIntegrationTestBase { + + protected static class TestSqlConnector extends DefaultTestConnector { + protected TestSqlConnector() { + super(); + } + } + + @Override + protected String[] resourceSchemaPaths() { + return new String[]{"h2/child-tables/schema.sql", "h2/child-tables/data.sql"}; + } + + @Override + protected SqlConnectorConfiguration buildConfiguration() { + var config = new SqlConnectorConfiguration(); + config.setJdbcUrl(url); + config.setUsername("sa"); + config.setPassword(new GuardedString("".toCharArray())); + config.setPoolSize(5); + config.setConnectionTimeout(10000); + config.setValidateConnectionOnBorrow(true); + config.setScanTables(true); + config.setScanViews(false); + config.setDevelopmentMode(false); + return config; + } + + @Override + protected void initConnector() { + connector = new TestSqlConnector(); + connector.init(defaultConfig()); + } + + @Test + public void deleteRemovesOwnedAndJunctionRows() throws Exception { + var uid = connector.create(usersClass(), Set.of( + AttributeBuilder.build(Name.NAME, "cleanup-user"), + AttributeBuilder.build("username", "cleanup-user"), + AttributeBuilder.build(childAttribute("user_emails"), "cleanup@example.com")), opts()); + execute("INSERT INTO user_group_membership (user_id, group_id) VALUES (?, 1)", uid.getUidValue()); + + connector.delete(usersClass(), uid, opts()); + + assertThat(count("SELECT COUNT(*) FROM users WHERE id = ?", uid.getUidValue())).isZero(); + assertThat(count("SELECT COUNT(*) FROM user_emails WHERE user_id = ?", uid.getUidValue())).isZero(); + assertThat(count("SELECT COUNT(*) FROM user_group_membership WHERE user_id = ?", uid.getUidValue())).isZero(); + } + + private ObjectClass usersClass() { + return new ObjectClass(findOC("users").getType()); + } + + private String childAttribute(String tableName) { + return findOC("users").getAttributeInfo().stream() + .map(info -> info.getName()) + .filter(name -> name.equalsIgnoreCase(tableName)) + .findFirst() + .orElseThrow(); + } + + private int count(String sql, Object parameter) throws Exception { + try (var connection = DriverManager.getConnection(url, "sa", ""); + var statement = connection.prepareStatement(sql)) { + statement.setObject(1, parameter); + try (var result = statement.executeQuery()) { + result.next(); + return result.getInt(1); + } + } + } + + private void execute(String sql, Object parameter) throws Exception { + try (var connection = DriverManager.getConnection(url, "sa", ""); + var statement = connection.prepareStatement(sql)) { + statement.setObject(1, parameter); + statement.executeUpdate(); + } + } +} diff --git a/base/src/test/resources/database/h2/contract-schema.sql b/base/src/test/resources/database/h2/contract-schema.sql index 633a46a..f1c1846 100644 --- a/base/src/test/resources/database/h2/contract-schema.sql +++ b/base/src/test/resources/database/h2/contract-schema.sql @@ -26,6 +26,43 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id BIGINT PRIMARY KEY, + bio VARCHAR(200), + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id BIGINT NOT NULL, + email_address VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id BIGINT NOT NULL, + phone_number VARCHAR(30) NOT NULL, + phone_type VARCHAR(30), + priority INTEGER, + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username VARCHAR(50) NOT NULL, + alias_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id BIGINT NOT NULL, record_id BIGINT NOT NULL, @@ -33,6 +70,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + tag_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -41,6 +87,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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 index 5880fe0..f12f3a0 100644 --- a/base/src/test/resources/database/mariadb/contract-schema.sql +++ b/base/src/test/resources/database/mariadb/contract-schema.sql @@ -27,6 +27,44 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id BIGINT NOT NULL, + bio VARCHAR(200), + CONSTRAINT pk_contract_user_profile PRIMARY KEY (user_id), + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id BIGINT NOT NULL, + email_address VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id BIGINT NOT NULL, + phone_number VARCHAR(30) NOT NULL, + phone_type VARCHAR(30), + priority INT, + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username VARCHAR(50) NOT NULL, + alias_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id BIGINT NOT NULL, record_id BIGINT NOT NULL, @@ -34,6 +72,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + tag_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -42,6 +89,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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 index 5880fe0..f12f3a0 100644 --- a/base/src/test/resources/database/mysql/contract-schema.sql +++ b/base/src/test/resources/database/mysql/contract-schema.sql @@ -27,6 +27,44 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id BIGINT NOT NULL, + bio VARCHAR(200), + CONSTRAINT pk_contract_user_profile PRIMARY KEY (user_id), + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id BIGINT NOT NULL, + email_address VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id BIGINT NOT NULL, + phone_number VARCHAR(30) NOT NULL, + phone_type VARCHAR(30), + priority INT, + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username VARCHAR(50) NOT NULL, + alias_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id BIGINT NOT NULL, record_id BIGINT NOT NULL, @@ -34,6 +72,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + tag_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -42,6 +89,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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 index 389c28f..9c75fcf 100644 --- a/base/src/test/resources/database/oracle/contract-schema.sql +++ b/base/src/test/resources/database/oracle/contract-schema.sql @@ -1,11 +1,12 @@ CREATE TABLE contract_user ( id NUMBER GENERATED BY DEFAULT AS IDENTITY, - username VARCHAR2(50) DEFAULT 'anonymous' NOT NULL UNIQUE, + username VARCHAR2(50) DEFAULT 'anonymous' NOT NULL, 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) + CONSTRAINT pk_contract_user PRIMARY KEY (id), + CONSTRAINT uk_contract_user_name UNIQUE (username) ); COMMENT ON TABLE contract_user IS 'Contract users'; COMMENT ON COLUMN contract_user.username IS 'Contract login name'; @@ -29,6 +30,44 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id NUMBER NOT NULL, + group_id NUMBER NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id NUMBER, + bio VARCHAR2(200), + CONSTRAINT pk_contract_user_profile PRIMARY KEY (user_id), + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id NUMBER NOT NULL, + email_address VARCHAR2(100) NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id NUMBER NOT NULL, + phone_number VARCHAR2(30) NOT NULL, + phone_type VARCHAR2(30), + priority NUMBER(10), + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username VARCHAR2(50) NOT NULL, + alias_value VARCHAR2(100) NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id NUMBER NOT NULL, record_id NUMBER NOT NULL, @@ -36,6 +75,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id NUMBER NOT NULL, + record_id NUMBER NOT NULL, + tag_value VARCHAR2(100) NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -44,6 +92,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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 index f17385b..62ef190 100644 --- a/base/src/test/resources/database/postgresql/contract-schema.sql +++ b/base/src/test/resources/database/postgresql/contract-schema.sql @@ -26,6 +26,43 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id BIGINT PRIMARY KEY, + bio VARCHAR(200), + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id BIGINT NOT NULL, + email_address VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id BIGINT NOT NULL, + phone_number VARCHAR(30) NOT NULL, + phone_type VARCHAR(30), + priority INTEGER, + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username VARCHAR(50) NOT NULL, + alias_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id BIGINT NOT NULL, record_id BIGINT NOT NULL, @@ -33,6 +70,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id BIGINT NOT NULL, + record_id BIGINT NOT NULL, + tag_value VARCHAR(100) NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -41,6 +87,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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 index f09c132..407a801 100644 --- a/base/src/test/resources/database/sqlite/contract-schema.sql +++ b/base/src/test/resources/database/sqlite/contract-schema.sql @@ -24,6 +24,43 @@ CREATE TABLE contract_address ( CONSTRAINT fk_contract_address_user FOREIGN KEY (user_id) REFERENCES contract_user(id) ); +CREATE TABLE contract_user_group ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + CONSTRAINT pk_contract_user_group PRIMARY KEY (user_id, group_id), + CONSTRAINT fk_contract_ug_user FOREIGN KEY (user_id) REFERENCES contract_user(id), + CONSTRAINT fk_contract_ug_group FOREIGN KEY (group_id) REFERENCES contract_group(id) +); + +CREATE TABLE contract_user_profile ( + user_id INTEGER PRIMARY KEY, + bio TEXT, + CONSTRAINT fk_contract_user_profile FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_email ( + user_id INTEGER NOT NULL, + email_address TEXT NOT NULL, + CONSTRAINT pk_contract_user_email PRIMARY KEY (user_id, email_address), + CONSTRAINT fk_contract_user_email FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_phone ( + user_id INTEGER NOT NULL, + phone_number TEXT NOT NULL, + phone_type TEXT, + priority INTEGER, + CONSTRAINT pk_contract_user_phone PRIMARY KEY (user_id, phone_number), + CONSTRAINT fk_contract_user_phone FOREIGN KEY (user_id) REFERENCES contract_user(id) +); + +CREATE TABLE contract_user_alias ( + username TEXT NOT NULL, + alias_value TEXT NOT NULL, + CONSTRAINT pk_contract_user_alias PRIMARY KEY (username, alias_value), + CONSTRAINT fk_contract_user_alias FOREIGN KEY (username) REFERENCES contract_user(username) +); + CREATE TABLE contract_composite ( tenant_id INTEGER NOT NULL, record_id INTEGER NOT NULL, @@ -31,6 +68,15 @@ CREATE TABLE contract_composite ( CONSTRAINT pk_contract_composite PRIMARY KEY (tenant_id, record_id) ); +CREATE TABLE contract_composite_tag ( + tenant_id INTEGER NOT NULL, + record_id INTEGER NOT NULL, + tag_value TEXT NOT NULL, + CONSTRAINT pk_contract_composite_tag PRIMARY KEY (tenant_id, record_id, tag_value), + CONSTRAINT fk_contract_ctag_parent FOREIGN KEY (tenant_id, record_id) + REFERENCES contract_composite(tenant_id, record_id) +); + CREATE VIEW contract_user_view AS SELECT id, username, email FROM contract_user; @@ -39,6 +85,7 @@ 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_user_group (user_id, group_id) VALUES (1, 1); 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');