diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java index 5ff2897c..29a94a85 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java @@ -57,6 +57,21 @@ public synchronized WorkspaceSnapshot replaceWithYaml(final Path path) throws IO return replaceWithYaml(Files.readString(path)); } + public synchronized WorkspaceSnapshot replaceWithMigratedThingifier( + final Thingifier newThingifier, final long expectedVersion) { + if (version != expectedVersion) { + throw new IllegalStateException( + "Workspace changed; refresh schema preview before applying"); + } + Thingifier oldThingifier = thingifier; + replaceWith(newThingifier); + version++; + if (oldThingifier != null) { + oldThingifier.close(); + } + return snapshot(); + } + private void replaceWith(final Thingifier newThingifier) { thingifier = newThingifier; definition = modelExporter.export(newThingifier); diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java index e0b0ffc8..dcc320d6 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java @@ -11,6 +11,7 @@ public final class CrudUiController { private final WorkspaceDataExporter exporter; private final WorkspaceDataImporter importer; private final SchemaPreviewService schemaPreviewService; + private final WorkspaceSchemaUpgradeService schemaUpgradeService; public CrudUiController(final ActiveThingifierWorkspace workspace) { this.workspace = workspace; @@ -19,6 +20,7 @@ public CrudUiController(final ActiveThingifierWorkspace workspace) { exporter = new WorkspaceDataExporter(workspace, apiProxy); importer = new WorkspaceDataImporter(workspace, apiProxy, metadataJson); schemaPreviewService = new SchemaPreviewService(); + schemaUpgradeService = new WorkspaceSchemaUpgradeService(workspace, metadataJson); } public UiHttpResponse workspace() { @@ -50,6 +52,26 @@ public UiHttpResponse previewSchema(final String schemaDraftJson) { } } + public UiHttpResponse previewSchemaUpgrade(final String upgradeRequestJson) { + try { + return schemaUpgradeService.preview(upgradeRequestJson); + } catch (CrudUiException e) { + return JsonSupport.error(e.statusCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse applySchemaUpgrade(final String upgradeRequestJson) { + try { + return schemaUpgradeService.apply(upgradeRequestJson); + } catch (CrudUiException e) { + return JsonSupport.error(e.statusCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + public UiHttpResponse exportData() { try { return exporter.exportData(); diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java new file mode 100644 index 00000000..23535db2 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeService.java @@ -0,0 +1,1305 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.EntityDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.FieldDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.SchemaDefinitionValidationReport; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelAssembler; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.FieldValue; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.domain.instances.InstanceFields; +import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlExporter; + +public final class WorkspaceSchemaUpgradeService { + + private static final String STALE_WORKSPACE_MESSAGE = + "Workspace changed; refresh schema preview before applying"; + + private final ActiveThingifierWorkspace workspace; + private final WorkspaceMetadataJson metadataJson; + private final SchemaDefinitionDraftJson draftJson; + private final ThingifierModelAssembler assembler; + private final ThingifierYamlExporter yamlExporter; + + public WorkspaceSchemaUpgradeService( + final ActiveThingifierWorkspace workspace, final WorkspaceMetadataJson metadataJson) { + this.workspace = workspace; + this.metadataJson = metadataJson; + this.draftJson = new SchemaDefinitionDraftJson(); + this.assembler = new ThingifierModelAssembler(); + this.yamlExporter = new ThingifierYamlExporter(); + } + + public UiHttpResponse preview(final String jsonText) { + UpgradeRequest request = requestFrom(jsonText, false); + MigrationResult result = migrationResultFor(request); + result.closeTargetThingifier(); + return UiHttpResponse.json(200, JsonSupport.toJson(result.body())); + } + + public UiHttpResponse apply(final String jsonText) { + UpgradeRequest request = requestFrom(jsonText, true); + MigrationResult result = migrationResultFor(request); + if (!result.canApply()) { + result.closeTargetThingifier(); + return UiHttpResponse.json(result.statusCode(), JsonSupport.toJson(result.body())); + } + + try { + WorkspaceSnapshot upgraded = + workspace.replaceWithMigratedThingifier( + result.targetThingifier(), request.expectedWorkspaceVersion()); + result.releaseTargetThingifier(); + result.markApplied(upgraded, metadataJson.toMap(upgraded)); + return UiHttpResponse.json(200, JsonSupport.toJson(result.body())); + } catch (IllegalStateException e) { + result.closeTargetThingifier(); + result.addBlockingError("workspaceVersion", e.getMessage()); + result.finish(); + return UiHttpResponse.json(409, JsonSupport.toJson(result.body())); + } + } + + private MigrationResult migrationResultFor(final UpgradeRequest request) { + WorkspaceSnapshot snapshot = workspace.snapshot(); + MigrationResult result = new MigrationResult(snapshot.version()); + result.putRequestMappings(request.mappings().asMap()); + + if (request.hasExpectedWorkspaceVersion() + && request.expectedWorkspaceVersion() != snapshot.version()) { + result.addBlockingError("workspaceVersion", STALE_WORKSPACE_MESSAGE); + result.finish(); + return result; + } + + SchemaDefinitionValidationReport validation = assembler.validate(request.definition()); + result.putSchemaValidation(validation); + if (!validation.isValid()) { + result.finish(); + return result; + } + + result.putYaml(yamlExporter.export(request.definition())); + SourceWorkspaceData sourceData = sourceDataFrom(snapshot); + EffectiveMappings mappings = + EffectiveMappings.from( + snapshot.definition(), request.definition(), request.mappings()); + result.putEffectiveMappings(mappings.asMap()); + result.addWarnings(mappings.warnings()); + result.putSummary(mappings.summaryMap(sourceData)); + + Thingifier targetThingifier = assembler.assemble(request.definition()); + try { + migrate(sourceData, request.definition(), targetThingifier, mappings, result); + validateFinalData(request.definition(), targetThingifier, result); + result.targetThingifier(targetThingifier); + } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException e) { + targetThingifier.close(); + result.addBlockingError("migration", e.getMessage()); + } + result.finish(); + return result; + } + + private void migrate( + final SourceWorkspaceData sourceData, + final ThingifierModelDefinition targetDefinition, + final Thingifier targetThingifier, + final EffectiveMappings mappings, + final MigrationResult result) { + ThingStore targetStore = targetThingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + Map migratedBySourceInternalId = new LinkedHashMap<>(); + + for (EntityDefinitionSpec targetEntity : targetDefinition.entities()) { + String sourceEntityName = mappings.sourceEntityFor(targetEntity.name()); + if (sourceEntityName == null) { + continue; + } + EntityDefinition targetRuntimeEntity = + targetThingifier.getDefinitionNamed(targetEntity.name()); + for (SourceRow sourceRow : sourceData.rowsFor(sourceEntityName)) { + EntityInstance migrated = + migrateRow( + sourceRow, + sourceData.sourceDefinition().entityNamed(sourceEntityName), + targetEntity, + targetRuntimeEntity, + targetStore, + mappings, + result); + migratedBySourceInternalId.put(sourceRow.internalId(), migrated); + result.incrementSummary("migratedInstances"); + } + } + + for (RelationshipMapping relationship : mappings.relationshipMappings()) { + migrateRelationship( + sourceData, targetStore, relationship, migratedBySourceInternalId, result); + } + } + + private EntityInstance migrateRow( + final SourceRow sourceRow, + final EntityDefinitionSpec sourceEntity, + final EntityDefinitionSpec targetEntity, + final EntityDefinition targetRuntimeEntity, + final ThingStore targetStore, + final EffectiveMappings mappings, + final MigrationResult result) { + EntityInstanceDraft draft = EntityInstanceDraft.forEntity(targetRuntimeEntity); + for (FieldDefinitionSpec targetField : targetEntity.fields()) { + migrateField( + sourceRow, sourceEntity, targetEntity, targetField, mappings, draft, result); + } + targetStore + .administration() + .accommodateProtectedIds(targetRuntimeEntity, protectedValues(draft)); + try { + EntityInstance migrated = targetStore.entities().create(draft); + result.incrementEntityCount(targetEntity.name()); + return migrated; + } catch (IllegalArgumentException | IllegalStateException | IndexOutOfBoundsException e) { + result.addBlockingError( + "entities." + targetEntity.name(), + "Could not migrate " + + targetEntity.name() + + " " + + sourceRow.displayIdentifier() + + ": " + + e.getMessage()); + throw e; + } + } + + private List protectedValues(final EntityInstanceDraft draft) { + return new ArrayList<>(draft.getProtectedFieldValues()); + } + + private void migrateField( + final SourceRow sourceRow, + final EntityDefinitionSpec sourceEntity, + final EntityDefinitionSpec targetEntity, + final FieldDefinitionSpec targetField, + final EffectiveMappings mappings, + final EntityInstanceDraft draft, + final MigrationResult result) { + String sourceFieldName = mappings.sourceFieldFor(targetEntity.name(), targetField.name()); + FieldDefinitionSpec sourceField = + sourceEntity == null || sourceFieldName == null + ? null + : sourceEntity.fieldNamed(sourceFieldName); + if ("object".equals(normalizedType(targetField))) { + migrateObjectField(sourceRow, sourceField, sourceFieldName, targetField, draft, result); + return; + } + + String value = + valueForField( + sourceRow, + sourceField, + sourceFieldName, + targetField, + targetField.name(), + result); + if (value == null) { + return; + } + addValueToDraft(draft, targetField, targetField.name(), value); + } + + private void migrateObjectField( + final SourceRow sourceRow, + final FieldDefinitionSpec sourceField, + final String sourceFieldName, + final FieldDefinitionSpec targetField, + final EntityInstanceDraft draft, + final MigrationResult result) { + if (sourceField != null && !"object".equals(normalizedType(sourceField))) { + result.addWarning( + "Field " + + sourceField.name() + + " could not be mapped into object field " + + targetField.name()); + } + for (FieldDefinitionSpec targetChild : targetField.objectFields()) { + String targetPath = targetField.name() + "." + targetChild.name(); + FieldDefinitionSpec sourceChild = null; + String sourcePath = null; + if (sourceField != null && "object".equals(normalizedType(sourceField))) { + sourceChild = childFieldNamed(sourceField, targetChild.name()); + if (sourceChild != null) { + sourcePath = sourceFieldName + "." + sourceChild.name(); + } + } + String value = + valueForField( + sourceRow, sourceChild, sourcePath, targetChild, targetPath, result); + if (value == null) { + continue; + } + addValueToDraft(draft, targetChild, targetPath, value); + } + } + + private String valueForField( + final SourceRow sourceRow, + final FieldDefinitionSpec sourceField, + final String sourceFieldName, + final FieldDefinitionSpec targetField, + final String targetPath, + final MigrationResult result) { + String sourceValue = sourceFieldName == null ? null : sourceRow.valueFor(sourceFieldName); + if (sourceField == null || sourceValue == null) { + return defaultOrFallback(targetField, targetPath, sourceRow, result); + } + + String coerced = coerce(sourceValue, targetField, targetPath, sourceRow, result); + if (!normalizedType(sourceField).equals(normalizedType(targetField)) + || !sourceValue.equals(coerced)) { + result.addCoercion( + sourceRow.entityName(), + sourceRow.displayIdentifier(), + targetPath, + sourceValue, + coerced, + "Converted " + sourceField.type() + " to " + targetField.type()); + } + return coerced; + } + + private String defaultOrFallback( + final FieldDefinitionSpec targetField, + final String targetPath, + final SourceRow sourceRow, + final MigrationResult result) { + if (isAutoField(targetField)) { + return null; + } + if (targetField.defaultValue() != null) { + result.addValueAssignment( + sourceRow.entityName(), + sourceRow.displayIdentifier(), + targetPath, + "", + targetField.defaultValue(), + "Used target default"); + return targetField.defaultValue(); + } + String fallback = fallbackFor(targetField); + if (fallback == null) { + return null; + } + result.addValueAssignment( + sourceRow.entityName(), + sourceRow.displayIdentifier(), + targetPath, + "", + fallback, + "Used target type fallback"); + return fallback; + } + + private String coerce( + final String sourceValue, + final FieldDefinitionSpec targetField, + final String targetPath, + final SourceRow sourceRow, + final MigrationResult result) { + String targetType = normalizedType(targetField); + try { + if ("boolean".equals(targetType)) { + if ("true".equalsIgnoreCase(sourceValue) || "false".equalsIgnoreCase(sourceValue)) { + return Boolean.valueOf(sourceValue).toString(); + } + return fallbackAfterFailedCoercion( + sourceValue, targetField, targetPath, sourceRow, result); + } + if ("integer".equals(targetType) || "auto-increment".equals(targetType)) { + BigDecimal numeric = new BigDecimal(sourceValue); + BigDecimal fractional = + numeric.abs().subtract(new BigDecimal(numeric.abs().toBigInteger())); + if (fractional.compareTo(BigDecimal.ZERO) == 0) { + return String.valueOf(numeric.intValue()); + } + return fallbackAfterFailedCoercion( + sourceValue, targetField, targetPath, sourceRow, result); + } + if ("float".equals(targetType)) { + return Float.valueOf(sourceValue).toString(); + } + if ("enum".equals(targetType) + && !targetField.examples().isEmpty() + && !targetField.examples().contains(sourceValue)) { + return fallbackAfterFailedCoercion( + sourceValue, targetField, targetPath, sourceRow, result); + } + return sourceValue; + } catch (NumberFormatException | ArithmeticException e) { + return fallbackAfterFailedCoercion( + sourceValue, targetField, targetPath, sourceRow, result); + } + } + + private String fallbackAfterFailedCoercion( + final String sourceValue, + final FieldDefinitionSpec targetField, + final String targetPath, + final SourceRow sourceRow, + final MigrationResult result) { + String fallback = defaultOrFallback(targetField, targetPath, sourceRow, result); + result.addWarning( + "Could not coerce " + + sourceRow.entityName() + + " " + + sourceRow.displayIdentifier() + + " field " + + targetPath + + " value " + + sourceValue + + "; used " + + fallback); + return fallback; + } + + private void addValueToDraft( + final EntityInstanceDraft draft, + final FieldDefinitionSpec field, + final String fieldPath, + final String value) { + if (isAutoField(field)) { + draft.withProtectedField(fieldPath, value); + } else { + draft.withField(fieldPath, value); + } + } + + private void migrateRelationship( + final SourceWorkspaceData sourceData, + final ThingStore targetStore, + final RelationshipMapping relationship, + final Map migratedBySourceInternalId, + final MigrationResult result) { + for (SourceEdge edge : + sourceData.edgesFor(relationship.sourceFromEntity(), relationship.sourceName())) { + EntityInstance from = migratedBySourceInternalId.get(edge.fromInternalId()); + EntityInstance to = migratedBySourceInternalId.get(edge.toInternalId()); + if (from == null || to == null) { + result.incrementSummary("droppedEdges"); + continue; + } + try { + targetStore.relationships().connect(from, relationship.targetName(), to); + result.incrementSummary("preservedEdges"); + } catch (IllegalArgumentException | IllegalStateException e) { + result.addBlockingError( + "relationships." + + relationship.targetFromEntity() + + "." + + relationship.targetName(), + "Could not migrate relationship " + + relationship.targetName() + + ": " + + e.getMessage()); + throw e; + } + } + } + + private void validateFinalData( + final ThingifierModelDefinition targetDefinition, + final Thingifier targetThingifier, + final MigrationResult result) { + ThingStore store = targetThingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + for (EntityDefinitionSpec entitySpec : targetDefinition.entities()) { + EntityDefinition entity = targetThingifier.getDefinitionNamed(entitySpec.name()); + for (EntityInstance instance : store.entityQueries().list(entity)) { + ValidationReport fieldValidation = instance.validateFieldValues(List.of(), true); + if (!fieldValidation.isValid()) { + result.addBlockingError( + "entities." + entitySpec.name(), + fieldValidation.getCombinedErrorMessages()); + } + ValidationReport relationshipValidation = store.relationships().validate(instance); + if (!relationshipValidation.isValid()) { + result.addBlockingError( + "relationships." + entitySpec.name(), + relationshipValidation.getCombinedErrorMessages()); + } + } + } + } + + private SourceWorkspaceData sourceDataFrom(final WorkspaceSnapshot snapshot) { + SourceWorkspaceData data = new SourceWorkspaceData(snapshot.definition()); + ThingStore store = snapshot.thingifier().getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + for (EntityDefinitionSpec entitySpec : snapshot.definition().entities()) { + EntityDefinition entity = snapshot.thingifier().getDefinitionNamed(entitySpec.name()); + if (entity == null) { + continue; + } + for (EntityInstance instance : store.entityQueries().list(entity)) { + data.addRow(sourceRowFor(entitySpec, instance)); + } + } + for (uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipDefinitionSpec + relationship : snapshot.definition().relationships()) { + EntityDefinition sourceEntity = + snapshot.thingifier().getDefinitionNamed(relationship.fromEntityName()); + if (sourceEntity == null) { + continue; + } + for (EntityInstance instance : store.entityQueries().list(sourceEntity)) { + for (EntityInstance related : + store.relationships().listRelated(instance, relationship.name())) { + data.addEdge( + new SourceEdge( + relationship.fromEntityName(), + relationship.name(), + instance.getInternalId(), + related.getInternalId())); + } + } + } + return data; + } + + private SourceRow sourceRowFor( + final EntityDefinitionSpec entitySpec, final EntityInstance instance) { + Map values = new LinkedHashMap<>(); + for (FieldDefinitionSpec field : entitySpec.fields()) { + collectFieldValues(values, field, field.name(), instance.getFieldValue(field.name())); + } + String identifier = ""; + if (entitySpec.hasPrimaryKeyField()) { + identifier = values.getOrDefault(entitySpec.primaryKeyFieldName(), ""); + } + return new SourceRow(entitySpec.name(), instance.getInternalId(), identifier, values); + } + + private void collectFieldValues( + final Map values, + final FieldDefinitionSpec field, + final String path, + final FieldValue value) { + if (value == null) { + return; + } + if ("object".equals(normalizedType(field))) { + InstanceFields object = value.asObject(); + if (object == null) { + return; + } + for (FieldDefinitionSpec child : field.objectFields()) { + collectObjectFieldValues(values, child, path + "." + child.name(), object); + } + return; + } + values.put(path, value.asString()); + } + + private void collectObjectFieldValues( + final Map values, + final FieldDefinitionSpec field, + final String path, + final InstanceFields object) { + FieldValue value = object.getFieldValue(field.name()); + collectFieldValues(values, field, path, value); + } + + private UpgradeRequest requestFrom(final String jsonText, final boolean apply) { + Map root = + JsonSupport.fromJsonMap( + jsonText, + "Schema upgrade request must contain a JSON object", + "Could not parse schema upgrade JSON"); + Object draftValue = root.get("draft"); + if (!(draftValue instanceof Map)) { + throw new CrudUiException(400, "Schema upgrade request must contain a draft object"); + } + Long expected = nullableLong(root.get("expectedWorkspaceVersion")); + if (apply && expected == null) { + throw new CrudUiException(400, "expectedWorkspaceVersion is required"); + } + return new UpgradeRequest( + draftJson.fromMap((Map) draftValue), + expected == null ? -1L : expected, + expected != null, + ManualMappings.from(mapValue(root.get("mappings")))); + } + + private String fallbackFor(final FieldDefinitionSpec field) { + String type = normalizedType(field); + if ("boolean".equals(type)) { + return "false"; + } + if ("integer".equals(type) || "auto-increment".equals(type)) { + return "0"; + } + if ("float".equals(type)) { + return "0.0"; + } + if ("enum".equals(type)) { + return field.examples().isEmpty() ? "" : field.examples().get(0); + } + if ("object".equals(type) || "auto-guid".equals(type)) { + return null; + } + return ""; + } + + private FieldDefinitionSpec childFieldNamed( + final FieldDefinitionSpec field, final String childName) { + for (FieldDefinitionSpec child : field.objectFields()) { + if (child.name().equals(childName)) { + return child; + } + } + return null; + } + + private boolean isAutoField(final FieldDefinitionSpec field) { + return "auto-increment".equals(normalizedType(field)) + || "auto-guid".equals(normalizedType(field)); + } + + private static String normalizedType(final FieldDefinitionSpec field) { + return field.type() == null ? "" : field.type().trim().toLowerCase(Locale.ROOT); + } + + private Long nullableLong(final Object value) { + if (value == null || stringValue(value).trim().isEmpty()) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return Long.parseLong(stringValue(value)); + } + + private static Map mapValue(final Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Map.of(); + } + + private static List listValue(final Object value) { + if (value instanceof List) { + return (List) value; + } + return List.of(); + } + + private static String stringValue(final Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static final class UpgradeRequest { + private final ThingifierModelDefinition definition; + private final long expectedWorkspaceVersion; + private final boolean hasExpectedWorkspaceVersion; + private final ManualMappings mappings; + + private UpgradeRequest( + final ThingifierModelDefinition definition, + final long expectedWorkspaceVersion, + final boolean hasExpectedWorkspaceVersion, + final ManualMappings mappings) { + this.definition = definition; + this.expectedWorkspaceVersion = expectedWorkspaceVersion; + this.hasExpectedWorkspaceVersion = hasExpectedWorkspaceVersion; + this.mappings = mappings; + } + + private ThingifierModelDefinition definition() { + return definition; + } + + private long expectedWorkspaceVersion() { + return expectedWorkspaceVersion; + } + + private boolean hasExpectedWorkspaceVersion() { + return hasExpectedWorkspaceVersion; + } + + private ManualMappings mappings() { + return mappings; + } + } + + private static final class ManualMappings { + private final Map entityMappings; + private final Map> fieldMappings; + private final List relationshipMappings; + + private ManualMappings( + final Map entityMappings, + final Map> fieldMappings, + final List relationshipMappings) { + this.entityMappings = entityMappings; + this.fieldMappings = fieldMappings; + this.relationshipMappings = relationshipMappings; + } + + private static ManualMappings from(final Map root) { + Map entityMappings = new LinkedHashMap<>(); + for (Map.Entry entry : mapValue(root.get("entityMappings")).entrySet()) { + String target = stringValue(entry.getKey()); + String source = stringValue(entry.getValue()); + if (!target.isBlank() && !source.isBlank()) { + entityMappings.put(target, source); + } + } + + Map> fieldMappings = new LinkedHashMap<>(); + for (Map.Entry entityEntry : mapValue(root.get("fieldMappings")).entrySet()) { + Map fields = new LinkedHashMap<>(); + for (Map.Entry fieldEntry : mapValue(entityEntry.getValue()).entrySet()) { + String targetField = stringValue(fieldEntry.getKey()); + String sourceField = stringValue(fieldEntry.getValue()); + if (!targetField.isBlank() && !sourceField.isBlank()) { + fields.put(targetField, sourceField); + } + } + if (!fields.isEmpty()) { + fieldMappings.put(stringValue(entityEntry.getKey()), fields); + } + } + + List relationshipMappings = new ArrayList<>(); + for (Object value : listValue(root.get("relationshipMappings"))) { + Map map = mapValue(value); + RelationshipMapping relationship = + new RelationshipMapping( + stringValue(map.get("targetFromEntity")), + stringValue(map.get("targetName")), + stringValue(map.get("sourceFromEntity")), + stringValue(map.get("sourceName"))); + if (relationship.isComplete()) { + relationshipMappings.add(relationship); + } + } + return new ManualMappings(entityMappings, fieldMappings, relationshipMappings); + } + + private Map asMap() { + Map values = new LinkedHashMap<>(); + values.put("entityMappings", entityMappings); + values.put("fieldMappings", fieldMappings); + values.put("relationshipMappings", relationshipMappingMaps(relationshipMappings)); + return values; + } + } + + private static final class EffectiveMappings { + private final Map entityMappings; + private final Map> fieldMappings; + private final Map relationshipMappings; + private final List warnings; + + private EffectiveMappings( + final Map entityMappings, + final Map> fieldMappings, + final Map relationshipMappings, + final List warnings) { + this.entityMappings = entityMappings; + this.fieldMappings = fieldMappings; + this.relationshipMappings = relationshipMappings; + this.warnings = warnings; + } + + private static EffectiveMappings from( + final ThingifierModelDefinition source, + final ThingifierModelDefinition target, + final ManualMappings manual) { + Map entityMappings = entityMappings(source, target, manual); + Map> fieldMappings = + fieldMappings(source, target, manual, entityMappings); + Map relationshipMappings = + relationshipMappings(source, target, manual, entityMappings); + List warnings = + warnings(source, target, entityMappings, fieldMappings, relationshipMappings); + return new EffectiveMappings( + entityMappings, fieldMappings, relationshipMappings, warnings); + } + + private static Map entityMappings( + final ThingifierModelDefinition source, + final ThingifierModelDefinition target, + final ManualMappings manual) { + Map mappings = new LinkedHashMap<>(); + for (EntityDefinitionSpec targetEntity : target.entities()) { + String sourceName = manual.entityMappings.get(targetEntity.name()); + if (sourceName == null && source.entityNamed(targetEntity.name()) != null) { + sourceName = targetEntity.name(); + } + if (sourceName != null && source.entityNamed(sourceName) != null) { + mappings.put(targetEntity.name(), sourceName); + } + } + return mappings; + } + + private static Map> fieldMappings( + final ThingifierModelDefinition source, + final ThingifierModelDefinition target, + final ManualMappings manual, + final Map entityMappings) { + Map> mappings = new LinkedHashMap<>(); + for (EntityDefinitionSpec targetEntity : target.entities()) { + EntityDefinitionSpec sourceEntity = + source.entityNamed(entityMappings.get(targetEntity.name())); + if (sourceEntity == null) { + continue; + } + Map entityFieldMappings = new LinkedHashMap<>(); + Map manualFields = + manual.fieldMappings.getOrDefault(targetEntity.name(), Map.of()); + for (FieldDefinitionSpec targetField : targetEntity.fields()) { + String sourceFieldName = manualFields.get(targetField.name()); + if (sourceFieldName == null + && sourceEntity.fieldNamed(targetField.name()) != null) { + sourceFieldName = targetField.name(); + } + if (sourceFieldName != null + && sourceEntity.fieldNamed(sourceFieldName) != null) { + entityFieldMappings.put(targetField.name(), sourceFieldName); + } + } + mappings.put(targetEntity.name(), entityFieldMappings); + } + return mappings; + } + + private static Map relationshipMappings( + final ThingifierModelDefinition source, + final ThingifierModelDefinition target, + final ManualMappings manual, + final Map entityMappings) { + Map mappings = new LinkedHashMap<>(); + for (var relationship : target.relationships()) { + String targetKey = + relationshipKey(relationship.fromEntityName(), relationship.name()); + RelationshipMapping manualMapping = manualRelationshipFor(manual, targetKey); + if (manualMapping != null && sourceRelationshipExists(source, manualMapping)) { + mappings.put(targetKey, manualMapping); + continue; + } + String sourceFrom = entityMappings.get(relationship.fromEntityName()); + RelationshipMapping exact = + new RelationshipMapping( + relationship.fromEntityName(), + relationship.name(), + sourceFrom, + relationship.name()); + if (sourceFrom != null && sourceRelationshipExists(source, exact)) { + mappings.put(targetKey, exact); + } + } + return mappings; + } + + private static RelationshipMapping manualRelationshipFor( + final ManualMappings manual, final String targetKey) { + for (RelationshipMapping relationship : manual.relationshipMappings) { + if (targetKey.equals( + relationshipKey( + relationship.targetFromEntity(), relationship.targetName()))) { + return relationship; + } + } + return null; + } + + private static boolean sourceRelationshipExists( + final ThingifierModelDefinition source, final RelationshipMapping mapping) { + for (var relationship : source.relationships()) { + if (relationship.fromEntityName().equals(mapping.sourceFromEntity()) + && relationship.name().equals(mapping.sourceName())) { + return true; + } + } + return false; + } + + private static List warnings( + final ThingifierModelDefinition source, + final ThingifierModelDefinition target, + final Map entityMappings, + final Map> fieldMappings, + final Map relationshipMappings) { + List warnings = new ArrayList<>(); + Set mappedSourceEntities = new LinkedHashSet<>(entityMappings.values()); + for (EntityDefinitionSpec sourceEntity : source.entities()) { + if (!mappedSourceEntities.contains(sourceEntity.name())) { + warnings.add("Source entity " + sourceEntity.name() + " will be dropped"); + } + } + for (EntityDefinitionSpec targetEntity : target.entities()) { + String sourceEntityName = entityMappings.get(targetEntity.name()); + if (sourceEntityName == null) { + warnings.add("Target entity " + targetEntity.name() + " will be added empty"); + continue; + } + EntityDefinitionSpec sourceEntity = source.entityNamed(sourceEntityName); + warnAboutFields(targetEntity, sourceEntity, fieldMappings, warnings); + } + Set mappedSourceRelationships = new LinkedHashSet<>(); + for (RelationshipMapping relationship : relationshipMappings.values()) { + mappedSourceRelationships.add( + relationshipKey( + relationship.sourceFromEntity(), relationship.sourceName())); + } + for (var relationship : source.relationships()) { + if (!mappedSourceRelationships.contains( + relationshipKey(relationship.fromEntityName(), relationship.name()))) { + warnings.add( + "Source relationship " + + relationship.fromEntityName() + + "." + + relationship.name() + + " will be dropped"); + } + } + return warnings; + } + + private static void warnAboutFields( + final EntityDefinitionSpec targetEntity, + final EntityDefinitionSpec sourceEntity, + final Map> fieldMappings, + final List warnings) { + Set mappedSourceFields = + new LinkedHashSet<>( + fieldMappings.getOrDefault(targetEntity.name(), Map.of()).values()); + for (FieldDefinitionSpec sourceField : sourceEntity.fields()) { + if (!mappedSourceFields.contains(sourceField.name())) { + warnings.add( + "Source field " + + sourceEntity.name() + + "." + + sourceField.name() + + " will be dropped"); + } + } + for (FieldDefinitionSpec targetField : targetEntity.fields()) { + if (!fieldMappings + .getOrDefault(targetEntity.name(), Map.of()) + .containsKey(targetField.name())) { + warnings.add( + "Target field " + + targetEntity.name() + + "." + + targetField.name() + + " will be added"); + } + } + } + + private String sourceEntityFor(final String targetEntityName) { + return entityMappings.get(targetEntityName); + } + + private String sourceFieldFor(final String targetEntityName, final String targetFieldName) { + return fieldMappings.getOrDefault(targetEntityName, Map.of()).get(targetFieldName); + } + + private Collection relationshipMappings() { + return relationshipMappings.values(); + } + + private List warnings() { + return warnings; + } + + private Map asMap() { + Map values = new LinkedHashMap<>(); + values.put("entityMappings", entityMappings); + values.put("fieldMappings", fieldMappings); + values.put( + "relationshipMappings", relationshipMappingMaps(relationshipMappings.values())); + return values; + } + + private Map summaryMap(final SourceWorkspaceData sourceData) { + Map summary = new LinkedHashMap<>(); + summary.put("sourceEntities", sourceData.sourceDefinition().entities().size()); + summary.put("targetEntities", entityMappings.size()); + summary.put("migratedInstances", 0); + summary.put("preservedEdges", 0); + summary.put("droppedEdges", 0); + return summary; + } + } + + private static List> relationshipMappingMaps( + final Collection relationshipMappings) { + List> values = new ArrayList<>(); + for (RelationshipMapping relationship : relationshipMappings) { + values.add(relationship.asMap()); + } + return values; + } + + private static String relationshipKey(final String fromEntity, final String name) { + return fromEntity + "|" + name; + } + + private static final class RelationshipMapping { + private final String targetFromEntity; + private final String targetName; + private final String sourceFromEntity; + private final String sourceName; + + private RelationshipMapping( + final String targetFromEntity, + final String targetName, + final String sourceFromEntity, + final String sourceName) { + this.targetFromEntity = targetFromEntity; + this.targetName = targetName; + this.sourceFromEntity = sourceFromEntity; + this.sourceName = sourceName; + } + + private boolean isComplete() { + return !targetFromEntity.isBlank() + && !targetName.isBlank() + && !sourceFromEntity.isBlank() + && !sourceName.isBlank(); + } + + private String targetFromEntity() { + return targetFromEntity; + } + + private String targetName() { + return targetName; + } + + private String sourceFromEntity() { + return sourceFromEntity; + } + + private String sourceName() { + return sourceName; + } + + private Map asMap() { + Map value = new LinkedHashMap<>(); + value.put("targetFromEntity", targetFromEntity); + value.put("targetName", targetName); + value.put("sourceFromEntity", sourceFromEntity); + value.put("sourceName", sourceName); + return value; + } + } + + private static final class SourceWorkspaceData { + private final ThingifierModelDefinition sourceDefinition; + private final Map> rowsByEntity; + private final List edges; + + private SourceWorkspaceData(final ThingifierModelDefinition sourceDefinition) { + this.sourceDefinition = sourceDefinition; + this.rowsByEntity = new LinkedHashMap<>(); + this.edges = new ArrayList<>(); + } + + private ThingifierModelDefinition sourceDefinition() { + return sourceDefinition; + } + + private void addRow(final SourceRow row) { + rowsByEntity.computeIfAbsent(row.entityName(), ignored -> new ArrayList<>()).add(row); + } + + private void addEdge(final SourceEdge edge) { + edges.add(edge); + } + + private List rowsFor(final String entityName) { + return rowsByEntity.getOrDefault(entityName, List.of()); + } + + private List edgesFor(final String fromEntity, final String relationshipName) { + List matches = new ArrayList<>(); + for (SourceEdge edge : edges) { + if (edge.fromEntity().equals(fromEntity) + && edge.relationshipName().equals(relationshipName)) { + matches.add(edge); + } + } + return matches; + } + } + + private static final class SourceRow { + private final String entityName; + private final String internalId; + private final String displayIdentifier; + private final Map values; + + private SourceRow( + final String entityName, + final String internalId, + final String displayIdentifier, + final Map values) { + this.entityName = entityName; + this.internalId = internalId; + this.displayIdentifier = + displayIdentifier == null || displayIdentifier.isBlank() + ? internalId + : displayIdentifier; + this.values = values; + } + + private String entityName() { + return entityName; + } + + private String internalId() { + return internalId; + } + + private String displayIdentifier() { + return displayIdentifier; + } + + private String valueFor(final String fieldName) { + return values.get(fieldName); + } + } + + private static final class SourceEdge { + private final String fromEntity; + private final String relationshipName; + private final String fromInternalId; + private final String toInternalId; + + private SourceEdge( + final String fromEntity, + final String relationshipName, + final String fromInternalId, + final String toInternalId) { + this.fromEntity = fromEntity; + this.relationshipName = relationshipName; + this.fromInternalId = fromInternalId; + this.toInternalId = toInternalId; + } + + private String fromEntity() { + return fromEntity; + } + + private String relationshipName() { + return relationshipName; + } + + private String fromInternalId() { + return fromInternalId; + } + + private String toInternalId() { + return toInternalId; + } + } + + private static final class MigrationResult { + private final Map body; + private final List> errors; + private final List warnings; + private final List> coercions; + private final List> valueAssignments; + private Thingifier targetThingifier; + private int statusCode; + + private MigrationResult(final long workspaceVersion) { + body = new LinkedHashMap<>(); + errors = new ArrayList<>(); + warnings = new ArrayList<>(); + coercions = new ArrayList<>(); + valueAssignments = new ArrayList<>(); + statusCode = 400; + body.put("valid", false); + body.put("canApply", false); + body.put("workspaceVersion", workspaceVersion); + body.put("summary", new LinkedHashMap()); + body.put("mappings", new LinkedHashMap()); + body.put("coercions", coercions); + body.put("valueAssignments", valueAssignments); + body.put("warnings", warnings); + body.put("errors", errors); + body.put("yaml", ""); + body.put("canonicalTargetYaml", ""); + } + + private void putRequestMappings(final Map mappings) { + body.put("requestMappings", mappings); + } + + private void putSchemaValidation(final SchemaDefinitionValidationReport report) { + body.put("valid", report.isValid()); + for (SchemaDefinitionValidationReport.SchemaDefinitionValidationError error : + report.errors()) { + addBlockingError(error.path(), error.message()); + } + } + + private void putYaml(final String yaml) { + body.put("yaml", yaml); + body.put("canonicalTargetYaml", yaml); + } + + private void putEffectiveMappings(final Map mappings) { + body.put("mappings", mappings); + } + + private void putSummary(final Map summary) { + body.put("summary", summary); + } + + private void incrementEntityCount(final String entityName) { + Map summary = summary(); + Map entityCounts = + (Map) + summary.computeIfAbsent( + "migratedEntityRows", ignored -> new LinkedHashMap<>()); + entityCounts.put(entityName, entityCounts.getOrDefault(entityName, 0) + 1); + } + + private void incrementSummary(final String key) { + Map summary = summary(); + Object existing = summary.get(key); + int value = existing instanceof Number ? ((Number) existing).intValue() : 0; + summary.put(key, value + 1); + } + + private Map summary() { + return (Map) body.get("summary"); + } + + private void addWarnings(final List values) { + warnings.addAll(values); + } + + private void addWarning(final String warning) { + warnings.add(warning); + } + + private void addCoercion( + final String entity, + final String identifier, + final String field, + final String from, + final String to, + final String reason) { + Map coercion = new LinkedHashMap<>(); + coercion.put("entity", entity); + coercion.put("identifier", identifier); + coercion.put("field", field); + coercion.put("from", from); + coercion.put("to", to); + coercion.put("reason", reason); + coercions.add(coercion); + } + + private void addValueAssignment( + final String entity, + final String identifier, + final String field, + final String from, + final String to, + final String reason) { + Map assignment = new LinkedHashMap<>(); + assignment.put("entity", entity); + assignment.put("identifier", identifier); + assignment.put("field", field); + assignment.put("from", from); + assignment.put("to", to); + assignment.put("reason", reason); + valueAssignments.add(assignment); + } + + private void addBlockingError(final String path, final String message) { + Map error = new LinkedHashMap<>(); + error.put("path", path); + error.put("message", message); + errors.add(error); + } + + private void targetThingifier(final Thingifier targetThingifier) { + this.targetThingifier = targetThingifier; + } + + private Thingifier targetThingifier() { + return targetThingifier; + } + + private boolean canApply() { + return Boolean.TRUE.equals(body.get("canApply")); + } + + private int statusCode() { + return statusCode; + } + + private void finish() { + boolean valid = Boolean.TRUE.equals(body.get("valid")); + boolean canApply = valid && errors.isEmpty() && targetThingifier != null; + body.put("canApply", canApply); + statusCode = staleWorkspace() ? 409 : 400; + } + + private boolean staleWorkspace() { + for (Map error : errors) { + if ("workspaceVersion".equals(error.get("path")) + && STALE_WORKSPACE_MESSAGE.equals(error.get("message"))) { + return true; + } + } + return false; + } + + private void markApplied( + final WorkspaceSnapshot upgraded, final Map workspaceMetadata) { + body.put("workspaceVersion", upgraded.version()); + body.put("workspace", workspaceMetadata); + body.put("applied", true); + body.put("canApply", true); + statusCode = 200; + } + + private Map body() { + return body; + } + + private void releaseTargetThingifier() { + targetThingifier = null; + } + + private void closeTargetThingifier() { + if (targetThingifier != null) { + targetThingifier.close(); + targetThingifier = null; + } + } + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java index 6fba8faa..1142d59b 100644 --- a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java @@ -48,6 +48,14 @@ public void configureRoutes() { Spark.post( "/ui/schema/preview", (request, response) -> write(response, controller.previewSchema(request.body()))); + Spark.post( + "/ui/schema/upgrade/preview", + (request, response) -> + write(response, controller.previewSchemaUpgrade(request.body()))); + Spark.post( + "/ui/schema/upgrade/apply", + (request, response) -> + write(response, controller.applySchemaUpgrade(request.body()))); Spark.get("/ui/export", (request, response) -> write(response, controller.exportData())); Spark.post( "/ui/import", diff --git a/thingifier-crud-ui/src/main/resources/public/assets/app.js b/thingifier-crud-ui/src/main/resources/public/assets/app.js index 34a0a4af..ce7c68a5 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/app.js +++ b/thingifier-crud-ui/src/main/resources/public/assets/app.js @@ -18,9 +18,16 @@ const state = { schemaExportsVisible: false, schemaDirty: false, schemaDiagramZoom: 1, - schemaDiagramDirection: "TB", + schemaDiagramDirection: "LR", schemaDiagramHeight: 280, - schemaDiagramResizeStart: null + schemaDiagramResizeStart: null, + schemaUpgradeDialogOpen: false, + schemaUpgradePreview: null, + schemaUpgradeMappings: { + entityMappings: {}, + fieldMappings: {}, + relationshipMappings: [] + } }; const els = { @@ -63,6 +70,12 @@ const els = { schemaYamlInput: document.getElementById("schema-yaml-input"), schemaParseYaml: document.getElementById("schema-parse-yaml-button"), schemaValidate: document.getElementById("schema-validate-button"), + schemaUpgradeDialog: document.getElementById("schema-upgrade-dialog"), + schemaUpgradePreview: document.getElementById("schema-upgrade-preview-button"), + schemaUpgradeConfirm: document.getElementById("schema-upgrade-confirm-button"), + schemaUpgradeCancel: document.getElementById("schema-upgrade-cancel-button"), + schemaApplyWorkspace: document.getElementById("schema-apply-workspace-button"), + schemaUpgradeHost: document.getElementById("schema-upgrade-host"), schemaValidation: document.getElementById("schema-validation"), schemaDownloadYaml: document.getElementById("schema-download-yaml"), schemaDownloadMermaid: document.getElementById("schema-download-mermaid"), @@ -1049,7 +1062,9 @@ function markSchemaDirty() { return; } state.schemaDirty = true; + state.schemaUpgradePreview = null; renderSchemaDirtyStatus(); + renderSchemaUpgradePanel(); } function markSchemaClean() { @@ -1105,6 +1120,8 @@ async function initializeSchemaEditor(force) { async function parseSchemaYaml(yamlText, updateInput) { const preview = await requestJson("/ui/schema/from-yaml", {method: "POST", body: yamlText}); state.schemaSelection = {type: "model"}; + state.schemaUpgradePreview = null; + state.schemaUpgradeMappings = {entityMappings: {}, fieldMappings: {}, relationshipMappings: []}; applySchemaPreview(preview); if (updateInput) { els.schemaYamlInput.value = preview.yaml || yamlText; @@ -1138,6 +1155,7 @@ function applySchemaPreview(preview) { state.schemaPreview = preview; state.schemaDraft = preview.draft || state.schemaDraft; renderSchemaPreview(); + renderSchemaUpgradePanel(); } function renderSchemaEditor() { @@ -1153,6 +1171,7 @@ function renderSchemaEditor() { renderSchemaTree(); renderSchemaDetail(); renderSchemaPanelVisibility(); + renderSchemaUpgradePanel(); initializeSchemaHelp(); } @@ -1655,6 +1674,381 @@ function renderSchemaValidation(preview) { } } +async function previewSchemaUpgrade() { + if (!state.schemaDraft) { + return; + } + if (!state.schemaUpgradeDialogOpen) { + openSchemaUpgradeDialog(); + } + const preview = await requestJson("/ui/schema/upgrade/preview", { + method: "POST", + body: JSON.stringify(schemaUpgradeRequest(true)) + }); + state.schemaUpgradePreview = preview; + if (preview.mappings) { + mergeEffectiveUpgradeMappings(preview.mappings); + } + renderSchemaUpgradePanel(); +} + +async function startSchemaUpgradeWorkflow() { + if (!state.schemaDraft || !state.workspace) { + showMessage("Load a schema draft before applying it to the workspace.", true); + return; + } + if (!state.schemaPreview || state.schemaPreview.valid !== true) { + await previewSchemaDraft(); + } + if (!state.schemaPreview || state.schemaPreview.valid !== true) { + showMessage("Fix schema validation errors before applying to the workspace.", true); + return; + } + openSchemaUpgradeDialog(); + await previewSchemaUpgrade(); +} + +function openSchemaUpgradeDialog() { + state.schemaUpgradeDialogOpen = true; + if (els.schemaUpgradeDialog) { + els.schemaUpgradeDialog.hidden = false; + } + renderSchemaUpgradePanel(); +} + +function closeSchemaUpgradeDialog() { + state.schemaUpgradeDialogOpen = false; + if (els.schemaUpgradeDialog) { + els.schemaUpgradeDialog.hidden = true; + } + renderSchemaUpgradePanel(); +} + +async function applySchemaUpgrade() { + if (!state.schemaUpgradePreview || !state.schemaUpgradePreview.canApply) { + await previewSchemaUpgrade(); + } + if (!state.schemaUpgradePreview || !state.schemaUpgradePreview.canApply) { + showMessage("Schema upgrade preview has blocking errors.", true); + return; + } + const response = await requestJson("/ui/schema/upgrade/apply", { + method: "POST", + body: JSON.stringify(schemaUpgradeRequest(true)) + }); + state.schemaUpgradePreview = response; + if (response.workspace) { + state.workspace = response.workspace; + } else { + await loadWorkspace(); + } + markSchemaClean(); + renderHeader(); + renderSchemaUpgradePanel(); + closeSchemaUpgradeDialog(); + showMessage("Schema upgrade applied to workspace."); +} + +function schemaUpgradeRequest(includeExpectedVersion) { + const request = { + draft: state.schemaDraft, + mappings: state.schemaUpgradeMappings + }; + if (includeExpectedVersion && state.workspace) { + request.expectedWorkspaceVersion = state.workspace.workspaceVersion; + } + return request; +} + +function mergeEffectiveUpgradeMappings(mappings) { + state.schemaUpgradeMappings.entityMappings = { + ...(mappings.entityMappings || {}), + ...(state.schemaUpgradeMappings.entityMappings || {}) + }; + state.schemaUpgradeMappings.fieldMappings = { + ...(mappings.fieldMappings || {}), + ...(state.schemaUpgradeMappings.fieldMappings || {}) + }; + state.schemaUpgradeMappings.relationshipMappings = + state.schemaUpgradeMappings.relationshipMappings.length > 0 + ? state.schemaUpgradeMappings.relationshipMappings + : (mappings.relationshipMappings || []); +} + +function renderSchemaUpgradePanel() { + setApplyWorkspaceEnabled(Boolean(state.schemaDraft && state.workspace + && state.schemaPreview && state.schemaPreview.valid === true)); + if (!els.schemaUpgradeHost || !state.schemaUpgradeDialogOpen) { + setConfirmWorkspaceEnabled(false); + return; + } + els.schemaUpgradeHost.innerHTML = ""; + if (!state.schemaDraft || !state.workspace) { + els.schemaUpgradeHost.innerHTML = `
Load a schema draft before previewing an upgrade.
`; + setConfirmWorkspaceEnabled(false); + return; + } + const layout = document.createElement("div"); + layout.className = "schema-upgrade-layout"; + layout.appendChild(upgradeMappingPanel()); + layout.appendChild(upgradeReportPanel()); + els.schemaUpgradeHost.appendChild(layout); + const canApply = Boolean(state.schemaUpgradePreview && state.schemaUpgradePreview.canApply) + && state.schemaUpgradePreview.workspaceVersion === state.workspace.workspaceVersion; + setConfirmWorkspaceEnabled(canApply); + initializeSchemaHelp(); +} + +function upgradeMappingPanel() { + const panel = document.createElement("div"); + panel.className = "schema-upgrade-card"; + panel.appendChild(sectionHeading("Migration Mappings")); + panel.appendChild(upgradeEntityMappings()); + panel.appendChild(upgradeFieldMappings()); + panel.appendChild(upgradeRelationshipMappings()); + return panel; +} + +function upgradeReportPanel() { + const panel = document.createElement("div"); + panel.className = "schema-upgrade-card"; + panel.appendChild(sectionHeading("Upgrade Preview")); + const preview = state.schemaUpgradePreview; + if (!preview) { + const empty = document.createElement("div"); + empty.className = "empty-state"; + empty.textContent = "Refresh Preview to see added, dropped, mapped, and coerced data."; + panel.appendChild(empty); + return panel; + } + const status = document.createElement("div"); + status.className = preview.canApply ? "schema-valid" : "schema-invalid"; + status.textContent = preview.canApply + ? "Upgrade can be applied." + : "Upgrade has blocking errors or stale workspace data."; + panel.appendChild(status); + panel.appendChild(upgradeSummary(preview.summary || {})); + panel.appendChild(upgradeList("Warnings", preview.warnings || [], "warning")); + panel.appendChild(upgradeObjectList("Errors", preview.errors || [], item => `${item.path || "migration"}: ${item.message}`)); + panel.appendChild(upgradeObjectList("Value assignments", preview.valueAssignments || [], item => + `${item.entity || ""} ${item.identifier || ""} ${item.field || ""}: ${item.to || ""} (${item.reason || ""})`)); + panel.appendChild(upgradeObjectList("Coercions", preview.coercions || [], item => + `${item.entity || ""} ${item.identifier || ""} ${item.field || ""}: ${item.from || ""} -> ${item.to || ""} (${item.reason || ""})`)); + return panel; +} + +function upgradeEntityMappings() { + const group = document.createElement("div"); + group.className = "schema-upgrade-group"; + group.appendChild(sectionHeading("Entities", "Map a target entity to an existing source entity when it was renamed.")); + (state.schemaDraft.entities || []).forEach(targetEntity => { + group.appendChild(upgradeSelectRow( + targetEntity.name, + currentEntityMapping(targetEntity.name), + ["", ...state.workspace.entities.map(entity => entity.name)], + "(new entity)", + value => { + if (value) { + state.schemaUpgradeMappings.entityMappings[targetEntity.name] = value; + } else { + delete state.schemaUpgradeMappings.entityMappings[targetEntity.name]; + } + state.schemaUpgradePreview = null; + renderSchemaUpgradePanel(); + })); + }); + return group; +} + +function upgradeFieldMappings() { + const group = document.createElement("div"); + group.className = "schema-upgrade-group"; + group.appendChild(sectionHeading("Fields", "Top-level field mappings preserve data when a field was renamed.")); + (state.schemaDraft.entities || []).forEach(targetEntity => { + const sourceEntity = workspaceEntityByName(currentEntityMapping(targetEntity.name)); + if (!sourceEntity) { + return; + } + const title = document.createElement("h4"); + title.textContent = targetEntity.name; + group.appendChild(title); + (targetEntity.fields || []).forEach(targetField => { + const fieldMappings = state.schemaUpgradeMappings.fieldMappings[targetEntity.name] || {}; + const exact = sourceEntity.fields.some(field => field.name === targetField.name) ? targetField.name : ""; + const current = fieldMappings[targetField.name] || exact; + group.appendChild(upgradeSelectRow( + targetField.name, + current, + ["", ...sourceEntity.fields.map(field => field.name)], + "(new field: use default/fallback)", + value => { + if (!state.schemaUpgradeMappings.fieldMappings[targetEntity.name]) { + state.schemaUpgradeMappings.fieldMappings[targetEntity.name] = {}; + } + if (value) { + state.schemaUpgradeMappings.fieldMappings[targetEntity.name][targetField.name] = value; + } else { + delete state.schemaUpgradeMappings.fieldMappings[targetEntity.name][targetField.name]; + } + state.schemaUpgradePreview = null; + renderSchemaUpgradePanel(); + })); + }); + }); + return group; +} + +function upgradeRelationshipMappings() { + const group = document.createElement("div"); + group.className = "schema-upgrade-group"; + group.appendChild(sectionHeading("Relationships", "Map target relationships to existing relationship edges.")); + const sourceRelationships = state.workspace.relationships || []; + (state.schemaDraft.relationships || []).forEach(targetRelationship => { + const current = currentRelationshipMapping(targetRelationship); + group.appendChild(upgradeSelectRow( + `${targetRelationship.from}.${targetRelationship.name}`, + current ? `${current.sourceFromEntity}|${current.sourceName}` : "", + ["", ...sourceRelationships.map(relationship => `${relationship.fromEntity}|${relationship.name}`)], + "(new relationship)", + value => { + setRelationshipMapping(targetRelationship, value); + state.schemaUpgradePreview = null; + renderSchemaUpgradePanel(); + })); + }); + return group; +} + +function upgradeSelectRow(labelText, value, options, emptyLabel, onChange) { + const row = document.createElement("label"); + row.className = "schema-upgrade-row"; + const label = document.createElement("span"); + label.textContent = labelText; + row.appendChild(label); + const select = document.createElement("select"); + options.forEach(optionValue => { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionValue || emptyLabel; + option.selected = optionValue === value; + select.appendChild(option); + }); + select.addEventListener("change", () => onChange(select.value)); + row.appendChild(select); + return row; +} + +function upgradeSummary(summary) { + const list = document.createElement("dl"); + list.className = "schema-upgrade-summary"; + ["sourceEntities", "targetEntities", "migratedInstances", "preservedEdges", "droppedEdges"].forEach(key => { + const term = document.createElement("dt"); + term.textContent = key; + const value = document.createElement("dd"); + value.textContent = valueText(summary[key] || 0); + list.appendChild(term); + list.appendChild(value); + }); + return list; +} + +function upgradeList(title, items) { + const section = document.createElement("div"); + section.className = "schema-upgrade-list"; + section.appendChild(sectionHeading(title)); + if (items.length === 0) { + const empty = document.createElement("p"); + empty.textContent = "None."; + section.appendChild(empty); + return section; + } + const list = document.createElement("ul"); + items.forEach(text => { + const item = document.createElement("li"); + item.textContent = text; + list.appendChild(item); + }); + section.appendChild(list); + return section; +} + +function upgradeObjectList(title, items, renderer) { + return upgradeList(title, items.map(renderer)); +} + +function sectionHeading(text, helpText = "") { + const heading = document.createElement("h3"); + heading.textContent = text; + if (helpText) { + heading.appendChild(helpIcon(helpText)); + } + return heading; +} + +function currentEntityMapping(targetEntityName) { + if (state.schemaUpgradeMappings.entityMappings[targetEntityName]) { + return state.schemaUpgradeMappings.entityMappings[targetEntityName]; + } + return workspaceEntityByName(targetEntityName) ? targetEntityName : ""; +} + +function currentRelationshipMapping(targetRelationship) { + const manual = state.schemaUpgradeMappings.relationshipMappings.find(relationship => + relationship.targetFromEntity === targetRelationship.from + && relationship.targetName === targetRelationship.name); + if (manual) { + return manual; + } + const sourceFrom = currentEntityMapping(targetRelationship.from); + const exact = (state.workspace.relationships || []).find(relationship => + relationship.fromEntity === sourceFrom && relationship.name === targetRelationship.name); + if (!exact) { + return null; + } + return { + targetFromEntity: targetRelationship.from, + targetName: targetRelationship.name, + sourceFromEntity: sourceFrom, + sourceName: exact.name + }; +} + +function setRelationshipMapping(targetRelationship, value) { + state.schemaUpgradeMappings.relationshipMappings = + state.schemaUpgradeMappings.relationshipMappings.filter(relationship => + relationship.targetFromEntity !== targetRelationship.from + || relationship.targetName !== targetRelationship.name); + if (!value) { + return; + } + const [sourceFromEntity, sourceName] = value.split("|"); + state.schemaUpgradeMappings.relationshipMappings.push({ + targetFromEntity: targetRelationship.from, + targetName: targetRelationship.name, + sourceFromEntity, + sourceName + }); +} + +function workspaceEntityByName(name) { + if (!state.workspace) { + return null; + } + return state.workspace.entities.find(entity => entity.name === name); +} + +function setApplyWorkspaceEnabled(enabled) { + if (els.schemaApplyWorkspace) { + els.schemaApplyWorkspace.disabled = !enabled; + } +} + +function setConfirmWorkspaceEnabled(enabled) { + if (els.schemaUpgradeConfirm) { + els.schemaUpgradeConfirm.disabled = !enabled; + } +} + async function renderMermaidDiagram(source) { if (!els.schemaMermaidDiagram) { return; @@ -2124,6 +2518,22 @@ if (els.schemaValidate) { els.schemaValidate.setAttribute("data-tippy-content", "Validate the current draft and refresh generated previews."); els.schemaValidate.addEventListener("click", () => previewSchemaDraft().catch(error => showMessage(error.message, true))); } +if (els.schemaUpgradePreview) { + els.schemaUpgradePreview.setAttribute("data-tippy-content", "Refresh the schema upgrade migration preview."); + els.schemaUpgradePreview.addEventListener("click", () => previewSchemaUpgrade().catch(error => showMessage(error.message, true))); +} +if (els.schemaUpgradeConfirm) { + els.schemaUpgradeConfirm.setAttribute("data-tippy-content", "Confirm and replace the live workspace with the migrated schema and data."); + els.schemaUpgradeConfirm.addEventListener("click", () => applySchemaUpgrade().catch(error => showMessage(error.message, true))); +} +if (els.schemaUpgradeCancel) { + els.schemaUpgradeCancel.setAttribute("data-tippy-content", "Cancel applying the schema draft and return to editing."); + els.schemaUpgradeCancel.addEventListener("click", closeSchemaUpgradeDialog); +} +if (els.schemaApplyWorkspace) { + els.schemaApplyWorkspace.setAttribute("data-tippy-content", "Preview and confirm applying the draft schema to the live workspace."); + els.schemaApplyWorkspace.addEventListener("click", () => startSchemaUpgradeWorkflow().catch(error => showMessage(error.message, true))); +} if (els.schemaDownloadYaml) { els.schemaDownloadYaml.addEventListener("click", () => { if (downloadSchemaOutput("yaml")) { diff --git a/thingifier-crud-ui/src/main/resources/public/assets/styles.css b/thingifier-crud-ui/src/main/resources/public/assets/styles.css index 468a8bfa..bf6372c8 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/styles.css +++ b/thingifier-crud-ui/src/main/resources/public/assets/styles.css @@ -598,6 +598,147 @@ button.danger { background: #aab5c3; } +.schema-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.42); + padding: 24px; +} + +.schema-dialog-backdrop[hidden] { + display: none; +} + +.schema-dialog { + width: min(1120px, 96vw); + max-height: min(760px, 92vh); + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); + overflow: hidden; +} + +.schema-dialog-header, +.schema-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} + +.schema-dialog-header h2 { + margin: 0 0 4px; + font-size: 18px; +} + +.schema-dialog-header p { + margin: 0; + color: var(--muted); +} + +.schema-dialog-footer { + justify-content: flex-end; + border-top: 1px solid var(--line); + border-bottom: 0; +} + +.schema-upgrade-host { + min-height: 160px; + overflow: auto; + padding: 12px 14px; +} + +.schema-upgrade-layout { + display: grid; + grid-template-columns: minmax(260px, 0.95fr) minmax(300px, 1.05fr); + gap: 12px; +} + +.schema-upgrade-card { + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + padding: 10px; +} + +.schema-upgrade-card h3, +.schema-upgrade-card h4 { + margin: 0 0 8px; + font-size: 14px; +} + +.schema-upgrade-group { + padding-top: 8px; + border-top: 1px solid var(--grid); +} + +.schema-upgrade-group:first-of-type { + border-top: 0; + padding-top: 0; +} + +.schema-upgrade-row { + display: grid; + grid-template-columns: minmax(110px, 0.8fr) minmax(130px, 1.2fr); + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.schema-upgrade-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schema-upgrade-row select { + width: 100%; + min-height: 32px; + border: 1px solid var(--line); + border-radius: 4px; + background: #ffffff; + padding: 5px 7px; +} + +.schema-upgrade-summary { + display: grid; + grid-template-columns: minmax(120px, 1fr) auto; + gap: 5px 12px; + margin: 10px 0; +} + +.schema-upgrade-summary dt { + color: var(--muted); +} + +.schema-upgrade-summary dd { + margin: 0; + font-weight: 700; +} + +.schema-upgrade-list { + margin-top: 10px; +} + +.schema-upgrade-list p, +.schema-upgrade-list ul { + margin: 0; +} + +.schema-upgrade-list ul { + padding-left: 18px; +} + .outline-panel, .data-panel { min-height: 0; diff --git a/thingifier-crud-ui/src/main/resources/public/schema.html b/thingifier-crud-ui/src/main/resources/public/schema.html index 517ae131..0067670b 100644 --- a/thingifier-crud-ui/src/main/resources/public/schema.html +++ b/thingifier-crud-ui/src/main/resources/public/schema.html @@ -24,6 +24,7 @@

Schema Edit

+ @@ -42,7 +43,7 @@

ER Diagram

- + @@ -157,6 +158,22 @@

Exports

+ diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java index 8975be45..71935503 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java @@ -132,9 +132,19 @@ public void staticSchemaResourceIsAvailable() { Assertions.assertTrue(schema.contains("id=\"schema-zoom-reset-button\"")); Assertions.assertTrue(schema.contains("id=\"schema-zoom-in-button\"")); Assertions.assertTrue(schema.contains("id=\"schema-layout-toggle-button\"")); + Assertions.assertTrue(schema.contains("Vertical Layout")); Assertions.assertTrue(schema.contains("id=\"schema-diagram-resizer\"")); Assertions.assertTrue(schema.contains("id=\"schema-toggle-exports-button\"")); Assertions.assertTrue(schema.contains("Save as YAML")); + Assertions.assertTrue(schema.contains("id=\"schema-apply-workspace-button\"")); + Assertions.assertTrue(schema.contains("id=\"schema-upgrade-dialog\"")); + Assertions.assertTrue(schema.contains("id=\"schema-upgrade-preview-button\"")); + Assertions.assertTrue(schema.contains("id=\"schema-upgrade-confirm-button\"")); + Assertions.assertTrue(schema.contains("id=\"schema-upgrade-cancel-button\"")); + Assertions.assertTrue(schema.contains("id=\"schema-upgrade-host\"")); + Assertions.assertTrue(schema.contains("Apply to Workspace")); + Assertions.assertTrue(schema.contains("Confirm Apply")); + Assertions.assertTrue(schema.contains("Cancel")); Assertions.assertTrue(schema.contains("YAML Draft")); Assertions.assertTrue(schema.contains("Parse YAML")); Assertions.assertTrue(schema.contains("Save Mermaid")); @@ -261,6 +271,22 @@ public void staticAssetsContainExpandableRelationshipOutline() { Assertions.assertTrue(script.contains("initializeSchemaEditor")); Assertions.assertTrue(script.contains("/ui/schema/from-yaml")); Assertions.assertTrue(script.contains("/ui/schema/preview")); + Assertions.assertTrue(script.contains("/ui/schema/upgrade/preview")); + Assertions.assertTrue(script.contains("/ui/schema/upgrade/apply")); + Assertions.assertTrue(script.contains("schemaUpgradeMappings")); + Assertions.assertTrue(script.contains("schemaUpgradeDialogOpen")); + Assertions.assertTrue(script.contains("previewSchemaUpgrade")); + Assertions.assertTrue(script.contains("startSchemaUpgradeWorkflow")); + Assertions.assertTrue(script.contains("applySchemaUpgrade")); + Assertions.assertTrue(script.contains("closeSchemaUpgradeDialog")); + Assertions.assertTrue(script.contains("Migration Mappings")); + Assertions.assertTrue(script.contains("Upgrade Preview")); + Assertions.assertTrue(script.contains("Value assignments")); + Assertions.assertTrue(script.contains("(new field: use default/fallback)")); + Assertions.assertFalse(script.contains("(new / drop source)")); + Assertions.assertTrue( + script.contains("workspaceVersion === state.workspace.workspaceVersion")); + Assertions.assertFalse(script.contains("window.confirm(\"Apply this schema")); Assertions.assertTrue(script.contains("renderSchemaTree")); Assertions.assertTrue(script.contains("renderSchemaDetail")); Assertions.assertTrue(script.contains("renderEntityDetail")); @@ -275,6 +301,7 @@ public void staticAssetsContainExpandableRelationshipOutline() { Assertions.assertTrue(script.contains("confirmSchemaSaveBefore")); Assertions.assertTrue(script.contains("schemaDiagramZoom")); Assertions.assertTrue(script.contains("schemaDiagramDirection")); + Assertions.assertTrue(script.contains("schemaDiagramDirection: \"LR\"")); Assertions.assertTrue(script.contains("mermaidSourceForCurrentLayout")); Assertions.assertTrue(script.contains("beginSchemaDiagramResize")); Assertions.assertFalse(script.contains("els.version.textContent")); @@ -292,6 +319,11 @@ public void staticAssetsContainExpandableRelationshipOutline() { Assertions.assertTrue(styles.contains(".schema-dirty-status")); Assertions.assertTrue(styles.contains(".schema-diagram-actions")); Assertions.assertTrue(styles.contains(".schema-diagram-resizer")); + Assertions.assertTrue(styles.contains(".schema-dialog-backdrop")); + Assertions.assertTrue(styles.contains(".schema-dialog-footer")); + Assertions.assertTrue(styles.contains(".schema-upgrade-layout")); + Assertions.assertTrue(styles.contains(".schema-upgrade-row")); + Assertions.assertTrue(styles.contains(".schema-upgrade-summary")); Assertions.assertTrue(styles.contains(".er-key-symbol")); Assertions.assertTrue(styles.contains(".relationship-manager")); Assertions.assertTrue(styles.contains(".relationship-manager-card")); diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java new file mode 100644 index 00000000..a6d9320d --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java @@ -0,0 +1,348 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class WorkspaceSchemaUpgradeServiceTest { + + @Test + public void previewDoesNotMutateWorkspaceSchemaVersionOrData() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createProject(proxy, "Project A"); + CrudUiController controller = new CrudUiController(workspace); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.previewSchemaUpgrade( + upgradeRequest(draftFromYaml("/models/minimal-todo.yaml"), null, null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("valid").getAsBoolean()); + Assertions.assertTrue(body.get("canApply").getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals("Project Tasks", workspace.snapshot().definition().title()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + } + } + + @Test + public void applyRejectsInvalidSchemaDraft() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + entityNamed(draft, "todo").addProperty("primaryKey", "missing"); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.applySchemaUpgrade(upgradeRequest(draft, version, new JsonObject())); + JsonObject body = json(response); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertFalse(body.get("valid").getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertTrue(body.get("errors").toString().contains("primaryKey")); + } + } + + @Test + public void applyRejectsStaleWorkspaceVersion() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + long staleVersion = workspace.snapshot().version(); + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest( + draftFromYaml("/models/minimal-todo.yaml"), + staleVersion, + null)); + + Assertions.assertEquals(409, response.statusCode()); + Assertions.assertTrue(response.body().contains("Workspace changed")); + } + } + + @Test + public void addFieldWithDefaultMigratesExistingRowsAndPreservesAutoKey() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + String todoId = createTodo(proxy, "Task A"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); + fields.add(field("status", "string", "open")); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("canApply").getAsBoolean()); + Assertions.assertTrue( + proxy.getJson("todos/" + todoId).body().contains("\"status\":\"open\"")); + Assertions.assertEquals( + "2", + fieldValue(proxy.postJson("todos", "{\"title\":\"Task B\"}").body(), "id")); + } + } + + @Test + public void droppedFieldIsRemovedAndReported() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createTodo(proxy, "Task A"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); + fields.remove(1); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + response.body().contains("Source field todo.title will be dropped")); + Assertions.assertFalse(proxy.getJson("todos").body().contains("Task A")); + } + } + + @Test + public void manualEntityAndFieldMappingsPreserveRenamedRows() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + String id = createTodo(proxy, "Task A"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/minimal-todo.yaml"); + JsonObject todo = entityNamed(draft, "todo"); + todo.addProperty("name", "item"); + todo.addProperty("plural", "items"); + fieldNamed(todo, "title").addProperty("name", "name"); + JsonObject mappings = new JsonObject(); + JsonObject entityMappings = new JsonObject(); + entityMappings.addProperty("item", "todo"); + JsonObject fieldMappings = new JsonObject(); + JsonObject itemFieldMappings = new JsonObject(); + itemFieldMappings.addProperty("name", "title"); + fieldMappings.add("item", itemFieldMappings); + mappings.add("entityMappings", entityMappings); + mappings.add("fieldMappings", fieldMappings); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), mappings)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + proxy.getJson("items/" + id).body().contains("\"name\":\"Task A\"")); + Assertions.assertEquals(404, proxy.getJson("todos").statusCode()); + } + } + + @Test + public void manualRelationshipMappingPreservesRenamedRelationshipEdges() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + String projectId = createProject(proxy, "Project A"); + String todoId = createTodo(proxy, "Task A"); + Assertions.assertEquals( + 201, + proxy.postJson( + "projects/" + projectId + "/tasks", + "{\"id\":\"" + todoId + "\"}") + .statusCode()); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonObject relationship = + draft.getAsJsonArray("relationships").get(0).getAsJsonObject(); + relationship.addProperty("name", "todos"); + relationship.getAsJsonObject("reverse").addProperty("name", "projectsof"); + JsonObject mappings = new JsonObject(); + JsonArray relationshipMappings = new JsonArray(); + JsonObject mapping = new JsonObject(); + mapping.addProperty("targetFromEntity", "project"); + mapping.addProperty("targetName", "todos"); + mapping.addProperty("sourceFromEntity", "project"); + mapping.addProperty("sourceName", "tasks"); + relationshipMappings.add(mapping); + mappings.add("relationshipMappings", relationshipMappings); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), mappings)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + proxy.getJson("projects/" + projectId + "/todos").body().contains("Task A")); + Assertions.assertEquals( + 404, proxy.getJson("projects/" + projectId + "/tasks").statusCode()); + } + } + + @Test + public void typeCoercionsAreReported() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createTodo(proxy, "123"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + fieldNamed(entityNamed(draft, "todo"), "title").addProperty("type", "integer"); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("coercions").toString().contains("string to integer")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("\"title\":123")); + } + } + + @Test + public void newFieldFallbacksAreReportedAsAssignmentsNotCoercions() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createProject(proxy, "Smoke Project"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + entityNamed(draft, "project") + .getAsJsonArray("fields") + .add(field("risk", "integer", null)); + + UiHttpResponse response = + controller.previewSchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + body.get("valueAssignments").toString().contains("Used target type fallback")); + Assertions.assertTrue(body.get("valueAssignments").toString().contains("risk")); + Assertions.assertEquals("[]", body.get("coercions").toString()); + } + } + + @Test + public void remainingValidationErrorsBlockApplyAndLeaveWorkspaceUnchanged() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createTodo(proxy, "Task A"); + CrudUiController controller = new CrudUiController(workspace); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonObject requiredCode = field("code", "string", null); + requiredCode.addProperty("required", true); + JsonArray validations = new JsonArray(); + JsonObject notEmpty = new JsonObject(); + notEmpty.addProperty("type", "notEmpty"); + validations.add(notEmpty); + requiredCode.add("validations", validations); + entityNamed(draft, "todo").getAsJsonArray("fields").add(requiredCode); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.applySchemaUpgrade(upgradeRequest(draft, version, null)); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertTrue(response.body().contains("Failed Validation")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("Task A")); + } + } + + private String createProject(final DynamicThingifierApiProxy proxy, final String title) { + return fieldValue(proxy.postJson("projects", "{\"title\":\"" + title + "\"}").body(), "id"); + } + + private String createTodo(final DynamicThingifierApiProxy proxy, final String title) { + return fieldValue(proxy.postJson("todos", "{\"title\":\"" + title + "\"}").body(), "id"); + } + + private String upgradeRequest( + final JsonObject draft, + final Long expectedWorkspaceVersion, + final JsonObject mappings) { + JsonObject request = new JsonObject(); + request.add("draft", draft); + if (expectedWorkspaceVersion != null) { + request.addProperty("expectedWorkspaceVersion", expectedWorkspaceVersion); + } + request.add("mappings", mappings == null ? new JsonObject() : mappings); + return request.toString(); + } + + private JsonObject draftFromYaml(final String resourceName) { + SchemaPreviewService previewService = new SchemaPreviewService(); + UiHttpResponse response = previewService.fromYaml(TestResources.text(resourceName)); + return json(response).getAsJsonObject("draft"); + } + + private JsonObject field(final String name, final String type, final String defaultValue) { + JsonObject field = new JsonObject(); + field.addProperty("name", name); + field.addProperty("type", type); + if (defaultValue != null) { + field.addProperty("defaultValue", defaultValue); + } + return field; + } + + private JsonObject entityNamed(final JsonObject draft, final String name) { + for (var element : draft.getAsJsonArray("entities")) { + JsonObject entity = element.getAsJsonObject(); + if (name.equals(entity.get("name").getAsString())) { + return entity; + } + } + throw new AssertionError("No entity named " + name); + } + + private JsonObject fieldNamed(final JsonObject entity, final String name) { + for (var element : entity.getAsJsonArray("fields")) { + JsonObject field = element.getAsJsonObject(); + if (name.equals(field.get("name").getAsString())) { + return field; + } + } + throw new AssertionError("No field named " + name); + } + + private String fieldValue(final String json, final String fieldName) { + return json(json).get(fieldName).getAsString(); + } + + private JsonObject json(final UiHttpResponse response) { + return json(response.body()); + } + + private JsonObject json(final String text) { + return JsonParser.parseString(text).getAsJsonObject(); + } +}