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 extends Map> 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');