diff --git a/core/pom.xml b/core/pom.xml index 76f027e1..64a141e4 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -126,6 +126,7 @@ **/tests/draft2020-12/required.json **/tests/draft2020-12/type.json + **/tests/draft2020-12/uniqueItems.json diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordType.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordType.java new file mode 100644 index 00000000..c2747423 --- /dev/null +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordType.java @@ -0,0 +1,146 @@ +/* + * The MIT License + * + * Copyright 2023 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; + +import io.github.sebastiantoepfer.jsonschema.InstanceType; +import io.github.sebastiantoepfer.jsonschema.JsonSchema; +import io.github.sebastiantoepfer.jsonschema.keyword.Assertion; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; +import jakarta.json.JsonArray; +import jakarta.json.JsonNumber; +import jakarta.json.JsonValue; +import jakarta.json.JsonValue.ValueType; +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +final class UniqueItemsKeywordType implements KeywordType { + + @Override + public String name() { + return "uniqueItems"; + } + + @Override + public Keyword createKeyword(final JsonSchema schema, final JsonValue value) { + if (InstanceType.BOOLEAN.isInstance(value)) { + return new UniqueItemsKeyword(value.getValueType() == JsonValue.ValueType.TRUE); + } else { + throw new IllegalArgumentException("Value must be a boolean!"); + } + } + + private class UniqueItemsKeyword implements Assertion { + + private final boolean unique; + + private UniqueItemsKeyword(final boolean unique) { + this.unique = unique; + } + + @Override + public boolean isValidFor(final JsonValue instance) { + return ( + !InstanceType.ARRAY.isInstance(instance) || + !unique || + new JsonArrayChecks(instance.asJsonArray()).areAllElementsUnique() + ); + } + + @Override + public boolean hasName(final String name) { + return Objects.equals(name(), name); + } + + private static class JsonArrayChecks { + + private final JsonArray values; + + public JsonArrayChecks(final JsonArray values) { + this.values = Objects.requireNonNull(values); + } + + public boolean areAllElementsUnique() { + final Set uniqueValues = new HashSet<>(); + return values.stream().map(JsonValueNumberEqualsFix::new).allMatch(uniqueValues::add); + } + } + } + + /* + bad class name :( -> + we must override equals for numbers. 1.0 and 1.00 must be equals. + but neither new BigDecimal("1.0").equals(new BigDecimal("1.00") + nor Json.create(new BigDecimal("1.0")).equals(Json.create(new BigDecimal("1.00")) + are. + */ + static class JsonValueNumberEqualsFix { + + private final JsonValue value; + + public JsonValueNumberEqualsFix(final JsonValue value) { + this.value = value; + } + + @Override + public int hashCode() { + final int result; + if (isNumber()) { + result = normalizeValue().hashCode(); + } else { + result = value.hashCode(); + } + return result; + } + + @Override + public boolean equals(final Object obj) { + return ( + this == obj || (obj != null && getClass() == obj.getClass() && equals((JsonValueNumberEqualsFix) obj)) + ); + } + + private boolean equals(final JsonValueNumberEqualsFix other) { + final boolean result; + if (isNumber() && other.isNumber()) { + result = normalizeValue().equals(other.normalizeValue()); + } else if (isNumber()) { + result = false; + } else { + result = value.equals(other.value); + } + return result; + } + + private BigDecimal normalizeValue() { + return ((JsonNumber) value).bigDecimalValue().stripTrailingZeros(); + } + + private boolean isNumber() { + return value.getValueType() == ValueType.NUMBER; + } + } +} diff --git a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java index 26e27f56..55a70772 100644 --- a/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java +++ b/core/src/main/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/ValidationVocabulary.java @@ -49,7 +49,8 @@ public ValidationVocabulary() { new ExclusiveMaximumKeywordType(), new MultipleOfKeywordType(), new MinItemsKeywordType(), - new MaxItemsKeywordType() + new MaxItemsKeywordType(), + new UniqueItemsKeywordType() ); } diff --git a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxLengthKeywordTypeTest.java b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxLengthKeywordTypeTest.java index db537124..34ca837a 100644 --- a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxLengthKeywordTypeTest.java +++ b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/MaxLengthKeywordTypeTest.java @@ -29,8 +29,6 @@ import io.github.sebastiantoepfer.jsonschema.JsonSchema; import io.github.sebastiantoepfer.jsonschema.core.DefaultJsonSchemaFactory; -import io.github.sebastiantoepfer.jsonschema.core.vocab.validation.MaxLengthKeywordType; -import io.github.sebastiantoepfer.jsonschema.core.vocab.validation.MinLengthKeywordType; import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; import jakarta.json.Json; import jakarta.json.JsonNumber; diff --git a/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordTypeTest.java b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordTypeTest.java new file mode 100644 index 00000000..405f2945 --- /dev/null +++ b/core/src/test/java/io/github/sebastiantoepfer/jsonschema/core/vocab/validation/UniqueItemsKeywordTypeTest.java @@ -0,0 +1,134 @@ +/* + * The MIT License + * + * Copyright 2023 sebastian. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package io.github.sebastiantoepfer.jsonschema.core.vocab.validation; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.sebastiantoepfer.jsonschema.JsonSchema; +import io.github.sebastiantoepfer.jsonschema.core.DefaultJsonSchemaFactory; +import io.github.sebastiantoepfer.jsonschema.keyword.Keyword; +import io.github.sebastiantoepfer.jsonschema.keyword.KeywordType; +import jakarta.json.Json; +import jakarta.json.JsonValue; +import java.io.StringReader; +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class UniqueItemsKeywordTypeTest { + + @Test + void should_know_his_name() { + final Keyword keyword = new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.FALSE); + + assertThat(keyword.hasName("uniqueItems"), is(true)); + assertThat(keyword.hasName("test"), is(false)); + } + + @Test + void should_not_be_createbale_from_non_boolean() { + final JsonSchema schema = new DefaultJsonSchemaFactory().create(JsonValue.TRUE); + final KeywordType keywordType = new UniqueItemsKeywordType(); + assertThrows( + IllegalArgumentException.class, + () -> keywordType.createKeyword(schema, JsonValue.EMPTY_JSON_OBJECT) + ); + } + + @Test + void should_be_valid_for_uniqueItems() { + assertThat( + new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.TRUE) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("1").add("2").build()), + is(true) + ); + } + + @Test + void should_be_valid_for_non_uniqueItems_if_false() { + assertThat( + new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.FALSE) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("1").add("1").build()), + is(true) + ); + } + + @Test + void should_be_invalid_for_non_uniqueItems() { + assertThat( + new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.TRUE) + .asAssertion() + .isValidFor(Json.createArrayBuilder().add("1").add("1").build()), + is(false) + ); + } + + @Test + void should_be_valid_for_non_arrays() { + assertThat( + new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.FALSE) + .asAssertion() + .isValidFor(JsonValue.EMPTY_JSON_OBJECT), + is(true) + ); + } + + @Test + void should_be_invalid_if_numbers_mathematically_unequal() { + assertThat( + new UniqueItemsKeywordType() + .createKeyword(new DefaultJsonSchemaFactory().create(JsonValue.TRUE), JsonValue.TRUE) + .asAssertion() + .isValidFor(Json.createReader(new StringReader("[1.0,1.00,1]")).readArray()), + is(false) + ); + } + + @Test + void pitests_say_i_must_write_this_tests() { + //hashset uses hashCode to determine if equals needs to be used, so we don't really need equals. + //but to have a valid java class we should override both + final UniqueItemsKeywordType.JsonValueNumberEqualsFix obj = new UniqueItemsKeywordType.JsonValueNumberEqualsFix( + JsonValue.EMPTY_JSON_OBJECT + ); + final UniqueItemsKeywordType.JsonValueNumberEqualsFix number1 = + new UniqueItemsKeywordType.JsonValueNumberEqualsFix(Json.createValue(new BigDecimal("1.0"))); + final UniqueItemsKeywordType.JsonValueNumberEqualsFix number2 = + new UniqueItemsKeywordType.JsonValueNumberEqualsFix(Json.createValue(new BigDecimal("1.00"))); + + assertThat(obj.equals(obj), is(true)); + assertThat(number1.equals(number1), is(true)); + assertThat(number1.equals(number2), is(true)); + + assertThat(obj.equals(number1), is(false)); + } +}