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