From 2b8737d3007298f7bbcb016e5fb1d205e5d69a86 Mon Sep 17 00:00:00 2001
From: Steven Schlansker <stevenschlansker@gmail.com>
Date: Fri, 4 Jan 2019 14:12:00 -0800
Subject: [PATCH] Further code review

---
 4.0_TODO                                      |   1 +
 .../core/argument/BeanPropertyArguments.java  |  12 ++
 .../internal/PojoPropertyArguments.java       |  45 +-----
 .../mapper/immutables/ImmutablesPlugin.java   | 144 ++++++++++++++++++
 .../internal}/ImmutablesMapperFactory.java    |  27 ++--
 .../v3/core/mapper/reflect/BeanMapper.java    |  20 +--
 .../core/mapper/reflect/PropertiesMapper.java |  49 ------
 .../mapper/reflect/internal/PojoMapper.java   |  25 +++
 .../internal/PojoPropertiesFactories.java     |  50 ++++++
 .../jdbi/v3/core/qualifier/Qualifiers.java    |   4 +-
 .../jdbi/v3/core/result/ResultBearing.java    |   1 +
 .../jdbi/v3/core/statement/SqlStatement.java  |   8 +-
 .../jdbi/v3/core/mapper/ImmutablesTest.java   |  24 +--
 docs/src/adoc/index.adoc                      |   6 +-
 pom.xml                                       |  10 +-
 postgres/pom.xml                              |   1 +
 .../v3/postgres/TestImmutablesHStore.java     |  12 +-
 .../internal/RegisterBeanMapperImpl.java      |   1 +
 .../{BindProperties.java => BindPojo.java}    |   6 +-
 ...rtiesFactory.java => BindPojoFactory.java} |  10 +-
 .../jdbi/v3/sqlobject/TestBindProperties.java |  11 +-
 21 files changed, 319 insertions(+), 148 deletions(-)
 create mode 100644 core/src/main/java/org/jdbi/v3/core/mapper/immutables/ImmutablesPlugin.java
 rename core/src/main/java/org/jdbi/v3/core/mapper/{reflect => immutables/internal}/ImmutablesMapperFactory.java (59%)
 delete mode 100644 core/src/main/java/org/jdbi/v3/core/mapper/reflect/PropertiesMapper.java
 create mode 100644 core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoMapper.java
 create mode 100644 core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoPropertiesFactories.java
 rename sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/{BindProperties.java => BindPojo.java} (86%)
 rename sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/{BindPropertiesFactory.java => BindPojoFactory.java} (84%)

diff --git a/4.0_TODO b/4.0_TODO
index 0dd080d902..3d1da39217 100644
--- a/4.0_TODO
+++ b/4.0_TODO
@@ -6,3 +6,4 @@
 * Require @HStore annotation for HSTORE mapping / binding (remove legacy support for unqualified
   maps in Postgres plugin)
 * Throwables throwOnlyException only exists because of the above <X extends Exception> issue, so clean that up too
+* BeanMapper should throw if it can't find a nested type rather than default to `rs.getObject` (strictColumnMapping)
diff --git a/core/src/main/java/org/jdbi/v3/core/argument/BeanPropertyArguments.java b/core/src/main/java/org/jdbi/v3/core/argument/BeanPropertyArguments.java
index 9167f12a96..ba6b70fece 100644
--- a/core/src/main/java/org/jdbi/v3/core/argument/BeanPropertyArguments.java
+++ b/core/src/main/java/org/jdbi/v3/core/argument/BeanPropertyArguments.java
@@ -24,7 +24,10 @@
 /**
  * Inspect a {@link java.beans} style object and bind parameters
  * based on each of its discovered properties.
+ *
+ * @deprecated this should never have been public API
  */
