From d8728fe41271905a621d274dd06f96c2bd7516a2 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Wed, 17 Jul 2024 16:18:17 +0300 Subject: [PATCH 01/11] Update submodule to commit 1e4076bf5e4eae9670cd005adea7a174ccfaf681 --- ramls/raml-storage | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ramls/raml-storage b/ramls/raml-storage index 195de7f9f..1e4076bf5 160000 --- a/ramls/raml-storage +++ b/ramls/raml-storage @@ -1 +1 @@ -Subproject commit 195de7f9fd0f5ef76aac6542e11b0391a67e8af0 +Subproject commit 1e4076bf5e4eae9670cd005adea7a174ccfaf681 From b0b0c680e4f48c65e148b5f8b072b64bc560c265 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Thu, 18 Jul 2024 18:01:12 +0300 Subject: [PATCH 02/11] MODSOURCE-783 add qualifier --- .../java/org/folio/dao/util/MatchField.java | 20 +++++ .../org/folio/dao/util/RecordDaoUtil.java | 75 ++++++++++++++++--- .../org/folio/services/RecordServiceImpl.java | 21 +++--- .../rest/impl/RecordsMatchingApiTest.java | 4 +- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/MatchField.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/MatchField.java index 3c93b7057..4d5a7982d 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/MatchField.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/MatchField.java @@ -1,6 +1,7 @@ package org.folio.dao.util; import org.folio.processing.value.Value; +import org.folio.rest.jaxrs.model.Filter; import org.marc4j.marc.impl.Verifier; /** @@ -15,6 +16,7 @@ public class MatchField { private final String ind2; private final String subfield; private final Value value; + private final QualifierMatch qualifierMatch; private final String fieldPath; public MatchField(String tag, String ind1, String ind2, String subfield, Value value) { @@ -24,6 +26,20 @@ public MatchField(String tag, String ind1, String ind2, String subfield, Value v this.subfield = subfield; this.value = value; this.fieldPath = tag + ind1 + ind2 + subfield; + this.qualifierMatch = null; + } + + public MatchField(String tag, String ind1, String ind2, String subfield, Value value, QualifierMatch qualifierMatch) { + this.tag = tag; + this.ind1 = ind1; + this.ind2 = ind2; + this.subfield = subfield; + this.value = value; + this.qualifierMatch = qualifierMatch; + this.fieldPath = tag + ind1 + ind2 + subfield; + } + + public record QualifierMatch(Filter.Qualifier qualifier, String value) { } public String getTag() { @@ -46,6 +62,10 @@ public Value getValue() { return value; } + public QualifierMatch getQualifierMatch() { + return qualifierMatch; + } + public boolean isControlField() { return Verifier.isControlField(tag); } diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java index 3202f44b8..1eb7c2b9c 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java @@ -51,6 +51,7 @@ public final class RecordDaoUtil { public static final String RECORD_NOT_FOUND_TEMPLATE = "Record with id '%s' was not found"; private static final String COMMA = ","; + private static final String LIKE_OPERATOR = "%"; private static final List DELETED_LEADER_RECORD_STATUS = Arrays.asList("d", "s", "x"); private RecordDaoUtil() {} @@ -77,6 +78,17 @@ public static Condition getExternalIdsCondition(List externalIds, IdType return getIdCondition(idType, idField -> idField.in(toUUIDs(externalIds))); } + /** + * Get {@link Condition} where in external list ids and {@link IdType} and match by qualifier value + * + * @param externalIds list of external id + * @param idType external id type + * @return condition + */ + public static Condition getExternalIdsConditionWithQualifier(List externalIds, IdType idType, MatchField.QualifierMatch qualifier) { + return getIdConditionWithQualifier(idType, idField -> idField.in(toUUIDs(externalIds)), qualifier); + } + /** * Count query by {@link Condition} * @@ -478,6 +490,18 @@ public static Condition filterRecordByExternalHridValues(List externalHr return RECORDS_LB.EXTERNAL_HRID.in(externalHridValues); } + /** + * Get {@link Condition} to filter by external entity hrid using specified values and match by qualifier value + * + * @param externalHridValues external entity hrid values to equal + * @return condition + */ + public static Condition filterRecordByExternalHridValuesWithQualifier(List externalHridValues, MatchField.QualifierMatch qualifier) { + var qualifierCondition = buildQualifierCondition(RECORDS_LB.EXTERNAL_HRID, qualifier); + var resultCondition = RECORDS_LB.EXTERNAL_HRID.in(externalHridValues); + return qualifierCondition != null ? resultCondition.and(qualifierCondition) : resultCondition; + } + /** * Get {@link Condition} to filter by snapshotId id * @@ -734,20 +758,47 @@ private static Condition getRecordTypeCondition(RecordType recordType) { } private static Condition getIdCondition(IdType idType, Function, Condition> idFieldToConditionMapper) { - IdType idTypeToUse = idType; - RecordType recordType = null; - if (idType == IdType.HOLDINGS) { - idTypeToUse = IdType.EXTERNAL; - recordType = RecordType.MARC_HOLDING; - } else if (idType == IdType.INSTANCE) { - idTypeToUse = IdType.EXTERNAL; - recordType = RecordType.MARC_BIB; - } else if (idType == IdType.AUTHORITY) { - idTypeToUse = IdType.EXTERNAL; - recordType = RecordType.MARC_AUTHORITY; - } + IdType idTypeToUse = getIdType(idType); + RecordType recordType = getRecordType(idTypeToUse); var idField = RECORDS_LB.field(LOWER_CAMEL.to(LOWER_UNDERSCORE, idTypeToUse.getIdField()), UUID.class); return idFieldToConditionMapper.apply(idField).and(getRecordTypeCondition(recordType)); } + private static Condition getIdConditionWithQualifier(IdType idType, Function, Condition> idFieldToConditionMapper, MatchField.QualifierMatch qualifier) { + IdType idTypeToUse = getIdType(idType); + RecordType recordType = getRecordType(idTypeToUse); + var idField = RECORDS_LB.field(LOWER_CAMEL.to(LOWER_UNDERSCORE, idTypeToUse.getIdField()), UUID.class); + var qualifierCondition = buildQualifierCondition(idField, qualifier); + var resultCondition = idFieldToConditionMapper.apply(idField).and(getRecordTypeCondition(recordType)); + return qualifierCondition != null ? resultCondition.and(qualifierCondition) : resultCondition; + } + + private static Condition buildQualifierCondition(Field field, MatchField.QualifierMatch qualifier) { + if (qualifier == null) { + return null; + } + var value = qualifier.value(); + return switch (qualifier.qualifier()) { + case BEGINS_WITH -> field.like(value + LIKE_OPERATOR); + case ENDS_WITH -> field.like(LIKE_OPERATOR + value); + case CONTAINS -> field.like(LIKE_OPERATOR + value + LIKE_OPERATOR); + }; + } + + private static RecordType getRecordType(IdType idType) { + return switch (idType) { + case HOLDINGS -> RecordType.MARC_HOLDING; + case INSTANCE -> RecordType.MARC_BIB; + case AUTHORITY -> RecordType.MARC_AUTHORITY; + default -> null; + }; + } + + private static IdType getIdType(IdType idType) { + return switch (idType) { + case HOLDINGS, INSTANCE, AUTHORITY -> IdType.EXTERNAL; + default -> idType; + }; + } + } diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java index b47400c15..43f8e2f3b 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java @@ -4,13 +4,7 @@ import static java.util.Objects.nonNull; import static java.util.stream.Collectors.collectingAndThen; import static java.util.stream.Collectors.toList; -import static org.folio.dao.util.RecordDaoUtil.RECORD_NOT_FOUND_TEMPLATE; -import static org.folio.dao.util.RecordDaoUtil.ensureRecordForeignKeys; -import static org.folio.dao.util.RecordDaoUtil.ensureRecordHasId; -import static org.folio.dao.util.RecordDaoUtil.ensureRecordHasSuppressDiscovery; -import static org.folio.dao.util.RecordDaoUtil.filterRecordByExternalHridValues; -import static org.folio.dao.util.RecordDaoUtil.filterRecordByState; -import static org.folio.dao.util.RecordDaoUtil.getExternalIdsCondition; +import static org.folio.dao.util.RecordDaoUtil.*; import static org.folio.dao.util.SnapshotDaoUtil.SNAPSHOT_NOT_FOUND_TEMPLATE; import static org.folio.dao.util.SnapshotDaoUtil.SNAPSHOT_NOT_STARTED_MESSAGE_TEMPLATE; import static org.folio.rest.util.QueryParamUtil.toRecordType; @@ -462,7 +456,11 @@ private MatchField prepareMatchField(RecordMatchingDto recordMatchingDto) { String ind1 = filter.getIndicator1() != null ? filter.getIndicator1() : StringUtils.EMPTY; String ind2 = filter.getIndicator2() != null ? filter.getIndicator2() : StringUtils.EMPTY; String subfield = filter.getSubfield() != null ? filter.getSubfield() : StringUtils.EMPTY; - return new MatchField(filter.getField(), ind1, ind2, subfield, ListValue.of(filter.getValues())); + MatchField.QualifierMatch qualifier = null; + if (filter.getQualifier() != null && filter.getQualifierValue() != null) { + qualifier = new MatchField.QualifierMatch(filter.getQualifier(), filter.getQualifierValue()); + } + return new MatchField(filter.getField(), ind1, ind2, subfield, ListValue.of(filter.getValues()), qualifier); } private TypeConnection getTypeConnection(RecordMatchingDto.RecordType recordType) { @@ -477,13 +475,14 @@ private Future processDefaultMatchField(MatchField RecordMatchingDto recordMatchingDto, String tenantId) { Condition condition = filterRecordByState(Record.State.ACTUAL.value()); List values = ((ListValue) matchField.getValue()).getValue(); + var qualifier = matchField.getQualifierMatch(); if (matchField.isMatchedId()) { - condition = condition.and(getExternalIdsCondition(values, IdType.RECORD)); + condition = condition.and(getExternalIdsConditionWithQualifier(values, IdType.RECORD, qualifier)); } else if (matchField.isExternalId()) { - condition = condition.and(getExternalIdsCondition(values, IdType.EXTERNAL)); + condition = condition.and(getExternalIdsConditionWithQualifier(values, IdType.EXTERNAL, qualifier)); } else if (matchField.isExternalHrid()) { - condition = condition.and(filterRecordByExternalHridValues(values)); + condition = condition.and(filterRecordByExternalHridValuesWithQualifier(values, qualifier)); } return recordDao.getRecords(condition, typeConnection.getDbType(), Collections.emptyList(), recordMatchingDto.getOffset(), diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index 455549408..1c654ace1 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -100,7 +100,9 @@ public void shouldReturnEmptyCollectionIfRecordsDoNotMatch() { .withField("999") .withIndicator1("f") .withIndicator2("f") - .withSubfield("s")))) + .withSubfield("s") + .withQualifier(Filter.Qualifier.BEGINS_WITH) + .withQualifierValue("TEST")))) .post(RECORDS_MATCHING_PATH) .then() .statusCode(HttpStatus.SC_OK) From a2f2a58518bfe33ab7ec6a29eaa1f7d68d97d7b6 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Fri, 19 Jul 2024 15:00:19 +0300 Subject: [PATCH 03/11] MODSOURCE-783 Add tests for match on external ids by qualifier --- .../org/folio/dao/util/RecordDaoUtil.java | 2 +- .../org/folio/services/RecordServiceImpl.java | 8 +- .../rest/impl/RecordsMatchingApiTest.java | 78 +++++++++++++++++-- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java index 1eb7c2b9c..0cb32dc9f 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java @@ -766,7 +766,7 @@ private static Condition getIdCondition(IdType idType, Function, Con private static Condition getIdConditionWithQualifier(IdType idType, Function, Condition> idFieldToConditionMapper, MatchField.QualifierMatch qualifier) { IdType idTypeToUse = getIdType(idType); - RecordType recordType = getRecordType(idTypeToUse); + RecordType recordType = getRecordType(idType); var idField = RECORDS_LB.field(LOWER_CAMEL.to(LOWER_UNDERSCORE, idTypeToUse.getIdField()), UUID.class); var qualifierCondition = buildQualifierCondition(idField, qualifier); var resultCondition = idFieldToConditionMapper.apply(idField).and(getRecordTypeCondition(recordType)); diff --git a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java index 43f8e2f3b..abcfadc35 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/services/RecordServiceImpl.java @@ -4,7 +4,13 @@ import static java.util.Objects.nonNull; import static java.util.stream.Collectors.collectingAndThen; import static java.util.stream.Collectors.toList; -import static org.folio.dao.util.RecordDaoUtil.*; +import static org.folio.dao.util.RecordDaoUtil.RECORD_NOT_FOUND_TEMPLATE; +import static org.folio.dao.util.RecordDaoUtil.ensureRecordForeignKeys; +import static org.folio.dao.util.RecordDaoUtil.ensureRecordHasId; +import static org.folio.dao.util.RecordDaoUtil.ensureRecordHasSuppressDiscovery; +import static org.folio.dao.util.RecordDaoUtil.filterRecordByExternalHridValuesWithQualifier; +import static org.folio.dao.util.RecordDaoUtil.filterRecordByState; +import static org.folio.dao.util.RecordDaoUtil.getExternalIdsConditionWithQualifier; import static org.folio.dao.util.SnapshotDaoUtil.SNAPSHOT_NOT_FOUND_TEMPLATE; import static org.folio.dao.util.SnapshotDaoUtil.SNAPSHOT_NOT_STARTED_MESSAGE_TEMPLATE; import static org.folio.rest.util.QueryParamUtil.toRecordType; diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index 1c654ace1..e75aafcab 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -4,18 +4,15 @@ import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; +import liquibase.util.StringUtil; import org.apache.http.HttpStatus; import org.folio.TestUtil; import org.folio.dao.PostgresClientFactory; +import org.folio.dao.util.MatchField; import org.folio.dao.util.RecordDaoUtil; import org.folio.dao.util.SnapshotDaoUtil; -import org.folio.rest.jaxrs.model.ExternalIdsHolder; -import org.folio.rest.jaxrs.model.Filter; -import org.folio.rest.jaxrs.model.ParsedRecord; -import org.folio.rest.jaxrs.model.RawRecord; +import org.folio.rest.jaxrs.model.*; import org.folio.rest.jaxrs.model.Record; -import org.folio.rest.jaxrs.model.RecordMatchingDto; -import org.folio.rest.jaxrs.model.Snapshot; import org.hamcrest.MatcherAssert; import org.junit.After; import org.junit.Before; @@ -45,6 +42,9 @@ public class RecordsMatchingApiTest extends AbstractRestVerticleTest { private static final String PARSED_MARC_AUTHORITY_WITH_999_FIELD_SAMPLE_PATH = "src/test/resources/mock/parsedContents/parsedMarcAuthorityWith999field.json"; private static final String PARSED_MARC_HOLDINGS_WITH_999_FIELD_SAMPLE_PATH = "src/test/resources/mock/parsedContents/marcHoldingsContentWith999field.json"; private static final String PARSED_MARC_WITH_035_FIELD_SAMPLE_PATH = "src/test/resources/parsedMarcRecordContent.sample"; + private static final String INSTANCE_ID = "681394b4-10d8-4cb1-a618-0f9bd6152119"; + private static final String HR_ID = "12345"; + private static final int SPLIT_INDEX = 2; private static String rawRecordContent; private static String parsedRecordContent; @@ -118,6 +118,8 @@ public void shouldMatchRecordByMatchedIdField() { .body(new RecordMatchingDto() .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) .withFilters(List.of(new Filter() + .withQualifier(Filter.Qualifier.BEGINS_WITH) + .withQualifierValue("acf") .withValues(List.of(existingRecord.getMatchedId())) .withField("999") .withIndicator1("f") @@ -137,6 +139,16 @@ public void shouldMatchMarcBibRecordByInstanceIdField() { shouldMatchRecordByExternalIdField(existingRecord); } + @Test + public void shouldMatchMarcBibRecordByInstanceIdFieldAndQualifier() { + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, INSTANCE_ID.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, INSTANCE_ID.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, INSTANCE_ID.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + shouldMatchRecordByExternalIdField(existingRecord, beginWith); + shouldMatchRecordByExternalIdField(existingRecord, endWith); + shouldMatchRecordByExternalIdField(existingRecord, contains); + } + @Test public void shouldMatchMarcAuthorityRecordByAuthorityIdField(TestContext context) throws IOException { String parsedRecordContent = TestUtil.readFileFromPath(PARSED_MARC_AUTHORITY_WITH_999_FIELD_SAMPLE_PATH); @@ -195,6 +207,50 @@ private void shouldMatchRecordByExternalIdField(Record sourceRecord) { .body("identifiers[0].externalId", is(externalId)); } + private void shouldMatchRecordByExternalIdField(Record sourceRecord, MatchField.QualifierMatch qualifier) { + var externalId = RecordDaoUtil.getExternalId(sourceRecord.getExternalIdsHolder(), sourceRecord.getRecordType()); + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.valueOf(sourceRecord.getRecordType().name())) + .withFilters(List.of(new Filter() + .withValues(List.of(externalId)) + .withField("999") + .withIndicator1("f") + .withIndicator2("f") + .withSubfield("i") + .withQualifier(qualifier.qualifier()) + .withQualifierValue(qualifier.value())))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(1)) + .body("identifiers.size()", is(1)) + .body("identifiers[0].recordId", is(sourceRecord.getId())) + .body("identifiers[0].externalId", is(externalId)); + } + + private void shouldMatchRecordByInstanceHridFieldAndQualifier(MatchField.QualifierMatch qualifier) { + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) + .withFilters(List.of(new Filter() + .withValues(List.of(existingRecord.getExternalIdsHolder().getInstanceHrid())) + .withField("001") + .withQualifier(qualifier.qualifier()) + .withQualifierValue(qualifier.value())))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(1)) + .body("identifiers.size()", is(1)) + .body("identifiers[0].recordId", is(existingRecord.getId())) + .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); + } + @Test public void shouldMatchRecordByInstanceHridField() { RestAssured.given() @@ -214,6 +270,16 @@ public void shouldMatchRecordByInstanceHridField() { .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); } + @Test + public void shouldMatchRecordByInstanceHridFieldAndQualifier() { + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, HR_ID.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, HR_ID.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, HR_ID.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + shouldMatchRecordByInstanceHridFieldAndQualifier(beginWith); + shouldMatchRecordByInstanceHridFieldAndQualifier(endWith); + shouldMatchRecordByInstanceHridFieldAndQualifier(contains); + } + @Test public void shouldMatchRecordByMultipleDataFields() { RestAssured.given() From 7b3092c1c4a5d7e78de1ea530503a00c9d41a7c3 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Fri, 19 Jul 2024 15:01:43 +0300 Subject: [PATCH 04/11] MODSOURCE-783 Remove unused import --- .../test/java/org/folio/rest/impl/RecordsMatchingApiTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index e75aafcab..8d32981a2 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -4,7 +4,6 @@ import io.vertx.ext.unit.Async; import io.vertx.ext.unit.TestContext; import io.vertx.ext.unit.junit.VertxUnitRunner; -import liquibase.util.StringUtil; import org.apache.http.HttpStatus; import org.folio.TestUtil; import org.folio.dao.PostgresClientFactory; From cdf47a8ed08a99540366a35e298ab6e13af3802e Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Fri, 19 Jul 2024 15:02:24 +0300 Subject: [PATCH 05/11] MODSOURCE-783 Remove wildcard import --- .../java/org/folio/rest/impl/RecordsMatchingApiTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index 8d32981a2..95c87086e 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -10,7 +10,12 @@ import org.folio.dao.util.MatchField; import org.folio.dao.util.RecordDaoUtil; import org.folio.dao.util.SnapshotDaoUtil; -import org.folio.rest.jaxrs.model.*; +import org.folio.rest.jaxrs.model.ExternalIdsHolder; +import org.folio.rest.jaxrs.model.Filter; +import org.folio.rest.jaxrs.model.ParsedRecord; +import org.folio.rest.jaxrs.model.RawRecord; +import org.folio.rest.jaxrs.model.RecordMatchingDto; +import org.folio.rest.jaxrs.model.Snapshot; import org.folio.rest.jaxrs.model.Record; import org.hamcrest.MatcherAssert; import org.junit.After; From bd2e7d56fc67e353ad7748f249f13ae15c1ed919 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 22 Jul 2024 15:20:06 +0300 Subject: [PATCH 06/11] MODSOURCE-783 Add search by qualifiers to non-external ids field --- .../java/org/folio/dao/RecordDaoImpl.java | 31 +++- .../rest/impl/RecordsMatchingApiTest.java | 138 +++++++++++++++++- 2 files changed, 161 insertions(+), 8 deletions(-) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java index b50397b67..ced60416d 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java @@ -156,7 +156,9 @@ public class RecordDaoImpl implements RecordDao { static final int INDEXERS_DELETION_LOCK_NAMESPACE_ID = "delete_marc_indexers".hashCode(); public static final String CONTROL_FIELD_CONDITION_TEMPLATE = "\"{partition}\".\"value\" in ({value})"; + public static final String CONTROL_FIELD_CONDITION_TEMPLATE_WITH_QUALIFIER = "\"{partition}\".\"value\" IN ({value}) AND \"{partition}\".\"value\" LIKE {qualifier}"; public static final String DATA_FIELD_CONDITION_TEMPLATE = "\"{partition}\".\"value\" in ({value}) and \"{partition}\".\"ind1\" LIKE '{ind1}' and \"{partition}\".\"ind2\" LIKE '{ind2}' and \"{partition}\".\"subfield_no\" = '{subfield}'"; + public static final String DATA_FIELD_CONDITION_TEMPLATE_WITH_QUALIFIER = "\"{partition}\".\"value\" IN ({value}) AND \"{partition}\".\"value\" LIKE {qualifier} AND \"{partition}\".\"ind1\" LIKE '{ind1}' AND \"{partition}\".\"ind2\" LIKE '{ind2}' AND \"{partition}\".\"subfield_no\" = '{subfield}'"; private static final String VALUE_IN_SINGLE_QUOTES = "'%s'"; private static final String RECORD_NOT_FOUND_BY_ID_TYPE = "Record with %s id: %s was not found"; private static final String INVALID_PARSED_RECORD_MESSAGE_TEMPLATE = "Record %s has invalid parsed record; %s"; @@ -352,18 +354,26 @@ public Future> getMatchedRecordsWithoutIndexersVersionUsage(MatchFi private Condition getMatchedFieldCondition(MatchField matchedField, String partition) { Map params = new HashMap<>(); + var qualifierSearch = false; params.put("partition", partition); params.put("value", getValueInSqlFormat(matchedField.getValue())); + if (matchedField.getQualifierMatch() != null) { + qualifierSearch = true; + params.put("qualifier", getSqlQualifier(matchedField.getQualifierMatch())); + } + String sql; + Condition condition; if (matchedField.isControlField()) { - String sql = StrSubstitutor.replace(CONTROL_FIELD_CONDITION_TEMPLATE, params, "{", "}"); - return condition(sql); + sql = qualifierSearch ? StrSubstitutor.replace(CONTROL_FIELD_CONDITION_TEMPLATE_WITH_QUALIFIER, params, "{", "}") + : StrSubstitutor.replace(CONTROL_FIELD_CONDITION_TEMPLATE, params, "{", "}"); } else { params.put("ind1", getSqlInd(matchedField.getInd1())); params.put("ind2", getSqlInd(matchedField.getInd2())); params.put("subfield", matchedField.getSubfield()); - String sql = StrSubstitutor.replace(DATA_FIELD_CONDITION_TEMPLATE, params, "{", "}"); - return condition(sql); + sql = qualifierSearch ? sql = StrSubstitutor.replace(DATA_FIELD_CONDITION_TEMPLATE_WITH_QUALIFIER, params, "{", "}") + : StrSubstitutor.replace(DATA_FIELD_CONDITION_TEMPLATE, params, "{", "}"); } + return condition(sql); } private String getSqlInd(String ind) { @@ -372,6 +382,19 @@ private String getSqlInd(String ind) { return ind; } + private String getSqlQualifier(MatchField.QualifierMatch qualifierMatch) { + if (qualifierMatch == null) { + return null; + } + var value = qualifierMatch.value(); + + return switch (qualifierMatch.qualifier()) { + case BEGINS_WITH -> "'" + value + "%'"; + case ENDS_WITH -> "'%" + value + "'"; + case CONTAINS -> "'%" + value + "%'"; + }; + } + private String getValueInSqlFormat(Value value) { if (Value.ValueType.STRING.equals(value.getType())) { return format(VALUE_IN_SINGLE_QUOTES, value.getValue()); diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index 95c87086e..0fa82958a 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -47,7 +47,8 @@ public class RecordsMatchingApiTest extends AbstractRestVerticleTest { private static final String PARSED_MARC_HOLDINGS_WITH_999_FIELD_SAMPLE_PATH = "src/test/resources/mock/parsedContents/marcHoldingsContentWith999field.json"; private static final String PARSED_MARC_WITH_035_FIELD_SAMPLE_PATH = "src/test/resources/parsedMarcRecordContent.sample"; private static final String INSTANCE_ID = "681394b4-10d8-4cb1-a618-0f9bd6152119"; - private static final String HR_ID = "12345"; + private static final String FIELD_035 = "12569"; + private static final String FIELD_007 = "12345"; private static final int SPLIT_INDEX = 2; private static String rawRecordContent; @@ -276,14 +277,39 @@ public void shouldMatchRecordByInstanceHridField() { @Test public void shouldMatchRecordByInstanceHridFieldAndQualifier() { - var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, HR_ID.substring(0, SPLIT_INDEX)); - var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, HR_ID.substring(SPLIT_INDEX)); - var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, HR_ID.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + var hrId = existingRecord.getExternalIdsHolder().getInstanceHrid(); + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, hrId.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, hrId.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, hrId.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); shouldMatchRecordByInstanceHridFieldAndQualifier(beginWith); shouldMatchRecordByInstanceHridFieldAndQualifier(endWith); shouldMatchRecordByInstanceHridFieldAndQualifier(contains); } + @Test + public void shouldNotMatchMarcBibRecordByInstanceIdFieldAndQualifier(){ + var externalId = RecordDaoUtil.getExternalId(existingRecord.getExternalIdsHolder(), existingRecord.getRecordType()); + var qualifier = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, "ABC"); + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.valueOf(existingRecord.getRecordType().name())) + .withFilters(List.of(new Filter() + .withValues(List.of(externalId)) + .withField("999") + .withIndicator1("f") + .withIndicator2("f") + .withSubfield("i") + .withQualifier(qualifier.qualifier()) + .withQualifierValue(qualifier.value())))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(0)) + .body("identifiers.size()", is(0)); + } + @Test public void shouldMatchRecordByMultipleDataFields() { RestAssured.given() @@ -306,6 +332,29 @@ public void shouldMatchRecordByMultipleDataFields() { .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); } + public void shouldMatchRecordByMultipleDataFieldsAndQualifier(MatchField.QualifierMatch qualifier) { + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) + .withFilters(List.of(new Filter() + .withValues(List.of("12345", "oclc1234567")) + .withQualifier(qualifier.qualifier()) + .withQualifierValue(qualifier.value()) + .withField("035") + .withIndicator1("") + .withIndicator2("") + .withSubfield("a")))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(1)) + .body("identifiers.size()", is(1)) + .body("identifiers[0].recordId", is(existingRecord.getId())) + .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); + } + @Test public void shouldMatchRecordByMultipleControlledFields() { RestAssured.given() @@ -325,6 +374,87 @@ public void shouldMatchRecordByMultipleControlledFields() { .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); } + @Test + public void shouldMatchRecordByMultipleDataFieldsAndQualifier() { + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, FIELD_007.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, FIELD_007.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, FIELD_007.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + shouldMatchRecordByMultipleDataFieldsAndQualifier(beginWith); + shouldMatchRecordByMultipleDataFieldsAndQualifier(endWith); + shouldMatchRecordByMultipleDataFieldsAndQualifier(contains); + } + + @Test + public void shouldNotMatchRecordByMultipleDataFieldsAndQualifier() { + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) + .withFilters(List.of(new Filter() + .withValues(List.of("12345", "oclc1234567")) + .withQualifier(Filter.Qualifier.BEGINS_WITH) + .withQualifierValue("ABC") + .withField("035") + .withIndicator1("") + .withIndicator2("") + .withSubfield("a")))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(0)) + .body("identifiers.size()", is(0)); + } + + private void shouldMatchRecordByMultipleControlledFieldsAndQualifier(MatchField.QualifierMatch qualifier){ + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) + .withFilters(List.of(new Filter() + .withValues(List.of("12569", "364345")) + .withQualifier(qualifier.qualifier()) + .withQualifierValue(qualifier.value()) + .withField("007")))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(1)) + .body("identifiers.size()", is(1)) + .body("identifiers[0].recordId", is(existingRecord.getId())) + .body("identifiers[0].externalId", is(existingRecord.getExternalIdsHolder().getInstanceId())); + } + + @Test + public void shouldMatchRecordByMultipleControlledFieldsAndQualifier() { + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, FIELD_035.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, FIELD_035.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, FIELD_035.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + shouldMatchRecordByMultipleControlledFieldsAndQualifier(beginWith); + shouldMatchRecordByMultipleControlledFieldsAndQualifier(endWith); + shouldMatchRecordByMultipleControlledFieldsAndQualifier(contains); + } + + @Test + public void shouldNotMatchRecordByMultipleControlledFieldsAndQualifier(){ + RestAssured.given() + .spec(spec) + .when() + .body(new RecordMatchingDto() + .withRecordType(RecordMatchingDto.RecordType.MARC_BIB) + .withFilters(List.of(new Filter() + .withValues(List.of("12569", "364345")) + .withQualifier(Filter.Qualifier.BEGINS_WITH) + .withQualifierValue("ABC") + .withField("007")))) + .post(RECORDS_MATCHING_PATH) + .then() + .statusCode(HttpStatus.SC_OK) + .body("totalRecords", is(0)) + .body("identifiers.size()", is(0)); + } + @Test public void shouldMatchRecordByMultiple024FieldsWithWildcardsInd() { RestAssured.given() From fe58f08591c98992070e1a3aed350af1a92aee65 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 22 Jul 2024 15:22:58 +0300 Subject: [PATCH 07/11] MODSOURCE-783 Remove redundant constant --- .../java/org/folio/rest/impl/RecordsMatchingApiTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java index 0fa82958a..47f7f62a9 100644 --- a/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java +++ b/mod-source-record-storage-server/src/test/java/org/folio/rest/impl/RecordsMatchingApiTest.java @@ -46,7 +46,6 @@ public class RecordsMatchingApiTest extends AbstractRestVerticleTest { private static final String PARSED_MARC_AUTHORITY_WITH_999_FIELD_SAMPLE_PATH = "src/test/resources/mock/parsedContents/parsedMarcAuthorityWith999field.json"; private static final String PARSED_MARC_HOLDINGS_WITH_999_FIELD_SAMPLE_PATH = "src/test/resources/mock/parsedContents/marcHoldingsContentWith999field.json"; private static final String PARSED_MARC_WITH_035_FIELD_SAMPLE_PATH = "src/test/resources/parsedMarcRecordContent.sample"; - private static final String INSTANCE_ID = "681394b4-10d8-4cb1-a618-0f9bd6152119"; private static final String FIELD_035 = "12569"; private static final String FIELD_007 = "12345"; private static final int SPLIT_INDEX = 2; @@ -146,9 +145,10 @@ public void shouldMatchMarcBibRecordByInstanceIdField() { @Test public void shouldMatchMarcBibRecordByInstanceIdFieldAndQualifier() { - var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, INSTANCE_ID.substring(0, SPLIT_INDEX)); - var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, INSTANCE_ID.substring(SPLIT_INDEX)); - var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, INSTANCE_ID.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); + var instanceId = existingRecord.getExternalIdsHolder().getInstanceId(); + var beginWith = new MatchField.QualifierMatch(Filter.Qualifier.BEGINS_WITH, instanceId.substring(0, SPLIT_INDEX)); + var endWith = new MatchField.QualifierMatch(Filter.Qualifier.ENDS_WITH, instanceId.substring(SPLIT_INDEX)); + var contains = new MatchField.QualifierMatch(Filter.Qualifier.CONTAINS, instanceId.substring(SPLIT_INDEX, SPLIT_INDEX + SPLIT_INDEX)); shouldMatchRecordByExternalIdField(existingRecord, beginWith); shouldMatchRecordByExternalIdField(existingRecord, endWith); shouldMatchRecordByExternalIdField(existingRecord, contains); From 29b33e3df62feba1b09a1906b75382aac4c21096 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 22 Jul 2024 17:06:38 +0300 Subject: [PATCH 08/11] MODSOURCE-783 Update NEWS.md --- NEWS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.md b/NEWS.md index 44e8e189c..aec5314f5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,7 @@ * [MODSOURCE-773](https://folio-org.atlassian.net/browse/MODSOURCE-773) MARC Search omits suppressed from discovery records in default search * [MODINV-1044](https://folio-org.atlassian.net/browse/MODINV-1044) Additional Requirements - Update Data Import logic to normalize OCLC 035 values * [MODSOURMAN-1200](https://folio-org.atlassian.net/browse/MODSOURMAN-1200) Find record by match id on update generation +* [MODSOURMAN-783](https://folio-org.atlassian.net/browse/MODSOURCE-783) Extend MARC-MARC search query to account for qualifiers ## 2024-03-20 5.8.0 * [MODSOURCE-733](https://issues.folio.org/browse/MODSOURCE-733) Reduce Memory Allocation of Strings From 5f41bff9369267a37609758458e71dced0db8931 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 22 Jul 2024 17:13:53 +0300 Subject: [PATCH 09/11] MODSOURCE-783 Update javadoc --- .../src/main/java/org/folio/dao/util/RecordDaoUtil.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java index 0cb32dc9f..6271de982 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/util/RecordDaoUtil.java @@ -83,6 +83,7 @@ public static Condition getExternalIdsCondition(List externalIds, IdType * * @param externalIds list of external id * @param idType external id type + * @param qualifier qualifier type and value * @return condition */ public static Condition getExternalIdsConditionWithQualifier(List externalIds, IdType idType, MatchField.QualifierMatch qualifier) { @@ -494,6 +495,7 @@ public static Condition filterRecordByExternalHridValues(List externalHr * Get {@link Condition} to filter by external entity hrid using specified values and match by qualifier value * * @param externalHridValues external entity hrid values to equal + * @param qualifier qualifier type and value * @return condition */ public static Condition filterRecordByExternalHridValuesWithQualifier(List externalHridValues, MatchField.QualifierMatch qualifier) { From 1e91501c7ab8861ec5aac3d82477333f32beecf3 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 29 Jul 2024 12:10:01 +0300 Subject: [PATCH 10/11] MODSOURCE-783 Remove wildcard import --- .../src/main/java/org/folio/dao/RecordDaoImpl.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java index ced60416d..e8a890d12 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java @@ -132,7 +132,18 @@ import static org.folio.rest.jooq.Tables.SNAPSHOTS_LB; import static org.folio.rest.jooq.enums.RecordType.MARC_BIB; import static org.folio.rest.util.QueryParamUtil.toRecordType; -import static org.jooq.impl.DSL.*; +import static org.jooq.impl.DSL.condition; +import static org.jooq.impl.DSL.countDistinct; +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.inline; +import static org.jooq.impl.DSL.max; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.primaryKey; +import static org.jooq.impl.DSL.select; +import static org.jooq.impl.DSL.table; +import static org.jooq.impl.DSL.trueCondition; +import static org.jooq.impl.DSL.selectDistinct; +import static org.jooq.impl.DSL.exists; @Component public class RecordDaoImpl implements RecordDao { From 1b21926fcf6aa1c363be62902ef84c5a9c22eea4 Mon Sep 17 00:00:00 2001 From: Dmytro Krutii Date: Mon, 29 Jul 2024 12:43:54 +0300 Subject: [PATCH 11/11] MODSOURCE-783 Remove unused variable --- .../src/main/java/org/folio/dao/RecordDaoImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java index e8a890d12..676def094 100644 --- a/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java +++ b/mod-source-record-storage-server/src/main/java/org/folio/dao/RecordDaoImpl.java @@ -373,7 +373,6 @@ private Condition getMatchedFieldCondition(MatchField matchedField, String parti params.put("qualifier", getSqlQualifier(matchedField.getQualifierMatch())); } String sql; - Condition condition; if (matchedField.isControlField()) { sql = qualifierSearch ? StrSubstitutor.replace(CONTROL_FIELD_CONDITION_TEMPLATE_WITH_QUALIFIER, params, "{", "}") : StrSubstitutor.replace(CONTROL_FIELD_CONDITION_TEMPLATE, params, "{", "}");