diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMap.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMap.java new file mode 100644 index 00000000..8cbcb54d --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMap.java @@ -0,0 +1,53 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.backend.core.utils.collections; + + +import java.util.Map; +import java.util.function.Supplier; + + +/******************************************************************************* + ** Version of map where string keys are handled case-insensitively. e.g., + ** map.put("One", 1); map.get("ONE") == 1. + *******************************************************************************/ +public class CaseInsensitiveKeyMap extends TransformedKeyMap +{ + /*************************************************************************** + * + ***************************************************************************/ + public CaseInsensitiveKeyMap() + { + super(key -> key.toLowerCase()); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public CaseInsensitiveKeyMap(Supplier> supplier) + { + super(key -> key.toLowerCase(), supplier); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMap.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMap.java new file mode 100644 index 00000000..afe1116d --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMap.java @@ -0,0 +1,400 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.backend.core.utils.collections; + + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + + +/******************************************************************************* + ** Version of a map that uses a transformation function on keys. The original + ** idea being, e.g., to support case-insensitive keys via a toLowerCase transform. + ** e.g., map.put("One", 1); map.get("ONE") == 1. + ** + ** But, implemented generically to support any transformation function. + ** + ** keySet() and entries() should give only the first version of a key that overlapped. + ** e.g., map.put("One", 1); map.put("one", 1); map.keySet() == Set.of("One"); + *******************************************************************************/ +public class TransformedKeyMap implements Map +{ + private Function keyTransformer; + private Map wrappedMap; + + private Map originalKeys = new HashMap<>(); + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public TransformedKeyMap(Function keyTransformer) + { + this.keyTransformer = keyTransformer; + this.wrappedMap = new HashMap<>(); + } + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public TransformedKeyMap(Function keyTransformer, Supplier> supplier) + { + this.keyTransformer = keyTransformer; + this.wrappedMap = supplier.get(); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public int size() + { + return (wrappedMap.size()); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public boolean isEmpty() + { + return (wrappedMap.isEmpty()); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public boolean containsKey(Object key) + { + try + { + TK transformed = keyTransformer.apply((OK) key); + return wrappedMap.containsKey(transformed); + } + catch(Exception e) + { + return (false); + } + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public boolean containsValue(Object value) + { + return (wrappedMap.containsValue(value)); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public V get(Object key) + { + try + { + TK transformed = keyTransformer.apply((OK) key); + return wrappedMap.get(transformed); + } + catch(Exception e) + { + return (null); + } + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public @Nullable V put(OK key, V value) + { + TK transformed = keyTransformer.apply(key); + originalKeys.putIfAbsent(transformed, key); + return wrappedMap.put(transformed, value); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public V remove(Object key) + { + try + { + TK transformed = keyTransformer.apply((OK) key); + originalKeys.remove(transformed); + return wrappedMap.remove(transformed); + } + catch(Exception e) + { + return (null); + } + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public void putAll(@NotNull Map m) + { + for(Entry entry : m.entrySet()) + { + put(entry.getKey(), entry.getValue()); + } + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public void clear() + { + wrappedMap.clear(); + originalKeys.clear(); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public @NotNull Set keySet() + { + return new HashSet<>(originalKeys.values()); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public @NotNull Collection values() + { + return wrappedMap.values(); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public @NotNull Set> entrySet() + { + Set> wrappedEntries = wrappedMap.entrySet(); + Set> originalEntries; + try + { + originalEntries = wrappedEntries.getClass().getConstructor().newInstance(); + } + catch(Exception e) + { + originalEntries = new HashSet<>(); + } + + for(Entry wrappedEntry : wrappedEntries) + { + OK originalKey = originalKeys.get(wrappedEntry.getKey()); + originalEntries.add(new TransformedKeyMapEntry<>(originalKey, wrappedEntry.getValue())); + } + + return (originalEntries); + } + + // methods with a default implementation below here // + + + + /* + @Override + public V getOrDefault(Object key, V defaultValue) + { + return Map.super.getOrDefault(key, defaultValue); + } + + + + @Override + public void forEach(BiConsumer action) + { + Map.super.forEach(action); + } + + + + @Override + public void replaceAll(BiFunction function) + { + Map.super.replaceAll(function); + } + + + + @Override + public @Nullable V putIfAbsent(OK key, V value) + { + return Map.super.putIfAbsent(key, value); + } + + + + @Override + public boolean remove(Object key, Object value) + { + return Map.super.remove(key, value); + } + + + + @Override + public boolean replace(OK key, V oldValue, V newValue) + { + return Map.super.replace(key, oldValue, newValue); + } + + + + @Override + public @Nullable V replace(OK key, V value) + { + return Map.super.replace(key, value); + } + + + + @Override + public V computeIfAbsent(OK key, @NotNull Function mappingFunction) + { + return Map.super.computeIfAbsent(key, mappingFunction); + } + + + + @Override + public V computeIfPresent(OK key, @NotNull BiFunction remappingFunction) + { + return Map.super.computeIfPresent(key, remappingFunction); + } + + + + @Override + public V compute(OK key, @NotNull BiFunction remappingFunction) + { + return Map.super.compute(key, remappingFunction); + } + + + + @Override + public V merge(OK key, @NotNull V value, @NotNull BiFunction remappingFunction) + { + return Map.super.merge(key, value, remappingFunction); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + public static class TransformedKeyMapEntry implements Map.Entry + { + private final EK key; + private EV value; + + + + /******************************************************************************* + ** Constructor + ** + *******************************************************************************/ + public TransformedKeyMapEntry(EK key, EV value) + { + this.key = key; + this.value = value; + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public EK getKey() + { + return (key); + } + + + + @Override + public EV getValue() + { + return (value); + } + + + + /*************************************************************************** + * + ***************************************************************************/ + @Override + public EV setValue(EV value) + { + throw (new UnsupportedOperationException("Setting value in an entry of a TransformedKeyMap is not supported.")); + } + } +} diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMapTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMapTest.java new file mode 100644 index 00000000..63919551 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/CaseInsensitiveKeyMapTest.java @@ -0,0 +1,52 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.backend.core.utils.collections; + + +import com.kingsrook.qqq.backend.core.BaseTest; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/******************************************************************************* + ** Unit test for CaseInsensitiveKeyMap + *******************************************************************************/ +class CaseInsensitiveKeyMapTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void test() + { + CaseInsensitiveKeyMap map = new CaseInsensitiveKeyMap<>(); + map.put("One", 1); + map.put("one", 1); + map.put("ONE", 1); + assertEquals(1, map.get("one")); + assertEquals(1, map.get("One")); + assertEquals(1, map.get("oNe")); + assertEquals(1, map.size()); + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMapTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMapTest.java new file mode 100644 index 00000000..f319706d --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/utils/collections/TransformedKeyMapTest.java @@ -0,0 +1,199 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 . + */ + +package com.kingsrook.qqq.backend.core.utils.collections; + + +import java.math.BigDecimal; +import java.util.Map; +import com.kingsrook.qqq.backend.core.BaseTest; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Unit test for TransformedKeyMap + *******************************************************************************/ +@SuppressWarnings({ "RedundantCollectionOperation", "RedundantOperationOnEmptyContainer" }) +class TransformedKeyMapTest extends BaseTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testCaseInsensitiveKeyMap() + { + TransformedKeyMap caseInsensitiveKeys = new TransformedKeyMap<>(key -> key.toLowerCase()); + caseInsensitiveKeys.put("One", 1); + caseInsensitiveKeys.put("one", 1); + caseInsensitiveKeys.put("ONE", 1); + assertEquals(1, caseInsensitiveKeys.get("one")); + assertEquals(1, caseInsensitiveKeys.get("One")); + assertEquals(1, caseInsensitiveKeys.get("oNe")); + assertEquals(1, caseInsensitiveKeys.size()); + + ////////////////////////////////////////////////// + // get back the first way it was put in the map // + ////////////////////////////////////////////////// + assertEquals("One", caseInsensitiveKeys.entrySet().iterator().next().getKey()); + assertEquals("One", caseInsensitiveKeys.keySet().iterator().next()); + + assertEquals(1, caseInsensitiveKeys.entrySet().size()); + assertEquals(1, caseInsensitiveKeys.keySet().size()); + + for(String key : caseInsensitiveKeys.keySet()) + { + assertEquals(1, caseInsensitiveKeys.get(key)); + } + + for(Map.Entry entry : caseInsensitiveKeys.entrySet()) + { + assertEquals("One", entry.getKey()); + assertEquals(1, entry.getValue()); + } + + ///////////////////////////// + // add a second unique key // + ///////////////////////////// + caseInsensitiveKeys.put("Two", 2); + assertEquals(2, caseInsensitiveKeys.size()); + assertEquals(2, caseInsensitiveKeys.entrySet().size()); + assertEquals(2, caseInsensitiveKeys.keySet().size()); + + //////////////////////////////////////// + // make sure remove works as expected // + //////////////////////////////////////// + caseInsensitiveKeys.remove("TWO"); + assertNull(caseInsensitiveKeys.get("Two")); + assertNull(caseInsensitiveKeys.get("two")); + assertEquals(1, caseInsensitiveKeys.size()); + assertEquals(1, caseInsensitiveKeys.keySet().size()); + assertEquals(1, caseInsensitiveKeys.entrySet().size()); + + /////////////////////////////////////// + // make sure clear works as expected // + /////////////////////////////////////// + caseInsensitiveKeys.clear(); + assertNull(caseInsensitiveKeys.get("one")); + assertEquals(0, caseInsensitiveKeys.size()); + assertEquals(0, caseInsensitiveKeys.keySet().size()); + assertEquals(0, caseInsensitiveKeys.entrySet().size()); + + ///////////////////////////////////////// + // make sure put-all works as expected // + ///////////////////////////////////////// + caseInsensitiveKeys.putAll(Map.of("One", 1, "one", 1, "ONE", 1, "TwO", 2, "tWo", 2, "three", 3)); + assertEquals(1, caseInsensitiveKeys.get("oNe")); + assertEquals(2, caseInsensitiveKeys.get("two")); + assertEquals(3, caseInsensitiveKeys.get("Three")); + assertEquals(3, caseInsensitiveKeys.size()); + assertEquals(3, caseInsensitiveKeys.entrySet().size()); + assertEquals(3, caseInsensitiveKeys.keySet().size()); + } + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testStringToNumberMap() + { + BigDecimal BIG_DECIMAL_TWO = BigDecimal.valueOf(2); + BigDecimal BIG_DECIMAL_THREE = BigDecimal.valueOf(3); + + TransformedKeyMap multiLingualWordToNumber = new TransformedKeyMap<>(key -> + switch (key.toLowerCase()) + { + case "one", "uno", "eins" -> 1; + case "two", "dos", "zwei" -> 2; + case "three", "tres", "drei" -> 3; + default -> null; + }); + multiLingualWordToNumber.put("One", BigDecimal.ONE); + multiLingualWordToNumber.put("uno", BigDecimal.ONE); + assertEquals(BigDecimal.ONE, multiLingualWordToNumber.get("one")); + assertEquals(BigDecimal.ONE, multiLingualWordToNumber.get("uno")); + assertEquals(BigDecimal.ONE, multiLingualWordToNumber.get("eins")); + assertEquals(1, multiLingualWordToNumber.size()); + + ////////////////////////////////////////////////// + // get back the first way it was put in the map // + ////////////////////////////////////////////////// + assertEquals("One", multiLingualWordToNumber.entrySet().iterator().next().getKey()); + assertEquals("One", multiLingualWordToNumber.keySet().iterator().next()); + + assertEquals(1, multiLingualWordToNumber.entrySet().size()); + assertEquals(1, multiLingualWordToNumber.keySet().size()); + + for(String key : multiLingualWordToNumber.keySet()) + { + assertEquals(BigDecimal.ONE, multiLingualWordToNumber.get(key)); + } + + for(Map.Entry entry : multiLingualWordToNumber.entrySet()) + { + assertEquals("One", entry.getKey()); + assertEquals(BigDecimal.ONE, entry.getValue()); + } + + ///////////////////////////// + // add a second unique key // + ///////////////////////////// + multiLingualWordToNumber.put("Two", BIG_DECIMAL_TWO); + assertEquals(BIG_DECIMAL_TWO, multiLingualWordToNumber.get("Dos")); + assertEquals(2, multiLingualWordToNumber.size()); + assertEquals(2, multiLingualWordToNumber.entrySet().size()); + assertEquals(2, multiLingualWordToNumber.keySet().size()); + + //////////////////////////////////////// + // make sure remove works as expected // + //////////////////////////////////////// + multiLingualWordToNumber.remove("ZWEI"); + assertNull(multiLingualWordToNumber.get("Two")); + assertNull(multiLingualWordToNumber.get("Dos")); + assertEquals(1, multiLingualWordToNumber.size()); + assertEquals(1, multiLingualWordToNumber.keySet().size()); + assertEquals(1, multiLingualWordToNumber.entrySet().size()); + + /////////////////////////////////////// + // make sure clear works as expected // + /////////////////////////////////////// + multiLingualWordToNumber.clear(); + assertNull(multiLingualWordToNumber.get("eins")); + assertNull(multiLingualWordToNumber.get("One")); + assertEquals(0, multiLingualWordToNumber.size()); + assertEquals(0, multiLingualWordToNumber.keySet().size()); + assertEquals(0, multiLingualWordToNumber.entrySet().size()); + + ///////////////////////////////////////// + // make sure put-all works as expected // + ///////////////////////////////////////// + multiLingualWordToNumber.putAll(Map.of("One", BigDecimal.ONE, "Uno", BigDecimal.ONE, "EINS", BigDecimal.ONE, "dos", BIG_DECIMAL_TWO, "zwei",BIG_DECIMAL_TWO, "tres", BIG_DECIMAL_THREE)); + assertEquals(BigDecimal.ONE, multiLingualWordToNumber.get("oNe")); + assertEquals(BIG_DECIMAL_TWO, multiLingualWordToNumber.get("dos")); + assertEquals(BIG_DECIMAL_THREE, multiLingualWordToNumber.get("drei")); + assertEquals(3, multiLingualWordToNumber.size()); + assertEquals(3, multiLingualWordToNumber.entrySet().size()); + assertEquals(3, multiLingualWordToNumber.keySet().size()); + } + +} \ No newline at end of file