You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by bd...@apache.org on 2021/06/24 11:53:53 UTC
[sling-org-apache-sling-graphql-core] branch master updated:
SLING-10502 - lazy loading helpers (#25)
This is an automated email from the ASF dual-hosted git repository.
bdelacretaz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-graphql-core.git
The following commit(s) were added to refs/heads/master by this push:
new 9a99ba5 SLING-10502 - lazy loading helpers (#25)
9a99ba5 is described below
commit 9a99ba53d0b36a83e3e7814e8be8866b6b15861c
Author: Bertrand Delacretaz <bd...@apache.org>
AuthorDate: Thu Jun 24 13:53:45 2021 +0200
SLING-10502 - lazy loading helpers (#25)
This introduces helper classes for lazy loading fields and Maps
---
README.md | 32 +-
.../helpers/lazyloading/LazyLoadingField.java | 48 +++
.../helpers/lazyloading/LazyLoadingMap.java | 197 ++++++++++
.../graphql/helpers/lazyloading/package-info.java | 26 ++
.../core/engine/DefaultQueryExecutorTest.java | 36 ++
.../sling/graphql/core/mocks/LazyDataFetcher.java | 70 ++++
.../apache/sling/graphql/core/util/LogCapture.java | 12 +-
.../helpers/lazyloading/LazyLoadingFieldTest.java | 64 ++++
.../helpers/lazyloading/LazyLoadingMapTest.java | 409 +++++++++++++++++++++
src/test/resources/test-schema.txt | 8 +
10 files changed, 899 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 3eddfd5..fc1a500 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,10 @@ need to use their APIs directly.
The [GraphQL sample website](https://github.com/apache/sling-samples/tree/master/org.apache.sling.graphql.samples.website)
provides usage examples and demonstrates using GraphQL queries (and Handlebars templates) on both the server and
-client sides.
+client sides. It's getting a bit old and as of June 2021 doesn't demonstrate the latest features.
+
+As usual, **the truth is in the tests**. If something's missing from this page you can probably find the details in
+this module's [extensive test suite](./src/test).
## Supported GraphQL endpoint styles
@@ -271,6 +274,33 @@ Usage of this `GenericConnection` helper is optional, although recommended for e
as the `SlingDataFetcher` provides a result that implements the [`org.apache.sling.graphql.api.pagination.Connection`](./src/main/java/org/apache/sling/graphql/api/pagination/Connection.java),
the output will be according to the Relay spec.
+## Lazy Loading of field values
+
+The [org.apache.sling.graphql.helpers.lazyloading](src/main/java/org/apache/sling/graphql/helpers/lazyloading) package provides helpers
+for lazy loading field values.
+
+Using this pattern, for example:
+
+ public class ExpensiveObject {
+ private final LazyLoadingField<String> lazyName;
+
+ ExpensiveObject(String name) {
+ lazyName = new LazyLoadingField<>(() -> {
+ // Not really expensive but that's the idea
+ return name.toUpperCase();
+ });
+ }
+
+ public String getExpensiveName() {
+ return lazyName.get();
+ }
+ }
+
+The `expensiveName` is only computed if its get method is called. This avoids executing expensive computations
+for fields that are not used in the GraphQL result set.
+
+A similar helper is provided for Maps with lazy loaded values.
+
## Caching: Persisted queries API
No matter how you decide to create your Sling GraphQL endpoints, you have the option to allow GraphQL clients to use persisted queries.
diff --git a/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingField.java b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingField.java
new file mode 100644
index 0000000..bae1e84
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingField.java
@@ -0,0 +1,48 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+
+package org.apache.sling.graphql.helpers.lazyloading;
+
+import java.util.function.Supplier;
+
+/** Helper for a single lazy-loading value */
+public class LazyLoadingField<T> {
+
+ @SuppressWarnings("squid:S3077")
+ // The Supplier itself is immutable, just "volatile" is fine
+ private volatile Supplier<T> supplier;
+
+ private T value;
+
+ public LazyLoadingField(Supplier<T> supplier) {
+ this.supplier = supplier;
+ }
+
+ public T get() {
+ if(supplier != null) {
+ synchronized(this) {
+ if(supplier != null) {
+ value = supplier.get();
+ supplier = null;
+ }
+ }
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMap.java b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMap.java
new file mode 100644
index 0000000..41398fd
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMap.java
@@ -0,0 +1,197 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+
+package org.apache.sling.graphql.helpers.lazyloading;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** A {@link java.util.HashMap} that optionally uses Suppliers to provide its
+ * values. Each Supplier is called at most once, if the corresponding
+ * value is requested. Some "global" operations requires all values
+ * to be computed, and should be considered costly.
+ *
+ * Like HashMap, this class is NOT thread safe. If needed,
+ * {@link java.util.Collections#synchronizedMap} can be used
+ * to sychronize it.
+ */
+public class LazyLoadingMap<K, T> extends HashMap<K, T> {
+
+ private final Logger log = LoggerFactory.getLogger(getClass());
+ private final Map<K, Supplier<T>> suppliers = new HashMap<>();
+ private boolean computeValueOnRemove;
+ private boolean forbidComputeAll;
+
+ public class Stats {
+ int suppliersCallCount;
+
+ public int getSuppliersCallCount() {
+ return suppliersCallCount;
+ }
+
+ public int getUnusedSuppliersCount() {
+ return suppliers.size();
+ }
+ }
+
+ private final Stats stats = new Stats();
+
+ /** Calls computeAll - should be avoided if possible */
+ @Override
+ public boolean equals(Object o) {
+ if(!(o instanceof LazyLoadingMap)) {
+ return false;
+ }
+ final LazyLoadingMap<?,?> other = (LazyLoadingMap<?,?>)o;
+
+ // Equality seems complicated to compute without this
+ computeAll();
+ return super.equals(other);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode() + suppliers.hashCode();
+ }
+
+ /** Adds a Supplier that provides a lazy loaded value.
+ * Removes existing value with the same key if it exists.
+ */
+ public Supplier<T> put(K key, Supplier<T> supplier) {
+ super.remove(key);
+ return suppliers.put(key, supplier);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T get(Object key) {
+ computeIfAbsent((K)key, k -> {
+ final Supplier<T> s = suppliers.remove(k);
+ if(s != null) {
+ stats.suppliersCallCount++;
+ return s.get();
+ }
+ return null;
+ });
+ return super.get(key);
+ }
+
+ /** Unless {@link #computeValueOnRemove(boolean)} is set to
+ * true, this returns null to avoid calling a supplier
+ * "for nothing".
+ */
+ @Override
+ @SuppressWarnings("squid:S2201")
+ public T remove(Object key) {
+ if(computeValueOnRemove) {
+ get(key);
+ }
+ super.remove(key);
+ suppliers.remove(key);
+ return null;
+ }
+
+ @Override
+ public void clear() {
+ suppliers.clear();
+ super.clear();
+ }
+
+ @Override
+ public boolean containsKey(Object key) {
+ return super.containsKey(key) || suppliers.containsKey(key);
+ }
+
+ @Override
+ public int size() {
+ return keySet().size();
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return super.isEmpty() && suppliers.isEmpty();
+ }
+
+ @Override
+ public Set<K> keySet() {
+ final Set<K> result = new HashSet<>();
+ result.addAll(super.keySet());
+ result.addAll(suppliers.keySet());
+ return result;
+ }
+
+ /** Required for some methods that need all our values
+ * Calling those methods should be avoided if possible
+ */
+ private void computeAll() {
+ if(forbidComputeAll) {
+ throw new RuntimeException("The computeAll() method has been disabled by the computeAllThrowsException option");
+ }
+ if(!suppliers.isEmpty()) {
+ log.debug("computeAll called, all remaining lazy values will be evaluated now");
+ final Set<K> keys = new HashSet<>(suppliers.keySet());
+ keys.forEach(this::get);
+ }
+ }
+
+ /** Calls computeAll - should be avoided if possible */
+ @Override
+ public Collection<T> values() {
+ computeAll();
+ return super.values();
+ }
+
+ /** Calls computeAll - should be avoided if possible */
+ @Override
+ public Set<Entry<K, T>> entrySet() {
+ computeAll();
+ return super.entrySet();
+ }
+
+ /** Calls computeAll - should be avoided if possible */
+ @Override
+ public boolean containsValue(Object value) {
+ computeAll();
+ return super.containsValue(value);
+ }
+
+ /** Return statistics on our suppliers, for metrics etc. */
+ public Stats getStats() {
+ return stats;
+ }
+
+ /** Optionally compute the value on remove, so that it doesn't return null */
+ public LazyLoadingMap<K,T> computeValueOnRemove(boolean b) {
+ computeValueOnRemove = b;
+ return this;
+ }
+
+ /** Optionally throw a RuntimeException if computeAll is called */
+ public LazyLoadingMap<K,T> computeAllThrowsException(boolean b) {
+ forbidComputeAll = b;
+ return this;
+ }
+}
diff --git a/src/main/java/org/apache/sling/graphql/helpers/lazyloading/package-info.java b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/package-info.java
new file mode 100644
index 0000000..511d2c5
--- /dev/null
+++ b/src/main/java/org/apache/sling/graphql/helpers/lazyloading/package-info.java
@@ -0,0 +1,26 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+
+ /**
+ * This package contains helpers which are independent of
+ * a specific implementation of the underlying graphQL engine.
+ */
+@Version("0.0.1")
+package org.apache.sling.graphql.helpers.lazyloading;
+import org.osgi.annotation.versioning.Version;
diff --git a/src/test/java/org/apache/sling/graphql/core/engine/DefaultQueryExecutorTest.java b/src/test/java/org/apache/sling/graphql/core/engine/DefaultQueryExecutorTest.java
index 1d5e517..19f9765 100644
--- a/src/test/java/org/apache/sling/graphql/core/engine/DefaultQueryExecutorTest.java
+++ b/src/test/java/org/apache/sling/graphql/core/engine/DefaultQueryExecutorTest.java
@@ -42,6 +42,7 @@ import org.apache.sling.graphql.core.mocks.DummyTypeResolver;
import org.apache.sling.graphql.core.mocks.EchoDataFetcher;
import org.apache.sling.graphql.core.mocks.FailingDataFetcher;
import org.apache.sling.graphql.core.mocks.HumanDTO;
+import org.apache.sling.graphql.core.mocks.LazyDataFetcher;
import org.apache.sling.graphql.core.mocks.MockSchemaProvider;
import org.apache.sling.graphql.core.mocks.TestUtil;
import org.apache.sling.graphql.core.schema.RankedSchemaProviders;
@@ -67,6 +68,8 @@ import static org.junit.Assert.assertTrue;
public class DefaultQueryExecutorTest extends ResourceQueryTestBase {
+ private final LazyDataFetcher lazyDataFetcher = new LazyDataFetcher();
+
protected void setupAdditionalServices() {
final Dictionary<String, Object> staticData = new Hashtable<>();
staticData.put("test", true);
@@ -88,6 +91,7 @@ public class DefaultQueryExecutorTest extends ResourceQueryTestBase {
TestUtil.registerSlingDataFetcher(context.bundleContext(), "test/static", new EchoDataFetcher(staticData));
TestUtil.registerSlingDataFetcher(context.bundleContext(), "test/fortyTwo", new EchoDataFetcher(42));
TestUtil.registerSlingDataFetcher(context.bundleContext(), "sling/digest", new DigestDataFetcher());
+ TestUtil.registerSlingDataFetcher(context.bundleContext(), "lazy/fetcher", lazyDataFetcher);
final Dictionary<String, Object> dataCombined = new Hashtable<>();
dataCombined.put("unionTest", characters);
@@ -385,4 +389,36 @@ public class DefaultQueryExecutorTest extends ResourceQueryTestBase {
assertNotEquals(registry3, registry4);
}
+ @Test
+ public void testLazyDataFetcher() throws Exception {
+ assertEquals(0, lazyDataFetcher.getCost());
+
+ {
+ // Without expensiveName, the Supplier is not called
+ final String json = queryJSON("{ lazyQuery { cheapCount }}");
+ assertThat(json, hasJsonPath("$.data.lazyQuery.cheapCount", equalTo(42)));
+ assertEquals(0, lazyDataFetcher.getCost());
+ }
+
+ {
+ // With expensiveName, the Supplier is called
+ lazyDataFetcher.resetCost();
+ final String json = queryJSON("{ lazyQuery { cheapCount expensiveName }}");
+ assertThat(json, hasJsonPath("$.data.lazyQuery.cheapCount", equalTo(42)));
+ assertThat(json, hasJsonPath("$.data.lazyQuery.expensiveName", equalTo("LAZYDATAFETCHER")));
+ assertEquals(1, lazyDataFetcher.getCost());
+ }
+
+ {
+ // With clone, the Supplier is also called once only
+ lazyDataFetcher.resetCost();
+ final String json = queryJSON("{ lazyQuery { cheapCount expensiveName expensiveNameClone }}");
+ assertThat(json, hasJsonPath("$.data.lazyQuery.cheapCount", equalTo(42)));
+ assertThat(json, hasJsonPath("$.data.lazyQuery.expensiveName", equalTo("LAZYDATAFETCHER")));
+ assertThat(json, hasJsonPath("$.data.lazyQuery.expensiveNameClone", equalTo("LAZYDATAFETCHER")));
+ assertEquals(1, lazyDataFetcher.getCost());
+ }
+
+ }
+
}
diff --git a/src/test/java/org/apache/sling/graphql/core/mocks/LazyDataFetcher.java b/src/test/java/org/apache/sling/graphql/core/mocks/LazyDataFetcher.java
new file mode 100644
index 0000000..93d7bc5
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/core/mocks/LazyDataFetcher.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.sling.graphql.core.mocks;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.sling.graphql.api.SlingDataFetcher;
+import org.apache.sling.graphql.api.SlingDataFetcherEnvironment;
+import org.apache.sling.graphql.helpers.lazyloading.LazyLoadingField;
+
+/** Used to verify that our lazy fetchers are used correctly by the GraphQL environment */
+public class LazyDataFetcher implements SlingDataFetcher<LazyDataFetcher.ExpensiveObject> {
+
+ private AtomicInteger cost = new AtomicInteger();
+
+ /** Simulate a lazy expensive computation */
+ public class ExpensiveObject {
+
+ private final LazyLoadingField<String> lazyName;
+
+ ExpensiveObject(String name) {
+ lazyName = new LazyLoadingField<>(() -> {
+ cost.incrementAndGet();
+ return name.toUpperCase();
+ });
+ }
+
+ public String getExpensiveName() {
+ return lazyName.get();
+ }
+
+ public String getExpensiveNameClone() {
+ return lazyName.get();
+ }
+
+ public int getCheapCount() {
+ return 42;
+ }
+ }
+
+ public int getCost() {
+ return cost.get();
+ }
+
+ public void resetCost() {
+ cost.set(0);
+ }
+
+ @Override
+ public ExpensiveObject get(SlingDataFetcherEnvironment e) throws Exception {
+ return new ExpensiveObject(getClass().getSimpleName());
+ }
+}
diff --git a/src/test/java/org/apache/sling/graphql/core/util/LogCapture.java b/src/test/java/org/apache/sling/graphql/core/util/LogCapture.java
index fb2133c..4d0b111 100644
--- a/src/test/java/org/apache/sling/graphql/core/util/LogCapture.java
+++ b/src/test/java/org/apache/sling/graphql/core/util/LogCapture.java
@@ -20,8 +20,9 @@ package org.apache.sling.graphql.core.util;
import static org.junit.Assert.fail;
+import java.io.Closeable;
+import java.io.IOException;
import java.util.function.Predicate;
-import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.slf4j.LoggerFactory;
@@ -33,15 +34,17 @@ import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
/** Capture logs for testing */
-public class LogCapture extends ListAppender<ILoggingEvent> {
+public class LogCapture extends ListAppender<ILoggingEvent> implements Closeable {
private final boolean verboseFailure;
+ /** Setup the capture and start it */
public LogCapture(String loggerName, boolean verboseFailure) {
this.verboseFailure = verboseFailure;
Logger logger = (Logger) LoggerFactory.getLogger(loggerName);
logger.setLevel(Level.ALL);
setContext((LoggerContext) LoggerFactory.getILoggerFactory());
logger.addAppender(this);
+ start();
}
public boolean anyMatch(Predicate<ILoggingEvent> p) {
@@ -59,4 +62,9 @@ public class LogCapture extends ListAppender<ILoggingEvent> {
}
});
}
+
+ @Override
+ public void close() throws IOException {
+ stop();
+ }
}
\ No newline at end of file
diff --git a/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingFieldTest.java b/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingFieldTest.java
new file mode 100644
index 0000000..c8486c9
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingFieldTest.java
@@ -0,0 +1,64 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+package org.apache.sling.graphql.helpers.lazyloading;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import org.junit.Test;
+
+public class LazyLoadingFieldTest {
+ @Test
+ public void basicTest() {
+ final AtomicInteger nCalls = new AtomicInteger();
+
+ final Supplier<String> sup = () -> {
+ nCalls.incrementAndGet();
+ return UUID.randomUUID().toString();
+ };
+
+ final LazyLoadingField<String> f = new LazyLoadingField<>(sup);
+ assertEquals(0, nCalls.get());
+ final String firstValue = f.get();
+ assertEquals(1, nCalls.get());
+ for(int i=0; i < 42; i++) {
+ assertEquals(firstValue, f.get());
+ }
+ assertEquals(1, nCalls.get());
+ }
+
+ @Test
+ public void nullSupplier() {
+ final AtomicInteger nCalls = new AtomicInteger();
+ final Supplier<Double> nullSup = () -> {
+ nCalls.incrementAndGet();
+ return null;
+ };
+ final LazyLoadingField<Double> f = new LazyLoadingField<>(nullSup);
+ assertEquals(0, nCalls.get());
+ assertNull(f.get());
+ assertEquals(1, nCalls.get());
+ assertNull(f.get());
+ assertEquals(1, nCalls.get());
+ }
+}
diff --git a/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMapTest.java b/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMapTest.java
new file mode 100644
index 0000000..84696ca
--- /dev/null
+++ b/src/test/java/org/apache/sling/graphql/helpers/lazyloading/LazyLoadingMapTest.java
@@ -0,0 +1,409 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
+package org.apache.sling.graphql.helpers.lazyloading;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.sling.graphql.core.util.LogCapture;
+import org.junit.Before;
+import org.junit.Test;
+
+import ch.qos.logback.classic.Level;
+
+public class LazyLoadingMapTest {
+ private static final String TEST_STRING = "Fritz Frisst etc. etc.";
+ private int counter;
+ private Supplier<String> counterSupplier = () -> "X" + String.valueOf(++counter);
+ private final Supplier<String> constantSupplier = () -> TEST_STRING;
+ private LazyLoadingMap<Integer, String> map;
+
+ private static final List<Consumer<Map<?,?>>> COMPUTE_ALL_TEST_CASES = new ArrayList<>();
+
+ static {
+ COMPUTE_ALL_TEST_CASES.add(m -> m.values());
+ COMPUTE_ALL_TEST_CASES.add(m -> m.entrySet());
+ COMPUTE_ALL_TEST_CASES.add(m -> m.equals(m));
+ COMPUTE_ALL_TEST_CASES.add(m -> m.containsValue(null));
+ }
+
+ @Before
+ public void setup() {
+ counter = 0;
+ map = new LazyLoadingMap<>();
+ }
+
+ @Test
+ public void basicTest() {
+ assertNull(map.get(42));
+
+ map.put(21, () -> UUID.randomUUID().toString());
+ map.put(42, () -> TEST_STRING);
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ assertEquals(2, map.getStats().getUnusedSuppliersCount());
+ assertEquals(TEST_STRING, map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertEquals(1, map.getStats().getUnusedSuppliersCount());
+ final String random = map.get(21);
+ assertNotNull(random);
+ assertEquals(random, map.get(21));
+ assertEquals(2, map.getStats().getSuppliersCallCount());
+ assertEquals(0, map.getStats().getUnusedSuppliersCount());
+ }
+
+ @Test
+ public void suppliersAndDirectValues() {
+ map.put(42, counterSupplier);
+ assertEquals(1, map.getStats().getUnusedSuppliersCount());
+ assertEquals("X1", map.get(42));
+ assertEquals(0, map.getStats().getUnusedSuppliersCount());
+ map.get(42);
+ map.put(42, TEST_STRING);
+ assertEquals(TEST_STRING, map.get(42));
+ assertEquals(0, map.getStats().getUnusedSuppliersCount());
+ map.put(42, counterSupplier);
+ assertEquals(1, map.getStats().getUnusedSuppliersCount());
+ assertEquals("X2", map.get(42));
+ assertEquals(0, map.getStats().getUnusedSuppliersCount());
+ map.get(42);
+ assertEquals(2, map.getStats().getSuppliersCallCount());
+ assertEquals(0, map.getStats().getUnusedSuppliersCount());
+ }
+
+ @Test
+ public void remove() {
+ map.put(21, counterSupplier);
+ map.put(42, TEST_STRING);
+ assertEquals(2, map.size());
+ assertEquals(TEST_STRING, map.get(42));
+ assertNull(map.remove(42));
+ assertNull(map.get(42));
+ assertEquals(1, map.size());
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+
+ // Remove before and after computing
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ map.put(112, counterSupplier);
+ map.put(113, counterSupplier);
+ assertEquals(3, map.size());
+ assertEquals("X1", map.get(113));
+ assertNull(map.remove(113));
+ assertEquals(2, map.size());
+ assertNull(map.get(113));
+ assertEquals(2, map.size());
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertNull(map.remove(112));
+ assertNull(map.remove(21));
+ assertEquals(0, map.size());
+ assertTrue(map.isEmpty());
+ assertNull(map.get(112));
+ assertEquals(0, map.size());
+ assertTrue(map.isEmpty());
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void containsValueComputesEverything() {
+ assertFalse(map.containsKey(42));
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+
+ assertFalse(map.containsValue("X1"));
+ map.put(42, counterSupplier);
+ assertTrue(map.containsValue("X1"));
+
+ assertFalse(map.containsValue("X2"));
+ map.put(21, counterSupplier);
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertTrue(map.containsValue("X1"));
+ assertTrue(map.containsValue("X2"));
+ assertEquals(2, map.getStats().getSuppliersCallCount());
+
+ assertFalse(map.containsValue(TEST_STRING));
+ map.put(71, TEST_STRING);
+ map.put(92, counterSupplier);
+ map.put(93, counterSupplier);
+ assertTrue(map.containsValue(TEST_STRING));
+ assertTrue(map.containsValue("X1"));
+ assertTrue(map.containsValue("X2"));
+ assertTrue(map.containsValue("X3"));
+ assertTrue(map.containsValue("X4"));
+ assertFalse(map.containsValue("X5"));
+
+ assertEquals(4, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void containsKey() {
+ assertFalse(map.containsKey(42));
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+
+ map.put(42, counterSupplier);
+ map.put(21, "nothing");
+
+ assertTrue(map.containsKey(42));
+ assertTrue(map.containsKey(21));
+ assertFalse(map.containsKey(22));
+
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void keySet() {
+ assertEquals(0, map.keySet().size());
+ map.put(112, "nothing");
+ assertEquals(1, map.keySet().size());
+ map.put(21, counterSupplier);
+ map.put(42, counterSupplier);
+ map.put(110, counterSupplier);
+
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ map.get(42);
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+
+ final Set<Integer> ks = map.keySet();
+ assertEquals(4, ks.size());
+ assertTrue(ks.contains(21));
+ assertTrue(ks.contains(42));
+ assertTrue(ks.contains(112));
+ assertTrue(ks.contains(110));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void entrySet() {
+ map.put(112, TEST_STRING);
+ map.put(21, counterSupplier);
+ map.put(42, counterSupplier);
+
+ final Set<String> toFind = new HashSet<>();
+ toFind.add(TEST_STRING);
+ toFind.add("X1");
+ toFind.add("X2");
+
+ assertEquals(3, toFind.size());
+ map.entrySet().forEach(e -> toFind.remove(e.getValue()));
+ assertEquals(0, toFind.size());
+ assertEquals(2, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void values() {
+ map.put(112, TEST_STRING);
+ map.put(21, counterSupplier);
+ map.put(42, counterSupplier);
+
+ final Set<String> toFind = new HashSet<>();
+ toFind.add(TEST_STRING);
+ toFind.add("X1");
+ toFind.add("X2");
+
+ assertEquals(3, toFind.size());
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ map.values().forEach(v -> toFind.remove(v));
+ assertEquals(0, toFind.size());
+ assertEquals(2, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void isEmpty() {
+ assertTrue(map.isEmpty());
+ assertTrue(map.values().isEmpty());
+ assertTrue(map.entrySet().isEmpty());
+ assertTrue(map.keySet().isEmpty());
+
+ map.put(112, TEST_STRING);
+ assertFalse(map.isEmpty());
+ map.put(42, counterSupplier);
+ assertFalse(map.isEmpty());
+ map.put(43, counterSupplier);
+ assertFalse(map.isEmpty());
+
+ map.get(112);
+ map.remove(112);
+ assertFalse(map.isEmpty());
+ assertEquals(2, map.size());
+ map.get(42);
+ assertEquals(2, map.size());
+ map.remove(42);
+ map.remove(43);
+ assertTrue(map.isEmpty());
+ assertEquals(0, map.size());
+
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+
+ assertTrue(map.values().isEmpty());
+ assertTrue(map.entrySet().isEmpty());
+ assertTrue(map.keySet().isEmpty());
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void nullSupplier() {
+ final Supplier<String> nullSup = () -> null;
+
+ map.put(42, nullSup);
+ assertEquals(0, map.getStats().getSuppliersCallCount());
+ assertEquals(1, map.size());
+
+ assertNull(map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertEquals(0, map.size());
+
+ assertNull(map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertEquals(0, map.size());
+ }
+
+ @Test
+ public void clear() {
+ map.put(21, counterSupplier);
+ map.put(42, counterSupplier);
+ assertEquals("X1", map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ assertEquals(2, map.size());
+ map.clear();
+ assertEquals(0, map.size());
+ assertNull(map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void testHashCode() {
+ int hc = map.hashCode();
+ map.put(21, TEST_STRING);
+ assertNotEquals(hc, hashCode());
+ hc = map.hashCode();
+ map.put(42, counterSupplier);
+ assertNotEquals(hc, hashCode());
+ }
+
+ @Test
+ public void testEquals() {
+ final LazyLoadingMap<Integer, String> A = new LazyLoadingMap<>();
+ final LazyLoadingMap<Integer, String> B = new LazyLoadingMap<>();
+ assertEquals(A, B);
+
+ A.put(42, constantSupplier);
+ A.put(21, TEST_STRING);
+ assertNotEquals(A, B);
+ assertEquals(1, A.getStats().getSuppliersCallCount());
+ assertEquals(0, B.getStats().getSuppliersCallCount());
+
+ B.put(42, constantSupplier);
+ assertNotEquals(A, B);
+ B.put(21, TEST_STRING);
+ assertEquals(B, A);
+ assertEquals(1, A.getStats().getSuppliersCallCount());
+ assertEquals(1, B.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void replaceSupplierBeforeUsingIt() {
+ map.put(42, counterSupplier);
+ map.put(42, constantSupplier);
+ assertEquals(TEST_STRING, map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void nullKeyAndValue() {
+ assertNull(map.get(null));
+ map.put(null, counterSupplier);
+
+ assertEquals("X1", map.get(null));
+ map.get(null);
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+
+ map.put(null, (String)null);
+ assertNull(map.get(null));
+ }
+
+ @Test
+ public void applesAndOranges() {
+ final boolean isEqual = map.equals((Object)"A string");
+ assertFalse(isEqual);
+ }
+
+ @Test
+ public void computeAllLogs() {
+ COMPUTE_ALL_TEST_CASES.stream().forEach(tc -> {
+ try (LogCapture capture = new LogCapture(LazyLoadingMap.class.getPackage().getName(), true)) {
+ map.values();
+ assertTrue(capture.list.isEmpty());
+
+ map.put(42, TEST_STRING);
+ map.values();
+ assertTrue(capture.list.isEmpty());
+
+ map.put(42, counterSupplier);
+ tc.accept(map);
+ capture.assertContains(Level.DEBUG, "computeAll called");
+ } catch(IOException iox) {
+ fail("Unexpected IOException:" + iox);
+ }
+ });
+ }
+
+ @Test
+ public void computeValueOnRemove() {
+ map.put(42, counterSupplier);
+ assertNull(map.remove(42));
+
+ assertEquals(map, map.computeValueOnRemove(true));
+ assertNull(map.remove(42));
+ map.put(42, counterSupplier);
+ assertEquals("X1", map.get(42));
+ assertEquals(1, map.getStats().getSuppliersCallCount());
+ }
+
+ @Test
+ public void forbidComputeAll() {
+ assertNotNull(map.computeAllThrowsException(true));
+ final String [] expected = { "computeAll()", "disabled", "computeAllThrowsException" };
+ COMPUTE_ALL_TEST_CASES.stream().forEach(tc -> {
+ final Throwable t = assertThrows(
+ "Expecting a RuntimeException",
+ RuntimeException.class, () -> tc.accept(map)
+ );
+ Stream.of(expected).forEach(exp -> {
+ assertTrue(
+ "Expecting message to contain [" + exp + "] but was " + t.getMessage(),
+ t.getMessage().contains(exp)
+ );
+ });
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/test-schema.txt b/src/test/resources/test-schema.txt
index 02dc7d5..3527581 100644
--- a/src/test/resources/test-schema.txt
+++ b/src/test/resources/test-schema.txt
@@ -32,6 +32,8 @@ type Query {
# Test interface query
interfaceQuery: CharactersAsInterface @fetcher(name:"character/fetcher")
+
+ lazyQuery : ExpensiveObject @fetcher(name:"lazy/fetcher")
}
interface CharacterInterface @resolver(name:"character/resolver" source:"CharacterInterface") {
@@ -40,6 +42,12 @@ interface CharacterInterface @resolver(name:"character/resolver" source:"Charact
union CharacterUnion @resolver(name:"character/resolver" source:"CharacterUnion") = Human | Droid
+type ExpensiveObject {
+ expensiveName : String!
+ expensiveNameClone : String!
+ cheapCount : Int
+}
+
# This should be omitted from the SlingResource type description
#
# SlingResource, for our tests