Add ValueRangeBehavior - e.g., for min/max numeric value

This commit is contained in:
2025-02-03 15:40:44 -06:00
parent 036b02bb6c
commit 33f3ebd4c6
5 changed files with 830 additions and 2 deletions

View File

@ -64,6 +64,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.DateTimeDisplayValue
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
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.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
@ -1926,6 +1927,20 @@ public class QInstanceValidatorTest extends BaseTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testFieldBehaviorsWithTheirOwnValidateMethods()
{
Function<QInstance, QFieldMetaData> fieldExtractor = qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).getField("id");
assertValidationFailureReasons((qInstance -> fieldExtractor.apply(qInstance).withBehavior(new ValueRangeBehavior())),
"Field id in table person: ValueRangeBehavior: Either minValue or maxValue (or both) must be set.");
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -0,0 +1,144 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.model.metadata.fields;
import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior.Behavior;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionAssert;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
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.fail;
/*******************************************************************************
** Unit test for ValueOutsideOfRangeBehavior
*******************************************************************************/
class ValueRangeBehaviorTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void test()
{
QInstance qInstance = QContext.getQInstance();
QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
table.getField("noOfShoes").withBehavior(new ValueRangeBehavior().withMinValue(0));
table.getField("cost").withBehavior(new ValueRangeBehavior().withMaxValue(new BigDecimal("3.50")));
table.getField("price").withBehavior(new ValueRangeBehavior()
.withMin(BigDecimal.ZERO, false, Behavior.CLIP, new BigDecimal(".01"))
.withMaxValue(new BigDecimal(100)).withMaxAllowEqualTo(false));
List<QRecord> recordList = List.of(
new QRecord().withValue("id", 1).withValue("noOfShoes", -1).withValue("cost", new BigDecimal("3.50")).withValue("price", new BigDecimal(-1)),
new QRecord().withValue("id", 2).withValue("noOfShoes", 0).withValue("cost", new BigDecimal("3.51")).withValue("price", new BigDecimal(200)),
new QRecord().withValue("id", 3).withValue("noOfShoes", 1).withValue("cost", new BigDecimal("3.50")).withValue("price", new BigDecimal("99.99"))
);
ValueBehaviorApplier.applyFieldBehaviors(ValueBehaviorApplier.Action.INSERT, qInstance, table, recordList, null);
{
QRecord record = getRecordById(recordList, 1);
assertEquals(-1, record.getValueInteger("noOfShoes")); // error (but didn't change value)
assertEquals(new BigDecimal("3.50"), record.getValueBigDecimal("cost")); // all okay
assertEquals(new BigDecimal("0.01"), record.getValueBigDecimal("price")); // got clipped
assertThat(record.getErrors())
.hasSize(1)
.anyMatch(e -> e.getMessage().equals("The value for No Of Shoes is too small (minimum allowed value is 0)"));
}
{
QRecord record = getRecordById(recordList, 2);
assertEquals(0, record.getValueInteger("noOfShoes")); // all ok
assertEquals(new BigDecimal("3.51"), record.getValueBigDecimal("cost")); // error (but didn't change value)
assertEquals(new BigDecimal(200), record.getValueBigDecimal("price")); // error (but didn't change value)
assertThat(record.getErrors())
.hasSize(2)
.anyMatch(e -> e.getMessage().equals("The value for Cost is too large (maximum allowed value is 3.50)"))
.anyMatch(e -> e.getMessage().equals("The value for Price is too large (maximum allowed value is less than 100)"));
}
{
QRecord record = getRecordById(recordList, 3);
assertEquals(1, record.getValueInteger("noOfShoes")); // all ok
assertEquals(new BigDecimal("3.50"), record.getValueBigDecimal("cost")); // all ok
assertEquals(new BigDecimal("99.99"), record.getValueBigDecimal("price")); // all ok
assertThat(record.getErrors()).isNullOrEmpty();
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testValidation()
{
QInstance qInstance = QContext.getQInstance();
QTableMetaData table = qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY);
QFieldMetaData noOfShoesField = table.getField("noOfShoes");
QFieldMetaData firstNameField = table.getField("firstName");
CollectionAssert.assertThat(new ValueRangeBehavior().validateBehaviorConfiguration(table, noOfShoesField))
.matchesAll(List.of("Either minValue or maxValue (or both) must be set."), Objects::equals);
CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(0).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty();
CollectionAssert.assertThat(new ValueRangeBehavior().withMaxValue(100).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty();
CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(0).withMaxValue(100).validateBehaviorConfiguration(table, noOfShoesField)).isNullOrEmpty();
CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(1).withMaxValue(0).validateBehaviorConfiguration(table, noOfShoesField))
.matchesAll(List.of("minValue must be >= maxValue."), Objects::equals);
CollectionAssert.assertThat(new ValueRangeBehavior().withMinValue(1).validateBehaviorConfiguration(table, firstNameField))
.matchesAll(List.of("can only be applied to a numeric type field."), Objects::equals);
}
/*******************************************************************************
**
*******************************************************************************/
private static QRecord getRecordById(List<QRecord> recordList, Integer id)
{
Optional<QRecord> recordOpt = recordList.stream().filter(r -> r.getValueInteger("id").equals(id)).findFirst();
if(recordOpt.isEmpty())
{
fail("Didn't find record with id=" + id);
}
return (recordOpt.get());
}
}

