diff --git a/challenger/src/main/java/uk/co/compendiumdev/challenge/practicemodes/simpleapi/SimpleApiRoutes.java b/challenger/src/main/java/uk/co/compendiumdev/challenge/practicemodes/simpleapi/SimpleApiRoutes.java index 652afb66..6fd88150 100644 --- a/challenger/src/main/java/uk/co/compendiumdev/challenge/practicemodes/simpleapi/SimpleApiRoutes.java +++ b/challenger/src/main/java/uk/co/compendiumdev/challenge/practicemodes/simpleapi/SimpleApiRoutes.java @@ -56,6 +56,7 @@ public SimpleApiRoutes(DefaultGUIHTML guiTemplates){ withValidation(new MatchesRegexValidationRule("[0-9]{3}[-]?[0-9]{1}[-]?[0-9]{2}[-]?[0-9]{6}[-]?[0-9]{1}")). withValidation(new MaximumLengthValidationRule(17)). setMustBeUnique(true). + setUniqueAfterTransform((s) -> s.replace("-","")). withExample("123-4-56-789012-3"), Field.is("price",FieldType.FLOAT). makeMandatory(). diff --git a/challenger/src/test/java/uk/co/compendiumdev/practicemodes/SimpleApiModeTest.java b/challenger/src/test/java/uk/co/compendiumdev/practicemodes/SimpleApiModeTest.java index 6d172224..597b207d 100644 --- a/challenger/src/test/java/uk/co/compendiumdev/practicemodes/SimpleApiModeTest.java +++ b/challenger/src/test/java/uk/co/compendiumdev/practicemodes/SimpleApiModeTest.java @@ -7,14 +7,12 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import uk.co.compendiumdev.challenger.http.httpclient.HttpMessageSender; import uk.co.compendiumdev.challenger.http.httpclient.HttpResponseDetails; import uk.co.compendiumdev.sparkstart.Environment; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.stream.Stream; /* @@ -117,6 +115,132 @@ public void canPostItemAsJsonAndAcceptJson() { Assertions.assertEquals("book", item.type); } + + @ParameterizedTest + @ValueSource(strings = { + "", + "-" + }) + public void canNotPostCreateItemsWithDuplicateISBN(String dupeSynonymReplace) { + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + + Random random = new Random(); + String aRandomIsbn = randomIsbn(random); + + // full valid payload + final HttpResponseDetails response = + http.send("/simpleapi/items", "POST", headers, + """ + { + "price":2.00, + "numberinstock":2, + "isbn13": "%s", + "type":book + } + """.formatted(aRandomIsbn).stripIndent()); + + final HttpResponseDetails duplicateIsbnResponse = + http.send("/simpleapi/items", "POST", headers, + """ + { + "price":2.00, + "numberinstock":2, + "isbn13": "%s", + "type":book + } + """.formatted(aRandomIsbn.replace("-",dupeSynonymReplace)).stripIndent()); + + Assertions.assertEquals(201, response.statusCode); + Assertions.assertEquals("application/json", response.getHeader("content-type")); + + Assertions.assertEquals(400, duplicateIsbnResponse.statusCode); + Assertions.assertTrue(duplicateIsbnResponse.body.contains("Field isbn13 Value is not unique"), "did not expect " + duplicateIsbnResponse.body); + } + + @ParameterizedTest + @ValueSource(strings = {"POST", "PUT"}) + public void canNotAmendItemsToHaveDuplicateISBN(String verbPostPut) { + + Random random = new Random(); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + + Item anItem = new Item(); + anItem.type="book"; + anItem.price=2.00F; + anItem.isbn13 = randomIsbn(random); + + Item anItemToAmend = new Item(); + anItemToAmend.type="book"; + anItemToAmend.price=3.00F; + anItemToAmend.isbn13 = randomIsbn(random); + + apiCreateItem(anItem); + Item itemToAmend = apiCreateItem(anItemToAmend); + + final HttpResponseDetails amendResponse = + http.send("/simpleapi/items/"+itemToAmend.id, verbPostPut, headers, + """ + { + "price":2.00, + "numberinstock":2, + "isbn13": "%s", + "type":book + } + """.formatted(anItem.isbn13).stripIndent()); + + + Assertions.assertEquals(400, amendResponse.statusCode, "should not be able to amend item to " + amendResponse.body); + Assertions.assertTrue(amendResponse.body.contains("Field isbn13 Value is not unique"), "did not expect " + amendResponse.body); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "-" + }) + public void canNotAmendItemsToHaveDuplicateISBNBasedOnUniqueComparison(String dupeSynonymReplace) { + + Random random = new Random(); + + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + + Item anItem = new Item(); + anItem.type="book"; + anItem.price=2.00F; + anItem.isbn13 = randomIsbn(random); + + Item anItemToAmend = new Item(); + anItemToAmend.type="book"; + anItemToAmend.price=3.00F; + anItemToAmend.isbn13 = randomIsbn(random); + + apiCreateItem(anItem); + Item itemToAmend = apiCreateItem(anItemToAmend); + + final HttpResponseDetails amendResponse = + http.send("/simpleapi/items/"+itemToAmend.id, "POST", headers, + """ + { + "price":2.00, + "numberinstock":2, + "isbn13": "%s", + "type":book + } + """.formatted(anItem.isbn13.replace("-",dupeSynonymReplace)).stripIndent()); + + + Assertions.assertEquals(400, amendResponse.statusCode, "should not be able to amend item to " + amendResponse.body); + Assertions.assertTrue(amendResponse.body.contains("Field isbn13 Value is not unique"), "did not expect " + amendResponse.body); + } + @Test public void cannotPostItemWithId() { @@ -150,7 +274,7 @@ public void cannotPostItemWithId() { public void canGetItemsAsJson() { Items items = apiGetItems(); - Assertions.assertTrue(!items.items.isEmpty()); + Assertions.assertFalse(items.items.isEmpty()); } @Test @@ -323,7 +447,7 @@ private Items apiGetItems(){ return new Gson().fromJson(response.body, Items.class); } - class Item{ + static class Item{ Integer id; Float price; @@ -332,11 +456,22 @@ class Item{ String type; } - class Items{ + static class Items{ List items; } - class ErrorMessagesResponse{ + static class ErrorMessagesResponse{ List errorMessages; } + + private String randomIsbn(Random random){ + + String isbn13 = "xxx-x-xx-xxxxxx-x"; + + while(isbn13.contains("x")){ + isbn13 = isbn13.replaceFirst("x", String.valueOf(random.nextInt(9))); + } + + return isbn13; + } } 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 fc79ca46..b83fd054 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 @@ -9,6 +9,7 @@ import java.math.BigDecimal; import java.util.*; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; // todo: beginning to think that we should have an XField for each field type // e.g. IdField, StringField, etc. - possibly with an interface or abstract @@ -24,12 +25,12 @@ public final class Field { // default value for the field private String defaultValue; - private List validationRules; + private final List validationRules; private boolean truncateStringIfTooLong; private int maximumIntegerValue; private int minimumIntegerValue; - private boolean allowedNullable; + private final boolean allowedNullable; // todo: use BigDecimal for the internal float representations private float maximumFloatValue; private float minimumFloatValue; @@ -38,6 +39,7 @@ public final class Field { private boolean mustBeUnique; private int truncatedStringLength; + private Function transformToMakeUnique; // todo: rather than all these fields, consider moving to more validation rules // to help keep the class to a more manageable size or create a FieldValidator class @@ -61,6 +63,8 @@ private Field(final String name, final FieldType type) { minimumFloatValue = Float.MIN_VALUE; mustBeUnique = false; allowedNullable=false; + + transformToMakeUnique = (s) -> s; } public static Field is(String name, FieldType type) { @@ -191,10 +195,9 @@ public ValidationReport validate(FieldValue value, boolean allowedToSetIds) { } private void validateObjectValue(final FieldValue value, final ValidationReport report) { - FieldValue object = value; - if(object!= null && object.asObject()!=null){ + if(value!= null && value.asObject()!=null){ final ValidationReport objectValidity = - object.asObject(). + value.asObject(). validateFields(new ArrayList<>(), true); report.combine(objectValidity); } @@ -277,7 +280,7 @@ private void reportThisValueDoesNotMatchType(final ValidationReport report, private void validateBooleanValue(final FieldValue value, final ValidationReport report) { try{ - boolean bool = value.asBoolean(); + value.asBoolean(); }catch(IllegalArgumentException e){ report.setValid(false); report.addErrorMessage( @@ -481,4 +484,18 @@ public String getActualValueToAdd(final FieldValue value) { public FieldValue valueFor(String value) { return FieldValue.is(this, value); } + + public Field setUniqueAfterTransform(Function transform) { + setMustBeUnique(true); + transformToMakeUnique = transform; + return this; + } + + public String uniqueAfterTransform(String string) { + try { + return transformToMakeUnique.apply(string); + }catch (Exception e){ + return "ERROR: " + string + " " + e.getMessage(); + } + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/instance/FieldValue.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/instance/FieldValue.java index 2e56149a..ebd60297 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/instance/FieldValue.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/field/instance/FieldValue.java @@ -10,23 +10,22 @@ public final class FieldValue { private final String fieldName; // should this be name or should it be a Field reference? private final String valueOfField; private final Field forField; // the related field + private final String valueForUniqueComparison; private InstanceFields objectValue; // todo: list of strings for an array // todo: list of InstanceFields for an array of objects - @Deprecated - private FieldValue(String fieldName, String fieldValue) { - this.forField = null; - this.fieldName = fieldName; - this.valueOfField = fieldValue; - this.objectValue = null; - } - public FieldValue(Field forField, String fieldValue) { this.forField = forField; this.fieldName = forField.getName(); this.valueOfField = fieldValue; this.objectValue = null; + + if(forField.mustBeUnique()){ + this.valueForUniqueComparison = forField.uniqueAfterTransform(fieldValue); + }else { + this.valueForUniqueComparison = fieldValue; + } } @Override @@ -35,7 +34,7 @@ public String toString() { "fieldName='" + fieldName + "'" + ", fieldValue='" + valueOfField + "'"; if(objectValue!=null){ - string = string + ",{ " + objectValue.toString() + " }"; + string = string + ",{ " + objectValue + " }"; } string = string + "}"; @@ -82,7 +81,7 @@ public InstanceFields asObject() { } public float asFloat() { - return Float.valueOf(valueOfField); + return Float.parseFloat(valueOfField); } public boolean asBoolean() { @@ -97,7 +96,7 @@ public boolean asBoolean() { } public int asInteger() { - return Integer.valueOf(valueOfField); + return Integer.parseInt(valueOfField); } public String asJsonValue() { @@ -120,4 +119,8 @@ public String asJsonValue() { private String quoted(String aString){ return "\"" + aString.replaceAll("\"", "\\\\\"") + "\""; } + + public String asUniqueComparisonString() { + return valueForUniqueComparison; + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollection.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollection.java index 1b1e9de9..4cf6ce78 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollection.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollection.java @@ -14,10 +14,10 @@ final public class EntityInstanceCollection { private final EntityDefinition definition; - private Map instances = new ConcurrentHashMap<>(); + private final Map instances = new ConcurrentHashMap<>(); // id's should be auto incremented at an instance collection level, not on the field definitions - private Map counters = new ConcurrentHashMap<>(); + private final Map counters = new ConcurrentHashMap<>(); public EntityInstanceCollection(EntityDefinition thingDefinition) { this.definition = thingDefinition; @@ -192,9 +192,6 @@ public Collection getInstances() { /** * This deletes the instance but does not delete any mandatorily related items, these need to be handled by * another class using the returned list of alsoDelete, otherwise the model will be invalid - * - * @param guid - * @return */ public List deleteInstance(String guid) { @@ -283,15 +280,15 @@ public ValidationReport checkFieldsForUniqueNess(EntityInstance instance, boolea for(String fieldName : instance.getEntity().getFieldNames()){ Field field = instance.getEntity().getField(fieldName); if(field.mustBeUnique()){ - String valueThatMustBeUnique = instance.getFieldValue(fieldName).asString(); + String valueThatMustBeUnique = instance.getFieldValue(fieldName).asUniqueComparisonString(); // check all instances to see if it is for(EntityInstance instanceToCheck : instances.values()){ FieldValue existingValue = instanceToCheck.getFieldValue(fieldName); - if(valueThatMustBeUnique.equals(existingValue.asString())){ + if(valueThatMustBeUnique.equals(existingValue.asUniqueComparisonString())){ // it is not boolean dupeFound=true; if(isAmendment){ - if(instanceToCheck.getPrimaryKeyValue().equals(instanceToCheck.getPrimaryKeyValue())){ + if(instanceToCheck.getPrimaryKeyValue().equals(instance.getPrimaryKeyValue())){ // same item so ignore this one dupeFound=false; } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/InstanceFields.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/InstanceFields.java index a538b11a..2b4334f5 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/InstanceFields.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/instances/InstanceFields.java @@ -18,8 +18,8 @@ public class InstanceFields { private final DefinedFields objectDefinition; - private Map values = new HashMap(); - private AutoIncrement defaultAuto; + private final Map values = new HashMap<>(); + private final AutoIncrement defaultAuto; public InstanceFields(final DefinedFields objectDefinition) { this.objectDefinition = objectDefinition; @@ -77,12 +77,12 @@ public FieldValue getFieldValue(String fieldName) { if (assignedValue == null) { // does definition have a default value? if (objectDefinition.getField(fieldName).hasDefaultValue()) { - return objectDefinition.getField(fieldName).getDefaultValue(); + assignedValue = objectDefinition.getField(fieldName).getDefaultValue(); } else { // return the field type default value String defaultVal = objectDefinition.getField(fieldName).getType().getDefault(); if (defaultVal != null) { - return FieldValue.is(field, defaultVal); + assignedValue = FieldValue.is(field, defaultVal); } } } @@ -102,10 +102,10 @@ public String toString() { } - public void deleteAllFieldValuesExcept(List fieldNamesToIgnore) { + public void deleteAllFieldValuesExcept(List fieldNamesToIgnore) { Set ignorekeys = new HashSet<>(fieldNamesToIgnore); - Set keys = new HashSet(values.keySet()); + Set keys = new HashSet<>(values.keySet()); for (String key : keys) { if (!ignorekeys.contains(key)) { @@ -163,8 +163,7 @@ private void setFieldNameAsPath(final String fieldName, final String value, bool // processing a complex set of fields final String[] fields = fieldName.split("\\."); - final List fieldNames = new ArrayList(); - fieldNames.addAll(Arrays.asList(fields)); + final List fieldNames = new ArrayList<>(Arrays.asList(fields)); // start recursive call to work through list setFieldValue(fieldNames, value, shouldValidateValue); @@ -189,7 +188,6 @@ private void setFieldValue(final List fieldNames, final String value, bo }else{ addValue(FieldValue.is(field, value)); } - return; }else{ if(field.getType()!= FieldType.OBJECT){ @@ -257,7 +255,7 @@ public ValidationReport validateFields(final List excluding, * look at all the GUIDs and IDs referenced * if they have different values to current then * report the differences as errormessages - * @param args + * * @return a List of error messages about the GUIDs and IDs mentioned */ public List findAnyGuidOrIdDifferences(final List args) { @@ -283,7 +281,7 @@ public List findAnyGuidOrIdDifferences(final List args) { } } - if (existingValue != null && existingValue.trim().length() > 0) { + if (existingValue != null && !existingValue.trim().isEmpty()) { // if value is different then it is an attempt to amend it if (!existingValue.equalsIgnoreCase(entryValue)) { errorMessages.add( diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollectionTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollectionTest.java index 5e9892d7..c180c805 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollectionTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/EntityInstanceCollectionTest.java @@ -29,9 +29,9 @@ public void cannotCreateInstanceWithoutPrimaryKeySet() { EntityInstance instance1 = new EntityInstance(entityDefn); - Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { - collection.addInstance(instance1); - }); + Exception exception = Assertions.assertThrows(RuntimeException.class, + () -> collection.addInstance(instance1) + ); Assertions.assertTrue(exception.getMessage().contains("Cannot add instance, primary key field pk not set")); } @@ -49,9 +49,9 @@ public void cannotCreateInstanceWithDuplicatePrimaryKey() { EntityInstance instance2 = new EntityInstance(entityDefn); instance2.setValue("pk", instance1.getPrimaryKeyValue()); - Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { - collection.addInstance(instance2); - }); + Exception exception = Assertions.assertThrows(RuntimeException.class, + () -> collection.addInstance(instance2) + ); Assertions.assertTrue(exception.getMessage().contains("another instance with primary key value exists")); } @@ -113,5 +113,6 @@ public void canAutoGuidAndIdOnAdd(){ Assertions.assertEquals(instance2.getFieldValue("guid").asString(), UUID.fromString(instance2.getFieldValue("guid").asString()).toString()); Assertions.assertNotEquals(instance1.getFieldValue("id").asString(), instance2.getFieldValue("id").asString()); Assertions.assertEquals(1, instance1.getFieldValue("id").asInteger()); - Assertions.assertEquals(2, instance2.getFieldValue("id").asInteger()); } + Assertions.assertEquals(2, instance2.getFieldValue("id").asInteger()); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/fields/UniqueFieldTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/fields/UniqueFieldTest.java new file mode 100644 index 00000000..107e4d5c --- /dev/null +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/instances/fields/UniqueFieldTest.java @@ -0,0 +1,88 @@ +package uk.co.compendiumdev.thingifier.core.domain.instances.fields; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +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.instances.EntityInstance; + +public class UniqueFieldTest { + + @Test + public void byDefaultAFieldIsNotUnique(){ + + EntityDefinition stringFieldEntity = new EntityDefinition("Entity", "Entities"); + stringFieldEntity.addFields(Field.is("field", FieldType.STRING)); + + EntityInstance instance = new EntityInstance(stringFieldEntity); + + Assertions.assertFalse(instance.getEntity().getField("field").mustBeUnique()); + } + + @Test + public void aFieldCanBeSetToBeUnique(){ + + EntityDefinition stringFieldEntity = new EntityDefinition("Entity", "Entities"); + stringFieldEntity.addFields(Field.is("field", FieldType.STRING).setMustBeUnique(true)); + + EntityInstance instance = new EntityInstance(stringFieldEntity); + + Assertions.assertTrue(instance.getEntity().getField("field").mustBeUnique()); + } + + @Test + public void aUniqueFieldCanBeUniqueAfterATransform(){ + + EntityDefinition stringFieldEntity = new EntityDefinition("Entity", "Entities"); + stringFieldEntity.addFields( + Field.is("field", FieldType.STRING). + setMustBeUnique(true). + setUniqueAfterTransform( + (s) -> s.replace("-", "") + )); + + EntityInstance instance = new EntityInstance(stringFieldEntity); + instance.setValue("field", "1-2-3"); + + Assertions.assertTrue(instance.getEntity().getField("field").mustBeUnique()); + Assertions.assertEquals("1-2-3",instance.getFieldValue("field").asString()); + Assertions.assertEquals("123",instance.getFieldValue("field").asUniqueComparisonString()); + } + + @Test + public void aUniqueFieldDoesNotNeedAUniqueTransformFunction(){ + + EntityDefinition stringFieldEntity = new EntityDefinition("Entity", "Entities"); + stringFieldEntity.addFields( + Field.is("field", FieldType.STRING). + setMustBeUnique(true)); + + EntityInstance instance = new EntityInstance(stringFieldEntity); + instance.setValue("field", "1-2-3"); + + Assertions.assertTrue(instance.getEntity().getField("field").mustBeUnique()); + Assertions.assertEquals("1-2-3",instance.getFieldValue("field").asString()); + Assertions.assertEquals("1-2-3",instance.getFieldValue("field").asUniqueComparisonString()); + } + + @Test + public void reportErrorsInTransformationFunctionResult(){ + + EntityDefinition stringFieldEntity = new EntityDefinition("Entity", "Entities"); + stringFieldEntity.addFields( + Field.is("field", FieldType.STRING). + setMustBeUnique(true). + setUniqueAfterTransform( + (s) -> {throw new RuntimeException("bob");} + )); + + EntityInstance instance = new EntityInstance(stringFieldEntity); + instance.setValue("field", "1-2-3"); + + Assertions.assertTrue(instance.getEntity().getField("field").mustBeUnique()); + Assertions.assertEquals("1-2-3",instance.getFieldValue("field").asString()); + Assertions.assertEquals("ERROR: 1-2-3 bob",instance.getFieldValue("field").asUniqueComparisonString()); + } + +}