+@Deprecated
 public class BeanPropertyArguments extends MethodReturnValueNamedArgumentFinder {
     private final PojoProperties<?> properties;
 
@@ -37,6 +40,15 @@ public BeanPropertyArguments(String prefix, Object bean) {
         properties = BeanPropertiesFactory.propertiesFor(obj.getClass());
     }
 
+    /**
+     * @param prefix an optional prefix (we insert a '.' as a separator)
+     * @param bean the bean to inspect and bind
+     */
+    protected BeanPropertyArguments(String prefix, Object bean, PojoProperties<?> properties) {
+        super(prefix, bean);
+        this.properties = properties;
+    }
+
     @Override
     protected Optional<TypedValue> getValue(String name, StatementContext ctx) {
         @SuppressWarnings("unchecked")
diff --git a/core/src/main/java/org/jdbi/v3/core/argument/internal/PojoPropertyArguments.java b/core/src/main/java/org/jdbi/v3/core/argument/internal/PojoPropertyArguments.java
index dabe817d87..24cfc826b2 100644
--- a/core/src/main/java/org/jdbi/v3/core/argument/internal/PojoPropertyArguments.java
+++ b/core/src/main/java/org/jdbi/v3/core/argument/internal/PojoPropertyArguments.java
@@ -13,57 +13,26 @@
  */
 package org.jdbi.v3.core.argument.internal;
 
-import java.util.Map;
-import java.util.Optional;
-
+import org.jdbi.v3.core.argument.BeanPropertyArguments;
 import org.jdbi.v3.core.argument.NamedArgumentFinder;
-import org.jdbi.v3.core.mapper.RowMapper;
-import org.jdbi.v3.core.mapper.reflect.BeanMapper;
-import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties.PojoProperty;
+import org.jdbi.v3.core.mapper.reflect.internal.PojoPropertiesFactories;
 import org.jdbi.v3.core.statement.StatementContext;
-import org.jdbi.v3.core.statement.UnableToCreateStatementException;
 
 /**
- * Inspect a object and bind parameters via {@link BeanMapper}'s properties.
+ * This class only exists to use the protected BeanPropertyArguments constructor.
+ * When we can remove that class from public API, this class will easily merge into it.
  */
-public class PojoPropertyArguments extends MethodReturnValueNamedArgumentFinder {
-    private final Map<String, ? extends PojoProperty<?>> properties;
+@SuppressWarnings("deprecation")
+public class PojoPropertyArguments extends BeanPropertyArguments {
     private final StatementContext ctx;
 
-    /**
-     * @param prefix an optional prefix (we insert a '.' as a separator)
-     * @param bean the bean to inspect and bind
-     * @param ctx the statement context
-     */
     public PojoPropertyArguments(String prefix, Object bean, StatementContext ctx) {
-        super(prefix, bean);
+        super(prefix, bean, ctx.getConfig(PojoPropertiesFactories.class).propertiesOf(bean.getClass()));
         this.ctx = ctx;
-        final RowMapper<? extends Object> mapper = ctx.findRowMapperFor(bean.getClass())
-                .orElseThrow(() -> new UnableToCreateStatementException("Couldn't find registered property mapper for " + bean.getClass()));
-        if (!(mapper instanceof BeanMapper<?>)) {
-            throw new UnableToCreateStatementException("Registered mapper for " + bean.getClass() + " is not a property based mapper");
-        }
-        properties = ((BeanMapper<?>) mapper).getBeanInfo().getProperties();
-    }
-
-    @Override
-    protected Optional<TypedValue> getValue(String name, StatementContext ctx2) {
-        @SuppressWarnings("unchecked")
-        PojoProperty<Object> property = (PojoProperty<Object>) properties.get(name);
-        if (property == null) {
-            return Optional.empty();
-        }
-
-        return Optional.of(new TypedValue(property.getQualifiedType(), property.get(obj)));
     }
 
     @Override
     protected NamedArgumentFinder getNestedArgumentFinder(Object o) {
         return new PojoPropertyArguments(null, o, ctx);
     }
-
-    @Override
-    public String toString() {
-        return "{lazy bean property arguments \"" + obj + "\"}";
-    }
 }
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/immutables/ImmutablesPlugin.java b/core/src/main/java/org/jdbi/v3/core/mapper/immutables/ImmutablesPlugin.java
new file mode 100644
index 0000000000..fd1784e553
--- /dev/null
+++ b/core/src/main/java/org/jdbi/v3/core/mapper/immutables/ImmutablesPlugin.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed 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.jdbi.v3.core.mapper.immutables;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import org.jdbi.v3.core.Jdbi;
+import org.jdbi.v3.core.internal.JdbiOptionals;
+import org.jdbi.v3.core.internal.exceptions.Unchecked;
+import org.jdbi.v3.core.mapper.immutables.internal.ImmutablesMapperFactory;
+import org.jdbi.v3.core.mapper.reflect.internal.ImmutablesPropertiesFactory;
+import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties;
+import org.jdbi.v3.core.mapper.reflect.internal.PojoPropertiesFactories;
+import org.jdbi.v3.core.spi.JdbiPlugin;
+import org.jdbi.v3.meta.Beta;
+
+/**
+ * Install support for an Immutables generated {@code Immutable} or {@code Modifiable} value type.
+ * Note that unlike most plugins, this plugin is expected to be installed multiple times, once for each value type you wish to handle.
+ */
+@Beta
+public class ImmutablesPlugin<S> implements JdbiPlugin {
+    private final Class<S> spec;
+    private final Class<? extends S> impl;
+    private final Function<Type, ? extends PojoProperties<S>> properties;
+
+    private ImmutablesPlugin(Class<S> spec, Class<? extends S> impl, Function<Type, ? extends PojoProperties<S>> properties) {
+        this.spec = spec;
+        this.impl = impl;
+        this.properties = properties;
+    }
+
+    /**
+     * Register bean arguments and row mapping for an {@code Immutable*} value class, expecting the default generated class and builder names.
+     * @param spec the specification interface or abstract class
+     * @param <S> the specification class
+     * @return a plugin that configures type mapping for the given class
+     */
+    public static <S> ImmutablesPlugin<S> forImmutable(Class<S> spec) {
+        final Class<? extends S> impl = classByPrefix("Immutable", spec);
+        return forImmutable(spec, impl, JdbiOptionals.findFirstPresent(
+                () -> nullaryMethodOf(spec, "builder"),
+                () -> nullaryMethodOf(impl, "builder"))
+                    .orElseThrow(() -> new IllegalArgumentException("Neither " + spec + " nor " + impl + " have a 'builder' method")));
+    }
+
+    /**
+     * Register bean arguments and row mapping for an {@code Immutable*} value class, using a supplied implementation and builder.
+     * @param spec the specification interface or abstract class
+     * @param impl the generated implementation class
+     * @param builder a supplier of new Builder instances
+     * @param <S> the specification class
+     * @param <I> the implementation class
+     * @return a plugin that configures type mapping for the given class
+     */
+    public static <S, I extends S> ImmutablesPlugin<S> forImmutable(Class<S> spec, Class<I> impl, Supplier<?> builder) {
+        return new ImmutablesPlugin<S>(spec, impl, ImmutablesPropertiesFactory.immutable(spec, builder));
+    }
+
+    /**
+     * Register bean arguments and row mapping for an {@code Modifiable*} value class, expecting the default generated class and public nullary constructor.
+     * @param spec the specification interface or abstract class
+     * @param <S> the specification class
+     * @return a plugin that configures type mapping for the given class
+     */
+    public static <S> ImmutablesPlugin<S> forModifiable(Class<S> spec) {
+        final Class<? extends S> impl = classByPrefix("Modifiable", spec);
+        return forModifiable(spec, impl,
+                nullaryMethodOf(impl, "create")
+                    .orElseGet(() -> constructorOf(impl)));
+    }
+
+    /**
+     * Register bean arguments and row mapping for an {@code Modifiable*} value class, using a supplied implementation and constructor.
+     * @param spec the specification interface or abstract class
+     * @param impl the modifiable class
+     * @param constructor a supplier of new Modifiable instances
+     * @param <S> the specification class
+     * @param <M> the modifiable class
+     * @return a plugin that configures type mapping for the given class
+     */
+    public static <S, M extends S> ImmutablesPlugin<S> forModifiable(Class<S> spec, Class<M> impl, Supplier<?> constructor) {
+        return new ImmutablesPlugin<S>(spec, impl, ImmutablesPropertiesFactory.modifiable(spec, () -> impl.cast(constructor.get())));
+    }
+
+    private static Optional<Supplier<?>> nullaryMethodOf(Class<?> impl, String methodName) {
+        try {
+            return Optional.of(Unchecked.supplier(MethodHandles.lookup()
+                                .unreflect(impl.getMethod(methodName))::invoke));
+        } catch (ReflectiveOperationException e) {
+            return Optional.empty();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <S> Supplier<S> constructorOf(Class<S> impl) {
+        try {
+            return (Supplier<S>) Unchecked.supplier(MethodHandles.lookup().findConstructor(impl, MethodType.methodType(void.class))::invoke);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalArgumentException("Couldn't find public constructor of " + impl, e);
+        }
+    }
+
+    private static <S, Sub extends S> Class<? extends S> classByPrefix(String prefix, Class<S> spec) {
+        final String implName = spec.getPackage().getName() + '.' + prefix + spec.getSimpleName();
+        try {
+            return Class.forName(implName).asSubclass(spec);
+        } catch (ClassNotFoundException e) {
+            throw new IllegalArgumentException("Couldn't locate default implementation class " + implName, e);
+        }
+    }
+
+    @Override
+    public void customizeJdbi(Jdbi jdbi) {
+        jdbi.getConfig(PojoPropertiesFactories.class).register(new Factory());
+        jdbi.registerRowMapper(new ImmutablesMapperFactory<S>(spec, impl, properties));
+    }
+
+    class Factory implements Function<Type, Optional<PojoProperties<?>>> {
+        @Override
+        public Optional<PojoProperties<?>> apply(Type t) {
+            if (t == spec || t == impl) {
+                return Optional.of(properties.apply(t));
+            }
+            return Optional.empty();
+        }
+    }
+}
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/ImmutablesMapperFactory.java b/core/src/main/java/org/jdbi/v3/core/mapper/immutables/internal/ImmutablesMapperFactory.java
similarity index 59%
rename from core/src/main/java/org/jdbi/v3/core/mapper/reflect/ImmutablesMapperFactory.java
rename to core/src/main/java/org/jdbi/v3/core/mapper/immutables/internal/ImmutablesMapperFactory.java
index a1c9d6a3a5..4f663eb413 100644
--- a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/ImmutablesMapperFactory.java
+++ b/core/src/main/java/org/jdbi/v3/core/mapper/immutables/internal/ImmutablesMapperFactory.java
@@ -11,48 +11,41 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.jdbi.v3.core.mapper.reflect;
+package org.jdbi.v3.core.mapper.immutables.internal;
 
 import java.lang.reflect.Type;
 import java.util.Optional;
 import java.util.function.Function;
-import java.util.function.Supplier;
-
 import org.jdbi.v3.core.config.ConfigRegistry;
 import org.jdbi.v3.core.generic.GenericTypes;
 import org.jdbi.v3.core.mapper.RowMapper;
 import org.jdbi.v3.core.mapper.RowMapperFactory;
-import org.jdbi.v3.core.mapper.reflect.internal.ImmutablesPropertiesFactory;
+import org.jdbi.v3.core.mapper.reflect.internal.PojoMapper;
 import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties;
-import org.jdbi.v3.meta.Beta;
 
-@Beta
+/**
+ * Row mapper that inspects an {@code immutables}-style Immutable or Modifiable value class for properties
+ * and binds them in the style of {@link org.jdbi.v3.core.mapper.reflect.BeanMapper}.
+ * @param <T> the mapped value type
+ */
 public class ImmutablesMapperFactory<T> implements RowMapperFactory {
 
     private final Class<T> defn;
     private final Class<? extends T> impl;
-    private final Function<Type, PojoProperties<T>> properties;
+    private final Function<Type, ? extends PojoProperties<T>> properties;
 
-    private ImmutablesMapperFactory(Class<T> defn, Class<? extends T> impl, Function<Type, PojoProperties<T>> properties) {
+    public ImmutablesMapperFactory(Class<T> defn, Class<? extends T> impl, Function<Type, ? extends PojoProperties<T>> properties) {
         this.defn = defn;
         this.impl = impl;
         this.properties = properties;
     }
 
-    public static <T, I extends T, B> RowMapperFactory mapImmutable(Class<T> defn, Class<I> immutable, Supplier<B> builder) {
-        return new ImmutablesMapperFactory<>(defn, immutable, ImmutablesPropertiesFactory.immutable(defn, builder));
-    }
-
-    public static <T, M extends T> RowMapperFactory mapModifiable(Class<T> defn, Class<M> modifiable, Supplier<M> constructor) {
-        return new ImmutablesMapperFactory<>(defn, modifiable, ImmutablesPropertiesFactory.modifiable(defn, constructor));
-    }
-
     @SuppressWarnings("unchecked")
     @Override
     public Optional<RowMapper<?>> build(Type type, ConfigRegistry config) {
         Class<?> erasedType = GenericTypes.getErasedType(type);
         if (defn.equals(erasedType) || impl.equals(erasedType)) {
-            return Optional.of(PropertiesMapper.of((Class<T>) erasedType, properties.apply(type)));
+            return Optional.of(new PojoMapper<>((Class<T>) erasedType, properties.apply(type), ""));
         }
         return Optional.empty();
     }
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/BeanMapper.java b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/BeanMapper.java
index df0f295a4d..81c368cf07 100644
--- a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/BeanMapper.java
+++ b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/BeanMapper.java
@@ -24,6 +24,7 @@
 import org.jdbi.v3.core.generic.GenericTypes;
 import org.jdbi.v3.core.mapper.ColumnMapper;
 import org.jdbi.v3.core.mapper.Nested;
+import org.jdbi.v3.core.mapper.NoSuchMapperException;
 import org.jdbi.v3.core.mapper.RowMapper;
 import org.jdbi.v3.core.mapper.RowMapperFactory;
 import org.jdbi.v3.core.mapper.SingleColumnMapper;
@@ -32,8 +33,6 @@
 import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties.PojoBuilder;
 import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties.PojoProperty;
 import org.jdbi.v3.core.statement.StatementContext;
-import org.jdbi.v3.meta.Beta;
-
 import static org.jdbi.v3.core.mapper.reflect.ReflectionMapperUtil.anyColumnsStartWithPrefix;
 import static org.jdbi.v3.core.mapper.reflect.ReflectionMapperUtil.findColumnIndex;
 import static org.jdbi.v3.core.mapper.reflect.ReflectionMapperUtil.getColumnNames;
@@ -46,7 +45,10 @@
  * properties.
  *
  * The mapped class must have a default constructor.
+ *
+ * @deprecated this class should not be public API, use {@link org.jdbi.v3.core.statement.SqlStatement#bindBean(Object)} instead.
  */
+@Deprecated
 public class BeanMapper<T> implements RowMapper<T> {
     static final String DEFAULT_PREFIX = "";
 
@@ -100,6 +102,7 @@ public static <T> RowMapper<T> of(Class<T> type, String prefix) {
         return new BeanMapper<>(type, prefix);
     }
 
+    protected boolean strictColumnMapping; // this should be default (only?) behavior but that's a breaking change
     protected final Class<T> type;
     protected final String prefix;
     private final PojoProperties<T> properties;
@@ -110,7 +113,7 @@ public static <T> RowMapper<T> of(Class<T> type, String prefix) {
         this(type, (PojoProperties<T>) BeanPropertiesFactory.propertiesFor(type), prefix);
     }
 
-    BeanMapper(Class<T> type, PojoProperties<T> properties, String prefix) {
+    protected BeanMapper(Class<T> type, PojoProperties<T> properties, String prefix) {
         this.type = type;
         this.properties = properties;
         this.prefix = prefix.toLowerCase();
@@ -198,15 +201,14 @@ private Optional<RowMapper<T>> specialize0(StatementContext ctx,
         });
     }
 
-    ColumnMapper<?> defaultColumnMapper(PojoProperty<T> property) {
+    private ColumnMapper<?> defaultColumnMapper(PojoProperty<T> property) {
+        if (strictColumnMapping) {
+            throw new NoSuchMapperException(String.format(
+                    "Couldn't find mapper for property '%s' of type '%s' from %s", property.getName(), property.getQualifiedType(), type));
+        }
         return (r, n, c) -> r.getObject(n);
     }
 
-    @Beta
-    public PojoProperties<T> getBeanInfo() {
-        return properties;
-    }
-
     private String getName(PojoProperty<T> property) {
         return property.getAnnotation(ColumnName.class)
                 .map(ColumnName::value)
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/PropertiesMapper.java b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/PropertiesMapper.java
deleted file mode 100644
index 222b0b7274..0000000000
--- a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/PropertiesMapper.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Licensed 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.jdbi.v3.core.mapper.reflect;
-
-import org.jdbi.v3.core.mapper.ColumnMapper;
-import org.jdbi.v3.core.mapper.NoSuchMapperException;
-import org.jdbi.v3.core.mapper.RowMapper;
-import org.jdbi.v3.core.mapper.RowMapperFactory;
-import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties;
-import org.jdbi.v3.core.mapper.reflect.internal.PojoProperties.PojoProperty;
-
-class PropertiesMapper<T> extends BeanMapper<T> {
-    private PropertiesMapper(Class<T> type, PojoProperties<T> properties, String prefix) {
-        super(type, properties, prefix);
-    }
-
-    public static <T> RowMapperFactory factory(Class<T> type, PojoProperties<T> properties) {
-        return RowMapperFactory.of(type, PropertiesMapper.of(type, properties));
-    }
-
-    public static <T> RowMapperFactory factory(Class<T> type, PojoProperties<T> properties, String prefix) {
-        return RowMapperFactory.of(type, PropertiesMapper.of(type, properties, prefix));
-    }
-
-    public static <T> RowMapper<T> of(Class<T> type, PojoProperties<T> properties) {
-        return PropertiesMapper.of(type, properties, DEFAULT_PREFIX);
-    }
-
-    public static <T> RowMapper<T> of(Class<T> type, PojoProperties<T> properties, String prefix) {
-        return new PropertiesMapper<>(type, properties, prefix);
-    }
-
-    @Override
-    protected ColumnMapper<?> defaultColumnMapper(PojoProperty<T> property) {
-        throw new NoSuchMapperException(String.format(
-                "Couldn't find mapper for property '%s' of type '%s' from %s", property.getName(), property.getQualifiedType(), type));
-    }
-}
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoMapper.java b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoMapper.java
new file mode 100644
index 0000000000..2a0c9a5590
--- /dev/null
+++ b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoMapper.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed 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.jdbi.v3.core.mapper.reflect.internal;
+
+import org.jdbi.v3.core.mapper.reflect.BeanMapper;
+
+/** This class only exists to extend BeanMapper with API that would otherwise be unavoidably public. */
+@SuppressWarnings("deprecation")
+public class PojoMapper<T> extends BeanMapper<T> {
+    public PojoMapper(Class<T> type, PojoProperties<T> properties, String prefix) {
+        super(type, properties, prefix);
+        strictColumnMapping = true;
+    }
+}
diff --git a/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoPropertiesFactories.java b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoPropertiesFactories.java
new file mode 100644
index 0000000000..f5ff037400
--- /dev/null
+++ b/core/src/main/java/org/jdbi/v3/core/mapper/reflect/internal/PojoPropertiesFactories.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed 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.jdbi.v3.core.mapper.reflect.internal;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+import org.jdbi.v3.core.config.JdbiConfig;
+import org.jdbi.v3.core.internal.JdbiOptionals;
+
+public class PojoPropertiesFactories implements JdbiConfig<PojoPropertiesFactories> {
+    private final List<Function<Type, Optional<PojoProperties<?>>>> factories = new ArrayList<>();
+
+    public PojoPropertiesFactories() {}
+
+    private PojoPropertiesFactories(PojoPropertiesFactories other) {
+        factories.addAll(other.factories);
+    }
+
+    public PojoPropertiesFactories register(Function<Type, Optional<PojoProperties<?>>> factory) {
+        factories.add(factory);
+        return this;
+    }
+
+    public PojoProperties<?> propertiesOf(Type type) {
+        return factories.stream()
+                .flatMap(ppf -> JdbiOptionals.stream(ppf.apply(type)))
+                .findFirst()
+                .orElseGet(() -> BeanPropertiesFactory.propertiesFor(type));
+    }
+
+    @Override
+    public PojoPropertiesFactories createCopy() {
+        return new PojoPropertiesFactories(this);
+    }
+}
diff --git a/core/src/main/java/org/jdbi/v3/core/qualifier/Qualifiers.java b/core/src/main/java/org/jdbi/v3/core/qualifier/Qualifiers.java
index 6de5f9b521..7b57f9b8e3 100644
--- a/core/src/main/java/org/jdbi/v3/core/qualifier/Qualifiers.java
+++ b/core/src/main/java/org/jdbi/v3/core/qualifier/Qualifiers.java
@@ -13,6 +13,8 @@
  */
 package org.jdbi.v3.core.qualifier;
 
+import static java.util.stream.Collectors.toSet;
+
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
 import java.util.Arrays;
@@ -21,8 +23,6 @@
 
 import org.jdbi.v3.meta.Beta;
 
-import static java.util.stream.Collectors.toSet;
-
 /**
  * Utility class for type qualifiers supported by Jdbi core.
  */
diff --git a/core/src/main/java/org/jdbi/v3/core/result/ResultBearing.java b/core/src/main/java/org/jdbi/v3/core/result/ResultBearing.java
index 69ff493a86..29ba654e72 100644
--- a/core/src/main/java/org/jdbi/v3/core/result/ResultBearing.java
+++ b/core/src/main/java/org/jdbi/v3/core/result/ResultBearing.java
@@ -179,6 +179,7 @@ default ResultIterable<?> mapTo(QualifiedType type) {
      * @param <T>  the bean type to map the result set rows to
      * @return a {@link ResultIterable} of the given type.
      */
+    @SuppressWarnings("deprecation")
     default <T> ResultIterable<T> mapToBean(Class<T> type) {
         return map(BeanMapper.of(type));
     }
diff --git a/core/src/main/java/org/jdbi/v3/core/statement/SqlStatement.java b/core/src/main/java/org/jdbi/v3/core/statement/SqlStatement.java
index c5cecb2def..48d1863bdb 100644
--- a/core/src/main/java/org/jdbi/v3/core/statement/SqlStatement.java
+++ b/core/src/main/java/org/jdbi/v3/core/statement/SqlStatement.java
@@ -166,6 +166,7 @@ public This bind(String name, Argument argument) {
      *
      * @return modified statement
      */
+    @SuppressWarnings("deprecation")
     public This bindBean(Object bean) {
         return bindNamedArgumentFinder(new BeanPropertyArguments(null, bean));
     }
@@ -180,6 +181,7 @@ public This bindBean(Object bean) {
      *
      * @return modified statement
      */
+    @SuppressWarnings("deprecation")
     public This bindBean(String prefix, Object bean) {
         return bindNamedArgumentFinder(new BeanPropertyArguments(prefix, bean));
     }
@@ -192,8 +194,8 @@ public This bindBean(String prefix, Object bean) {
      * @return modified statement
      */
     @Beta
-    public This bindProperties(Object pojo) {
-        return bindNamedArgumentFinder(new PojoPropertyArguments(null, pojo, getContext()));
+    public This bindPojo(Object pojo) {
+        return bindPojo(null, pojo);
     }
 
     /**
@@ -207,7 +209,7 @@ public This bindProperties(Object pojo) {
      * @return modified statement
      */
     @Beta
-    public This bindProperties(String prefix, Object pojo) {
+    public This bindPojo(String prefix, Object pojo) {
         return bindNamedArgumentFinder(new PojoPropertyArguments(prefix, pojo, getContext()));
     }
 
diff --git a/core/src/test/java/org/jdbi/v3/core/mapper/ImmutablesTest.java b/core/src/test/java/org/jdbi/v3/core/mapper/ImmutablesTest.java
index ac48ed78e8..73a923d488 100644
--- a/core/src/test/java/org/jdbi/v3/core/mapper/ImmutablesTest.java
+++ b/core/src/test/java/org/jdbi/v3/core/mapper/ImmutablesTest.java
@@ -19,8 +19,9 @@
 
 import org.immutables.value.Value;
 import org.jdbi.v3.core.Handle;
+import org.jdbi.v3.core.Jdbi;
 import org.jdbi.v3.core.generic.GenericType;
-import org.jdbi.v3.core.mapper.reflect.ImmutablesMapperFactory;
+import org.jdbi.v3.core.mapper.immutables.ImmutablesPlugin;
 import org.jdbi.v3.core.rule.H2DatabaseRule;
 import org.junit.Before;
 import org.junit.Rule;
@@ -30,19 +31,24 @@
 
 public class ImmutablesTest {
     @Rule
-    public H2DatabaseRule dbRule = new H2DatabaseRule();
+    public H2DatabaseRule dbRule = new H2DatabaseRule()
+        .withPlugin(ImmutablesPlugin.forImmutable(SubValue.class))
+        .withPlugin(ImmutablesPlugin.forImmutable(FooBarBaz.class))
+        .withPlugin(ImmutablesPlugin.forModifiable(FooBarBaz.class));
 
+    private Jdbi jdbi;
     private Handle h;
 
     @Before
     public void setup() {
+        jdbi = dbRule.getJdbi();
         h = dbRule.getSharedHandle();
         h.execute("create table immutables (t int, x varchar)");
-
-        h.registerRowMapper(ImmutablesMapperFactory.mapImmutable(SubValue.class, ImmutableSubValue.class, ImmutableSubValue::builder));
     }
 
     // tag::example[]
+    // First, install the plugin: ;
+
     @Value.Immutable
     public interface Train {
         String name();
@@ -52,12 +58,12 @@ public interface Train {
 
     @Test
     public void simpleTest() {
+        jdbi.installPlugin(ImmutablesPlugin.forImmutable(Train.class));
         h.execute("create table train (name varchar, carriages int, observation_car boolean)");
-        h.registerRowMapper(ImmutablesMapperFactory.mapImmutable(Train.class, ImmutableTrain.class, ImmutableTrain::builder));
 
         assertThat(
             h.createUpdate("insert into train(name, carriages, observation_car) values (:name, :carriages, :observationCar)")
-                .bindProperties(ImmutableTrain.builder().name("Zephyr").carriages(8).observationCar(true).build())
+                .bindPojo(ImmutableTrain.builder().name("Zephyr").carriages(8).observationCar(true).build())
                 .execute())
             .isEqualTo(1);
 
@@ -74,7 +80,7 @@ public void simpleTest() {
     public void parameterizedTest() {
         assertThat(
             h.createUpdate("insert into immutables(t, x) values (:t, :x)")
-                .bindProperties(ImmutableSubValue.<String, Integer>builder().t(42).x("foo").build())
+                .bindPojo(ImmutableSubValue.<String, Integer>builder().t(42).x("foo").build())
                 .execute())
             .isEqualTo(1);
 
@@ -106,12 +112,10 @@ public interface FooBarBaz {
 
     @Test
     public void testModifiable() {
-        h.registerRowMapper(ImmutablesMapperFactory.mapImmutable(FooBarBaz.class, ImmutableFooBarBaz.class, ImmutableFooBarBaz::builder));
-        h.registerRowMapper(ImmutablesMapperFactory.mapModifiable(FooBarBaz.class, ModifiableFooBarBaz.class, ModifiableFooBarBaz::create));
         h.execute("create table fbb (id serial, foo varchar, bar int, baz real)");
 
         assertThat(h.createUpdate("insert into fbb (id, foo, bar, baz) values (:id, :foo, :bar, :baz)")
-                .bindProperties(ModifiableFooBarBaz.create().setFoo("foo").setBar(42).setBaz(1.0))
+                .bindPojo(ModifiableFooBarBaz.create().setFoo("foo").setBar(42).setBaz(1.0))
                 .execute())
             .isEqualTo(1);
 
diff --git a/docs/src/adoc/index.adoc b/docs/src/adoc/index.adoc
index a5fce44cc7..74d177c0f3 100644
--- a/docs/src/adoc/index.adoc
+++ b/docs/src/adoc/index.adoc
@@ -3800,7 +3800,11 @@ to `Jdbi` properties binding and row mapping.
 [WARNING]
 Immutables support is still experimental and does not yet support custom naming schemes.
 
-Just tell us about your types, and we do the rest:
+Just tell us about your types by installing the plugin (possibly multiple times):
+
+`jdbi.installPlugin(ImmutablesPlugin.forImmutable(MyValueType.class))`
+
+and we do the rest:
 
 [source,java,indent=0]
 ----
diff --git a/pom.xml b/pom.xml
index 153385ed69..f9db44e7d1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -84,6 +84,7 @@
         <dep.antlr.version>3.4</dep.antlr.version>
         <dep.checkstyle.version>8.10</dep.checkstyle.version>
         <dep.dokka.version>0.9.17</dep.dokka.version>
+        <dep.immutables.version>2.7.1</dep.immutables.version>
         <dep.jackson2.version>2.9.8</dep.jackson2.version>
         <dep.jetbrainsAnnotations.version>13.0</dep.jetbrainsAnnotations.version>
         <dep.kotlin.version>1.2.31</dep.kotlin.version>
@@ -242,7 +243,7 @@
             <dependency>
                 <groupId>org.immutables</groupId>
                 <artifactId>value</artifactId>
-                <version>2.7.1</version>
+                <version>${dep.immutables.version}</version>
             </dependency>
 
             <dependency>
@@ -506,6 +507,13 @@
                     <configuration>
                         <source>${project.build.targetJdk}</source>
                         <target>${project.build.targetJdk}</target>
+                        <annotationProcessorPaths>
+                            <annotationProcessorPath>
+                                <groupId>org.immutables</groupId>
+                                <artifactId>value</artifactId>
+                                <version>${dep.immutables.version}</version>
+                            </annotationProcessorPath>
+                        </annotationProcessorPaths>
                     </configuration>
                 </plugin>
                 <plugin>
diff --git a/postgres/pom.xml b/postgres/pom.xml
index 63d40d17cb..78f9f31bbc 100644
--- a/postgres/pom.xml
+++ b/postgres/pom.xml
@@ -60,6 +60,7 @@
             <artifactId>value</artifactId>
             <scope>test</scope>
         </dependency>
+
         <dependency>
             <groupId>org.jdbi</groupId>
             <artifactId>jdbi3-core</artifactId>
diff --git a/postgres/src/test/java/org/jdbi/v3/postgres/TestImmutablesHStore.java b/postgres/src/test/java/org/jdbi/v3/postgres/TestImmutablesHStore.java
index a11a443205..cbace59abd 100644
--- a/postgres/src/test/java/org/jdbi/v3/postgres/TestImmutablesHStore.java
+++ b/postgres/src/test/java/org/jdbi/v3/postgres/TestImmutablesHStore.java
@@ -18,10 +18,10 @@
 
 import org.immutables.value.Value;
 import org.jdbi.v3.core.Handle;
-import org.jdbi.v3.core.mapper.reflect.ImmutablesMapperFactory;
+import org.jdbi.v3.core.mapper.immutables.ImmutablesPlugin;
 import org.jdbi.v3.core.rule.PgDatabaseRule;
 import org.jdbi.v3.sqlobject.SqlObjectPlugin;
-import org.jdbi.v3.sqlobject.customizer.BindProperties;
+import org.jdbi.v3.sqlobject.customizer.BindPojo;
 import org.jdbi.v3.sqlobject.statement.SqlQuery;
 import org.jdbi.v3.sqlobject.statement.SqlUpdate;
 import org.junit.After;
@@ -34,14 +34,16 @@
 public class TestImmutablesHStore {
 
     @Rule
-    public PgDatabaseRule dbRule = new PgDatabaseRule().withPlugin(new PostgresPlugin()).withPlugin(new SqlObjectPlugin());
+    public PgDatabaseRule dbRule = new PgDatabaseRule()
+        .withPlugin(new PostgresPlugin())
+        .withPlugin(new SqlObjectPlugin())
+        .withPlugin(ImmutablesPlugin.forImmutable(Mappy.class));
 
     private Handle h;
 
     @Before
     public void setup() {
         h = dbRule.openHandle();
-        h.registerRowMapper(ImmutablesMapperFactory.mapImmutable(Mappy.class, ImmutableMappy.class, Mappy::builder));
         h.execute("create extension if not exists \"hstore\"");
         h.execute("create table mappy (numbers hstore not null)");
     }
@@ -87,7 +89,7 @@ public void testMap() {
 
     public interface MappyDao {
         @SqlUpdate("insert into mappy (numbers) values (:numbers)")
-        int insert(@BindProperties Mappy mappy);
+        int insert(@BindPojo Mappy mappy);
 
         @SqlQuery("select numbers from mappy")
         List<Mappy> select();
diff --git a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/config/internal/RegisterBeanMapperImpl.java b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/config/internal/RegisterBeanMapperImpl.java
index 78507bc75b..fe662f42b3 100644
--- a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/config/internal/RegisterBeanMapperImpl.java
+++ b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/config/internal/RegisterBeanMapperImpl.java
@@ -24,6 +24,7 @@
 
 public class RegisterBeanMapperImpl implements Configurer {
     @Override
+    @SuppressWarnings("deprecation")
     public void configureForType(ConfigRegistry registry, Annotation annotation, Class<?> sqlObjectType) {
         RegisterBeanMapper registerBeanMapper = (RegisterBeanMapper) annotation;
         Class<?> beanClass = registerBeanMapper.value();
diff --git a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindProperties.java b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindPojo.java
similarity index 86%
rename from sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindProperties.java
rename to sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindPojo.java
index 02b222cf81..e3913d99b5 100644
--- a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindProperties.java
+++ b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/BindPojo.java
@@ -18,15 +18,15 @@
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
-import org.jdbi.v3.sqlobject.customizer.internal.BindPropertiesFactory;
+import org.jdbi.v3.sqlobject.customizer.internal.BindPojoFactory;
 
 /**
  * Binds the properties of an object to a SQL statement.
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ElementType.PARAMETER})
-@SqlStatementCustomizingAnnotation(BindPropertiesFactory.class)
-public @interface BindProperties {
+@SqlStatementCustomizingAnnotation(BindPojoFactory.class)
+public @interface BindPojo {
     /**
      * Prefix to apply to each property. If specified, properties will be bound as
      * {@code prefix.propertyName}.
diff --git a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPropertiesFactory.java b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPojoFactory.java
similarity index 84%
rename from sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPropertiesFactory.java
rename to sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPojoFactory.java
index 3b85f73708..af837ce305 100644
--- a/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPropertiesFactory.java
+++ b/sqlobject/src/main/java/org/jdbi/v3/sqlobject/customizer/internal/BindPojoFactory.java
@@ -18,11 +18,11 @@
 import java.lang.reflect.Parameter;
 import java.lang.reflect.Type;
 
-import org.jdbi.v3.sqlobject.customizer.BindProperties;
+import org.jdbi.v3.sqlobject.customizer.BindPojo;
 import org.jdbi.v3.sqlobject.customizer.SqlStatementCustomizerFactory;
 import org.jdbi.v3.sqlobject.customizer.SqlStatementParameterCustomizer;
 
-public class BindPropertiesFactory implements SqlStatementCustomizerFactory {
+public class BindPojoFactory implements SqlStatementCustomizerFactory {
     @Override
     public SqlStatementParameterCustomizer createForParameter(Annotation annotation,
                                                               Class<?> sqlObjectType,
@@ -30,13 +30,13 @@ public SqlStatementParameterCustomizer createForParameter(Annotation annotation,
                                                               Parameter param,
                                                               int index,
                                                               Type type) {
-        BindProperties bind = (BindProperties) annotation;
+        BindPojo bind = (BindPojo) annotation;
         return (stmt, bean) -> {
             String prefix = bind.value();
             if (prefix.isEmpty()) {
-                stmt.bindProperties(bean);
+                stmt.bindPojo(bean);
             } else {
-                stmt.bindProperties(prefix, bean);
+                stmt.bindPojo(prefix, bean);
             }
         };
     }
diff --git a/sqlobject/src/test/java/org/jdbi/v3/sqlobject/TestBindProperties.java b/sqlobject/src/test/java/org/jdbi/v3/sqlobject/TestBindProperties.java
index 3e92b558a9..7c830edd0a 100644
--- a/sqlobject/src/test/java/org/jdbi/v3/sqlobject/TestBindProperties.java
+++ b/sqlobject/src/test/java/org/jdbi/v3/sqlobject/TestBindProperties.java
@@ -18,9 +18,9 @@
 import org.jdbi.v3.core.Handle;
 import org.jdbi.v3.core.mapper.ImmutableTrain;
 import org.jdbi.v3.core.mapper.ImmutablesTest.Train;
-import org.jdbi.v3.core.mapper.reflect.ImmutablesMapperFactory;
+import org.jdbi.v3.core.mapper.immutables.ImmutablesPlugin;
 import org.jdbi.v3.core.rule.H2DatabaseRule;
-import org.jdbi.v3.sqlobject.customizer.BindProperties;
+import org.jdbi.v3.sqlobject.customizer.BindPojo;
 import org.jdbi.v3.sqlobject.statement.SqlQuery;
 import org.jdbi.v3.sqlobject.statement.SqlUpdate;
 import org.junit.Before;
@@ -31,7 +31,9 @@
 
 public class TestBindProperties {
     @Rule
-    public H2DatabaseRule dbRule = new H2DatabaseRule().withPlugin(new SqlObjectPlugin());
+    public H2DatabaseRule dbRule = new H2DatabaseRule()
+        .withPlugin(new SqlObjectPlugin())
+        .withPlugin(ImmutablesPlugin.forImmutable(Train.class));
 
     private Handle h;
 
@@ -40,7 +42,6 @@ public class TestBindProperties {
     @Before
     public void setUp() {
         h = dbRule.getSharedHandle();
-        h.registerRowMapper(ImmutablesMapperFactory.mapImmutable(Train.class, ImmutableTrain.class, ImmutableTrain::builder));
         h.execute("create table train (name varchar, carriages int, observation_car boolean)");
 
         dao = h.attach(Dao.class);
@@ -63,7 +64,7 @@ public void testBindBean() {
 
     public interface Dao {
         @SqlUpdate("insert into train(name, carriages, observation_car) values (:name, :carriages, :observationCar)")
-        int insert(@BindProperties Train train);
+        int insert(@BindPojo Train train);
 
         @SqlQuery("select * from train")
         List<Train> getTrains();