From 7200df541ea00fa3ce768550882bfce66fd92d23 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 15 Jul 2026 10:25:13 +0100 Subject: [PATCH] Add YAML schema adapter --- checkstyle-project-fqn.xml | 38 ++ .../definitions/field/definition/Field.java | 19 + .../validation/EnumValidationRule.java | 5 + .../validation/FindsRegexValidationRule.java | 4 + .../MatchesRegexValidationRule.java | 4 + .../MaximumLengthValidationRule.java | 4 + pom.xml | 2 + thingifier-yaml/pom.xml | 87 ++++ .../yaml/ThingifierYamlException.java | 12 + .../yaml/ThingifierYamlExporter.java | 35 ++ .../thingifier/yaml/ThingifierYamlLoader.java | 92 ++++ .../yaml/internal/YamlEntityDocument.java | 65 +++ .../yaml/internal/YamlFieldDocument.java | 122 +++++ .../yaml/internal/YamlMapSupport.java | 72 +++ .../internal/YamlRelationshipDocument.java | 70 +++ .../YamlRelationshipReverseDocument.java | 37 ++ .../yaml/internal/YamlThingifierDocument.java | 81 ++++ .../YamlThingifierDocumentMapper.java | 231 +++++++++ .../yaml/ThingifierYamlExporterTest.java | 73 +++ .../yaml/ThingifierYamlLoaderTest.java | 175 +++++++ .../test/resources/models/field-types.yaml | 34 ++ .../resources/models/invalid-field-type.yaml | 11 + .../models/invalid-missing-entity.yaml | 6 + .../resources/models/invalid-primary-key.yaml | 12 + .../models/invalid-relationship-target.yaml | 16 + .../test/resources/models/max-instances.yaml | 12 + .../test/resources/models/minimal-todo.yaml | 18 + .../test/resources/models/object-fields.yaml | 16 + .../models/relationships-one-way.yaml | 21 + .../models/relationships-two-way.yaml | 25 + .../test/resources/models/todo-manager.yaml | 83 ++++ .../test/resources/models/validations.yaml | 30 ++ .../schema/definition/CardinalitySpec.java | 90 ++++ .../definition/EntityDefinitionSpec.java | 103 ++++ .../definition/FieldDefinitionSpec.java | 181 +++++++ .../RelationshipDefinitionSpec.java | 54 +++ .../definition/RelationshipVectorSpec.java | 27 ++ .../SchemaDefinitionValidationReport.java | 65 +++ .../definition/ThingifierModelAssembler.java | 453 ++++++++++++++++++ .../definition/ThingifierModelDefinition.java | 109 +++++ .../definition/ThingifierModelExporter.java | 204 ++++++++ .../schema/definition/ValidationRuleSpec.java | 41 ++ .../ThingifierModelAssemblerTest.java | 179 +++++++ .../ThingifierModelExporterTest.java | 119 +++++ 44 files changed, 3137 insertions(+) create mode 100644 thingifier-yaml/pom.xml create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlException.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporter.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoader.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlEntityDocument.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlFieldDocument.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlMapSupport.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipDocument.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipReverseDocument.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocument.java create mode 100644 thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocumentMapper.java create mode 100644 thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporterTest.java create mode 100644 thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoaderTest.java create mode 100644 thingifier-yaml/src/test/resources/models/field-types.yaml create mode 100644 thingifier-yaml/src/test/resources/models/invalid-field-type.yaml create mode 100644 thingifier-yaml/src/test/resources/models/invalid-missing-entity.yaml create mode 100644 thingifier-yaml/src/test/resources/models/invalid-primary-key.yaml create mode 100644 thingifier-yaml/src/test/resources/models/invalid-relationship-target.yaml create mode 100644 thingifier-yaml/src/test/resources/models/max-instances.yaml create mode 100644 thingifier-yaml/src/test/resources/models/minimal-todo.yaml create mode 100644 thingifier-yaml/src/test/resources/models/object-fields.yaml create mode 100644 thingifier-yaml/src/test/resources/models/relationships-one-way.yaml create mode 100644 thingifier-yaml/src/test/resources/models/relationships-two-way.yaml create mode 100644 thingifier-yaml/src/test/resources/models/todo-manager.yaml create mode 100644 thingifier-yaml/src/test/resources/models/validations.yaml create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/CardinalitySpec.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/FieldDefinitionSpec.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipDefinitionSpec.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipVectorSpec.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/SchemaDefinitionValidationReport.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelDefinition.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ValidationRuleSpec.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java diff --git a/checkstyle-project-fqn.xml b/checkstyle-project-fqn.xml index 001e0e7a..8532d42f 100644 --- a/checkstyle-project-fqn.xml +++ b/checkstyle-project-fqn.xml @@ -128,6 +128,22 @@ + + + + + + + + + + + + + + + + @@ -173,7 +189,29 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/definition/Field.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/definition/Field.java index 309e81ea..9be9a10e 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/definition/Field.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/definition/Field.java @@ -14,6 +14,7 @@ public final class Field { private final String name; private final FieldType type; private final Set fieldExamples; + private final Set configuredExamples; private boolean fieldIsOptional; // default value for the field @@ -50,6 +51,7 @@ public Field(final String name, final FieldType type) { truncateStringIfTooLong = false; truncatedStringLength = -1; fieldExamples = new HashSet<>(); + configuredExamples = new HashSet<>(); if (type == FieldType.INTEGER) { typeValidationRule = new IntegerValidationRule(); @@ -270,6 +272,7 @@ public String getAsTruncatedString(FieldValue value) { } public Field withExample(final String anExample) { + configuredExamples.add(anExample); fieldExamples.add(anExample); if (type == FieldType.ENUM) { typeValidationRule = new EnumValidationRule(getExamples()); @@ -336,6 +339,22 @@ public List getExamples() { return new ArrayList<>(buildExamples); } + public List getConfiguredExamples() { + return new ArrayList<>(configuredExamples); + } + + public String getConfiguredDefaultValue() { + return defaultValue; + } + + public int getTruncatedStringLength() { + return truncatedStringLength; + } + + public ValidationRule getTypeValidationRule() { + return typeValidationRule; + } + public String getRandomExampleValue() { final List examples = getExamples(); diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/EnumValidationRule.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/EnumValidationRule.java index 28fb31e3..bdb89fcb 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/EnumValidationRule.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/EnumValidationRule.java @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.core.domain.definitions.validation; +import java.util.ArrayList; import java.util.List; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.FieldValue; @@ -36,4 +37,8 @@ public String getErrorMessage(FieldValue value) { public String getExplanation() { return String.format("Value must be an Enum (%s) value", valuesAsCsv()); } + + public List getValidValues() { + return new ArrayList<>(validValues); + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/FindsRegexValidationRule.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/FindsRegexValidationRule.java index c473e396..fde703fd 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/FindsRegexValidationRule.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/FindsRegexValidationRule.java @@ -33,4 +33,8 @@ public String getExplanation() { return String.format( "Value must contain text that matches the regex %s", this.regexToMatch); } + + public String getRegexToFind() { + return regexToMatch; + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MatchesRegexValidationRule.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MatchesRegexValidationRule.java index d73dfd7b..bae90e59 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MatchesRegexValidationRule.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MatchesRegexValidationRule.java @@ -31,4 +31,8 @@ public String getErrorMessage(final FieldValue value) { public String getExplanation() { return String.format("Value must match the regex %s", this.regexToMatch); } + + public String getRegexToMatch() { + return regexToMatch; + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MaximumLengthValidationRule.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MaximumLengthValidationRule.java index 839380cf..057d0d15 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MaximumLengthValidationRule.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/validation/MaximumLengthValidationRule.java @@ -36,4 +36,8 @@ public String getErrorMessage(final FieldValue value) { public String getExplanation() { return "Maximum length allowed is %d".formatted(maxLength); } + + public int getMaximumLength() { + return maxLength; + } } diff --git a/pom.xml b/pom.xml index eff25411..cc527e3e 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ 2.2.27 2.8.6 3.53.2.0 + 2.6 5.6.2 16 5.5.0 @@ -133,6 +134,7 @@ ercoremodel thingifier + thingifier-yaml examplemodels todoManagerRestAuto challenger diff --git a/thingifier-yaml/pom.xml b/thingifier-yaml/pom.xml new file mode 100644 index 00000000..8e45389d --- /dev/null +++ b/thingifier-yaml/pom.xml @@ -0,0 +1,87 @@ + + 4.0.0 + + + uk.co.compendiumdev.thingifier + thingifier-root + 1.5.6-SNAPSHOT + + + uk.co.compendiumdev + thingifier-yaml + jar + + thingifier yaml adapter + https://compendiumdev.co.uk + + + + uk.co.compendiumdev + thingifier + ${thingifier.version} + + + org.yaml + snakeyaml + ${snakeyaml-version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.jupiter.version} + test + + + uk.co.compendiumdev.thingifier + examplemodels + ${thingifier.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + ${java.version} + ${java.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M4 + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + checkstyle-check + verify + + check + + + + + + + diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlException.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlException.java new file mode 100644 index 00000000..ebeb8f59 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlException.java @@ -0,0 +1,12 @@ +package uk.co.compendiumdev.thingifier.yaml; + +public class ThingifierYamlException extends RuntimeException { + + public ThingifierYamlException(final String message) { + super(message); + } + + public ThingifierYamlException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporter.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporter.java new file mode 100644 index 00000000..f3261051 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporter.java @@ -0,0 +1,35 @@ +package uk.co.compendiumdev.thingifier.yaml; + +import java.util.Map; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelExporter; +import uk.co.compendiumdev.thingifier.yaml.internal.YamlThingifierDocumentMapper; + +public final class ThingifierYamlExporter { + + private final YamlThingifierDocumentMapper mapper; + + public ThingifierYamlExporter() { + mapper = new YamlThingifierDocumentMapper(); + } + + public String export(final Thingifier thingifier) { + return export(new ThingifierModelExporter().export(thingifier)); + } + + public String export(final ThingifierModelDefinition definition) { + final Map document = mapper.toYamlMap(definition); + return yaml().dump(document); + } + + private Yaml yaml() { + final DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + options.setSplitLines(false); + return new Yaml(options); + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoader.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoader.java new file mode 100644 index 00000000..9ef0608d --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoader.java @@ -0,0 +1,92 @@ +package uk.co.compendiumdev.thingifier.yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; +import uk.co.compendiumdev.thingifier.Thingifier; +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.yaml.internal.YamlThingifierDocument; +import uk.co.compendiumdev.thingifier.yaml.internal.YamlThingifierDocumentMapper; + +public final class ThingifierYamlLoader { + + private final ThingifierModelAssembler assembler; + private final YamlThingifierDocumentMapper mapper; + + public ThingifierYamlLoader() { + assembler = new ThingifierModelAssembler(); + mapper = new YamlThingifierDocumentMapper(); + } + + public ThingifierModelDefinition loadDefinition(final Path path) throws IOException { + try (InputStream input = Files.newInputStream(path)) { + return loadDefinition(input); + } + } + + public ThingifierModelDefinition loadDefinition(final InputStream input) { + return definitionFrom(loadDocument(input)); + } + + public ThingifierModelDefinition loadDefinition(final String yamlText) { + return definitionFrom(loadDocument(yamlText)); + } + + public Thingifier loadThingifier(final Path path) throws IOException { + return assemble(loadDefinition(path)); + } + + public Thingifier loadThingifier(final InputStream input) { + return assemble(loadDefinition(input)); + } + + public Thingifier loadThingifier(final String yamlText) { + return assemble(loadDefinition(yamlText)); + } + + private Thingifier assemble(final ThingifierModelDefinition definition) { + final SchemaDefinitionValidationReport report = assembler.validate(definition); + if (!report.isValid()) { + throw new ThingifierYamlException(report.combinedMessages()); + } + return assembler.assemble(definition); + } + + private YamlThingifierDocument loadDocument(final InputStream input) { + try { + return YamlThingifierDocument.fromObject(yaml().load(input)); + } catch (YAMLException | ClassCastException | IllegalArgumentException e) { + throw new ThingifierYamlException("Could not parse YAML schema", e); + } + } + + private YamlThingifierDocument loadDocument(final String yamlText) { + try { + return YamlThingifierDocument.fromObject(yaml().load(yamlText)); + } catch (YAMLException | ClassCastException | IllegalArgumentException e) { + throw new ThingifierYamlException("Could not parse YAML schema", e); + } + } + + private ThingifierModelDefinition definitionFrom(final YamlThingifierDocument document) { + try { + return mapper.toDefinition(document); + } catch (ClassCastException | IllegalArgumentException e) { + throw new ThingifierYamlException("Could not map YAML schema", e); + } + } + + private Yaml yaml() { + final LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setMaxAliasesForCollections(20); + return new Yaml(new SafeConstructor(options)); + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlEntityDocument.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlEntityDocument.java new file mode 100644 index 00000000..ff891a5b --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlEntityDocument.java @@ -0,0 +1,65 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class YamlEntityDocument { + + private final String name; + private final String pluralName; + private final Integer maxInstances; + private final String primaryKey; + private final Map fields; + + private YamlEntityDocument( + final String name, + final String pluralName, + final Integer maxInstances, + final String primaryKey, + final Map fields) { + this.name = name; + this.pluralName = pluralName; + this.maxInstances = maxInstances; + this.primaryKey = primaryKey; + this.fields = fields; + } + + static YamlEntityDocument fromEntry(final String name, final Object source) { + final Map map = YamlMapSupport.asMap(source); + return new YamlEntityDocument( + name, + YamlMapSupport.stringValue(map.get("plural")), + YamlMapSupport.integerValue(map.get("maxInstances")), + YamlMapSupport.stringValue(map.get("primaryKey")), + fieldsFrom(map.get("fields"))); + } + + public String name() { + return name; + } + + public String pluralName() { + return pluralName; + } + + public Integer maxInstances() { + return maxInstances; + } + + public String primaryKey() { + return primaryKey; + } + + public Map fields() { + return fields; + } + + private static Map fieldsFrom(final Object source) { + final Map fields = new LinkedHashMap<>(); + for (Map.Entry entry : YamlMapSupport.stringMap(source).entrySet()) { + fields.put( + entry.getKey(), YamlFieldDocument.fromEntry(entry.getKey(), entry.getValue())); + } + return fields; + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlFieldDocument.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlFieldDocument.java new file mode 100644 index 00000000..5a63d3dc --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlFieldDocument.java @@ -0,0 +1,122 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class YamlFieldDocument { + + private final String name; + private final String type; + private final boolean required; + private final boolean unique; + private final String defaultValue; + private final String description; + private final List examples; + private final Integer truncateTo; + private final String minValue; + private final String maxValue; + private final List validations; + private final Map fields; + + private YamlFieldDocument( + final String name, + final String type, + final boolean required, + final boolean unique, + final String defaultValue, + final String description, + final List examples, + final Integer truncateTo, + final String minValue, + final String maxValue, + final List validations, + final Map fields) { + this.name = name; + this.type = type; + this.required = required; + this.unique = unique; + this.defaultValue = defaultValue; + this.description = description; + this.examples = examples; + this.truncateTo = truncateTo; + this.minValue = minValue; + this.maxValue = maxValue; + this.validations = validations; + this.fields = fields; + } + + static YamlFieldDocument fromEntry(final String name, final Object source) { + final Map map = YamlMapSupport.asMap(source); + return new YamlFieldDocument( + name, + YamlMapSupport.stringValue(map.get("type")), + YamlMapSupport.booleanValue(map.get("required")), + YamlMapSupport.booleanValue(map.get("unique")), + YamlMapSupport.stringValue(map.get("default")), + YamlMapSupport.stringValue(map.get("description")), + YamlMapSupport.stringList(map.get("examples")), + YamlMapSupport.integerValue(map.get("truncateTo")), + YamlMapSupport.stringValue(map.get("min")), + YamlMapSupport.stringValue(map.get("max")), + YamlMapSupport.listValue(map.get("validations")), + fieldsFrom(map.get("fields"))); + } + + public String name() { + return name; + } + + public String type() { + return type; + } + + public boolean required() { + return required; + } + + public boolean unique() { + return unique; + } + + public String defaultValue() { + return defaultValue; + } + + public String description() { + return description; + } + + public List examples() { + return examples; + } + + public Integer truncateTo() { + return truncateTo; + } + + public String minValue() { + return minValue; + } + + public String maxValue() { + return maxValue; + } + + public List validations() { + return validations; + } + + public Map fields() { + return fields; + } + + private static Map fieldsFrom(final Object source) { + final Map fields = new LinkedHashMap<>(); + for (Map.Entry entry : YamlMapSupport.stringMap(source).entrySet()) { + fields.put( + entry.getKey(), YamlFieldDocument.fromEntry(entry.getKey(), entry.getValue())); + } + return fields; + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlMapSupport.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlMapSupport.java new file mode 100644 index 00000000..7a3cee41 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlMapSupport.java @@ -0,0 +1,72 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class YamlMapSupport { + + private YamlMapSupport() { + // utility class + } + + static Map asMap(final Object source) { + if (source == null) { + return new LinkedHashMap<>(); + } + if (!(source instanceof Map)) { + throw new IllegalArgumentException("Expected YAML object"); + } + final Map result = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) source).entrySet()) { + result.put(String.valueOf(entry.getKey()), entry.getValue()); + } + return result; + } + + static Map stringMap(final Object source) { + return asMap(source); + } + + static List listValue(final Object source) { + final List values = new ArrayList<>(); + if (source == null) { + return values; + } + if (!(source instanceof List)) { + throw new IllegalArgumentException("Expected YAML list"); + } + values.addAll((List) source); + return values; + } + + static List stringList(final Object source) { + final List values = new ArrayList<>(); + for (Object value : listValue(source)) { + values.add(stringValue(value)); + } + return values; + } + + static String stringValue(final Object source) { + return source == null ? null : String.valueOf(source); + } + + static boolean booleanValue(final Object source) { + if (source instanceof Boolean) { + return (Boolean) source; + } + return source != null && Boolean.parseBoolean(String.valueOf(source)); + } + + static Integer integerValue(final Object source) { + if (source == null) { + return null; + } + if (source instanceof Number) { + return ((Number) source).intValue(); + } + return Integer.parseInt(String.valueOf(source)); + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipDocument.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipDocument.java new file mode 100644 index 00000000..996e03be --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipDocument.java @@ -0,0 +1,70 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.Map; + +public final class YamlRelationshipDocument { + + private final String fromEntityName; + private final String name; + private final String toEntityName; + private final String cardinality; + private final String optionality; + private final YamlRelationshipReverseDocument reverse; + + private YamlRelationshipDocument( + final String fromEntityName, + final String name, + final String toEntityName, + final String cardinality, + final String optionality, + final YamlRelationshipReverseDocument reverse) { + this.fromEntityName = fromEntityName; + this.name = name; + this.toEntityName = toEntityName; + this.cardinality = cardinality; + this.optionality = optionality; + this.reverse = reverse; + } + + static YamlRelationshipDocument fromObject(final Object source) { + final Map map = YamlMapSupport.asMap(source); + return new YamlRelationshipDocument( + YamlMapSupport.stringValue(map.get("from")), + YamlMapSupport.stringValue(map.get("name")), + YamlMapSupport.stringValue(map.get("to")), + YamlMapSupport.stringValue(map.get("cardinality")), + YamlMapSupport.stringValue(map.get("optionality")), + reverseFrom(map.get("reverse"))); + } + + public String fromEntityName() { + return fromEntityName; + } + + public String name() { + return name; + } + + public String toEntityName() { + return toEntityName; + } + + public String cardinality() { + return cardinality; + } + + public String optionality() { + return optionality; + } + + public YamlRelationshipReverseDocument reverse() { + return reverse; + } + + private static YamlRelationshipReverseDocument reverseFrom(final Object source) { + if (source == null) { + return null; + } + return YamlRelationshipReverseDocument.fromObject(source); + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipReverseDocument.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipReverseDocument.java new file mode 100644 index 00000000..904eb5c3 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlRelationshipReverseDocument.java @@ -0,0 +1,37 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.Map; + +public final class YamlRelationshipReverseDocument { + + private final String name; + private final String cardinality; + private final String optionality; + + private YamlRelationshipReverseDocument( + final String name, final String cardinality, final String optionality) { + this.name = name; + this.cardinality = cardinality; + this.optionality = optionality; + } + + static YamlRelationshipReverseDocument fromObject(final Object source) { + final Map map = YamlMapSupport.asMap(source); + return new YamlRelationshipReverseDocument( + YamlMapSupport.stringValue(map.get("name")), + YamlMapSupport.stringValue(map.get("cardinality")), + YamlMapSupport.stringValue(map.get("optionality"))); + } + + public String name() { + return name; + } + + public String cardinality() { + return cardinality; + } + + public String optionality() { + return optionality; + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocument.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocument.java new file mode 100644 index 00000000..98aaaea6 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocument.java @@ -0,0 +1,81 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class YamlThingifierDocument { + + private final int formatVersion; + private final String title; + private final String description; + private final Map entities; + private final List relationships; + + private YamlThingifierDocument( + final int formatVersion, + final String title, + final String description, + final Map entities, + final List relationships) { + this.formatVersion = formatVersion; + this.title = title; + this.description = description; + this.entities = entities; + this.relationships = relationships; + } + + public static YamlThingifierDocument fromObject(final Object source) { + final Map map = YamlMapSupport.asMap(source); + final Map model = YamlMapSupport.asMap(map.get("model")); + return new YamlThingifierDocument( + formatVersionFrom(map.get("formatVersion")), + YamlMapSupport.stringValue(model.get("title")), + YamlMapSupport.stringValue(model.get("description")), + entitiesFrom(map.get("entities")), + relationshipsFrom(map.get("relationships"))); + } + + public int formatVersion() { + return formatVersion; + } + + public String title() { + return title; + } + + public String description() { + return description; + } + + public Map entities() { + return entities; + } + + public List relationships() { + return relationships; + } + + private static int formatVersionFrom(final Object source) { + final Integer value = YamlMapSupport.integerValue(source); + return value == null ? -1 : value; + } + + private static Map entitiesFrom(final Object source) { + final Map entities = new LinkedHashMap<>(); + for (Map.Entry entry : YamlMapSupport.stringMap(source).entrySet()) { + entities.put( + entry.getKey(), YamlEntityDocument.fromEntry(entry.getKey(), entry.getValue())); + } + return entities; + } + + private static List relationshipsFrom(final Object source) { + final List relationships = new ArrayList<>(); + for (Object item : YamlMapSupport.listValue(source)) { + relationships.add(YamlRelationshipDocument.fromObject(item)); + } + return relationships; + } +} diff --git a/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocumentMapper.java b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocumentMapper.java new file mode 100644 index 00000000..f7567b12 --- /dev/null +++ b/thingifier-yaml/src/main/java/uk/co/compendiumdev/thingifier/yaml/internal/YamlThingifierDocumentMapper.java @@ -0,0 +1,231 @@ +package uk.co.compendiumdev.thingifier.yaml.internal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.application.schema.definition.CardinalitySpec; +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.RelationshipDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipVectorSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.application.schema.definition.ValidationRuleSpec; + +public final class YamlThingifierDocumentMapper { + + public ThingifierModelDefinition toDefinition(final YamlThingifierDocument document) { + final ThingifierModelDefinition.Builder builder = + ThingifierModelDefinition.builder() + .formatVersion(document.formatVersion()) + .title(document.title()) + .description(document.description()); + for (YamlEntityDocument entity : document.entities().values()) { + builder.entity(entitySpecFor(entity)); + } + for (YamlRelationshipDocument relationship : document.relationships()) { + builder.relationship(relationshipSpecFor(relationship)); + } + return builder.build(); + } + + public Map toYamlMap(final ThingifierModelDefinition definition) { + final Map root = new LinkedHashMap<>(); + root.put("formatVersion", definition.formatVersion()); + root.put("model", modelMapFor(definition)); + root.put("entities", entitiesMapFor(definition)); + root.put("relationships", relationshipsListFor(definition)); + return root; + } + + private EntityDefinitionSpec entitySpecFor(final YamlEntityDocument entity) { + final EntityDefinitionSpec.Builder builder = + EntityDefinitionSpec.named(entity.name()) + .plural(entity.pluralName()) + .maxInstances(entity.maxInstances() == null ? -1 : entity.maxInstances()) + .primaryKey(entity.primaryKey()); + for (YamlFieldDocument field : entity.fields().values()) { + builder.field(fieldSpecFor(field)); + } + return builder.build(); + } + + private FieldDefinitionSpec fieldSpecFor(final YamlFieldDocument field) { + final FieldDefinitionSpec.Builder builder = + FieldDefinitionSpec.named(field.name(), field.type()) + .required(field.required()) + .unique(field.unique()) + .defaultValue(field.defaultValue()) + .description(field.description()) + .examples(field.examples()) + .truncateTo(field.truncateTo()) + .range(field.minValue(), field.maxValue()) + .validationRules(validationRulesFor(field.validations())); + for (YamlFieldDocument child : field.fields().values()) { + builder.objectField(fieldSpecFor(child)); + } + return builder.build(); + } + + private List validationRulesFor(final List validations) { + final List rules = new ArrayList<>(); + for (Object validation : validations) { + if (validation instanceof String) { + rules.add(new ValidationRuleSpec(String.valueOf(validation), null)); + } else { + rules.add(ruleFromMap(validation)); + } + } + return rules; + } + + private ValidationRuleSpec ruleFromMap(final Object validation) { + final Map map = YamlMapSupport.asMap(validation); + if (map.isEmpty()) { + return new ValidationRuleSpec("", null); + } + final Map.Entry entry = map.entrySet().iterator().next(); + return new ValidationRuleSpec(entry.getKey(), YamlMapSupport.stringValue(entry.getValue())); + } + + private RelationshipDefinitionSpec relationshipSpecFor( + final YamlRelationshipDocument relationship) { + return new RelationshipDefinitionSpec( + relationship.fromEntityName(), + relationship.name(), + relationship.toEntityName(), + cardinalityFor(relationship.cardinality()), + relationship.optionality(), + reverseSpecFor(relationship.reverse())); + } + + private RelationshipVectorSpec reverseSpecFor(final YamlRelationshipReverseDocument reverse) { + if (reverse == null) { + return null; + } + return new RelationshipVectorSpec( + reverse.name(), cardinalityFor(reverse.cardinality()), reverse.optionality()); + } + + private CardinalitySpec cardinalityFor(final String text) { + if (text == null || text.trim().isEmpty()) { + return CardinalitySpec.oneToMany(); + } + try { + return CardinalitySpec.fromText(text); + } catch (IllegalArgumentException e) { + return new CardinalitySpec(text, ""); + } + } + + private Map modelMapFor(final ThingifierModelDefinition definition) { + final Map model = new LinkedHashMap<>(); + model.put("title", definition.title()); + model.put("description", definition.description()); + return model; + } + + private Map entitiesMapFor(final ThingifierModelDefinition definition) { + final Map entities = new LinkedHashMap<>(); + for (EntityDefinitionSpec entity : definition.entities()) { + entities.put(entity.name(), entityMapFor(entity)); + } + return entities; + } + + private Map entityMapFor(final EntityDefinitionSpec entity) { + final Map map = new LinkedHashMap<>(); + map.put("plural", entity.pluralName()); + map.put("maxInstances", entity.maxInstances()); + if (entity.hasPrimaryKeyField()) { + map.put("primaryKey", entity.primaryKeyFieldName()); + } + map.put("fields", fieldsMapFor(entity.fields())); + return map; + } + + private Map fieldsMapFor(final List fields) { + final Map map = new LinkedHashMap<>(); + for (FieldDefinitionSpec field : fields) { + map.put(field.name(), fieldMapFor(field)); + } + return map; + } + + private Map fieldMapFor(final FieldDefinitionSpec field) { + final Map map = new LinkedHashMap<>(); + map.put("type", field.type()); + putIfTrue(map, "required", field.required()); + putIfTrue(map, "unique", field.unique()); + putIfPresent(map, "default", field.defaultValue()); + putIfPresent(map, "description", field.description()); + if (!field.examples().isEmpty()) { + map.put("examples", field.examples()); + } + putIfPresent(map, "truncateTo", field.truncateTo()); + putIfPresent(map, "min", field.minValue()); + putIfPresent(map, "max", field.maxValue()); + if (!field.validationRules().isEmpty()) { + map.put("validations", validationRulesListFor(field.validationRules())); + } + if (!field.objectFields().isEmpty()) { + map.put("fields", fieldsMapFor(field.objectFields())); + } + return map; + } + + private List validationRulesListFor(final List rules) { + final List values = new ArrayList<>(); + for (ValidationRuleSpec rule : rules) { + if (rule.value() == null) { + values.add(rule.name()); + } else { + final Map map = new LinkedHashMap<>(); + map.put(rule.name(), rule.value()); + values.add(map); + } + } + return values; + } + + private List relationshipsListFor(final ThingifierModelDefinition definition) { + final List relationships = new ArrayList<>(); + for (RelationshipDefinitionSpec relationship : definition.relationships()) { + relationships.add(relationshipMapFor(relationship)); + } + return relationships; + } + + private Map relationshipMapFor(final RelationshipDefinitionSpec relationship) { + final Map map = new LinkedHashMap<>(); + map.put("from", relationship.fromEntityName()); + map.put("name", relationship.name()); + map.put("to", relationship.toEntityName()); + map.put("cardinality", relationship.cardinality().canonicalName()); + putIfPresent(map, "optionality", relationship.optionality()); + if (relationship.hasReverse()) { + map.put("reverse", reverseMapFor(relationship.reverse())); + } + return map; + } + + private Map reverseMapFor(final RelationshipVectorSpec reverse) { + final Map map = new LinkedHashMap<>(); + map.put("name", reverse.name()); + map.put("cardinality", reverse.cardinality().canonicalName()); + putIfPresent(map, "optionality", reverse.optionality()); + return map; + } + + private void putIfPresent(final Map map, final String key, final Object value) { + if (value != null) { + map.put(key, value); + } + } + + private void putIfTrue(final Map map, final String key, final boolean value) { + if (value) { + map.put(key, Boolean.TRUE); + } + } +} diff --git a/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporterTest.java b/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporterTest.java new file mode 100644 index 00000000..f9414793 --- /dev/null +++ b/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlExporterTest.java @@ -0,0 +1,73 @@ +package uk.co.compendiumdev.thingifier.yaml; + +import java.io.InputStream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; + +class ThingifierYamlExporterTest { + + @Test + void exporterEmitsCanonicalYamlForDefinition() { + ThingifierModelDefinition definition = + new ThingifierYamlLoader().loadDefinition(resource("minimal-todo.yaml")); + + String yaml = new ThingifierYamlExporter().export(definition); + + Assertions.assertTrue(yaml.contains("formatVersion: 1")); + Assertions.assertTrue(yaml.contains("entities:")); + Assertions.assertTrue(yaml.contains("todo:")); + Assertions.assertTrue( + new ThingifierYamlLoader().loadDefinition(yaml).relationships().isEmpty()); + } + + @Test + void exportedYamlCanBeLoadedAgain() { + Thingifier first = new ThingifierYamlLoader().loadThingifier(resource("validations.yaml")); + + String yaml = new ThingifierYamlExporter().export(first); + Thingifier second = new ThingifierYamlLoader().loadThingifier(yaml); + + Assertions.assertEquals(first.getThingNames().size(), second.getThingNames().size()); + Assertions.assertEquals( + first.getDefinitionNamed("item").getFieldNames(), + second.getDefinitionNamed("item").getFieldNames()); + Assertions.assertTrue(second.getDefinitionNamed("item").getField("title").isMandatory()); + Assertions.assertTrue(second.getDefinitionNamed("item").getField("title").mustBeUnique()); + } + + @Test + void loadExportLoadRoundTripPreservesRelationshipSemantics() { + Thingifier first = + new ThingifierYamlLoader().loadThingifier(resource("relationships-two-way.yaml")); + + String yaml = new ThingifierYamlExporter().export(first); + Thingifier second = new ThingifierYamlLoader().loadThingifier(yaml); + + Assertions.assertTrue(second.hasRelationshipNamed("tasks")); + Assertions.assertTrue(second.hasRelationshipNamed("taskof")); + Assertions.assertEquals( + first.getRelationshipDefinitions().size(), + second.getRelationshipDefinitions().size()); + } + + @Test + void exporterCanEmitYamlFromRuntimeThingifier() { + Thingifier thingifier = + new ThingifierYamlLoader().loadThingifier(resource("field-types.yaml")); + + String yaml = new ThingifierYamlExporter().export(thingifier); + + Assertions.assertTrue(yaml.contains("auto-guid")); + Assertions.assertTrue(yaml.contains("auto-increment")); + Assertions.assertTrue(yaml.contains("metadata:")); + Assertions.assertTrue(yaml.contains("source:")); + } + + private InputStream resource(final String resourceName) { + InputStream stream = getClass().getResourceAsStream("/models/" + resourceName); + Assertions.assertNotNull(stream, resourceName); + return stream; + } +} diff --git a/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoaderTest.java b/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoaderTest.java new file mode 100644 index 00000000..88e8cd20 --- /dev/null +++ b/thingifier-yaml/src/test/java/uk/co/compendiumdev/thingifier/yaml/ThingifierYamlLoaderTest.java @@ -0,0 +1,175 @@ +package uk.co.compendiumdev.thingifier.yaml; + +import com.google.gson.Gson; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.examples.TodoManagerThingifier; +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.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +class ThingifierYamlLoaderTest { + + @ParameterizedTest + @ValueSource( + strings = { + "minimal-todo.yaml", + "todo-manager.yaml", + "relationships-one-way.yaml", + "relationships-two-way.yaml", + "field-types.yaml", + "validations.yaml", + "object-fields.yaml", + "max-instances.yaml" + }) + void validYamlResourcesLoadIntoDefinitions(final String resourceName) { + ThingifierModelDefinition definition = + new ThingifierYamlLoader().loadDefinition(resource(resourceName)); + + SchemaDefinitionValidationReport report = + new ThingifierModelAssembler().validate(definition); + + Assertions.assertTrue(report.isValid(), report.combinedMessages()); + Assertions.assertFalse(definition.entities().isEmpty()); + } + + @Test + void loaderBuildsThingifierFromPath() throws Exception { + Thingifier thingifier = + new ThingifierYamlLoader().loadThingifier(resourcePath("minimal-todo.yaml")); + + Assertions.assertEquals("Minimal Todo", thingifier.getTitle()); + Assertions.assertNotNull(thingifier.getDefinitionNamed("todo")); + Assertions.assertEquals( + "id", thingifier.getDefinitionNamed("todo").getPrimaryKeyField().getName()); + } + + @Test + void invalidSemanticYamlReportsValidationErrors() { + ThingifierYamlException error = + Assertions.assertThrows( + ThingifierYamlException.class, + () -> + new ThingifierYamlLoader() + .loadThingifier(resource("invalid-primary-key.yaml"))); + + Assertions.assertTrue( + error.getMessage().contains("Primary key field missing is not defined")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "invalid-missing-entity.yaml", + "invalid-primary-key.yaml", + "invalid-relationship-target.yaml", + "invalid-field-type.yaml" + }) + void invalidYamlResourcesLoadAsDefinitionsButDoNotAssemble(final String resourceName) { + ThingifierModelDefinition definition = + new ThingifierYamlLoader().loadDefinition(resource(resourceName)); + + SchemaDefinitionValidationReport report = + new ThingifierModelAssembler().validate(definition); + + Assertions.assertFalse(report.isValid()); + } + + @Test + void malformedYamlReportsParseErrorSeparatelyFromSemanticErrors() { + ThingifierYamlException error = + Assertions.assertThrows( + ThingifierYamlException.class, + () -> new ThingifierYamlLoader().loadDefinition("formatVersion: [")); + + Assertions.assertTrue(error.getMessage().contains("Could not parse YAML schema")); + } + + @Test + void yamlLoadedTodoManagerMatchesJavaDefinedSchemaShape() { + Thingifier yamlThingifier = + new ThingifierYamlLoader().loadThingifier(resource("todo-manager.yaml")); + Thingifier javaThingifier = new TodoManagerThingifier().get(); + + Stream.of("todo", "project", "category") + .forEach( + entityName -> + assertEntityMatches(javaThingifier, yamlThingifier, entityName)); + Stream.of("tasks", "tasksof", "categories", "todos", "projects") + .forEach( + relationship -> + Assertions.assertTrue( + yamlThingifier.hasRelationshipNamed(relationship))); + } + + @Test + void yamlLoadedModelCanBackExistingNonHttpApiPath() { + Thingifier thingifier = + new ThingifierYamlLoader().loadThingifier(resource("minimal-todo.yaml")); + Map body = new HashMap<>(); + body.put("title", "Yaml API task"); + + ApiResponse postResponse = + thingifier.api().post("todo", bodyParser(thingifier, body), new HttpHeadersBlock()); + + Assertions.assertEquals(201, postResponse.getStatusCode()); + Assertions.assertEquals( + "Yaml API task", + postResponse.getReturnedInstance().getFieldValue("title").asString()); + + ApiResponse getResponse = + thingifier + .api() + .get( + "/todo/" + postResponse.getReturnedInstance().getPrimaryKeyValue(), + new QueryFilterParams(), + new HttpHeadersBlock()); + + Assertions.assertEquals(200, getResponse.getStatusCode()); + Assertions.assertEquals( + postResponse.getReturnedInstance(), getResponse.getReturnedInstance()); + } + + private void assertEntityMatches( + final Thingifier expected, final Thingifier actual, final String entityName) { + EntityDefinition expectedEntity = expected.getDefinitionNamed(entityName); + EntityDefinition actualEntity = actual.getDefinitionNamed(entityName); + + Assertions.assertEquals(expectedEntity.getPlural(), actualEntity.getPlural()); + Assertions.assertEquals( + expectedEntity.getPrimaryKeyField().getName(), + actualEntity.getPrimaryKeyField().getName()); + Assertions.assertEquals(expectedEntity.getFieldNames(), actualEntity.getFieldNames()); + } + + private BodyParser bodyParser(final Thingifier thingifier, final Map body) { + return new BodyParser( + new HttpApiRequest("/path").setBody(new Gson().toJson(body)), + thingifier.getThingNames()); + } + + private InputStream resource(final String resourceName) { + InputStream stream = getClass().getResourceAsStream("/models/" + resourceName); + Assertions.assertNotNull(stream, resourceName); + return stream; + } + + private Path resourcePath(final String resourceName) throws URISyntaxException { + return Path.of(getClass().getResource("/models/" + resourceName).toURI()); + } +} diff --git a/thingifier-yaml/src/test/resources/models/field-types.yaml b/thingifier-yaml/src/test/resources/models/field-types.yaml new file mode 100644 index 00000000..41470aec --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/field-types.yaml @@ -0,0 +1,34 @@ +formatVersion: 1 +model: + title: Field Types + description: Covers supported field types. +entities: + sample: + plural: samples + primaryKey: guid + fields: + guid: + type: auto-guid + autoNumber: + type: auto-increment + text: + type: string + count: + type: integer + price: + type: float + active: + type: boolean + state: + type: enum + examples: + - new + - done + dueDate: + type: date + metadata: + type: object + fields: + source: + type: string +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/invalid-field-type.yaml b/thingifier-yaml/src/test/resources/models/invalid-field-type.yaml new file mode 100644 index 00000000..da0d6a43 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/invalid-field-type.yaml @@ -0,0 +1,11 @@ +formatVersion: 1 +model: + title: Invalid Field Type + description: Field type is not supported. +entities: + task: + plural: tasks + fields: + title: + type: sentence +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/invalid-missing-entity.yaml b/thingifier-yaml/src/test/resources/models/invalid-missing-entity.yaml new file mode 100644 index 00000000..3d4033d1 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/invalid-missing-entity.yaml @@ -0,0 +1,6 @@ +formatVersion: 1 +model: + title: Invalid Missing Entity + description: No entities are defined. +entities: {} +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/invalid-primary-key.yaml b/thingifier-yaml/src/test/resources/models/invalid-primary-key.yaml new file mode 100644 index 00000000..2e573362 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/invalid-primary-key.yaml @@ -0,0 +1,12 @@ +formatVersion: 1 +model: + title: Invalid Primary Key + description: Primary key references a missing field. +entities: + task: + plural: tasks + primaryKey: missing + fields: + title: + type: string +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/invalid-relationship-target.yaml b/thingifier-yaml/src/test/resources/models/invalid-relationship-target.yaml new file mode 100644 index 00000000..ba7ec611 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/invalid-relationship-target.yaml @@ -0,0 +1,16 @@ +formatVersion: 1 +model: + title: Invalid Relationship Target + description: Relationship target does not exist. +entities: + project: + plural: projects + fields: + title: + type: string +relationships: + - from: project + name: tasks + to: task + cardinality: one-to-many + optionality: optional diff --git a/thingifier-yaml/src/test/resources/models/max-instances.yaml b/thingifier-yaml/src/test/resources/models/max-instances.yaml new file mode 100644 index 00000000..be5d53ca --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/max-instances.yaml @@ -0,0 +1,12 @@ +formatVersion: 1 +model: + title: Max Instances + description: Entity with an instance cap. +entities: + singleton: + plural: singletons + maxInstances: 1 + fields: + name: + type: string +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/minimal-todo.yaml b/thingifier-yaml/src/test/resources/models/minimal-todo.yaml new file mode 100644 index 00000000..8b0b5260 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/minimal-todo.yaml @@ -0,0 +1,18 @@ +formatVersion: 1 +model: + title: Minimal Todo + description: A tiny todo schema. +entities: + todo: + plural: todos + maxInstances: -1 + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + required: true + validations: + - notEmpty +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/object-fields.yaml b/thingifier-yaml/src/test/resources/models/object-fields.yaml new file mode 100644 index 00000000..eaea37a8 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/object-fields.yaml @@ -0,0 +1,16 @@ +formatVersion: 1 +model: + title: Object Fields + description: Nested object field schema. +entities: + audit: + plural: audits + fields: + payload: + type: object + fields: + source: + type: string + count: + type: integer +relationships: [] diff --git a/thingifier-yaml/src/test/resources/models/relationships-one-way.yaml b/thingifier-yaml/src/test/resources/models/relationships-one-way.yaml new file mode 100644 index 00000000..01cc565a --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/relationships-one-way.yaml @@ -0,0 +1,21 @@ +formatVersion: 1 +model: + title: One Way Relationships + description: Project tasks relationship. +entities: + project: + plural: projects + fields: + title: + type: string + task: + plural: tasks + fields: + title: + type: string +relationships: + - from: project + name: tasks + to: task + cardinality: one-to-many + optionality: optional diff --git a/thingifier-yaml/src/test/resources/models/relationships-two-way.yaml b/thingifier-yaml/src/test/resources/models/relationships-two-way.yaml new file mode 100644 index 00000000..a4d14a6f --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/relationships-two-way.yaml @@ -0,0 +1,25 @@ +formatVersion: 1 +model: + title: Two Way Relationships + description: Project tasks relationship with reverse lookup. +entities: + project: + plural: projects + fields: + title: + type: string + task: + plural: tasks + fields: + title: + type: string +relationships: + - from: project + name: tasks + to: task + cardinality: one-to-many + optionality: optional + reverse: + name: taskof + cardinality: one-to-one + optionality: mandatory diff --git a/thingifier-yaml/src/test/resources/models/todo-manager.yaml b/thingifier-yaml/src/test/resources/models/todo-manager.yaml new file mode 100644 index 00000000..ec669052 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/todo-manager.yaml @@ -0,0 +1,83 @@ +formatVersion: 1 +model: + title: Todo Manager + description: A Simple todo manager with categories and projects. +entities: + todo: + plural: todos + maxInstances: -1 + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + required: true + validations: + - notEmpty + doneStatus: + type: boolean + default: "false" + description: + type: string + project: + plural: projects + maxInstances: -1 + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + completed: + type: boolean + default: "false" + active: + type: boolean + default: "false" + description: + type: string + category: + plural: categories + maxInstances: -1 + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + required: true + validations: + - notEmpty + description: + type: string +relationships: + - from: project + name: tasks + to: todo + cardinality: one-to-many + optionality: optional + reverse: + name: tasksof + cardinality: one-to-many + optionality: optional + - from: project + name: categories + to: category + cardinality: one-to-many + optionality: optional + - from: category + name: todos + to: todo + cardinality: one-to-many + optionality: optional + - from: category + name: projects + to: project + cardinality: one-to-many + optionality: optional + - from: todo + name: categories + to: category + cardinality: one-to-many + optionality: optional diff --git a/thingifier-yaml/src/test/resources/models/validations.yaml b/thingifier-yaml/src/test/resources/models/validations.yaml new file mode 100644 index 00000000..ecf846e1 --- /dev/null +++ b/thingifier-yaml/src/test/resources/models/validations.yaml @@ -0,0 +1,30 @@ +formatVersion: 1 +model: + title: Validations + description: Covers validation rule mapping. +entities: + item: + plural: items + fields: + title: + type: string + required: true + unique: true + description: Item title + examples: + - A title + truncateTo: 20 + validations: + - notEmpty + - maximumLength: 50 + - matchesRegex: ".+" + - satisfiesRegex: "[A-Z]" + priority: + type: integer + min: 1 + max: 5 + score: + type: float + min: 0.5 + max: 10.5 +relationships: [] diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/CardinalitySpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/CardinalitySpec.java new file mode 100644 index 00000000..d58f7620 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/CardinalitySpec.java @@ -0,0 +1,90 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +public final class CardinalitySpec { + + private final String left; + private final String right; + + public CardinalitySpec(final String left, final String right) { + this.left = left; + this.right = right; + } + + public static CardinalitySpec oneToMany() { + return new CardinalitySpec("1", "*"); + } + + public static CardinalitySpec oneToOne() { + return new CardinalitySpec("1", "1"); + } + + public static CardinalitySpec zeroToOne() { + return new CardinalitySpec("0", "1"); + } + + public static CardinalitySpec zeroToMany() { + return new CardinalitySpec("0", "*"); + } + + public static CardinalitySpec fromText(final String text) { + if (text == null || text.trim().isEmpty()) { + return oneToMany(); + } + + final String normalized = text.trim().toLowerCase(); + switch (normalized) { + case "one-to-many": + return oneToMany(); + case "one-to-one": + return oneToOne(); + case "zero-to-one": + return zeroToOne(); + case "zero-to-many": + return zeroToMany(); + default: + if (normalized.contains(":")) { + final String[] parts = normalized.split(":", -1); + if (parts.length == 2 && isValidAmount(parts[0]) && isValidAmount(parts[1])) { + return new CardinalitySpec(parts[0], parts[1]); + } + } + throw new IllegalArgumentException("Unsupported cardinality " + text); + } + } + + public String left() { + return left; + } + + public String right() { + return right; + } + + public String canonicalName() { + if ("1".equals(left) && "*".equals(right)) { + return "one-to-many"; + } + if ("1".equals(left) && "1".equals(right)) { + return "one-to-one"; + } + if ("0".equals(left) && "1".equals(right)) { + return "zero-to-one"; + } + if ("0".equals(left) && "*".equals(right)) { + return "zero-to-many"; + } + return left + ":" + right; + } + + private static boolean isValidAmount(final String amount) { + if ("*".equals(amount)) { + return true; + } + try { + Integer.parseInt(amount); + return true; + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java new file mode 100644 index 00000000..0b2a2ad2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java @@ -0,0 +1,103 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class EntityDefinitionSpec { + + private final String name; + private final String pluralName; + private final int maxInstances; + private final String primaryKeyFieldName; + private final List fields; + + private EntityDefinitionSpec(final Builder builder) { + name = builder.name; + pluralName = builder.pluralName; + maxInstances = builder.maxInstances; + primaryKeyFieldName = builder.primaryKeyFieldName; + fields = Collections.unmodifiableList(new ArrayList<>(builder.fields)); + } + + public static Builder named(final String name) { + return new Builder(name); + } + + public String name() { + return name; + } + + public String pluralName() { + return pluralName; + } + + public int maxInstances() { + return maxInstances; + } + + public String primaryKeyFieldName() { + return primaryKeyFieldName; + } + + public boolean hasPrimaryKeyField() { + return primaryKeyFieldName != null && !primaryKeyFieldName.trim().isEmpty(); + } + + public List fields() { + return fields; + } + + public FieldDefinitionSpec fieldNamed(final String fieldName) { + for (FieldDefinitionSpec field : fields) { + if (field.name().equals(fieldName)) { + return field; + } + } + return null; + } + + public static final class Builder { + + private final String name; + private String pluralName; + private int maxInstances; + private String primaryKeyFieldName; + private final List fields; + + private Builder(final String name) { + this.name = name; + maxInstances = -1; + fields = new ArrayList<>(); + } + + public Builder plural(final String pluralName) { + this.pluralName = pluralName; + return this; + } + + public Builder maxInstances(final int maxInstances) { + this.maxInstances = maxInstances; + return this; + } + + public Builder primaryKey(final String primaryKeyFieldName) { + this.primaryKeyFieldName = primaryKeyFieldName; + return this; + } + + public Builder field(final FieldDefinitionSpec field) { + fields.add(field); + return this; + } + + public Builder fields(final List fields) { + this.fields.addAll(fields); + return this; + } + + public EntityDefinitionSpec build() { + return new EntityDefinitionSpec(this); + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/FieldDefinitionSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/FieldDefinitionSpec.java new file mode 100644 index 00000000..2f017c79 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/FieldDefinitionSpec.java @@ -0,0 +1,181 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class FieldDefinitionSpec { + + private final String name; + private final String type; + private final boolean required; + private final boolean unique; + private final String defaultValue; + private final String description; + private final List examples; + private final Integer truncateTo; + private final String minValue; + private final String maxValue; + private final List validationRules; + private final List objectFields; + + private FieldDefinitionSpec(final Builder builder) { + name = builder.name; + type = builder.type; + required = builder.required; + unique = builder.unique; + defaultValue = builder.defaultValue; + description = builder.description; + examples = Collections.unmodifiableList(new ArrayList<>(builder.examples)); + truncateTo = builder.truncateTo; + minValue = builder.minValue; + maxValue = builder.maxValue; + validationRules = Collections.unmodifiableList(new ArrayList<>(builder.validationRules)); + objectFields = Collections.unmodifiableList(new ArrayList<>(builder.objectFields)); + } + + public static Builder named(final String name, final String type) { + return new Builder(name, type); + } + + public String name() { + return name; + } + + public String type() { + return type; + } + + public boolean required() { + return required; + } + + public boolean unique() { + return unique; + } + + public String defaultValue() { + return defaultValue; + } + + public String description() { + return description; + } + + public List examples() { + return examples; + } + + public Integer truncateTo() { + return truncateTo; + } + + public String minValue() { + return minValue; + } + + public String maxValue() { + return maxValue; + } + + public List validationRules() { + return validationRules; + } + + public List objectFields() { + return objectFields; + } + + public boolean hasRange() { + return minValue != null || maxValue != null; + } + + public static final class Builder { + + private final String name; + private final String type; + private boolean required; + private boolean unique; + private String defaultValue; + private String description; + private final List examples; + private Integer truncateTo; + private String minValue; + private String maxValue; + private final List validationRules; + private final List objectFields; + + private Builder(final String name, final String type) { + this.name = name; + this.type = type; + examples = new ArrayList<>(); + validationRules = new ArrayList<>(); + objectFields = new ArrayList<>(); + } + + public Builder required(final boolean required) { + this.required = required; + return this; + } + + public Builder unique(final boolean unique) { + this.unique = unique; + return this; + } + + public Builder defaultValue(final String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public Builder description(final String description) { + this.description = description; + return this; + } + + public Builder example(final String example) { + examples.add(example); + return this; + } + + public Builder examples(final List examples) { + this.examples.addAll(examples); + return this; + } + + public Builder truncateTo(final Integer truncateTo) { + this.truncateTo = truncateTo; + return this; + } + + public Builder range(final String minValue, final String maxValue) { + this.minValue = minValue; + this.maxValue = maxValue; + return this; + } + + public Builder validationRule(final ValidationRuleSpec validationRule) { + validationRules.add(validationRule); + return this; + } + + public Builder validationRules(final List validationRules) { + this.validationRules.addAll(validationRules); + return this; + } + + public Builder objectField(final FieldDefinitionSpec objectField) { + objectFields.add(objectField); + return this; + } + + public Builder objectFields(final List objectFields) { + this.objectFields.addAll(objectFields); + return this; + } + + public FieldDefinitionSpec build() { + return new FieldDefinitionSpec(this); + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipDefinitionSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipDefinitionSpec.java new file mode 100644 index 00000000..e1cd17a4 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipDefinitionSpec.java @@ -0,0 +1,54 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +public final class RelationshipDefinitionSpec { + + private final String fromEntityName; + private final String name; + private final String toEntityName; + private final CardinalitySpec cardinality; + private final String optionality; + private final RelationshipVectorSpec reverse; + + public RelationshipDefinitionSpec( + final String fromEntityName, + final String name, + final String toEntityName, + final CardinalitySpec cardinality, + final String optionality, + final RelationshipVectorSpec reverse) { + this.fromEntityName = fromEntityName; + this.name = name; + this.toEntityName = toEntityName; + this.cardinality = cardinality; + this.optionality = optionality; + this.reverse = reverse; + } + + public String fromEntityName() { + return fromEntityName; + } + + public String name() { + return name; + } + + public String toEntityName() { + return toEntityName; + } + + public CardinalitySpec cardinality() { + return cardinality; + } + + public String optionality() { + return optionality; + } + + public RelationshipVectorSpec reverse() { + return reverse; + } + + public boolean hasReverse() { + return reverse != null; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipVectorSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipVectorSpec.java new file mode 100644 index 00000000..a45089be --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/RelationshipVectorSpec.java @@ -0,0 +1,27 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +public final class RelationshipVectorSpec { + + private final String name; + private final CardinalitySpec cardinality; + private final String optionality; + + public RelationshipVectorSpec( + final String name, final CardinalitySpec cardinality, final String optionality) { + this.name = name; + this.cardinality = cardinality; + this.optionality = optionality; + } + + public String name() { + return name; + } + + public CardinalitySpec cardinality() { + return cardinality; + } + + public String optionality() { + return optionality; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/SchemaDefinitionValidationReport.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/SchemaDefinitionValidationReport.java new file mode 100644 index 00000000..2a003b73 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/SchemaDefinitionValidationReport.java @@ -0,0 +1,65 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SchemaDefinitionValidationReport { + + private final List errors; + + public SchemaDefinitionValidationReport() { + errors = new ArrayList<>(); + } + + public void addError(final String path, final String message) { + errors.add(new SchemaDefinitionValidationError(path, message)); + } + + public boolean isValid() { + return errors.isEmpty(); + } + + public List errors() { + return Collections.unmodifiableList(errors); + } + + public List messages() { + final List messages = new ArrayList<>(); + for (SchemaDefinitionValidationError error : errors) { + messages.add(error.toString()); + } + return messages; + } + + public String combinedMessages() { + return String.join("; ", messages()); + } + + public static final class SchemaDefinitionValidationError { + + private final String path; + private final String message; + + private SchemaDefinitionValidationError(final String path, final String message) { + this.path = path; + this.message = message; + } + + public String path() { + return path; + } + + public String message() { + return message; + } + + @Override + public String toString() { + if (path == null || path.trim().isEmpty()) { + return message; + } + return path + ": " + message; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java new file mode 100644 index 00000000..25e8cee2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java @@ -0,0 +1,453 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.Optionality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.VRule; + +public final class ThingifierModelAssembler { + + public SchemaDefinitionValidationReport validate(final ThingifierModelDefinition definition) { + final SchemaDefinitionValidationReport report = new SchemaDefinitionValidationReport(); + if (definition == null) { + report.addError("model", "Model definition is required"); + return report; + } + + if (definition.formatVersion() != 1) { + report.addError("formatVersion", "Only formatVersion 1 is supported"); + } + validateEntities(definition, report); + validateRelationships(definition, report); + return report; + } + + public Thingifier assemble(final ThingifierModelDefinition definition) { + final SchemaDefinitionValidationReport report = validate(definition); + if (!report.isValid()) { + throw new IllegalArgumentException(report.combinedMessages()); + } + + final Thingifier thingifier = new Thingifier(); + thingifier.setDocumentation( + emptyIfNull(definition.title()), emptyIfNull(definition.description())); + + for (EntityDefinitionSpec entitySpec : definition.entities()) { + thingifier.defineThing( + entitySpec.name(), entitySpec.pluralName(), entitySpec.maxInstances()); + } + + for (EntityDefinitionSpec entitySpec : definition.entities()) { + final EntityDefinition entity = thingifier.getDefinitionNamed(entitySpec.name()); + for (FieldDefinitionSpec fieldSpec : entitySpec.fields()) { + final Field field = createField(fieldSpec); + if (fieldSpec.name().equals(entitySpec.primaryKeyFieldName())) { + entity.addAsPrimaryKeyField(field); + } else { + entity.addField(field); + } + } + } + + for (RelationshipDefinitionSpec relationshipSpec : definition.relationships()) { + final EntityDefinition from = + thingifier.getDefinitionNamed(relationshipSpec.fromEntityName()); + final EntityDefinition to = + thingifier.getDefinitionNamed(relationshipSpec.toEntityName()); + final RelationshipDefinition relationship = + thingifier.defineRelationship( + from, + to, + relationshipSpec.name(), + cardinalityFor(relationshipSpec.cardinality())); + applyOptionality(relationship.getFromRelationship(), relationshipSpec.optionality()); + if (relationshipSpec.hasReverse()) { + relationship.whenReversed( + cardinalityFor(relationshipSpec.reverse().cardinality()), + relationshipSpec.reverse().name()); + applyOptionality( + relationship.getReversedRelationship(), + relationshipSpec.reverse().optionality()); + } + } + + return thingifier; + } + + private void validateEntities( + final ThingifierModelDefinition definition, + final SchemaDefinitionValidationReport report) { + if (definition.entities().isEmpty()) { + report.addError("entities", "At least one entity is required"); + } + + final Set entityNames = new HashSet<>(); + final Set pluralNames = new HashSet<>(); + for (EntityDefinitionSpec entity : definition.entities()) { + final String entityPath = "entities." + emptyIfNull(entity.name()); + validateEntity(entity, entityPath, entityNames, pluralNames, report); + } + } + + private void validateEntity( + final EntityDefinitionSpec entity, + final String entityPath, + final Set entityNames, + final Set pluralNames, + final SchemaDefinitionValidationReport report) { + if (isBlank(entity.name())) { + report.addError(entityPath + ".name", "Entity name is required"); + } else if (!entityNames.add(entity.name().toLowerCase(Locale.ROOT))) { + report.addError(entityPath, "Duplicate entity name " + entity.name()); + } + + if (isBlank(entity.pluralName())) { + report.addError(entityPath + ".plural", "Entity plural name is required"); + } else if (!pluralNames.add(entity.pluralName().toLowerCase(Locale.ROOT))) { + report.addError(entityPath, "Duplicate entity plural " + entity.pluralName()); + } + + if (entity.maxInstances() < -1) { + report.addError(entityPath + ".maxInstances", "maxInstances must be -1 or greater"); + } + + validateFields(entity, entityPath, report); + } + + private void validateFields( + final EntityDefinitionSpec entity, + final String entityPath, + final SchemaDefinitionValidationReport report) { + final Set fieldNames = new HashSet<>(); + for (FieldDefinitionSpec field : entity.fields()) { + validateField( + field, entityPath + ".fields." + emptyIfNull(field.name()), fieldNames, report); + } + + if (entity.hasPrimaryKeyField() && !fieldNames.contains(entity.primaryKeyFieldName())) { + report.addError( + entityPath + ".primaryKey", + "Primary key field " + entity.primaryKeyFieldName() + " is not defined"); + } + } + + private void validateField( + final FieldDefinitionSpec field, + final String fieldPath, + final Set siblingFieldNames, + final SchemaDefinitionValidationReport report) { + if (isBlank(field.name())) { + report.addError(fieldPath + ".name", "Field name is required"); + } else if (!siblingFieldNames.add(field.name())) { + report.addError(fieldPath, "Duplicate field name " + field.name()); + } + + final FieldType fieldType = fieldTypeFor(field.type()); + if (fieldType == null) { + report.addError(fieldPath + ".type", "Unsupported field type " + field.type()); + return; + } + + validateFieldConfiguration(field, fieldPath, fieldType, report); + } + + private void validateFieldConfiguration( + final FieldDefinitionSpec field, + final String fieldPath, + final FieldType fieldType, + final SchemaDefinitionValidationReport report) { + if (fieldType == FieldType.ENUM && field.examples().isEmpty()) { + report.addError(fieldPath + ".examples", "Enum fields require examples"); + } + + if (fieldType == FieldType.OBJECT) { + final Set childNames = new HashSet<>(); + for (FieldDefinitionSpec child : field.objectFields()) { + validateField( + child, + fieldPath + ".fields." + emptyIfNull(child.name()), + childNames, + report); + } + } else if (!field.objectFields().isEmpty()) { + report.addError(fieldPath + ".fields", "Only object fields can define child fields"); + } + + validateRange(field, fieldPath, fieldType, report); + validateRules(field, fieldPath, report); + } + + private void validateRange( + final FieldDefinitionSpec field, + final String fieldPath, + final FieldType fieldType, + final SchemaDefinitionValidationReport report) { + if (!field.hasRange()) { + return; + } + + if (field.minValue() == null || field.maxValue() == null) { + report.addError(fieldPath, "Both min and max are required when defining a range"); + return; + } + + if (fieldType == FieldType.INTEGER) { + validateIntegerRange(field, fieldPath, report); + return; + } + + if (fieldType == FieldType.FLOAT) { + validateFloatRange(field, fieldPath, report); + return; + } + + report.addError(fieldPath, "Only integer and float fields support min and max"); + } + + private void validateIntegerRange( + final FieldDefinitionSpec field, + final String fieldPath, + final SchemaDefinitionValidationReport report) { + try { + final int minimum = Integer.parseInt(field.minValue()); + final int maximum = Integer.parseInt(field.maxValue()); + if (minimum > maximum) { + report.addError(fieldPath, "min must be less than or equal to max"); + } + } catch (NumberFormatException e) { + report.addError(fieldPath, "Integer min and max must be whole numbers"); + } + } + + private void validateFloatRange( + final FieldDefinitionSpec field, + final String fieldPath, + final SchemaDefinitionValidationReport report) { + try { + final float minimum = Float.parseFloat(field.minValue()); + final float maximum = Float.parseFloat(field.maxValue()); + if (minimum > maximum) { + report.addError(fieldPath, "min must be less than or equal to max"); + } + } catch (NumberFormatException e) { + report.addError(fieldPath, "Float min and max must be numeric"); + } + } + + private void validateRules( + final FieldDefinitionSpec field, + final String fieldPath, + final SchemaDefinitionValidationReport report) { + for (ValidationRuleSpec rule : field.validationRules()) { + if (ValidationRuleSpec.NOT_EMPTY.equals(rule.name())) { + continue; + } + if (ValidationRuleSpec.MAXIMUM_LENGTH.equals(rule.name())) { + validateMaximumLength(rule, fieldPath, report); + continue; + } + if (ValidationRuleSpec.MATCHES_REGEX.equals(rule.name()) + || ValidationRuleSpec.SATISFIES_REGEX.equals(rule.name())) { + validateRegex(rule, fieldPath, report); + continue; + } + report.addError( + fieldPath + ".validations", "Unsupported validation rule " + rule.name()); + } + } + + private void validateMaximumLength( + final ValidationRuleSpec rule, + final String fieldPath, + final SchemaDefinitionValidationReport report) { + try { + if (Integer.parseInt(rule.value()) < 0) { + report.addError( + fieldPath + ".validations", "maximumLength must be zero or greater"); + } + } catch (NumberFormatException e) { + report.addError(fieldPath + ".validations", "maximumLength must be a whole number"); + } + } + + private void validateRegex( + final ValidationRuleSpec rule, + final String fieldPath, + final SchemaDefinitionValidationReport report) { + try { + Pattern.compile(rule.value()); + } catch (PatternSyntaxException | NullPointerException e) { + report.addError(fieldPath + ".validations", rule.name() + " must define a valid regex"); + } + } + + private void validateRelationships( + final ThingifierModelDefinition definition, + final SchemaDefinitionValidationReport report) { + for (int index = 0; index < definition.relationships().size(); index++) { + final RelationshipDefinitionSpec relationship = definition.relationships().get(index); + final String path = "relationships[" + index + "]"; + if (definition.entityNamed(relationship.fromEntityName()) == null) { + report.addError( + path + ".from", + "Unknown relationship source " + relationship.fromEntityName()); + } + if (definition.entityNamed(relationship.toEntityName()) == null) { + report.addError( + path + ".to", "Unknown relationship target " + relationship.toEntityName()); + } + if (isBlank(relationship.name())) { + report.addError(path + ".name", "Relationship name is required"); + } + validateCardinality(relationship.cardinality(), path + ".cardinality", report); + validateOptionality(relationship.optionality(), path + ".optionality", report); + if (relationship.hasReverse()) { + validateReverse(relationship.reverse(), path + ".reverse", report); + } + } + } + + private void validateReverse( + final RelationshipVectorSpec reverse, + final String path, + final SchemaDefinitionValidationReport report) { + if (isBlank(reverse.name())) { + report.addError(path + ".name", "Reverse relationship name is required"); + } + validateCardinality(reverse.cardinality(), path + ".cardinality", report); + validateOptionality(reverse.optionality(), path + ".optionality", report); + } + + private void validateCardinality( + final CardinalitySpec cardinality, + final String path, + final SchemaDefinitionValidationReport report) { + if (cardinality == null) { + return; + } + try { + CardinalitySpec.fromText(cardinality.canonicalName()); + } catch (IllegalArgumentException e) { + report.addError(path, e.getMessage()); + } + } + + private void validateOptionality( + final String optionality, + final String path, + final SchemaDefinitionValidationReport report) { + if (optionality == null || optionality.trim().isEmpty()) { + return; + } + final String normalized = optionality.trim().toLowerCase(Locale.ROOT); + if (!"optional".equals(normalized) && !"mandatory".equals(normalized)) { + report.addError(path, "Optionality must be optional or mandatory"); + } + } + + private Field createField(final FieldDefinitionSpec fieldSpec) { + final Field field = Field.is(fieldSpec.name(), fieldTypeFor(fieldSpec.type())); + if (fieldSpec.required()) { + field.makeMandatory(); + } + if (fieldSpec.unique()) { + field.setMustBeUnique(true); + } + if (fieldSpec.defaultValue() != null) { + field.withDefaultValue(fieldSpec.defaultValue()); + } + if (fieldSpec.description() != null) { + field.withDescription(fieldSpec.description()); + } + for (String example : fieldSpec.examples()) { + field.withExample(example); + } + if (fieldSpec.truncateTo() != null) { + field.truncateStringTo(fieldSpec.truncateTo()); + } + applyRange(field, fieldSpec); + applyValidationRules(field, fieldSpec); + for (FieldDefinitionSpec child : fieldSpec.objectFields()) { + field.withField(createField(child)); + } + return field; + } + + private void applyRange(final Field field, final FieldDefinitionSpec fieldSpec) { + if (!fieldSpec.hasRange()) { + return; + } + if (field.getType() == FieldType.INTEGER) { + field.withMinMaxValues( + Integer.parseInt(fieldSpec.minValue()), Integer.parseInt(fieldSpec.maxValue())); + } + if (field.getType() == FieldType.FLOAT) { + field.withMinMaxValues( + Float.parseFloat(fieldSpec.minValue()), Float.parseFloat(fieldSpec.maxValue())); + } + } + + private void applyValidationRules(final Field field, final FieldDefinitionSpec fieldSpec) { + for (ValidationRuleSpec rule : fieldSpec.validationRules()) { + if (ValidationRuleSpec.NOT_EMPTY.equals(rule.name())) { + field.withValidation(VRule.notEmpty()); + } + if (ValidationRuleSpec.MAXIMUM_LENGTH.equals(rule.name())) { + field.withValidation(VRule.maximumLength(Integer.parseInt(rule.value()))); + } + if (ValidationRuleSpec.MATCHES_REGEX.equals(rule.name())) { + field.withValidation(VRule.matchesRegex(rule.value())); + } + if (ValidationRuleSpec.SATISFIES_REGEX.equals(rule.name())) { + field.withValidation(VRule.satisfiesRegex(rule.value())); + } + } + } + + private Cardinality cardinalityFor(final CardinalitySpec spec) { + final CardinalitySpec cardinality = spec == null ? CardinalitySpec.oneToMany() : spec; + return new Cardinality(cardinality.left(), cardinality.right()); + } + + private void applyOptionality( + final RelationshipVectorDefinition relationship, final String optionality) { + relationship.setOptionality(optionalityFor(optionality)); + } + + private Optionality optionalityFor(final String optionality) { + if (optionality == null || !"mandatory".equalsIgnoreCase(optionality.trim())) { + return Optionality.OPTIONAL_RELATIONSHIP; + } + return Optionality.MANDATORY_RELATIONSHIP; + } + + private FieldType fieldTypeFor(final String type) { + if (type == null) { + return null; + } + final String normalized = type.trim().toLowerCase(Locale.ROOT).replace("-", "_"); + try { + return FieldType.valueOf(normalized.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private boolean isBlank(final String text) { + return text == null || text.trim().isEmpty(); + } + + private String emptyIfNull(final String text) { + return text == null ? "" : text; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelDefinition.java new file mode 100644 index 00000000..fc30dd36 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelDefinition.java @@ -0,0 +1,109 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ThingifierModelDefinition { + + private final int formatVersion; + private final String title; + private final String description; + private final List entities; + private final List relationships; + + private ThingifierModelDefinition(final Builder builder) { + formatVersion = builder.formatVersion; + title = builder.title; + description = builder.description; + entities = Collections.unmodifiableList(new ArrayList<>(builder.entities)); + relationships = Collections.unmodifiableList(new ArrayList<>(builder.relationships)); + } + + public static Builder builder() { + return new Builder(); + } + + public int formatVersion() { + return formatVersion; + } + + public String title() { + return title; + } + + public String description() { + return description; + } + + public List entities() { + return entities; + } + + public List relationships() { + return relationships; + } + + public EntityDefinitionSpec entityNamed(final String entityName) { + for (EntityDefinitionSpec entity : entities) { + if (entity.name().equals(entityName)) { + return entity; + } + } + return null; + } + + public static final class Builder { + + private int formatVersion; + private String title; + private String description; + private final List entities; + private final List relationships; + + private Builder() { + formatVersion = 1; + entities = new ArrayList<>(); + relationships = new ArrayList<>(); + } + + public Builder formatVersion(final int formatVersion) { + this.formatVersion = formatVersion; + return this; + } + + public Builder title(final String title) { + this.title = title; + return this; + } + + public Builder description(final String description) { + this.description = description; + return this; + } + + public Builder entity(final EntityDefinitionSpec entity) { + entities.add(entity); + return this; + } + + public Builder entities(final List entities) { + this.entities.addAll(entities); + return this; + } + + public Builder relationship(final RelationshipDefinitionSpec relationship) { + relationships.add(relationship); + return this; + } + + public Builder relationships(final List relationships) { + this.relationships.addAll(relationships); + return this; + } + + public ThingifierModelDefinition build() { + return new ThingifierModelDefinition(this); + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java new file mode 100644 index 00000000..f2ef1a4e --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java @@ -0,0 +1,204 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.DefinedFields; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.Optionality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.EnumValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.FindsRegexValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.FloatValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.IntegerValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.MatchesRegexValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.MaximumLengthValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.NotEmptyValidationRule; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.ValidationRule; + +public final class ThingifierModelExporter { + + public ThingifierModelDefinition export(final Thingifier thingifier) { + final ThingifierModelDefinition.Builder builder = + ThingifierModelDefinition.builder() + .formatVersion(1) + .title(thingifier.getTitle()) + .description(thingifier.getInitialParagraph()); + + for (EntityDefinition entity : sortedEntities(thingifier)) { + builder.entity(entitySpecFor(entity)); + } + for (RelationshipDefinition relationship : sortedRelationships(thingifier)) { + builder.relationship(relationshipSpecFor(relationship)); + } + return builder.build(); + } + + private List sortedEntities(final Thingifier thingifier) { + final List entities = + new ArrayList<>(thingifier.getERmodel().getSchema().getEntityDefinitions()); + entities.sort(Comparator.comparing(EntityDefinition::getName)); + return entities; + } + + private EntityDefinitionSpec entitySpecFor(final EntityDefinition entity) { + final EntityDefinitionSpec.Builder builder = + EntityDefinitionSpec.named(entity.getName()) + .plural(entity.getPlural()) + .maxInstances(entity.getMaxInstanceLimit()); + if (entity.hasPrimaryKeyField()) { + builder.primaryKey(entity.getPrimaryKeyField().getName()); + } + for (String fieldName : entity.getFieldNames()) { + builder.field(fieldSpecFor(entity.getField(fieldName))); + } + return builder.build(); + } + + private FieldDefinitionSpec fieldSpecFor(final Field field) { + final FieldDefinitionSpec.Builder builder = + FieldDefinitionSpec.named(field.getName(), typeNameFor(field.getType())) + .unique(field.mustBeUnique()); + if (field.isMandatory() && !isAutoField(field)) { + builder.required(true); + } + if (field.getConfiguredDefaultValue() != null) { + builder.defaultValue(field.getConfiguredDefaultValue()); + } + if (field.hasDescription()) { + builder.description(field.getDescription()); + } + builder.examples(configuredExamplesFor(field)); + if (field.shouldTruncate()) { + builder.truncateTo(field.getTruncatedStringLength()); + } + applyRange(builder, field); + for (ValidationRule rule : field.validationRules()) { + addRule(builder, rule); + } + if (field.getObjectDefinition() != null) { + addObjectFields(builder, field.getObjectDefinition()); + } + return builder.build(); + } + + private List configuredExamplesFor(final Field field) { + final List examples = new ArrayList<>(field.getConfiguredExamples()); + if (field.getType() == FieldType.ENUM && examples.isEmpty()) { + final ValidationRule rule = field.getTypeValidationRule(); + if (rule instanceof EnumValidationRule) { + examples.addAll(((EnumValidationRule) rule).getValidValues()); + } + } + examples.sort(String::compareTo); + return examples; + } + + private void addObjectFields( + final FieldDefinitionSpec.Builder builder, final DefinedFields objectDefinition) { + for (String childName : objectDefinition.getFieldNames()) { + builder.objectField(fieldSpecFor(objectDefinition.getField(childName))); + } + } + + private void applyRange(final FieldDefinitionSpec.Builder builder, final Field field) { + final ValidationRule typeRule = field.getTypeValidationRule(); + if (typeRule instanceof IntegerValidationRule) { + final IntegerValidationRule integerRule = (IntegerValidationRule) typeRule; + if (integerRule.getMinimumIntegerValue() != null) { + builder.range( + String.valueOf(integerRule.getMinimumIntegerValue()), + String.valueOf(integerRule.getMaximumIntegerValue())); + } + } + if (typeRule instanceof FloatValidationRule) { + final FloatValidationRule floatRule = (FloatValidationRule) typeRule; + if (floatRule.getMinimumFloatValue() != null) { + builder.range( + String.valueOf(floatRule.getMinimumFloatValue()), + String.valueOf(floatRule.getMaximumFloatValue())); + } + } + } + + private void addRule(final FieldDefinitionSpec.Builder builder, final ValidationRule rule) { + if (rule instanceof NotEmptyValidationRule) { + builder.validationRule(ValidationRuleSpec.notEmpty()); + } + if (rule instanceof MaximumLengthValidationRule) { + builder.validationRule( + ValidationRuleSpec.maximumLength( + ((MaximumLengthValidationRule) rule).getMaximumLength())); + } + if (rule instanceof MatchesRegexValidationRule) { + builder.validationRule( + ValidationRuleSpec.matchesRegex( + ((MatchesRegexValidationRule) rule).getRegexToMatch())); + } + if (rule instanceof FindsRegexValidationRule) { + builder.validationRule( + ValidationRuleSpec.satisfiesRegex( + ((FindsRegexValidationRule) rule).getRegexToFind())); + } + } + + private List sortedRelationships(final Thingifier thingifier) { + final List relationships = + new ArrayList<>(thingifier.getRelationshipDefinitions()); + relationships.sort( + Comparator.comparing( + relationship -> + relationship.getFromRelationship().getFrom().getName() + + "/" + + relationship.getFromRelationship().getName() + + "/" + + relationship.getFromRelationship().getTo().getName())); + return relationships; + } + + private RelationshipDefinitionSpec relationshipSpecFor( + final RelationshipDefinition relationship) { + final RelationshipVectorDefinition forward = relationship.getFromRelationship(); + final RelationshipVectorSpec reverse = + relationship.isTwoWay() + ? vectorSpecFor(relationship.getReversedRelationship()) + : null; + return new RelationshipDefinitionSpec( + forward.getFrom().getName(), + forward.getName(), + forward.getTo().getName(), + cardinalitySpecFor(forward.getCardinality()), + optionalityNameFor(forward.getOptionality()), + reverse); + } + + private RelationshipVectorSpec vectorSpecFor(final RelationshipVectorDefinition vector) { + return new RelationshipVectorSpec( + vector.getName(), + cardinalitySpecFor(vector.getCardinality()), + optionalityNameFor(vector.getOptionality())); + } + + private CardinalitySpec cardinalitySpecFor(final Cardinality cardinality) { + return new CardinalitySpec(cardinality.left(), cardinality.right()); + } + + private String optionalityNameFor(final Optionality optionality) { + return optionality == Optionality.MANDATORY_RELATIONSHIP ? "mandatory" : "optional"; + } + + private boolean isAutoField(final Field field) { + return field.getType() == FieldType.AUTO_INCREMENT + || field.getType() == FieldType.AUTO_GUID; + } + + private String typeNameFor(final FieldType type) { + return type.name().toLowerCase(Locale.ROOT).replace("_", "-"); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ValidationRuleSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ValidationRuleSpec.java new file mode 100644 index 00000000..c103fe72 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ValidationRuleSpec.java @@ -0,0 +1,41 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +public final class ValidationRuleSpec { + + public static final String NOT_EMPTY = "notEmpty"; + public static final String MAXIMUM_LENGTH = "maximumLength"; + public static final String MATCHES_REGEX = "matchesRegex"; + public static final String SATISFIES_REGEX = "satisfiesRegex"; + + private final String name; + private final String value; + + public ValidationRuleSpec(final String name, final String value) { + this.name = name; + this.value = value; + } + + public static ValidationRuleSpec notEmpty() { + return new ValidationRuleSpec(NOT_EMPTY, null); + } + + public static ValidationRuleSpec maximumLength(final int length) { + return new ValidationRuleSpec(MAXIMUM_LENGTH, String.valueOf(length)); + } + + public static ValidationRuleSpec matchesRegex(final String regex) { + return new ValidationRuleSpec(MATCHES_REGEX, regex); + } + + public static ValidationRuleSpec satisfiesRegex(final String regex) { + return new ValidationRuleSpec(SATISFIES_REGEX, regex); + } + + public String name() { + return name; + } + + public String value() { + return value; + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java new file mode 100644 index 00000000..74fb5626 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java @@ -0,0 +1,179 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierSchemaCatalog; +import uk.co.compendiumdev.thingifier.application.schema.EntityTypeRef; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; + +class ThingifierModelAssemblerTest { + + @Test + void validSpecsAssembleIntoThingifierSchema() { + Thingifier thingifier = new ThingifierModelAssembler().assemble(taskProjectDefinition()); + + Assertions.assertEquals("Task Project", thingifier.getTitle()); + Assertions.assertEquals("Task project schema", thingifier.getInitialParagraph()); + Assertions.assertNotNull(thingifier.getDefinitionNamed("task")); + Assertions.assertNotNull(thingifier.getDefinitionNamed("project")); + + EntityDefinition task = thingifier.getDefinitionNamed("task"); + Assertions.assertEquals("id", task.getPrimaryKeyField().getName()); + Assertions.assertTrue(task.getField("title").isMandatory()); + Assertions.assertEquals(FieldType.STRING, task.getField("title").getType()); + Assertions.assertTrue(task.getField("priority").mustBeUnique()); + + EntityTypeRef project = + new ThingifierSchemaCatalog(thingifier).entityWithSingularOrPluralName("projects"); + Assertions.assertTrue(project.hasRelationship("tasks")); + } + + @Test + void assemblerOutputWorksWithSchemaViewCatalog() { + Thingifier thingifier = new ThingifierModelAssembler().assemble(taskProjectDefinition()); + ThingifierSchemaCatalog catalog = new ThingifierSchemaCatalog(thingifier); + + EntityTypeRef task = catalog.entityWithSingularOrPluralName("todos"); + + Assertions.assertEquals("task", task.name()); + Assertions.assertEquals("todos", task.pluralName()); + Assertions.assertEquals("id", task.primaryKeyFieldName()); + Assertions.assertEquals("title", task.fieldNamed("title").name()); + } + + @Test + void invalidSpecsReturnPathSpecificValidationErrors() { + ThingifierModelDefinition definition = + ThingifierModelDefinition.builder() + .entity( + EntityDefinitionSpec.named("task") + .plural("tasks") + .primaryKey("missing") + .field(FieldDefinitionSpec.named("title", "string").build()) + .build()) + .entity(EntityDefinitionSpec.named("task").plural("tasks").build()) + .relationship( + new RelationshipDefinitionSpec( + "task", + "widgets", + "widget", + CardinalitySpec.oneToMany(), + "optional", + null)) + .build(); + + SchemaDefinitionValidationReport report = + new ThingifierModelAssembler().validate(definition); + + Assertions.assertFalse(report.isValid()); + Assertions.assertTrue( + messages(report).contains("entities.task: Duplicate entity name task")); + Assertions.assertTrue( + messages(report) + .contains( + "entities.task.primaryKey: Primary key field missing is not defined")); + Assertions.assertTrue( + messages(report) + .contains("relationships[0].to: Unknown relationship target widget")); + } + + @Test + void invalidFieldConfigurationIsReportedBeforeAssembly() { + ThingifierModelDefinition definition = + ThingifierModelDefinition.builder() + .entity( + EntityDefinitionSpec.named("item") + .plural("items") + .field(FieldDefinitionSpec.named("state", "enum").build()) + .field( + FieldDefinitionSpec.named("count", "integer") + .range("10", "2") + .build()) + .field( + FieldDefinitionSpec.named("broken", "not-a-type") + .build()) + .build()) + .build(); + + SchemaDefinitionValidationReport report = + new ThingifierModelAssembler().validate(definition); + + Assertions.assertFalse(report.isValid()); + Assertions.assertTrue( + messages(report) + .contains( + "entities.item.fields.state.examples: Enum fields require examples")); + Assertions.assertTrue( + messages(report) + .contains( + "entities.item.fields.count: min must be less than or equal to max")); + Assertions.assertTrue( + messages(report) + .contains( + "entities.item.fields.broken.type: Unsupported field type not-a-type")); + } + + @Test + void invalidDefinitionsAreNotAssembled() { + ThingifierModelDefinition definition = + ThingifierModelDefinition.builder() + .entity(EntityDefinitionSpec.named("item").plural("items").build()) + .relationship( + new RelationshipDefinitionSpec( + "item", + "bad", + "missing", + CardinalitySpec.oneToMany(), + "optional", + null)) + .build(); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new ThingifierModelAssembler().assemble(definition)); + } + + private ThingifierModelDefinition taskProjectDefinition() { + return ThingifierModelDefinition.builder() + .title("Task Project") + .description("Task project schema") + .entity( + EntityDefinitionSpec.named("task") + .plural("todos") + .primaryKey("id") + .field(FieldDefinitionSpec.named("id", "auto-increment").build()) + .field( + FieldDefinitionSpec.named("title", "string") + .required(true) + .validationRule(ValidationRuleSpec.notEmpty()) + .build()) + .field( + FieldDefinitionSpec.named("priority", "integer") + .unique(true) + .range("1", "5") + .build()) + .build()) + .entity( + EntityDefinitionSpec.named("project") + .plural("projects") + .field(FieldDefinitionSpec.named("title", "string").build()) + .build()) + .relationship( + new RelationshipDefinitionSpec( + "project", + "tasks", + "task", + CardinalitySpec.oneToMany(), + "optional", + new RelationshipVectorSpec( + "taskof", CardinalitySpec.oneToOne(), "mandatory"))) + .build(); + } + + private List messages(final SchemaDefinitionValidationReport report) { + return report.messages(); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java new file mode 100644 index 00000000..cb44f585 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java @@ -0,0 +1,119 @@ +package uk.co.compendiumdev.thingifier.application.schema.definition; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.VRule; + +class ThingifierModelExporterTest { + + @Test + void exporterCapturesJavaBuiltEntitiesAndFieldMetadata() { + ThingifierModelDefinition definition = new ThingifierModelExporter().export(model()); + + EntityDefinitionSpec task = definition.entityNamed("task"); + FieldDefinitionSpec title = task.fieldNamed("title"); + FieldDefinitionSpec status = task.fieldNamed("status"); + FieldDefinitionSpec priority = task.fieldNamed("priority"); + FieldDefinitionSpec metadata = task.fieldNamed("metadata"); + + Assertions.assertEquals("Todo Model", definition.title()); + Assertions.assertEquals("Model description", definition.description()); + Assertions.assertEquals("id", task.primaryKeyFieldName()); + Assertions.assertEquals("auto-increment", task.fieldNamed("id").type()); + Assertions.assertTrue(title.required()); + Assertions.assertEquals("Task title", title.description()); + Assertions.assertEquals(List.of("example title"), title.examples()); + Assertions.assertEquals(Integer.valueOf(30), title.truncateTo()); + Assertions.assertEquals("false", status.defaultValue()); + Assertions.assertEquals("1", priority.minValue()); + Assertions.assertEquals("5", priority.maxValue()); + Assertions.assertEquals("object", metadata.type()); + Assertions.assertEquals("source", metadata.objectFields().get(0).name()); + } + + @Test + void exporterCapturesValidationRules() { + FieldDefinitionSpec title = + new ThingifierModelExporter() + .export(model()) + .entityNamed("task") + .fieldNamed("title"); + + Assertions.assertEquals(3, title.validationRules().size()); + Assertions.assertEquals( + ValidationRuleSpec.NOT_EMPTY, title.validationRules().get(0).name()); + Assertions.assertEquals( + ValidationRuleSpec.MAXIMUM_LENGTH, title.validationRules().get(1).name()); + Assertions.assertEquals("50", title.validationRules().get(1).value()); + Assertions.assertEquals( + ValidationRuleSpec.MATCHES_REGEX, title.validationRules().get(2).name()); + Assertions.assertEquals(".+", title.validationRules().get(2).value()); + } + + @Test + void exporterCapturesRelationshipsAndReverseVectors() { + ThingifierModelDefinition definition = new ThingifierModelExporter().export(model()); + + RelationshipDefinitionSpec relationship = definition.relationships().get(0); + + Assertions.assertEquals("project", relationship.fromEntityName()); + Assertions.assertEquals("tasks", relationship.name()); + Assertions.assertEquals("task", relationship.toEntityName()); + Assertions.assertEquals("one-to-many", relationship.cardinality().canonicalName()); + Assertions.assertTrue(relationship.hasReverse()); + Assertions.assertEquals("taskof", relationship.reverse().name()); + Assertions.assertEquals("one-to-one", relationship.reverse().cardinality().canonicalName()); + } + + @Test + void exportedDefinitionCanBeAssembledAgain() { + ThingifierModelDefinition definition = new ThingifierModelExporter().export(model()); + + Thingifier assembled = new ThingifierModelAssembler().assemble(definition); + + Assertions.assertNotNull(assembled.getDefinitionNamed("task")); + Assertions.assertEquals( + "id", assembled.getDefinitionNamed("task").getPrimaryKeyField().getName()); + Assertions.assertTrue(assembled.hasRelationshipNamed("tasks")); + Assertions.assertTrue(assembled.hasRelationshipNamed("taskof")); + } + + private Thingifier model() { + Thingifier thingifier = new Thingifier(); + thingifier.setDocumentation("Todo Model", "Model description"); + + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + task.addFields( + Field.is("title", FieldType.STRING) + .makeMandatory() + .withDescription("Task title") + .withExample("example title") + .truncateStringTo(30) + .withValidation( + VRule.notEmpty(), + VRule.maximumLength(50), + VRule.matchesRegex(".+")), + Field.is("status", FieldType.BOOLEAN).withDefaultValue("false"), + Field.is("priority", FieldType.INTEGER) + .setMustBeUnique(true) + .withMinMaxValues(1, 5), + Field.is("metadata", FieldType.OBJECT) + .withField(Field.is("source", FieldType.STRING))); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_ONE(), "taskof"); + + return thingifier; + } +}