From a6001af7b56062866c7b72de52adbcafa0c46a52 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 23 Dec 2024 14:59:28 -0600 Subject: [PATCH] Add overload of toQRecordOnlyChangedFields that allows primary keys to be included (more useful for the update use-case) --- .../core/model/data/QRecordEntity.java | 141 +++++++++------ .../model/metadata/tables/QTableMetaData.java | 14 +- .../core/model/data/QRecordEntityTest.java | 162 +++++++++++++++++- .../core/model/data/testentities/Item.java | 130 ++++++++++++++ 4 files changed, 387 insertions(+), 60 deletions(-) diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntity.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntity.java index e62757e1..6425893a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntity.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntity.java @@ -41,11 +41,14 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Function; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QRuntimeException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.ListingHash; +import com.kingsrook.qqq.backend.core.utils.ObjectUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -61,6 +64,11 @@ public abstract class QRecordEntity private Map originalRecordValues; + //////////////////////////////////////////////////////////////////////////////// + // map of entity class names to QTableMetaData objects that they helped build // + //////////////////////////////////////////////////////////////////////////////// + private static Map tableReferences = new HashMap<>(); + /******************************************************************************* @@ -95,6 +103,19 @@ public abstract class QRecordEntity + /*************************************************************************** + ** register a mapping between an entity class and a table that it is associated with. + ***************************************************************************/ + public static void registerTable(Class entityClass, QTableMetaData table) + { + if(entityClass != null && table != null) + { + tableReferences.put(entityClass.getName(), table); + } + } + + + /******************************************************************************* ** Build an entity of this QRecord type from a QRecord ** @@ -176,7 +197,10 @@ public abstract class QRecordEntity /******************************************************************************* - ** Convert this entity to a QRecord. + ** Convert this entity to a QRecord. ALL fields in the entity will be set + ** in the QRecord. Note that, if you're using this for an input to the UpdateAction, + ** that this could cause values to be set to null, e.g., if you constructed + ** a entity from scratch, and didn't set all values in it!! ** *******************************************************************************/ public QRecord toQRecord() throws QRuntimeException @@ -190,25 +214,7 @@ public abstract class QRecordEntity qRecord.setValue(qRecordEntityField.getFieldName(), (Serializable) qRecordEntityField.getGetter().invoke(this)); } - for(QRecordEntityAssociation qRecordEntityAssociation : getAssociationList(this.getClass())) - { - @SuppressWarnings("unchecked") - List associatedEntities = (List) qRecordEntityAssociation.getGetter().invoke(this); - String associationName = qRecordEntityAssociation.getAssociationAnnotation().name(); - - if(associatedEntities != null) - { - ///////////////////////////////////////////////////////////////////////////////// - // do this so an empty list in the entity becomes an empty list in the QRecord // - ///////////////////////////////////////////////////////////////////////////////// - qRecord.withAssociatedRecords(associationName, new ArrayList<>()); - } - - for(QRecordEntity associatedEntity : CollectionUtils.nonNullList(associatedEntities)) - { - qRecord.withAssociatedRecord(associationName, associatedEntity.toQRecord()); - } - } + toQRecordProcessAssociations(qRecord, (entity) -> entity.toQRecord()); return (qRecord); } @@ -220,15 +226,65 @@ public abstract class QRecordEntity + /*************************************************************************** + * + ***************************************************************************/ + private void toQRecordProcessAssociations(QRecord outputRecord, Function toRecordFunction) throws Exception + { + for(QRecordEntityAssociation qRecordEntityAssociation : getAssociationList(this.getClass())) + { + @SuppressWarnings("unchecked") + List associatedEntities = (List) qRecordEntityAssociation.getGetter().invoke(this); + String associationName = qRecordEntityAssociation.getAssociationAnnotation().name(); + + if(associatedEntities != null) + { + outputRecord.withAssociatedRecords(associationName, new ArrayList<>()); + for(QRecordEntity associatedEntity : associatedEntities) + { + outputRecord.withAssociatedRecord(associationName, toRecordFunction.apply(associatedEntity)); + } + } + } + } + + + /******************************************************************************* - ** + ** Overload of toQRecordOnlyChangedFields that preserves original behavior of + ** that method, which is, to NOT includePrimaryKey *******************************************************************************/ + @Deprecated(since = "includePrimaryKey param was added") public QRecord toQRecordOnlyChangedFields() + { + return toQRecordOnlyChangedFields(false); + } + + + + /******************************************************************************* + ** Useful for the use-case of: + ** - fetch a QRecord (e.g., QueryAction or GetAction) + ** - build a QRecordEntity out of it + ** - change a field (or two) in it + ** - want to pass it into an UpdateAction, and want to see only the fields that + ** you know you changed get passed in to UpdateAction (e.g., PATCH semantics). + ** + ** But also - per the includePrimaryKey param, include the primaryKey in the + ** records (e.g., to tell the Update which records to update). + ** + ** Also, useful for: + ** - construct new entity, calling setters to populate some fields + ** - pass that entity into + *******************************************************************************/ + public QRecord toQRecordOnlyChangedFields(boolean includePrimaryKey) { try { QRecord qRecord = new QRecord(); + String primaryKeyFieldName = ObjectUtils.tryElse(() -> tableReferences.get(getClass().getName()).getPrimaryKeyField(), null); + for(QRecordEntityField qRecordEntityField : getFieldList(this.getClass())) { Serializable thisValue = (Serializable) qRecordEntityField.getGetter().invoke(this); @@ -238,31 +294,16 @@ public abstract class QRecordEntity originalValue = originalRecordValues.get(qRecordEntityField.getFieldName()); } - if(!Objects.equals(thisValue, originalValue)) + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if this value and the original value don't match - OR - this is the table's primary key field - then put the value in the record. // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(!Objects.equals(thisValue, originalValue) || (includePrimaryKey && Objects.equals(primaryKeyFieldName, qRecordEntityField.getFieldName()))) { qRecord.setValue(qRecordEntityField.getFieldName(), thisValue); } } - for(QRecordEntityAssociation qRecordEntityAssociation : getAssociationList(this.getClass())) - { - @SuppressWarnings("unchecked") - List associatedEntities = (List) qRecordEntityAssociation.getGetter().invoke(this); - String associationName = qRecordEntityAssociation.getAssociationAnnotation().name(); - - if(associatedEntities != null) - { - ///////////////////////////////////////////////////////////////////////////////// - // do this so an empty list in the entity becomes an empty list in the QRecord // - ///////////////////////////////////////////////////////////////////////////////// - qRecord.withAssociatedRecords(associationName, new ArrayList<>()); - } - - for(QRecordEntity associatedEntity : CollectionUtils.nonNullList(associatedEntities)) - { - qRecord.withAssociatedRecord(associationName, associatedEntity.toQRecord()); - } - } + toQRecordProcessAssociations(qRecord, (entity) -> entity.toQRecordOnlyChangedFields(includePrimaryKey)); return (qRecord); } @@ -488,15 +529,15 @@ public abstract class QRecordEntity { // todo - more types!! return (returnType.equals(String.class) - || returnType.equals(Integer.class) - || returnType.equals(int.class) - || returnType.equals(Boolean.class) - || returnType.equals(boolean.class) - || returnType.equals(BigDecimal.class) - || returnType.equals(Instant.class) - || returnType.equals(LocalDate.class) - || returnType.equals(LocalTime.class) - || returnType.equals(byte[].class)); + || returnType.equals(Integer.class) + || returnType.equals(int.class) + || returnType.equals(Boolean.class) + || returnType.equals(boolean.class) + || returnType.equals(BigDecimal.class) + || returnType.equals(Instant.class) + || returnType.equals(LocalDate.class) + || returnType.equals(LocalTime.class) + || returnType.equals(byte[].class)); ///////////////////////////////////////////// // note - this list has implications upon: // // - QFieldType.fromClass // diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java index e6078562..93ae4f96 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java @@ -68,14 +68,6 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData private String name; private String label; - // TODO: resolve confusion over: - // Is this name of what backend the table is stored in (yes) - // Or the "name" of the table WITHIN the backend (no) - // although that's how "backendName" is used in QFieldMetaData. - // Idea: - // rename "backendName" here to "backend" - // add "nameInBackend" (or similar) for the table name in the backend - // OR - add a whole "backendDetails" object, with different details per backend-type private String backendName; private String primaryKeyField; private boolean isHidden = false; @@ -184,6 +176,12 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData } } + /////////////////////////////////////////////////////////////////////////////////////////////////// + // stash a reference from this entityClass to this table in the QRecordEntity class // + // (used within that class later, if it wants to know about a table that an Entity helped build) // + /////////////////////////////////////////////////////////////////////////////////////////////////// + QRecordEntity.registerTable(entityClass, this); + return (this); } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntityTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntityTest.java index f038c27c..38beaae4 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntityTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/QRecordEntityTest.java @@ -24,8 +24,10 @@ package com.kingsrook.qqq.backend.core.model.data; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import com.kingsrook.qqq.backend.core.BaseTest; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.testentities.Item; import com.kingsrook.qqq.backend.core.model.data.testentities.ItemWithPrimitives; @@ -35,7 +37,10 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -49,6 +54,31 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class QRecordEntityTest extends BaseTest { + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void beforeEach() throws QException + { + QContext.getQInstance().addTable(new QTableMetaData() + .withName(Item.TABLE_NAME) + .withFieldsFromEntity(Item.class) + ); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void afterEach() + { + QContext.getQInstance().getTables().remove(Item.TABLE_NAME); + } + + + /******************************************************************************* ** *******************************************************************************/ @@ -68,6 +98,19 @@ class QRecordEntityTest extends BaseTest assertEquals(47, qRecord.getValueInteger("quantity")); assertEquals(new BigDecimal("3.50"), qRecord.getValueBigDecimal("price")); assertTrue(qRecord.getValueBoolean("featured")); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + // assert that, if we had no lists of associations in the entity, that we also have none in the record // + ///////////////////////////////////////////////////////////////////////////////////////////////////////// + assertThat(qRecord.getAssociatedRecords()).isNullOrEmpty(); + + /////////////////////////////////////////////////////////////////////// + // now assert that an empty list translates through to an empty list // + /////////////////////////////////////////////////////////////////////// + item.setItemAlternates(Collections.emptyList()); + qRecord = item.toQRecord(); + assertTrue(qRecord.getAssociatedRecords().containsKey(Item.ASSOCIATION_ITEM_ALTERNATES_NAME)); + assertTrue(qRecord.getAssociatedRecords().get(Item.ASSOCIATION_ITEM_ALTERNATES_NAME).isEmpty()); } @@ -76,9 +119,40 @@ class QRecordEntityTest extends BaseTest ** *******************************************************************************/ @Test - void testItemToQRecordOnlyChangedFields() throws QException + void testItemToQRecordWithAssociations() throws QException + { + Item item = new Item(); + item.setSku("ABC-123"); + item.setQuantity(47); + item.setItemAlternates(List.of( + new Item().withSku("DEF"), + new Item().withSku("GHI").withQuantity(3) + )); + + QRecord qRecord = item.toQRecord(); + assertEquals("ABC-123", qRecord.getValueString("sku")); + assertEquals(47, qRecord.getValueInteger("quantity")); + + List associatedRecords = qRecord.getAssociatedRecords().get(Item.ASSOCIATION_ITEM_ALTERNATES_NAME); + assertEquals(2, associatedRecords.size()); + assertEquals("DEF", associatedRecords.get(0).getValue("sku")); + assertTrue(associatedRecords.get(0).getValues().containsKey("quantity")); + assertNull(associatedRecords.get(0).getValue("quantity")); + assertEquals("GHI", associatedRecords.get(1).getValue("sku")); + assertEquals(3, associatedRecords.get(1).getValue("quantity")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @SuppressWarnings("deprecation") + @Test + void testItemToQRecordOnlyChangedFieldsEntityThatCameFromQRecord() throws QException { Item item = new Item(new QRecord() + .withValue("id", 1701) .withValue("sku", "ABC-123") .withValue("description", null) .withValue("quantity", 47) @@ -88,11 +162,20 @@ class QRecordEntityTest extends BaseTest QRecord qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(); assertTrue(qRecordOnlyChangedFields.getValues().isEmpty()); + QRecord qRecordOnlyChangedFieldsIncludePKey = item.toQRecordOnlyChangedFields(true); + assertEquals(1, qRecordOnlyChangedFieldsIncludePKey.getValues().size()); + assertEquals(1701, qRecordOnlyChangedFieldsIncludePKey.getValue("id")); + item.setDescription("My Changed Item"); - qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(); + qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(false); assertEquals(1, qRecordOnlyChangedFields.getValues().size()); assertEquals("My Changed Item", qRecordOnlyChangedFields.getValueString("description")); + qRecordOnlyChangedFieldsIncludePKey = item.toQRecordOnlyChangedFields(true); + assertEquals(2, qRecordOnlyChangedFieldsIncludePKey.getValues().size()); + assertEquals("My Changed Item", qRecordOnlyChangedFieldsIncludePKey.getValueString("description")); + assertEquals(1701, qRecordOnlyChangedFieldsIncludePKey.getValue("id")); + item.setPrice(null); qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(); assertEquals(2, qRecordOnlyChangedFields.getValues().size()); @@ -101,6 +184,81 @@ class QRecordEntityTest extends BaseTest + /******************************************************************************* + ** + *******************************************************************************/ + @SuppressWarnings("deprecation") + @Test + void testItemToQRecordOnlyChangedFieldsFromNewEntity() throws QException + { + Item item = new Item() + .withId(1701) + .withSku("ABC-123"); + + QRecord qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(); + assertEquals(2, qRecordOnlyChangedFields.getValues().size()); + assertEquals(1701, qRecordOnlyChangedFields.getValue("id")); + assertEquals("ABC-123", qRecordOnlyChangedFields.getValue("sku")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @SuppressWarnings("deprecation") + @Test + void testItemToQRecordOnlyChangedFieldsWithAssociations() throws QException + { + Item item = new Item(new QRecord() + .withValue("id", 1701) + .withValue("sku", "ABC-123") + .withAssociatedRecord(Item.ASSOCIATION_ITEM_ALTERNATES_NAME, new Item(new QRecord() + .withValue("id", 1702) + .withValue("sku", "DEF") + .withValue("quantity", 3) + .withValue("price", new BigDecimal("3.50")) + ).toQRecord()) + ); + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // if no values were changed in the entities, from when they were constructed (from records), then value maps should be empty // + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + QRecord qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(false); + assertTrue(qRecordOnlyChangedFields.getValues().isEmpty()); + List associatedRecords = qRecordOnlyChangedFields.getAssociatedRecords().get(Item.ASSOCIATION_ITEM_ALTERNATES_NAME); + assertTrue(associatedRecords.get(0).getValues().isEmpty()); + + /////////////////////////////////////////////////////// + // but - if pkeys are requested, confirm we get them // + /////////////////////////////////////////////////////// + qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(true); + assertEquals(1, qRecordOnlyChangedFields.getValues().size()); + assertEquals(1701, qRecordOnlyChangedFields.getValue("id")); + associatedRecords = qRecordOnlyChangedFields.getAssociatedRecords().get(Item.ASSOCIATION_ITEM_ALTERNATES_NAME); + assertEquals(1, associatedRecords.get(0).getValues().size()); + assertEquals(1702, associatedRecords.get(0).getValue("id")); + + //////////////////////////////////////////// + // change some properties in the entities // + //////////////////////////////////////////// + item.setDescription("My Changed Item"); + item.getItemAlternates().get(0).setQuantity(4); + item.getItemAlternates().get(0).setPrice(null); + + qRecordOnlyChangedFields = item.toQRecordOnlyChangedFields(true); + assertEquals(2, qRecordOnlyChangedFields.getValues().size()); + assertEquals(1701, qRecordOnlyChangedFields.getValue("id")); + assertEquals("My Changed Item", qRecordOnlyChangedFields.getValue("description")); + associatedRecords = qRecordOnlyChangedFields.getAssociatedRecords().get(Item.ASSOCIATION_ITEM_ALTERNATES_NAME); + assertEquals(3, associatedRecords.get(0).getValues().size()); + assertEquals(1702, associatedRecords.get(0).getValue("id")); + assertEquals(4, associatedRecords.get(0).getValue("quantity")); + assertNull(associatedRecords.get(0).getValue("price")); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/testentities/Item.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/testentities/Item.java index 51be0f3e..7efb8270 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/testentities/Item.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/model/data/testentities/Item.java @@ -23,6 +23,8 @@ package com.kingsrook.qqq.backend.core.model.data.testentities; import java.math.BigDecimal; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.data.QAssociation; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; @@ -34,6 +36,13 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat; *******************************************************************************/ public class Item extends QRecordEntity { + public static final String TABLE_NAME = "item"; + + public static final String ASSOCIATION_ITEM_ALTERNATES_NAME = "itemAlternates"; + + @QField(isPrimaryKey = true) + private Integer id; + @QField(isRequired = true, label = "SKU") private String sku; @@ -49,6 +58,9 @@ public class Item extends QRecordEntity @QField(backendName = "is_featured") private Boolean featured; + @QAssociation(name = ASSOCIATION_ITEM_ALTERNATES_NAME) + private List itemAlternates; + /******************************************************************************* @@ -179,4 +191,122 @@ public class Item extends QRecordEntity { this.featured = featured; } + + + + /******************************************************************************* + ** Fluent setter for sku + *******************************************************************************/ + public Item withSku(String sku) + { + this.sku = sku; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for description + *******************************************************************************/ + public Item withDescription(String description) + { + this.description = description; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for quantity + *******************************************************************************/ + public Item withQuantity(Integer quantity) + { + this.quantity = quantity; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for price + *******************************************************************************/ + public Item withPrice(BigDecimal price) + { + this.price = price; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for featured + *******************************************************************************/ + public Item withFeatured(Boolean featured) + { + this.featured = featured; + return (this); + } + + + + /******************************************************************************* + ** Getter for itemAlternates + *******************************************************************************/ + public List getItemAlternates() + { + return (this.itemAlternates); + } + + + + /******************************************************************************* + ** Setter for itemAlternates + *******************************************************************************/ + public void setItemAlternates(List itemAlternates) + { + this.itemAlternates = itemAlternates; + } + + + + /******************************************************************************* + ** Fluent setter for itemAlternates + *******************************************************************************/ + public Item withItemAlternates(List itemAlternates) + { + this.itemAlternates = itemAlternates; + return (this); + } + + + /******************************************************************************* + ** Getter for id + *******************************************************************************/ + public Integer getId() + { + return (this.id); + } + + + + /******************************************************************************* + ** Setter for id + *******************************************************************************/ + public void setId(Integer id) + { + this.id = id; + } + + + + /******************************************************************************* + ** Fluent setter for id + *******************************************************************************/ + public Item withId(Integer id) + { + this.id = id; + return (this); + } + + }