View File

@ -0,0 +1,184 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.utils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.BiFunction;
import org.assertj.core.api.AbstractAssert;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/*******************************************************************************
** assertions against collections
*******************************************************************************/
public class CollectionAssert<A> extends AbstractAssert<CollectionAssert<A>, Collection<A>>
{
/***************************************************************************
**
***************************************************************************/
protected CollectionAssert(Collection<A> actual, Class<?> selfType)
{
super(actual, selfType);
}
/***************************************************************************
**
***************************************************************************/
public static <A> CollectionAssert<A> assertThat(Collection<A> actualCollection)
{
return (new CollectionAssert<>(actualCollection, CollectionAssert.class));
}
/***************************************************************************
*
***************************************************************************/
public <E> CollectionAssert<A> matchesAllowingExpectedToHaveMore(Collection<E> expected, BiFunction<E, A, Boolean> predicate)
{
if(actual == null && expected != null)
{
fail("Actual collection was null, but expected collection was not-null");
return (this);
}
else if(actual != null && expected == null)
{
fail("Actual collection was not null, but expected collection was null");
return (this);
}
else if(actual == null && expected == null)
{
return (this);
}
assertTrue(actual.size() >= expected.size(), "Actual collection size [" + actual.size() + "] should be >= expected collection size [" + expected.size() + "]");
matchElements(expected, predicate);
return (this);
}
/***************************************************************************
*
***************************************************************************/
private <E> void matchElements(Collection<E> expected, BiFunction<E, A, Boolean> predicate)
{
List<Integer> nonMatchingExpectedIndexes = new ArrayList<>();
Set<Integer> matchedActualIndexes = new HashSet<>();
List<E> expectedList = new ArrayList<>(expected);
List<A> actualList = new ArrayList<>(actual);
for(int eIndex = 0; eIndex < expectedList.size(); eIndex++)
{
E e = expectedList.get(eIndex);
boolean matchedThieE = false;
for(int aIndex = 0; aIndex < actualList.size(); aIndex++)
{
A a = actualList.get(aIndex);
if(!matchedThieE && !matchedActualIndexes.contains(aIndex)) // don't re-check an already-matched item
{
if(predicate.apply(e, a))
{
matchedActualIndexes.add(aIndex);
matchedThieE = true;
}
}
}
if(!matchedThieE)
{
nonMatchingExpectedIndexes.add(eIndex);
}
}
assertTrue(nonMatchingExpectedIndexes.isEmpty(), "Did not find a match for indexes " + nonMatchingExpectedIndexes + "\n from expected collection: " + expected + "\n in actual collection: " + actual);
}
/***************************************************************************
*
***************************************************************************/
public <E> CollectionAssert<A> matchesAll(Collection<E> expected, BiFunction<E, A, Boolean> predicate)
{
if(actual == null && expected != null)
{
fail("Actual collection was null, but expected collection was not-null");
return (this);
}
else if(actual != null && expected == null)
{
fail("Actual collection was not null, but expected collection was null");
return (this);
}
else if(actual == null && expected == null)
{
return (this);
}
assertEquals(expected.size(), actual.size(), "Expected size of collections");
matchElements(expected, predicate);
return (this);
}
/***************************************************************************
*
***************************************************************************/
public CollectionAssert<A> isNullOrEmpty()
{
if(actual != null)
{
assertEquals(0, actual.size(), "Expected collection to be null or empty");
}
return (this);
}
/***************************************************************************
*
***************************************************************************/
public CollectionAssert<A> isEmpty()
{
assertEquals(0, actual.size(), "Expected collection to be empty");
return (this);
}
}