ArchitectureRelationDslTransformer.java
package com.taxonomy.dsl.command;
import com.taxonomy.dsl.ast.BlockAst;
import com.taxonomy.dsl.ast.DocumentAst;
import com.taxonomy.dsl.ast.PropertyAst;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.serializer.TaxDslSerializer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
/**
* Applies one deterministic relation command to an {@code architecture.taxdsl}
* document before the result is committed to JGit.
*
* <p>The transformer is deliberately independent of repositories, transactions,
* controllers and projections. It establishes the pure semantic boundary needed
* by Git-authoritative relation commands: exact identity, idempotent upsert,
* exact removal, duplicate rejection and canonical changed output.</p>
*/
public final class ArchitectureRelationDslTransformer {
private static final String RELATION_KIND = "relation";
private static final List<String> MUTABLE_PROPERTY_ORDER = List.of(
"status", "confidence", "provenance");
private final TaxDslParser parser;
private final TaxDslSerializer serializer;
public ArchitectureRelationDslTransformer() {
this(new TaxDslParser(), new TaxDslSerializer());
}
ArchitectureRelationDslTransformer(
TaxDslParser parser,
TaxDslSerializer serializer) {
this.parser = Objects.requireNonNull(parser, "parser");
this.serializer = Objects.requireNonNull(serializer, "serializer");
}
/**
* Adds the relation when absent or updates only the supplied review fields
* and extensions when exactly one matching relation exists.
*/
public ChangeResult upsert(String sourceDsl, RelationDefinition definition) {
Objects.requireNonNull(definition, "definition");
String original = sourceDsl == null ? "" : sourceDsl;
DocumentAst document = parser.parse(original, "architecture.taxdsl");
List<Integer> matches = matchingIndexes(
document.getBlocks(), definition.identity());
rejectAmbiguity(definition.identity(), matches.size());
List<BlockAst> changedBlocks = new ArrayList<>(document.getBlocks());
if (matches.isEmpty()) {
changedBlocks.add(newRelationBlock(definition));
return changed(document, changedBlocks, ChangeKind.ADDED);
}
int index = matches.getFirst();
BlockAst existing = changedBlocks.get(index);
requireCanonicalHeader(existing, definition.identity());
BlockAst replacement = merge(existing, definition);
if (canonicalBlock(existing).equals(canonicalBlock(replacement))) {
return new ChangeResult(original, ChangeKind.UNCHANGED);
}
changedBlocks.set(index, replacement);
return changed(document, changedBlocks, ChangeKind.UPDATED);
}
/** Removes exactly one matching relation and leaves all other blocks intact. */
public ChangeResult remove(String sourceDsl, RelationIdentity identity) {
Objects.requireNonNull(identity, "identity");
String original = sourceDsl == null ? "" : sourceDsl;
DocumentAst document = parser.parse(original, "architecture.taxdsl");
List<Integer> matches = matchingIndexes(document.getBlocks(), identity);
rejectAmbiguity(identity, matches.size());
if (matches.isEmpty()) {
return new ChangeResult(original, ChangeKind.UNCHANGED);
}
int index = matches.getFirst();
requireCanonicalHeader(document.getBlocks().get(index), identity);
List<BlockAst> changedBlocks = new ArrayList<>(document.getBlocks());
changedBlocks.remove(index);
return changed(document, changedBlocks, ChangeKind.REMOVED);
}
private ChangeResult changed(
DocumentAst original,
List<BlockAst> blocks,
ChangeKind kind) {
DocumentAst changed = new DocumentAst(original.getMeta(), blocks);
return new ChangeResult(serializer.serialize(changed), kind);
}
private BlockAst newRelationBlock(RelationDefinition definition) {
List<PropertyAst> properties = new ArrayList<>();
addSuppliedProperties(properties, definition);
return new BlockAst(
RELATION_KIND,
definition.identity().headerTokens(),
properties,
List.of(),
extensionMap(properties),
null);
}
private BlockAst merge(
BlockAst existing,
RelationDefinition definition) {
Map<String, String> overrides = suppliedProperties(definition);
List<PropertyAst> properties = new ArrayList<>();
Set<String> emittedOverrides = new LinkedHashSet<>();
for (PropertyAst property : existing.getProperties()) {
String key = property.key();
if (!overrides.containsKey(key)) {
properties.add(property);
continue;
}
if (emittedOverrides.add(key)) {
properties.add(new PropertyAst(key, overrides.get(key), null));
}
}
MUTABLE_PROPERTY_ORDER.stream()
.filter(overrides::containsKey)
.filter(emittedOverrides::add)
.forEach(key -> properties.add(
new PropertyAst(key, overrides.get(key), null)));
overrides.keySet().stream()
.filter(key -> key.startsWith("x-"))
.sorted()
.filter(emittedOverrides::add)
.forEach(key -> properties.add(
new PropertyAst(key, overrides.get(key), null)));
return new BlockAst(
RELATION_KIND,
definition.identity().headerTokens(),
properties,
existing.getChildren(),
extensionMap(properties),
existing.getSourceLocation());
}
private void addSuppliedProperties(
List<PropertyAst> properties,
RelationDefinition definition) {
Map<String, String> supplied = suppliedProperties(definition);
MUTABLE_PROPERTY_ORDER.stream()
.filter(supplied::containsKey)
.forEach(key -> properties.add(
new PropertyAst(key, supplied.get(key), null)));
supplied.keySet().stream()
.filter(key -> key.startsWith("x-"))
.sorted()
.forEach(key -> properties.add(
new PropertyAst(key, supplied.get(key), null)));
}
private Map<String, String> suppliedProperties(
RelationDefinition definition) {
Map<String, String> supplied = new LinkedHashMap<>();
if (definition.status() != null) {
supplied.put("status", definition.status());
}
if (definition.confidence() != null) {
supplied.put("confidence", String.valueOf(definition.confidence()));
}
if (definition.provenance() != null) {
supplied.put("provenance", definition.provenance());
}
supplied.putAll(definition.extensions());
return supplied;
}
private Map<String, String> extensionMap(List<PropertyAst> properties) {
Map<String, String> extensions = new LinkedHashMap<>();
properties.stream()
.filter(PropertyAst::isExtension)
.forEach(property -> extensions.put(
property.key(), property.value()));
return extensions;
}
private List<Integer> matchingIndexes(
List<BlockAst> blocks,
RelationIdentity identity) {
List<Integer> matches = new ArrayList<>();
for (int index = 0; index < blocks.size(); index++) {
BlockAst block = blocks.get(index);
if (RELATION_KIND.equals(block.getKind())
&& headerStartsWith(block, identity)) {
matches.add(index);
}
}
return matches;
}
private boolean headerStartsWith(
BlockAst block,
RelationIdentity identity) {
List<String> tokens = block.getHeaderTokens();
return tokens.size() >= 3
&& identity.sourceId().equals(tokens.get(0))
&& identity.relationType().equals(
tokens.get(1).toUpperCase(Locale.ROOT))
&& identity.targetId().equals(tokens.get(2));
}
private void requireCanonicalHeader(
BlockAst block,
RelationIdentity identity) {
if (block.getHeaderTokens().size() != 3) {
throw new InvalidRelationBlockException(
"Relation " + identity.asDisplayText()
+ " has a malformed header with "
+ block.getHeaderTokens().size() + " tokens");
}
}
private void rejectAmbiguity(RelationIdentity identity, int matches) {
if (matches > 1) {
throw new AmbiguousRelationException(
"Relation " + identity.asDisplayText()
+ " occurs " + matches + " times");
}
}
private String canonicalBlock(BlockAst block) {
return serializer.serialize(new DocumentAst(null, List.of(block)));
}
public enum ChangeKind {
ADDED,
UPDATED,
REMOVED,
UNCHANGED
}
public record ChangeResult(String dsl, ChangeKind kind) {
public ChangeResult {
dsl = Objects.requireNonNull(dsl, "dsl");
kind = Objects.requireNonNull(kind, "kind");
}
public boolean changed() {
return kind != ChangeKind.UNCHANGED;
}
}
public record RelationIdentity(
String sourceId,
String relationType,
String targetId) {
public RelationIdentity {
sourceId = requireToken(sourceId, "sourceId");
relationType = requireToken(relationType, "relationType")
.toUpperCase(Locale.ROOT);
targetId = requireToken(targetId, "targetId");
}
List<String> headerTokens() {
return List.of(sourceId, relationType, targetId);
}
String asDisplayText() {
return sourceId + " " + relationType + " " + targetId;
}
}
public record RelationDefinition(
RelationIdentity identity,
String status,
Double confidence,
String provenance,
Map<String, String> extensions) {
public RelationDefinition {
identity = Objects.requireNonNull(identity, "identity");
status = normalizeOptionalToken(status, "status");
provenance = normalizeOptionalToken(provenance, "provenance");
if (confidence != null
&& (!Double.isFinite(confidence)
|| confidence < 0.0
|| confidence > 1.0)) {
throw new IllegalArgumentException(
"confidence must be finite and between 0.0 and 1.0");
}
Map<String, String> normalizedExtensions = new TreeMap<>();
if (extensions != null) {
extensions.forEach((key, value) -> {
String normalizedKey = requireToken(key, "extension key");
if (!normalizedKey.startsWith("x-")) {
throw new IllegalArgumentException(
"extension key must start with x-: " + normalizedKey);
}
normalizedExtensions.put(
normalizedKey,
Objects.requireNonNull(value,
"extension value for " + normalizedKey));
});
}
extensions = Map.copyOf(normalizedExtensions);
}
public RelationDefinition(
RelationIdentity identity,
String status,
Double confidence,
String provenance) {
this(identity, status, confidence, provenance, Map.of());
}
}
public static final class AmbiguousRelationException
extends IllegalStateException {
public AmbiguousRelationException(String message) {
super(message);
}
}
public static final class InvalidRelationBlockException
extends IllegalStateException {
public InvalidRelationBlockException(String message) {
super(message);
}
}
private static String requireToken(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
String normalized = value.strip();
if (normalized.chars().anyMatch(Character::isWhitespace)) {
throw new IllegalArgumentException(
field + " must be one DSL token: " + normalized);
}
return normalized;
}
private static String normalizeOptionalToken(
String value,
String field) {
if (value == null) {
return null;
}
return requireToken(value, field).toLowerCase(Locale.ROOT);
}
}