diff --git a/examples/src/main/java/com/basistech/rosette/examples/RecordSimilarityExample.java b/examples/src/main/java/com/basistech/rosette/examples/RecordSimilarityExample.java index a3191ce3c..e58a9667d 100644 --- a/examples/src/main/java/com/basistech/rosette/examples/RecordSimilarityExample.java +++ b/examples/src/main/java/com/basistech/rosette/examples/RecordSimilarityExample.java @@ -74,39 +74,51 @@ private void run() throws IOException { .left( List.of( Map.of( - primaryNameField, NameField.FieldedName.builder() - .text("Ethan R").entityType("PERSON") - .language(LanguageCode.ENGLISH) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - dobField, DateField.UnfieldedDate.builder().date(dobHyphen).build(), - dob2Field, DateField.FieldedDate.builder().date("04161993").format("MMddyyyy").build(), - addrField, AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build(), - strField, StringField.builder().data("software engineer").build() + primaryNameField, NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R").entityType("PERSON") + .language(LanguageCode.ENGLISH) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + dobField, DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date(dobHyphen).build())).build(), + dob2Field, DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("04161993").format("MMddyyyy").build())).build(), + addrField, AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build())).build(), + strField, StringField.builder().data(List.of("software engineer")).build() ), Map.of( - primaryNameField, NameField.FieldedName.builder().text("Evan R").build(), - dobField, DateField.FieldedDate.builder().date(dobHyphen).build(), - numberField, NumberField.builder().data(47).build(), - boolField, BooleanField.builder().data(false).build() + primaryNameField, NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Evan R").build())).build(), + dobField, DateField.builder().data(List.of( + DateField.FieldedDate.builder().date(dobHyphen).build())).build(), + numberField, NumberField.builder().data(List.of(47)).build(), + boolField, BooleanField.builder().data(List.of(false)).build() ) ) ).right( List.of( Map.of( - primaryNameField, NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build(), - dobField, DateField.FieldedDate.builder().date(dobHyphen).build(), - strField, StringField.builder().data("manager").build(), - boolField, BooleanField.builder().data(true).build() + primaryNameField, NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build())).build(), + dobField, DateField.builder().data(List.of( + DateField.FieldedDate.builder().date(dobHyphen).build())).build(), + strField, StringField.builder().data(List.of("manager")).build(), + boolField, BooleanField.builder().data(List.of(true)).build() ), Map.of( - primaryNameField, NameField.UnfieldedName.builder().text("Ivan R").build(), - dobField, DateField.FieldedDate.builder().date(dobHyphen).build(), - dob2Field, DateField.FieldedDate.builder().date("1993/04/16").build(), - addrField, AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build(), - numberField, NumberField.builder().data(72).build(), - boolField, BooleanField.builder().data(true).build() + primaryNameField, NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build())).build(), + dobField, DateField.builder().data(List.of( + DateField.FieldedDate.builder().date(dobHyphen).build())).build(), + dob2Field, DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + addrField, AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build())).build(), + numberField, NumberField.builder().data(List.of(72)).build(), + boolField, BooleanField.builder().data(List.of(true)).build() ) ) ).build() diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/ApiModelMixinModule.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/ApiModelMixinModule.java index 16d30a1f3..a3b484737 100644 --- a/json/src/main/java/com/basistech/rosette/apimodel/jackson/ApiModelMixinModule.java +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/ApiModelMixinModule.java @@ -25,13 +25,19 @@ import com.basistech.rosette.apimodel.Name; import com.basistech.rosette.apimodel.NameDeduplicationRequest; import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.AddressFieldDeserializer; +import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.BooleanFieldDeserializer; import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.DateFieldDeserializer; import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.NameFieldDeserializer; +import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.NumberFieldDeserializer; import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.RecordSimilarityResponseDeserializer; +import com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers.StringFieldDeserializer; import com.basistech.rosette.apimodel.recordsimilarity.RecordSimilarityResponse; import com.basistech.rosette.apimodel.recordsimilarity.records.AddressField; +import com.basistech.rosette.apimodel.recordsimilarity.records.BooleanField; import com.basistech.rosette.apimodel.recordsimilarity.records.DateField; import com.basistech.rosette.apimodel.recordsimilarity.records.NameField; +import com.basistech.rosette.apimodel.recordsimilarity.records.NumberField; +import com.basistech.rosette.apimodel.recordsimilarity.records.StringField; import com.basistech.rosette.apimodel.NameSimilarityRequest; import com.basistech.rosette.apimodel.NameTranslationRequest; import com.basistech.rosette.apimodel.UnfieldedAddress; @@ -98,6 +104,9 @@ public void setupModule(Module.SetupContext context) { deserializers.addDeserializer(NameField.class, new NameFieldDeserializer()); deserializers.addDeserializer(DateField.class, new DateFieldDeserializer()); deserializers.addDeserializer(AddressField.class, new AddressFieldDeserializer()); + deserializers.addDeserializer(StringField.class, new StringFieldDeserializer()); + deserializers.addDeserializer(NumberField.class, new NumberFieldDeserializer()); + deserializers.addDeserializer(BooleanField.class, new BooleanFieldDeserializer()); deserializers.addDeserializer(RecordSimilarityRequest.class, new RecordSimilarityRequestDeserializer()); deserializers.addDeserializer(RecordSimilarityResponse.class, new RecordSimilarityResponseDeserializer()); context.addDeserializers(deserializers); diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/AddressFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/AddressFieldDeserializer.java index b38c46217..bc2fa0a20 100644 --- a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/AddressFieldDeserializer.java +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/AddressFieldDeserializer.java @@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; public class AddressFieldDeserializer extends StdDeserializer { public AddressFieldDeserializer() { @@ -32,11 +34,23 @@ public AddressFieldDeserializer() { @Override public AddressField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + data.add(deserializeElement(element, jsonParser)); + } + } else { + data.add(deserializeElement(node, jsonParser)); + } + return AddressField.builder().data(data).build(); + } + + private static AddressField.AddressFieldData deserializeElement(JsonNode node, JsonParser jsonParser) throws IOException { if (node.isObject()) { return jsonParser.getCodec().treeToValue(node, AddressField.FieldedAddress.class); } else if (node.isTextual()) { return AddressField.UnfieldedAddress.builder().address(node.textValue()).build(); } - throw new IOException("Invalid JSON structure: unexpected node type"); + throw new IOException("Invalid JSON structure: unexpected node type for AddressFieldData"); } } diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/BooleanFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/BooleanFieldDeserializer.java new file mode 100644 index 000000000..d773d95e0 --- /dev/null +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/BooleanFieldDeserializer.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Babel Street Rosette Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers; + +import com.basistech.rosette.apimodel.recordsimilarity.records.BooleanField; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class BooleanFieldDeserializer extends StdDeserializer { + public BooleanFieldDeserializer() { + super(BooleanField.class); + } + + @Override + public BooleanField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + // Be sure not to accidentally convert non-boolean values to 'false' + data.add(element.isBoolean() ? element.booleanValue() : null); + } + } else { + data.add(node.isBoolean() ? node.booleanValue() : null); + } + return BooleanField.builder().data(data).build(); + } +} diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/DateFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/DateFieldDeserializer.java index 422f34252..08b3e1696 100644 --- a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/DateFieldDeserializer.java +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/DateFieldDeserializer.java @@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; public class DateFieldDeserializer extends StdDeserializer { public DateFieldDeserializer() { @@ -32,11 +34,23 @@ public DateFieldDeserializer() { @Override public DateField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + data.add(deserializeElement(element, jsonParser)); + } + } else { + data.add(deserializeElement(node, jsonParser)); + } + return DateField.builder().data(data).build(); + } + + private static DateField.DateFieldData deserializeElement(JsonNode node, JsonParser jsonParser) throws IOException { if (node.isObject()) { return jsonParser.getCodec().treeToValue(node, DateField.FieldedDate.class); } else if (node.isTextual()) { return DateField.UnfieldedDate.builder().date(node.textValue()).build(); } - throw new IOException("Invalid JSON structure: unexpected node type"); + throw new IOException("Invalid JSON structure: unexpected node type for DateFieldData"); } } diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NameFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NameFieldDeserializer.java index 8fb372bc0..25d76f2cb 100644 --- a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NameFieldDeserializer.java +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NameFieldDeserializer.java @@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; public class NameFieldDeserializer extends StdDeserializer { public NameFieldDeserializer() { @@ -32,11 +34,23 @@ public NameFieldDeserializer() { @Override public NameField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + data.add(deserializeElement(element, jsonParser)); + } + } else { + data.add(deserializeElement(node, jsonParser)); + } + return NameField.builder().data(data).build(); + } + + private static NameField.NameFieldData deserializeElement(JsonNode node, JsonParser jsonParser) throws IOException { if (node.isObject()) { return jsonParser.getCodec().treeToValue(node, NameField.FieldedName.class); } else if (node.isTextual()) { return NameField.UnfieldedName.builder().text(node.textValue()).build(); } - throw new IOException("Invalid JSON structure: unexpected node type"); + throw new IOException("Invalid JSON structure: unexpected node type for NameFieldData"); } } diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NumberFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NumberFieldDeserializer.java new file mode 100644 index 000000000..44b183d66 --- /dev/null +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/NumberFieldDeserializer.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Babel Street Rosette Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers; + +import com.basistech.rosette.apimodel.recordsimilarity.records.NumberField; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class NumberFieldDeserializer extends StdDeserializer { + public NumberFieldDeserializer() { + super(NumberField.class); + } + + @Override + public NumberField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + data.add(element.numberValue()); + } + } else { + data.add(node.numberValue()); + } + return NumberField.builder().data(data).build(); + } +} diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/RecordSimilarityDeserializerUtilities.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/RecordSimilarityDeserializerUtilities.java index 2618938e8..6b4745522 100644 --- a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/RecordSimilarityDeserializerUtilities.java +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/RecordSimilarityDeserializerUtilities.java @@ -128,16 +128,13 @@ static Map parseRecord( fieldData = fieldValue.traverse(jsonParser.getCodec()).readValueAs(AddressField.class); break; case RecordFieldType.RNI_STRING: - fieldData = StringField.builder().data(fieldValue.textValue()).build(); + fieldData = fieldValue.traverse(jsonParser.getCodec()).readValueAs(StringField.class); break; case RecordFieldType.RNI_NUMBER: - fieldData = NumberField.builder().data(fieldValue.numberValue()).build(); + fieldData = fieldValue.traverse(jsonParser.getCodec()).readValueAs(NumberField.class); break; case RecordFieldType.RNI_BOOLEAN: - // Be sure not to accidentally convert non-boolean values to 'false' - fieldData = BooleanField.builder() - .data(fieldValue.isBoolean() ? fieldValue.booleanValue() : null) - .build(); + fieldData = fieldValue.traverse(jsonParser.getCodec()).readValueAs(BooleanField.class); break; default: fieldData = fieldValue.traverse(jsonParser.getCodec()).readValueAs(UnknownField.class); diff --git a/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/StringFieldDeserializer.java b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/StringFieldDeserializer.java new file mode 100644 index 000000000..5e5b8f767 --- /dev/null +++ b/json/src/main/java/com/basistech/rosette/apimodel/jackson/recordsimilaritydeserializers/StringFieldDeserializer.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Babel Street Rosette Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.basistech.rosette.apimodel.jackson.recordsimilaritydeserializers; + +import com.basistech.rosette.apimodel.recordsimilarity.records.StringField; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class StringFieldDeserializer extends StdDeserializer { + public StringFieldDeserializer() { + super(StringField.class); + } + + @Override + public StringField deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + final JsonNode node = jsonParser.getCodec().readTree(jsonParser); + final List data = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode element : node) { + data.add(element.textValue()); + } + } else { + data.add(node.textValue()); + } + return StringField.builder().data(data).build(); + } +} diff --git a/json/src/test/java/com/basistech/rosette/apimodel/RecordSimilarityRequestTest.java b/json/src/test/java/com/basistech/rosette/apimodel/RecordSimilarityRequestTest.java index 254a60998..0e5f6d310 100644 --- a/json/src/test/java/com/basistech/rosette/apimodel/RecordSimilarityRequestTest.java +++ b/json/src/test/java/com/basistech/rosette/apimodel/RecordSimilarityRequestTest.java @@ -40,14 +40,19 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class RecordSimilarityRequestTest { private static final ObjectMapper MAPPER = ApiModelMixinModule.setupObjectMapper(new ObjectMapper()); + private static final String EXPECTED_JSON = "{\"fields\":{\"num\":{\"type\":\"rni_number\",\"weight\":0.25},\"dob2\":{\"type\":\"rni_date\",\"weight\":0.1},\"bool\":{\"type\":\"rni_boolean\",\"weight\":0.05},\"addr\":{\"type\":\"rni_address\",\"weight\":0.5,\"scoreIfNull\":0.8},\"str\":{\"type\":\"rni_string\",\"weight\":0.8},\"primaryName\":{\"type\":\"rni_name\",\"weight\":0.5},\"dob\":{\"type\":\"rni_date\",\"weight\":0.2}},\"properties\":{\"threshold\":0.7,\"includeExplainInfo\":true},\"records\":{\"left\":[{\"primaryName\":{\"text\":\"Ethan R\",\"entityType\":\"PERSON\",\"language\":\"eng\",\"languageOfOrigin\":\"eng\",\"script\":\"Latn\"},\"num\":42.0,\"dob\":\"1993-04-16\",\"dob2\":{\"date\":\"1993/04/16\",\"format\":\"yyyy/MM/dd\"},\"addr\":\"123 Roadlane Ave\"},{\"primaryName\":{\"text\":\"Evan R\"},\"dob\":{\"date\":\"1993-04-16\"},\"str\":\"some string\",\"bool\":false}],\"right\":[{\"primaryName\":{\"text\":\"Seth R\",\"language\":\"eng\"},\"num\":74301945813095,\"dob\":{\"date\":\"1993-04-16\"},\"bool\":true},{\"addr\":{\"houseNumber\":\"123\",\"road\":\"Roadlane Ave\"},\"str\":\"some other string\",\"dob2\":{\"date\":\"1993/04/16\"},\"primaryName\":\"Ivan R\",\"bool\":true,\"dob\":{\"date\":\"1993-04-16\"}}]}}"; private static final String EXPECTED_JSON_WITH_PARAMS = "{\"fields\":{\"dob\":{\"type\":\"rni_date\",\"weight\":0.2},\"primaryName\":{\"type\":\"rni_name\",\"weight\":0.5},\"str\":{\"type\":\"rni_string\",\"weight\":0.8},\"addr\":{\"type\":\"rni_address\",\"weight\":0.5},\"bool\":{\"type\":\"rni_boolean\",\"weight\":0.05},\"dob2\":{\"type\":\"rni_date\",\"weight\":0.1},\"num\":{\"type\":\"rni_number\",\"weight\":0.25}},\"properties\":{\"threshold\":0.7,\"includeExplainInfo\":true,\"parameters\":{\"stringDistanceWeight\":\"0.1\",\"timeDistanceWeight\":\"0.8\"}},\"records\":{\"left\":[{\"addr\":\"123 Roadlane Ave\",\"dob2\":{\"date\":\"1993/04/16\"},\"dob\":\"1993-04-16\",\"num\":42.0,\"primaryName\":{\"text\":\"Ethan R\",\"entityType\":\"PERSON\",\"language\":\"eng\",\"languageOfOrigin\":\"eng\",\"script\":\"Latn\"}},{\"dob\":{\"date\":\"1993-04-16\"},\"primaryName\":{\"text\":\"Evan R\"},\"bool\":false,\"str\":\"some string\"}],\"right\":[{\"dob\":{\"date\":\"1993-04-16\"},\"num\":74301945813095,\"primaryName\":{\"text\":\"Seth R\",\"language\":\"eng\"},\"bool\":true},{\"primaryName\":\"Ivan R\",\"dob2\":{\"date\":\"1993/04/16\"},\"str\":\"some other string\",\"addr\":{\"houseNumber\":\"123\",\"road\":\"Roadlane Ave\"},\"dob\":{\"date\":\"1993-04-16\"},\"bool\":true}]}}"; private static final String EXPECTED_JSON_WITH_UNIVERSE = "{\"fields\":{\"num\":{\"type\":\"rni_number\",\"weight\":0.25},\"primaryName\":{\"type\":\"rni_name\",\"weight\":0.5},\"dob\":{\"type\":\"rni_date\",\"weight\":0.2},\"str\":{\"type\":\"rni_string\",\"weight\":0.8},\"addr\":{\"type\":\"rni_address\",\"weight\":0.5},\"bool\":{\"type\":\"rni_boolean\",\"weight\":0.05},\"dob2\":{\"type\":\"rni_date\",\"weight\":0.1}},\"properties\":{\"threshold\":0.7,\"includeExplainInfo\":true,\"parameterUniverse\":\"myParameterUniverse\"},\"records\":{\"left\":[{\"num\":42.0,\"primaryName\":{\"text\":\"Ethan R\",\"entityType\":\"PERSON\",\"language\":\"eng\",\"languageOfOrigin\":\"eng\",\"script\":\"Latn\"},\"addr\":\"123 Roadlane Ave\",\"dob2\":{\"date\":\"1993/04/16\"},\"dob\":\"1993-04-16\"},{\"primaryName\":{\"text\":\"Evan R\"},\"bool\":false,\"str\":\"some string\",\"dob\":{\"date\":\"1993-04-16\"}}],\"right\":[{\"primaryName\":{\"text\":\"Seth R\",\"language\":\"eng\"},\"bool\":true,\"dob\":{\"date\":\"1993-04-16\"},\"num\":74301945813095},{\"addr\":{\"houseNumber\":\"123\",\"road\":\"Roadlane Ave\"},\"dob\":{\"date\":\"1993-04-16\"},\"bool\":true,\"primaryName\":\"Ivan R\",\"dob2\":{\"date\":\"1993/04/16\"},\"str\":\"some other string\"}]}}"; + // Old single-value JSON format — deserializers must still accept this as single-element lists. + private static final String BACKWARD_COMPAT_JSON = "{\"fields\":{\"name\":{\"type\":\"rni_name\",\"weight\":0.5},\"dob\":{\"type\":\"rni_date\",\"weight\":0.2},\"addr\":{\"type\":\"rni_address\",\"weight\":0.5},\"str\":{\"type\":\"rni_string\",\"weight\":0.8},\"num\":{\"type\":\"rni_number\",\"weight\":0.25},\"bool\":{\"type\":\"rni_boolean\",\"weight\":0.05}},\"properties\":{\"threshold\":0.7},\"records\":{\"left\":[{\"name\":\"Ivan R\",\"dob\":\"1993-04-16\",\"addr\":\"123 Main St\",\"str\":\"engineer\",\"num\":42.0,\"bool\":true}],\"right\":[{\"name\":{\"text\":\"Ivan R\"},\"dob\":{\"date\":\"1993-04-16\"},\"addr\":{\"houseNumber\":\"123\",\"road\":\"Main St\"},\"str\":\"engineer\",\"num\":42.0,\"bool\":true}]}}"; + private static final RecordSimilarityRequest EXPECTED_REQUEST = RecordSimilarityRequest.builder() .fields(Map.of( "addr", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_ADDRESS).weight(0.5).scoreIfNull(0.8).build(), @@ -59,45 +64,55 @@ class RecordSimilarityRequestTest { "bool", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_BOOLEAN).weight(0.05).build())) .properties(RecordSimilarityProperties.builder().threshold(0.7).includeExplainInfo(true).build()) .records(RecordSimilarityRecords.builder() - .left( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder() - .text("Ethan R").entityType("PERSON") - .language(LanguageCode.ENGLISH) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - "dob", DateField.UnfieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").format("yyyy/MM/dd").build(), - "addr", AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build(), - "num", NumberField.builder().data(42.0).build() - ), - Map.of( - "primaryName", NameField.FieldedName.builder().text("Evan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "str", StringField.builder().data("some string").build(), - "bool", BooleanField.builder().data(false).build() - ) + .left(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R").entityType("PERSON") + .language(LanguageCode.ENGLISH) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").format("yyyy/MM/dd").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build())).build(), + "num", NumberField.builder().data(List.of(42.0)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Evan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "str", StringField.builder().data(List.of("some string")).build(), + "bool", BooleanField.builder().data(List.of(false)).build() ) - ).right( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "num", NumberField.builder().data(74301945813095L).build(), - "bool", BooleanField.builder().data(true).build() - ), - Map.of( - "primaryName", NameField.UnfieldedName.builder().text("Ivan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").build(), - "addr", AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build(), - "str", StringField.builder().data("some other string").build(), - "bool", BooleanField.builder().data(true).build() - ) + )) + .right(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "num", NumberField.builder().data(List.of(74301945813095L)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build())).build(), + "str", StringField.builder().data(List.of("some other string")).build(), + "bool", BooleanField.builder().data(List.of(true)).build() ) - ).build() + )) + .build() ).build(); private static final RecordSimilarityRequest EXPECTED_REQUEST_WITH_PARAMS = RecordSimilarityRequest.builder() @@ -112,53 +127,60 @@ class RecordSimilarityRequestTest { .properties(RecordSimilarityProperties.builder() .threshold(0.7) .includeExplainInfo(true) - .parameters( - Map.of( - "timeDistanceWeight", "0.8", - "stringDistanceWeight", "0.1" - ) - ) + .parameters(Map.of( + "timeDistanceWeight", "0.8", + "stringDistanceWeight", "0.1")) .build()) .records(RecordSimilarityRecords.builder() - .left( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder() - .text("Ethan R").entityType("PERSON") - .language(LanguageCode.ENGLISH) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - "dob", DateField.UnfieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").build(), - "addr", AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build(), - "num", NumberField.builder().data(42.0).build() - ), - Map.of( - "primaryName", NameField.FieldedName.builder().text("Evan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "str", StringField.builder().data("some string").build(), - "bool", BooleanField.builder().data(false).build() - ) - ) - ).right( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "num", NumberField.builder().data(74301945813095L).build(), - "bool", BooleanField.builder().data(true).build() - ), - Map.of( - "primaryName", NameField.UnfieldedName.builder().text("Ivan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").build(), - "addr", AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build(), - "str", StringField.builder().data("some other string").build(), - "bool", BooleanField.builder().data(true).build() - ) - ) - ).build() + .left(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R").entityType("PERSON") + .language(LanguageCode.ENGLISH) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build())).build(), + "num", NumberField.builder().data(List.of(42.0)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Evan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "str", StringField.builder().data(List.of("some string")).build(), + "bool", BooleanField.builder().data(List.of(false)).build() + ) + )) + .right(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "num", NumberField.builder().data(List.of(74301945813095L)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build())).build(), + "str", StringField.builder().data(List.of("some other string")).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ) + )) + .build() ).build(); private static final RecordSimilarityRequest EXPECTED_REQUEST_WITH_UNIVERSE = RecordSimilarityRequest.builder() @@ -176,45 +198,55 @@ class RecordSimilarityRequestTest { .parameterUniverse("myParameterUniverse") .build()) .records(RecordSimilarityRecords.builder() - .left( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder() - .text("Ethan R").entityType("PERSON") - .language(LanguageCode.ENGLISH) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - "dob", DateField.UnfieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").build(), - "addr", AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build(), - "num", NumberField.builder().data(42.0).build() - ), - Map.of( - "primaryName", NameField.FieldedName.builder().text("Evan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "str", StringField.builder().data("some string").build(), - "bool", BooleanField.builder().data(false).build() - ) + .left(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R").entityType("PERSON") + .language(LanguageCode.ENGLISH) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Roadlane Ave").build())).build(), + "num", NumberField.builder().data(List.of(42.0)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Evan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "str", StringField.builder().data(List.of("some string")).build(), + "bool", BooleanField.builder().data(List.of(false)).build() ) - ).right( - List.of( - Map.of( - "primaryName", NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "num", NumberField.builder().data(74301945813095L).build(), - "bool", BooleanField.builder().data(true).build() - ), - Map.of( - "primaryName", NameField.UnfieldedName.builder().text("Ivan R").build(), - "dob", DateField.FieldedDate.builder().date("1993-04-16").build(), - "dob2", DateField.FieldedDate.builder().date("1993/04/16").build(), - "addr", AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build(), - "str", StringField.builder().data("some other string").build(), - "bool", BooleanField.builder().data(true).build() - ) + )) + .right(List.of( + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Seth R").language(LanguageCode.ENGLISH).build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "num", NumberField.builder().data(List.of(74301945813095L)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ), + Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "dob2", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993/04/16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Roadlane Ave").build())).build(), + "str", StringField.builder().data(List.of("some other string")).build(), + "bool", BooleanField.builder().data(List.of(true)).build() ) - ).build() + )) + .build() ).build(); @Test @@ -244,4 +276,158 @@ void testSerialization() throws JsonProcessingException { assertEquals(expectedUniverseJson, actualUniverseJson); } + @Test + void testSingleValueDeserializationBackwardCompat() throws JsonProcessingException { + // Old-format JSON uses bare single values instead of arrays; deserializers must produce single-element lists. + final RecordSimilarityRequest actual = MAPPER.readValue(BACKWARD_COMPAT_JSON, new TypeReference<>() { }); + + final RecordSimilarityRequest expected = RecordSimilarityRequest.builder() + .fields(Map.of( + "name", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_NAME).weight(0.5).build(), + "dob", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_DATE).weight(0.2).build(), + "addr", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_ADDRESS).weight(0.5).build(), + "str", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_STRING).weight(0.8).build(), + "num", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_NUMBER).weight(0.25).build(), + "bool", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_BOOLEAN).weight(0.05).build())) + .properties(RecordSimilarityProperties.builder().threshold(0.7).build()) + .records(RecordSimilarityRecords.builder() + .left(List.of(Map.of( + "name", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Main St").build())).build(), + "str", StringField.builder().data(List.of("engineer")).build(), + "num", NumberField.builder().data(List.of(42.0)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ))) + .right(List.of(Map.of( + "name", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Ivan R").build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder().date("1993-04-16").build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Main St").build())).build(), + "str", StringField.builder().data(List.of("engineer")).build(), + "num", NumberField.builder().data(List.of(42.0)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ))) + .build()) + .build(); + + assertEquals(expected, actual); + } + + @Test + void testMultiElementFieldRoundTrip() throws JsonProcessingException { + // Use a fresh mapper to avoid polluting the shared MAPPER's serializer cache + final ObjectMapper mapper = ApiModelMixinModule.setupObjectMapper(new ObjectMapper()); + + // A record with multiple values in every field type + final RecordSimilarityRequest request = RecordSimilarityRequest.builder() + .fields(Map.of( + "name", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_NAME).weight(0.5).build(), + "dob", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_DATE).weight(0.2).build(), + "addr", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_ADDRESS).weight(0.5).build(), + "str", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_STRING).weight(0.8).build(), + "num", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_NUMBER).weight(0.25).build(), + "bool", RecordSimilarityFieldInfo.builder().type(RecordFieldType.RNI_BOOLEAN).weight(0.05).build())) + .properties(RecordSimilarityProperties.builder().threshold(0.7).build()) + .records(RecordSimilarityRecords.builder() + .left(List.of(Map.of( + "name", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build(), + NameField.FieldedName.builder().text("Ivan Rossi").language(LanguageCode.ITALIAN).build() + )).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build(), + DateField.FieldedDate.builder().date("1993/04/16").format("yyyy/MM/dd").build() + )).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Main St").build(), + AddressField.FieldedAddress.builder().houseNumber("123").road("Main St").city("Springfield").build() + )).build(), + "str", StringField.builder().data(List.of("engineer", "developer")).build(), + "num", NumberField.builder().data(List.of(42.0, 43.5)).build(), + "bool", BooleanField.builder().data(List.of(true, false)).build() + ))) + .right(List.of(Map.of( + "name", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Ivan R").build(), + NameField.UnfieldedName.builder().text("I. Rossi").build() + )).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build() + )).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder().houseNumber("123").road("Main St").build() + )).build(), + "str", StringField.builder().data(List.of("software engineer")).build(), + "num", NumberField.builder().data(List.of(42.0)).build(), + "bool", BooleanField.builder().data(List.of(true)).build() + ))) + .build()) + .build(); + + // Multi-element fields must serialize as JSON arrays + final JsonNode tree = mapper.valueToTree(request); + final JsonNode leftRecord = tree.get("records").get("left").get(0); + assertEquals(2, leftRecord.get("name").size(), "multi-element name should be an array of 2"); + assertEquals(2, leftRecord.get("dob").size(), "multi-element dob should be an array of 2"); + assertEquals(2, leftRecord.get("addr").size(), "multi-element addr should be an array of 2"); + assertEquals(2, leftRecord.get("str").size(), "multi-element str should be an array of 2"); + assertEquals(2, leftRecord.get("num").size(), "multi-element num should be an array of 2"); + assertEquals(2, leftRecord.get("bool").size(), "multi-element bool should be an array of 2"); + + // Mixed unfielded/fielded name elements + assertEquals("Ivan R", leftRecord.get("name").get(0).asText()); + assertEquals("Ivan Rossi", leftRecord.get("name").get(1).get("text").asText()); + + // Round-trip: deserialize back and compare + final String json = mapper.writeValueAsString(request); + final RecordSimilarityRequest roundTripped = mapper.readValue(json, new TypeReference<>() { }); + assertEquals(request, roundTripped); + } + + @Test + void testNullDataThrowsOnConstruction() { + assertThrows(NullPointerException.class, () -> NameField.builder().build(), + "NameField.builder().build() with null data should throw NPE"); + assertThrows(NullPointerException.class, () -> DateField.builder().build(), + "DateField.builder().build() with null data should throw NPE"); + assertThrows(NullPointerException.class, () -> AddressField.builder().build(), + "AddressField.builder().build() with null data should throw NPE"); + assertThrows(NullPointerException.class, () -> StringField.builder().build(), + "StringField.builder().build() with null data should throw NPE"); + assertThrows(NullPointerException.class, () -> NumberField.builder().build(), + "NumberField.builder().build() with null data should throw NPE"); + assertThrows(NullPointerException.class, () -> BooleanField.builder().build(), + "BooleanField.builder().build() with null data should throw NPE"); + } + + @Test + void testMalformedArrayElementThrows() { + // A number where a name element is expected is invalid JSON for NameField + final String badNameJson = "{\"fields\":{\"name\":{\"type\":\"rni_name\",\"weight\":0.5}}," + + "\"records\":{\"left\":[{\"name\":[42]}],\"right\":[]}}"; + assertThrows(JsonProcessingException.class, + () -> MAPPER.readValue(badNameJson, new TypeReference() { }), + "Numeric element inside name array should fail deserialization"); + + // A number where a date element is expected + final String badDateJson = "{\"fields\":{\"dob\":{\"type\":\"rni_date\",\"weight\":0.2}}," + + "\"records\":{\"left\":[{\"dob\":[42]}],\"right\":[]}}"; + assertThrows(JsonProcessingException.class, + () -> MAPPER.readValue(badDateJson, new TypeReference() { }), + "Numeric element inside date array should fail deserialization"); + + // A number where an address element is expected + final String badAddrJson = "{\"fields\":{\"addr\":{\"type\":\"rni_address\",\"weight\":0.5}}," + + "\"records\":{\"left\":[{\"addr\":[42]}],\"right\":[]}}"; + assertThrows(JsonProcessingException.class, + () -> MAPPER.readValue(badAddrJson, new TypeReference() { }), + "Numeric element inside address array should fail deserialization"); + } + } diff --git a/json/src/test/java/com/basistech/rosette/apimodel/recordsimilarity/RecordSimilarityResponseTest.java b/json/src/test/java/com/basistech/rosette/apimodel/recordsimilarity/RecordSimilarityResponseTest.java index ec127440f..4f3d4f362 100644 --- a/json/src/test/java/com/basistech/rosette/apimodel/recordsimilarity/RecordSimilarityResponseTest.java +++ b/json/src/test/java/com/basistech/rosette/apimodel/recordsimilarity/RecordSimilarityResponseTest.java @@ -50,29 +50,36 @@ public class RecordSimilarityResponseTest { temp = RecordSimilarityResponse.builder() .results(List.of(RecordSimilarityResult.builder() .score(0.87) - .left(Map.of("primaryName", NameField.FieldedName.builder() - .text("Ethan R") - .language(LanguageCode.ENGLISH) - .entityType(NEConstants.toString(NEConstants.NE_TYPE_PERSON)) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - "dob", DateField.FieldedDate.builder() - .date("1993-04-16") - .build(), - "addr", AddressField.FieldedAddress.builder() - .houseNumber("123").road("Roadlane Ave") - .build(), - "str", StringField.builder().data("some string").build(), - "num", NumberField.builder().data(2342.15).build())) - .right(Map.of("primaryName", NameField.FieldedName.builder() - .text("Seth R") - .build(), - "dob", DateField.UnfieldedDate.builder() - .date("1993-04-16") - .build(), - "str", StringField.builder().data("some other string").build(), - "bool", BooleanField.builder().data(false).build())) + .left(Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R") + .language(LanguageCode.ENGLISH) + .entityType(NEConstants.toString(NEConstants.NE_TYPE_PERSON)) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder() + .date("1993-04-16") + .build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder() + .houseNumber("123").road("Roadlane Ave") + .build())).build(), + "str", StringField.builder().data(List.of("some string")).build(), + "num", NumberField.builder().data(List.of(2342.15)).build())) + .right(Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Seth R") + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder() + .date("1993-04-16") + .build())).build(), + "str", StringField.builder().data(List.of("some other string")).build(), + "bool", BooleanField.builder().data(List.of(false)).build())) .explainInfo(RecordSimilarityExplainInfo.builder() .leftOnlyFields(List.of("addr")) .rightOnlyFields(List.of("bool")) @@ -101,25 +108,32 @@ public class RecordSimilarityResponseTest { .build()) .build(), RecordSimilarityResult.builder() - .left(Map.of("primaryName", NameField.FieldedName.builder() - .text("Ethan R") - .language(LanguageCode.ENGLISH) - .entityType(NEConstants.toString(NEConstants.NE_TYPE_PERSON)) - .languageOfOrigin(LanguageCode.ENGLISH) - .script(ISO15924.Latn) - .build(), - "dob", DateField.FieldedDate.builder() - .date("1993-04-16") - .build(), - "addr", AddressField.FieldedAddress.builder() - .houseNumber("123").road("Roadlane Ave") - .build())) - .right(Map.of("primaryName", NameField.FieldedName.builder() - .text("Seth R") - .build(), - "dob", DateField.UnfieldedDate.builder() - .date("1993-04-16") - .build())) + .left(Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Ethan R") + .language(LanguageCode.ENGLISH) + .entityType(NEConstants.toString(NEConstants.NE_TYPE_PERSON)) + .languageOfOrigin(LanguageCode.ENGLISH) + .script(ISO15924.Latn) + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.FieldedDate.builder() + .date("1993-04-16") + .build())).build(), + "addr", AddressField.builder().data(List.of( + AddressField.FieldedAddress.builder() + .houseNumber("123").road("Roadlane Ave") + .build())).build())) + .right(Map.of( + "primaryName", NameField.builder().data(List.of( + NameField.FieldedName.builder() + .text("Seth R") + .build())).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder() + .date("1993-04-16") + .build())).build())) .error(List.of("Field foo not found in field mapping")) .info(List.of("Some info message", "Some other info message")) .build())) @@ -152,4 +166,58 @@ public void testSerialization() throws JsonProcessingException { MAPPER.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); assertEquals(EXPECTED_JSON, MAPPER.writeValueAsString(EXPECTED_RESPONSE)); } + + @Test + public void testMultiElementFieldRoundTrip() throws JsonProcessingException { + // Use a fresh mapper to avoid polluting the shared MAPPER's serializer cache + final ObjectMapper mapper = ApiModelMixinModule.setupObjectMapper(new ObjectMapper()); + + // Build a response where each field in a result record has multiple values + final RecordSimilarityResponse response = RecordSimilarityResponse.builder() + .results(List.of(RecordSimilarityResult.builder() + .score(0.75) + .left(Map.of( + "name", NameField.builder().data(List.of( + NameField.UnfieldedName.builder().text("Ivan R").build(), + NameField.FieldedName.builder().text("Ivan Rossi").language(LanguageCode.ITALIAN).build() + )).build(), + "dob", DateField.builder().data(List.of( + DateField.UnfieldedDate.builder().date("1993-04-16").build(), + DateField.FieldedDate.builder().date("1993/04/16").format("yyyy/MM/dd").build() + )).build(), + "addr", AddressField.builder().data(List.of( + AddressField.UnfieldedAddress.builder().address("123 Main St").build(), + AddressField.FieldedAddress.builder().houseNumber("123").road("Main St").build() + )).build(), + "str", StringField.builder().data(List.of("engineer", "developer")).build(), + "num", NumberField.builder().data(List.of(1.0, 2.0)).build(), + "bool", BooleanField.builder().data(List.of(true, false)).build())) + .right(Map.of( + "name", NameField.builder().data(List.of( + NameField.FieldedName.builder().text("Ivan R").build() + )).build())) + .build())) + .build(); + + // Multi-element fields must serialize as JSON arrays + final com.fasterxml.jackson.databind.JsonNode tree = mapper.valueToTree(response); + final com.fasterxml.jackson.databind.JsonNode left = tree.get("results").get(0).get("left"); + assertEquals(2, left.get("name").size(), "multi-element name should serialize as array of 2"); + assertEquals(2, left.get("dob").size(), "multi-element dob should serialize as array of 2"); + assertEquals(2, left.get("addr").size(), "multi-element addr should serialize as array of 2"); + assertEquals(2, left.get("str").size(), "multi-element str should serialize as array of 2"); + assertEquals(2, left.get("num").size(), "multi-element num should serialize as array of 2"); + assertEquals(2, left.get("bool").size(), "multi-element bool should serialize as array of 2"); + + // First name element is the unfielded string form; second is an object + assertEquals("Ivan R", left.get("name").get(0).asText()); + assertEquals("Ivan Rossi", left.get("name").get(1).get("text").asText()); + + // Round-trip: compare JSON trees (order-insensitive) since deserialized left/right maps + // hold raw Java objects (RecordSimilarityField is an interface with no type info), + // not the typed field objects in the original, so object equality cannot be used. + final String json = mapper.writeValueAsString(response); + final RecordSimilarityResponse roundTripped = mapper.readValue(json, RecordSimilarityResponse.class); + assertEquals(mapper.readTree(json), mapper.readTree(mapper.writeValueAsString(roundTripped))); + } } diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/AddressField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/AddressField.java index 8ec4880e9..e613ec337 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/AddressField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/AddressField.java @@ -19,22 +19,41 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.experimental.NonFinal; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; -@SuperBuilder +import java.util.List; + +@Builder @Value -@NonFinal -public abstract class AddressField implements RecordSimilarityField { +public class AddressField implements RecordSimilarityField { + + @NonNull + @NotEmpty + List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } + + @SuperBuilder + @Value + @NonFinal + public abstract static class AddressFieldData { + } @Jacksonized @SuperBuilder @Value - public static class UnfieldedAddress extends AddressField { + public static class UnfieldedAddress extends AddressFieldData { @NotBlank String address; @JsonValue public String toJson() { return address; @@ -45,7 +64,7 @@ public static class UnfieldedAddress extends AddressField { @SuperBuilder @Value @JsonInclude(JsonInclude.Include.NON_NULL) - public static class FieldedAddress extends AddressField { + public static class FieldedAddress extends AddressFieldData { String house; String houseNumber; String road; diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/BooleanField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/BooleanField.java index 84fdfb3e9..9404e0dd6 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/BooleanField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/BooleanField.java @@ -15,16 +15,21 @@ */ package com.basistech.rosette.apimodel.recordsimilarity.records; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; -import lombok.experimental.SuperBuilder; -import lombok.extern.jackson.Jacksonized; -@SuperBuilder +import java.util.List; + +@Builder @Value -@Jacksonized public class BooleanField implements RecordSimilarityField { - @JsonValue @JsonInclude @NotNull Boolean data; + @NonNull @NotEmpty List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } } diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/DateField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/DateField.java index cd2ebdc1f..85dcdf178 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/DateField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/DateField.java @@ -18,23 +18,42 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.experimental.NonFinal; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; -@SuperBuilder +import java.util.List; + +@Builder @Value -@NonFinal -public abstract class DateField implements RecordSimilarityField { - @NotBlank String date; +public class DateField implements RecordSimilarityField { + + @NonNull + @NotEmpty + List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } + + @SuperBuilder + @Value + @NonFinal + public abstract static class DateFieldData { + @NotBlank String date; + } @Jacksonized @SuperBuilder @Value - public static class UnfieldedDate extends DateField { + public static class UnfieldedDate extends DateFieldData { @JsonValue public String toJson() { return super.getDate(); } @@ -44,7 +63,7 @@ public static class UnfieldedDate extends DateField { @SuperBuilder @Value @JsonInclude(JsonInclude.Include.NON_NULL) - public static class FieldedDate extends DateField { + public static class FieldedDate extends DateFieldData { @NotBlank String format; } diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NameField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NameField.java index 87cacf982..6f6ea44d2 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NameField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NameField.java @@ -20,23 +20,42 @@ import com.basistech.util.LanguageCode; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; import lombok.experimental.NonFinal; import lombok.experimental.SuperBuilder; import lombok.extern.jackson.Jacksonized; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; -@SuperBuilder +import java.util.List; + +@Builder @Value -@NonFinal -public abstract class NameField implements RecordSimilarityField { - @NotBlank String text; +public class NameField implements RecordSimilarityField { + + @NonNull + @NotEmpty + List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } + + @SuperBuilder + @Value + @NonFinal + public abstract static class NameFieldData { + @NotBlank String text; + } @Jacksonized @SuperBuilder @Value - public static class UnfieldedName extends NameField { + public static class UnfieldedName extends NameFieldData { @JsonValue public String toJson() { return super.getText(); } @@ -46,7 +65,7 @@ public static class UnfieldedName extends NameField { @SuperBuilder @Value @JsonInclude(JsonInclude.Include.NON_NULL) - public static class FieldedName extends NameField { + public static class FieldedName extends NameFieldData { String entityType; LanguageCode language; LanguageCode languageOfOrigin; diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NumberField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NumberField.java index 247aa935c..c78915c40 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NumberField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/NumberField.java @@ -15,16 +15,21 @@ */ package com.basistech.rosette.apimodel.recordsimilarity.records; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; -import lombok.experimental.SuperBuilder; -import lombok.extern.jackson.Jacksonized; -@SuperBuilder +import java.util.List; + +@Builder @Value -@Jacksonized public class NumberField implements RecordSimilarityField { - @NotNull @JsonValue @JsonInclude Number data; + @NonNull @NotEmpty List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } } diff --git a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/StringField.java b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/StringField.java index 83f25f9b6..e542053ca 100644 --- a/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/StringField.java +++ b/model/src/main/java/com/basistech/rosette/apimodel/recordsimilarity/records/StringField.java @@ -15,16 +15,21 @@ */ package com.basistech.rosette.apimodel.recordsimilarity.records; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonValue; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.NonNull; import lombok.Value; -import lombok.experimental.SuperBuilder; -import lombok.extern.jackson.Jacksonized; -@SuperBuilder +import java.util.List; + +@Builder @Value -@Jacksonized public class StringField implements RecordSimilarityField { - @NotBlank @JsonValue @JsonInclude String data; + @NonNull @NotEmpty List data; + + @JsonValue + public Object toJson() { + return data.size() == 1 ? data.get(0) : data; + } }