From 5a2aeb3ab59f286a6d2a5d8b7d62f4b17132b2b7 Mon Sep 17 00:00:00 2001
From: "Brian S. O'Neill" <bronee@gmail.com>
Date: Wed, 30 Aug 2006 02:24:36 +0000
Subject: Add core repository implementations

---
 .../repo/indexed/IndexEntryAccessCapability.java   |   40 +
 .../carbonado/repo/indexed/IndexEntryAccessor.java |   73 +
 .../repo/indexed/IndexEntryGenerator.java          |  173 ++
 .../carbonado/repo/indexed/IndexedCursor.java      |  183 ++
 .../carbonado/repo/indexed/IndexedRepository.java  |  184 ++
 .../repo/indexed/IndexedRepositoryBuilder.java     |  114 ++
 .../carbonado/repo/indexed/IndexedStorage.java     |  408 +++++
 .../carbonado/repo/indexed/IndexesTrigger.java     |  102 ++
 .../carbonado/repo/indexed/ManagedIndex.java       |  440 +++++
 .../carbonado/repo/indexed/StoredIndexInfo.java    |   84 +
 .../amazon/carbonado/repo/indexed/Unindexed.java   |   27 +
 .../carbonado/repo/indexed/package-info.java       |   26 +
 .../com/amazon/carbonado/repo/jdbc/JDBCBlob.java   |  233 +++
 .../amazon/carbonado/repo/jdbc/JDBCBlobLoader.java |   33 +
 .../com/amazon/carbonado/repo/jdbc/JDBCClob.java   |  236 +++
 .../amazon/carbonado/repo/jdbc/JDBCClobLoader.java |   33 +
 .../repo/jdbc/JDBCConnectionCapability.java        |   76 +
 .../com/amazon/carbonado/repo/jdbc/JDBCCursor.java |  132 ++
 .../repo/jdbc/JDBCExceptionTransformer.java        |  110 ++
 .../com/amazon/carbonado/repo/jdbc/JDBCLob.java    |   28 +
 .../amazon/carbonado/repo/jdbc/JDBCRepository.java |  665 +++++++
 .../carbonado/repo/jdbc/JDBCRepositoryBuilder.java |  255 +++
 .../repo/jdbc/JDBCSequenceValueProducer.java       |   68 +
 .../carbonado/repo/jdbc/JDBCStorableGenerator.java | 1892 ++++++++++++++++++++
 .../carbonado/repo/jdbc/JDBCStorableInfo.java      |   75 +
 .../repo/jdbc/JDBCStorableIntrospector.java        | 1365 ++++++++++++++
 .../carbonado/repo/jdbc/JDBCStorableProperty.java  |  129 ++
 .../amazon/carbonado/repo/jdbc/JDBCStorage.java    | 1129 ++++++++++++
 .../amazon/carbonado/repo/jdbc/JDBCSupport.java    |   74 +
 .../carbonado/repo/jdbc/JDBCSupportStrategy.java   |  233 +++
 .../carbonado/repo/jdbc/JDBCTransaction.java       |  122 ++
 .../repo/jdbc/JDBCTransactionManager.java          |   94 +
 .../repo/jdbc/LoggingCallableStatement.java        |  392 ++++
 .../carbonado/repo/jdbc/LoggingConnection.java     |  227 +++
 .../carbonado/repo/jdbc/LoggingDataSource.java     |   93 +
 .../repo/jdbc/LoggingPreparedStatement.java        |  255 +++
 .../carbonado/repo/jdbc/LoggingStatement.java      |  200 +++
 .../carbonado/repo/jdbc/SimpleDataSource.java      |   89 +
 .../amazon/carbonado/repo/jdbc/package-info.java   |   28 +
 .../amazon/carbonado/repo/logging/CommonsLog.java  |   44 +
 .../com/amazon/carbonado/repo/logging/Log.java     |   30 +
 .../repo/logging/LogAccessCapability.java          |   30 +
 .../carbonado/repo/logging/LoggingQuery.java       |   98 +
 .../carbonado/repo/logging/LoggingRepository.java  |  114 ++
 .../repo/logging/LoggingRepositoryBuilder.java     |  150 ++
 .../carbonado/repo/logging/LoggingStorage.java     |  138 ++
 .../carbonado/repo/logging/LoggingTransaction.java |   69 +
 .../carbonado/repo/logging/package-info.java       |   25 +
 .../repo/replicated/ReplicatedRepository.java      |  583 ++++++
 .../replicated/ReplicatedRepositoryBuilder.java    |  161 ++
 .../repo/replicated/ReplicatedStorage.java         |  121 ++
 .../repo/replicated/ReplicationTrigger.java        |  398 ++++
 .../carbonado/repo/replicated/package-info.java    |   27 +
 53 files changed, 12108 insertions(+)
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessCapability.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessor.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryGenerator.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexedCursor.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepository.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepositoryBuilder.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexedStorage.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/IndexesTrigger.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/ManagedIndex.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/StoredIndexInfo.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/Unindexed.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/indexed/package-info.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlob.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlobLoader.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClob.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClobLoader.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCConnectionCapability.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCCursor.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCExceptionTransformer.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCLob.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepository.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepositoryBuilder.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSequenceValueProducer.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableGenerator.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableInfo.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableIntrospector.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableProperty.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorage.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupport.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupportStrategy.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransaction.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransactionManager.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/LoggingCallableStatement.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/LoggingConnection.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/LoggingDataSource.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/LoggingPreparedStatement.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/LoggingStatement.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/SimpleDataSource.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/jdbc/package-info.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/CommonsLog.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/Log.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LogAccessCapability.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LoggingQuery.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LoggingRepository.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LoggingRepositoryBuilder.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LoggingStorage.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/LoggingTransaction.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/logging/package-info.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepository.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepositoryBuilder.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedStorage.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/replicated/ReplicationTrigger.java
 create mode 100644 src/main/java/com/amazon/carbonado/repo/replicated/package-info.java

(limited to 'src/main/java')

diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessCapability.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessCapability.java
new file mode 100644
index 0000000..82f9d1f
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessCapability.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+
+import com.amazon.carbonado.capability.Capability;
+
+/**
+ * Capability for gaining low-level access to index data, which can be used for
+ * manual inspection and repair.
+ *
+ * @author Brian S O'Neill
+ */
+public interface IndexEntryAccessCapability extends Capability {
+    /**
+     * Returns index entry accessors for the known indexes of the given
+     * storable type. The array might be empty, but it is never null. The array
+     * is a copy, and so it may be safely modified.
+     */
+    <S extends Storable> IndexEntryAccessor<S>[] getIndexEntryAccessors(Class<S> storableType)
+        throws RepositoryException;
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessor.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessor.java
new file mode 100644
index 0000000..16a980d
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryAccessor.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.Comparator;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+
+import com.amazon.carbonado.capability.IndexInfo;
+
+/**
+ * Provides low-level access to index data, which can be used for manual
+ * inspection and repair.
+ *
+ * @author Brian S O'Neill
+ */
+public interface IndexEntryAccessor<S extends Storable> extends IndexInfo {
+    /**
+     * Returns the index entry storage. Index entry properties can only be
+     * accessed via reflection.
+     */
+    Storage<?> getIndexEntryStorage();
+
+    /**
+     * Loads the master object referenced by the given index entry.
+     *
+     * @param indexEntry index entry which points to master
+     * @return master or null if missing
+     */
+    S loadMaster(Storable indexEntry) throws FetchException;
+
+    /**
+     * Sets all the properties of the given index entry, using the applicable
+     * properties of the given master.
+     *
+     * @param indexEntry index entry whose properties will be set
+     * @param master source of property values
+     */
+    void setAllProperties(Storable indexEntry, S master);
+
+    /**
+     * Returns true if the properties of the given index entry match those
+     * contained in the master, exluding any version property. This will always
+     * return true after a call to setAllProperties.
+     *
+     * @param indexEntry index entry whose properties will be tested
+     * @param master source of property values
+     */
+    boolean isConsistent(Storable indexEntry, S master);
+
+    /**
+     * Returns a comparator for ordering index entries.
+     */
+    Comparator<? extends Storable> getComparator();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryGenerator.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryGenerator.java
new file mode 100644
index 0000000..3da1a5b
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexEntryGenerator.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.Comparator;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.lang.ref.Reference;
+import java.lang.ref.SoftReference;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.info.StorableIndex;
+import com.amazon.carbonado.info.StorableProperty;
+import com.amazon.carbonado.synthetic.SyntheticStorableReferenceBuilder;
+
+/**
+ * IndexEntryGenerator creates new kinds of Storables suitable for indexing a
+ * master Storable.
+ *
+ * @author Brian S O'Neill
+ * @author Don Schneider
+ *
+ */
+class IndexEntryGenerator <S extends Storable> {
+
+    // cache for generators
+    private static Map<StorableIndex, Reference<IndexEntryGenerator>> cCache =
+            new WeakHashMap<StorableIndex, Reference<IndexEntryGenerator>>();
+
+
+    /**
+     * Returns a new or cached generator instance. The caching of generators is
+     * soft, so if no references remain to a given instance it may be garbage
+     * collected. A subsequent call will return a newly created instance.
+     *
+     * <p>In addition to generating an index entry storable, this class
+     * contains methods to operate on it. Care must be taken to ensure that the
+     * index entry instances are of the same type that the generator expects.
+     * Since the generator may be garbage collected freely of the generated
+     * index entry class, it is possible for index entries to be passed to a
+     * generator instance that does not understand it. For example:
+     *
+     * <pre>
+     * StorableIndex index = ...
+     * Class indexEntryClass = IndexEntryGenerator.getInstance(index).getIndexEntryClass();
+     * ...
+     * garbage collection
+     * ...
+     * Storable indexEntry = instance of indexEntryClass
+     * // Might fail because generator instance is new
+     * IndexEntryGenerator.getInstance(index).setAllProperties(indexEntry, source);
+     * </pre>
+     *
+     * The above code can be fixed by saving a local reference to the generator:
+     *
+     * <pre>
+     * StorableIndex index = ...
+     * IndexEntryGenerator generator = IndexEntryGenerator.getInstance(index);
+     * Class indexEntryClass = generator.getIndexEntryClass();
+     * ...
+     * Storable indexEntry = instance of indexEntryClass
+     * generator.setAllProperties(indexEntry, source);
+     * </pre>
+     *
+     * @throws SupportException if any non-primary key property doesn't have a
+     * public read method.
+     */
+    public static <S extends Storable> IndexEntryGenerator<S>
+            getInstance(StorableIndex<S> index) throws SupportException
+    {
+        synchronized(cCache) {
+            IndexEntryGenerator<S> generator;
+            Reference<IndexEntryGenerator> ref = cCache.get(index);
+            if (ref != null) {
+                generator = ref.get();
+                if (generator != null) {
+                    return generator;
+                }
+            }
+            generator = new IndexEntryGenerator<S>(index);
+            cCache.put(index, new SoftReference<IndexEntryGenerator>(generator));
+            return generator;
+        }
+    }
+
+    private SyntheticStorableReferenceBuilder<S> mBuilder;
+
+    /**
+     * Convenience class for gluing new "builder" style synthetics to the traditional
+     * generator style.
+     * @param index Generator style index specification
+     */
+    public IndexEntryGenerator(StorableIndex<S> index) throws SupportException {
+        // Need to try to find the base type.  This is an awkward way to do it,
+        // but we have nothing better available to us
+        Class<S> type = index.getProperty(0).getEnclosingType();
+
+        mBuilder = new SyntheticStorableReferenceBuilder<S>(type, index.isUnique());
+
+        for (int i=0; i<index.getPropertyCount();  i++) {
+            StorableProperty source = index.getProperty(i);
+            mBuilder.addKeyProperty(source.getName(), index.getPropertyDirection(i));
+        }
+        mBuilder.build();
+    }
+
+    /**
+     * Returns generated index entry class, which is abstract.
+     *
+     * @return class of index entry, which is a custom Storable
+     */
+    public Class<? extends Storable> getIndexEntryClass() {
+        return mBuilder.getStorableClass();
+    }
+
+    /**
+     * Loads the master object referenced by the given index entry.
+     *
+     * @param indexEntry index entry which points to master
+     * @return master or null if missing
+     */
+    public S loadMaster(Storable ref)  throws FetchException {
+        return mBuilder.loadMaster(ref);
+    }
+
+    /**
+     * Sets all the properties of the given index entry, using the applicable
+     * properties of the given master.
+     *
+     * @param indexEntry index entry whose properties will be set
+     * @param master source of property values
+     */
+    public void setAllProperties(Storable indexEntry, S master) {
+        mBuilder.setAllProperties(indexEntry, master);
+    }
+
+    /**
+     * Returns true if the properties of the given index entry match those
+     * contained in the master. This will always return true after a call to
+     * setAllProperties.
+     *
+     * @param indexEntry index entry whose properties will be tested
+     * @param master source of property values
+     */
+    public boolean isConsistent(Storable indexEntry, S master) {
+        return mBuilder.isConsistent(indexEntry, master);
+    }
+
+    /**
+     * Returns a comparator for ordering index entries.
+     */
+    public Comparator<? extends Storable> getComparator() {
+        return mBuilder.getComparator();
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexedCursor.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedCursor.java
new file mode 100644
index 0000000..a6df5e0
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedCursor.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.NoSuchElementException;
+
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.Transaction;
+
+import com.amazon.carbonado.cursor.AbstractCursor;
+
+import com.amazon.carbonado.spi.RepairExecutor;
+
+/**
+ * Wraps another cursor which contains index entries and extracts master
+ * objects from them.
+ *
+ * @author Brian S O'Neill
+ */
+class IndexedCursor<S extends Storable> extends AbstractCursor<S> {
+    private final Cursor<? extends Storable> mCursor;
+    private final IndexedStorage<S> mStorage;
+    private final IndexEntryGenerator<S> mGenerator;
+
+    private S mNext;
+
+    IndexedCursor(Cursor<? extends Storable> indexEntryCursor,
+                  IndexedStorage<S> storage,
+                  IndexEntryGenerator<S> indexEntryGenerator) {
+        mCursor = indexEntryCursor;
+        mStorage = storage;
+        mGenerator = indexEntryGenerator;
+    }
+
+    public void close() throws FetchException {
+        mCursor.close();
+    }
+
+    public boolean hasNext() throws FetchException {
+        synchronized (mCursor) {
+            if (mNext != null) {
+                return true;
+            }
+            while (mCursor.hasNext()) {
+                final Storable indexEntry = mCursor.next();
+                S master = mGenerator.loadMaster(indexEntry);
+                if (master == null) {
+                    LogFactory.getLog(getClass()).warn
+                        ("Master is missing for index entry: " + indexEntry);
+                } else {
+                    if (mGenerator.isConsistent(indexEntry, master)) {
+                        mNext = master;
+                        return true;
+                    }
+
+                    // This index entry is stale. Repair is needed.
+
+                    // Insert a correct index entry, just to be sure.
+                    try {
+                        final IndexedRepository repo = mStorage.mRepository;
+                        final Storage<?> indexEntryStorage =
+                            repo.getIndexEntryStorageFor(mGenerator.getIndexEntryClass());
+                        Storable newIndexEntry = indexEntryStorage.prepare();
+                        mGenerator.setAllProperties(newIndexEntry, master);
+
+                        if (newIndexEntry.tryLoad()) {
+                            // Good, the correct index entry exists. We'll see
+                            // the master record eventually, so skip.
+                        } else {
+                            // We have no choice but to return the master, at
+                            // the risk of seeing it multiple times. This is
+                            // better than seeing it never.
+                            LogFactory.getLog(getClass()).warn
+                                ("Inconsistent index entry: " + indexEntry);
+                            mNext = master;
+                        }
+
+                        // Repair the stale index entry.
+                        RepairExecutor.execute(new Runnable() {
+                            public void run() {
+                                Transaction txn = repo.enterTransaction();
+                                try {
+                                    // Reload master and verify inconsistency.
+                                    S master = mGenerator.loadMaster(indexEntry);
+                                    if (mGenerator.isConsistent(indexEntry, master)) {
+                                        return;
+                                    }
+
+                                    Storable newIndexEntry = indexEntryStorage.prepare();
+                                    mGenerator.setAllProperties(newIndexEntry, master);
+
+                                    newIndexEntry.tryInsert();
+
+                                    indexEntry.tryDelete();
+                                    txn.commit();
+                                } catch (FetchException fe) {
+                                    LogFactory.getLog(IndexedCursor.class).warn
+                                        ("Unable to check if repair required for " +
+                                         "inconsistent index entry " +
+                                         indexEntry, fe);
+                                } catch (PersistException pe) {
+                                    LogFactory.getLog(IndexedCursor.class).error
+                                        ("Unable to repair inconsistent index entry " +
+                                         indexEntry, pe);
+                                } finally {
+                                    try {
+                                        txn.exit();
+                                    } catch (PersistException pe) {
+                                        LogFactory.getLog(IndexedCursor.class).error
+                                            ("Unable to repair inconsistent index entry " +
+                                             indexEntry, pe);
+                                    }
+                                }
+                            }
+                        });
+                    } catch (RepositoryException re) {
+                        LogFactory.getLog(getClass()).error
+                            ("Unable to inspect inconsistent index entry " +
+                             indexEntry, re);
+                    }
+
+                    if (mNext != null) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        }
+    }
+
+    public S next() throws FetchException {
+        synchronized (mCursor) {
+            if (hasNext()) {
+                S next = mNext;
+                mNext = null;
+                return next;
+            }
+        }
+        throw new NoSuchElementException();
+    }
+
+    public int skipNext(int amount) throws FetchException {
+        synchronized (mCursor) {
+            if (mNext == null) {
+                return mCursor.skipNext(amount);
+            }
+
+            if (amount <= 0) {
+                if (amount < 0) {
+                    throw new IllegalArgumentException("Cannot skip negative amount: " + amount);
+                }
+                return 0;
+            }
+
+            mNext = null;
+            return 1 + mCursor.skipNext(amount - 1);
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepository.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepository.java
new file mode 100644
index 0000000..714e938
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepository.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.IdentityHashMap;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.MalformedTypeException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Transaction;
+
+import com.amazon.carbonado.capability.Capability;
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.capability.IndexInfoCapability;
+import com.amazon.carbonado.capability.StorableInfoCapability;
+
+import com.amazon.carbonado.info.StorableIntrospector;
+
+/**
+ * Wraps another repository in order to make it support indexes. The wrapped
+ * repository must support creation of new types.
+ *
+ * @author Brian S O'Neill
+ */
+class IndexedRepository implements Repository,
+                                   IndexInfoCapability,
+                                   StorableInfoCapability,
+                                   IndexEntryAccessCapability
+{
+    private final Repository mRepository;
+    private final String mName;
+    private final Map<Class<?>, IndexedStorage<?>> mStorages;
+
+    IndexedRepository(String name, Repository repository) {
+        mRepository = repository;
+        mName = name;
+        mStorages = new IdentityHashMap<Class<?>, IndexedStorage<?>>();
+        if (repository.getCapability(IndexInfoCapability.class) == null) {
+            throw new UnsupportedOperationException
+                ("Wrapped repository doesn't support being indexed");
+        }
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    @SuppressWarnings("unchecked")
+    public <S extends Storable> Storage<S> storageFor(Class<S> type)
+        throws MalformedTypeException, SupportException, RepositoryException
+    {
+        synchronized (mStorages) {
+            IndexedStorage<S> storage = (IndexedStorage<S>) mStorages.get(type);
+            if (storage == null) {
+                Storage<S> masterStorage = mRepository.storageFor(type);
+
+                if (Unindexed.class.isAssignableFrom(type)) {
+                    // Verify no indexes.
+                    int indexCount = IndexedStorage
+                        .gatherRequiredIndexes(StorableIntrospector.examine(type)).size();
+                    if (indexCount > 0) {
+                        throw new MalformedTypeException
+                            (type, "Storable cannot have any indexes: " + type +
+                             ", " + indexCount);
+                    }
+                    return mRepository.storageFor(type);
+                }
+
+                storage = new IndexedStorage<S>(this, masterStorage);
+                mStorages.put(type, storage);
+            }
+            return storage;
+        }
+    }
+
+    public Transaction enterTransaction() {
+        return mRepository.enterTransaction();
+    }
+
+    public Transaction enterTransaction(IsolationLevel level) {
+        return mRepository.enterTransaction(level);
+    }
+
+    public Transaction enterTopTransaction(IsolationLevel level) {
+        return mRepository.enterTopTransaction(level);
+    }
+
+    public IsolationLevel getTransactionIsolationLevel() {
+        return mRepository.getTransactionIsolationLevel();
+    }
+
+    @SuppressWarnings("unchecked")
+    public <C extends Capability> C getCapability(Class<C> capabilityType) {
+        if (capabilityType.isInstance(this)) {
+            return (C) this;
+        }
+        return mRepository.getCapability(capabilityType);
+    }
+
+    public <S extends Storable> IndexInfo[] getIndexInfo(Class<S> storableType)
+        throws RepositoryException
+    {
+        return ((IndexedStorage) storageFor(storableType)).getIndexInfo();
+    }
+
+    public <S extends Storable> IndexEntryAccessor<S>[]
+        getIndexEntryAccessors(Class<S> storableType)
+        throws RepositoryException
+    {
+        return ((IndexedStorage<S>) storageFor(storableType)).getIndexEntryAccessors();
+    }
+
+    public String[] getUserStorableTypeNames() throws RepositoryException {
+        StorableInfoCapability cap = mRepository.getCapability(StorableInfoCapability.class);
+        if (cap == null) {
+            return new String[0];
+        }
+        ArrayList<String> names =
+            new ArrayList<String>(Arrays.asList(cap.getUserStorableTypeNames()));
+
+        // Exclude our own metadata types as well as indexes.
+
+        names.remove(StoredIndexInfo.class.getName());
+
+        Cursor<StoredIndexInfo> cursor =
+            mRepository.storageFor(StoredIndexInfo.class)
+            .query().fetch();
+
+        while (cursor.hasNext()) {
+            StoredIndexInfo info = cursor.next();
+            names.remove(info.getIndexName());
+        }
+
+        return names.toArray(new String[names.size()]);
+    }
+
+    public boolean isSupported(Class<Storable> type) {
+        StorableInfoCapability cap = mRepository.getCapability(StorableInfoCapability.class);
+        return (cap == null) ? null : cap.isSupported(type);
+    }
+
+    public boolean isPropertySupported(Class<Storable> type, String name) {
+        StorableInfoCapability cap = mRepository.getCapability(StorableInfoCapability.class);
+        return (cap == null) ? null : cap.isPropertySupported(type, name);
+    }
+
+    public void close() {
+        mRepository.close();
+    }
+
+    Storage<?> getIndexEntryStorageFor(Class<? extends Storable> indexEntryClass)
+        throws RepositoryException
+    {
+        return mRepository.storageFor(indexEntryClass);
+    }
+
+    Repository getWrappedRepository() {
+        return mRepository;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepositoryBuilder.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepositoryBuilder.java
new file mode 100644
index 0000000..cfbe128
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedRepositoryBuilder.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.Collection;
+
+import com.amazon.carbonado.ConfigurationException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryBuilder;
+import com.amazon.carbonado.RepositoryException;
+
+import com.amazon.carbonado.spi.AbstractRepositoryBuilder;
+
+/**
+ * Repository builder for the indexed repository.
+ * <p>
+ * In addition to supporting the capabilities of the wrapped repository, the
+ * following extra capabilities are supported:
+ * <ul>
+ * <li>{@link com.amazon.carbonado.capability.IndexInfoCapability IndexInfoCapability}
+ * <li>{@link com.amazon.carbonado.capability.StorableInfoCapability StorableInfoCapability}
+ * <li>{@link IndexEntryAccessCapability IndexEntryAccessCapability}
+ * </ul>
+ *
+ * @author Brian S O'Neill
+ */
+public class IndexedRepositoryBuilder extends AbstractRepositoryBuilder {
+    private String mName;
+    private boolean mIsMaster = true;
+    private RepositoryBuilder mRepoBuilder;
+
+    public IndexedRepositoryBuilder() {
+    }
+
+    public Repository build(RepositoryReference rootRef) throws RepositoryException {
+        assertReady();
+
+        Repository wrapped;
+
+        boolean originalOption = mRepoBuilder.isMaster();
+        try {
+            mRepoBuilder.setMaster(mIsMaster);
+            wrapped = mRepoBuilder.build(rootRef);
+        } finally {
+            mRepoBuilder.setMaster(originalOption);
+        }
+
+        if (wrapped instanceof IndexedRepository) {
+            return wrapped;
+        }
+
+        Repository repo = new IndexedRepository(getName(), wrapped);
+        rootRef.set(repo);
+        return repo;
+    }
+
+    public String getName() {
+        String name = mName;
+        if (name == null && mRepoBuilder != null) {
+            name = mRepoBuilder.getName();
+        }
+        return name;
+    }
+
+    public void setName(String name) {
+        mName = name;
+    }
+
+    public boolean isMaster() {
+        return mIsMaster;
+    }
+
+    public void setMaster(boolean b) {
+        mIsMaster = b;
+    }
+
+    /**
+     * @return wrapped respository
+     */
+    public RepositoryBuilder getWrappedRepository() {
+        return mRepoBuilder;
+    }
+
+    /**
+     * Set the required wrapped respository, which must support the
+     * {@link com.amazon.carbonado.capability.IndexInfoCapability IndexInfoCapability}.
+     */
+    public void setWrappedRepository(RepositoryBuilder repoBuilder) {
+        mRepoBuilder = repoBuilder;
+    }
+
+    public void errorCheck(Collection<String> messages) throws ConfigurationException {
+        super.errorCheck(messages);
+        if (null == getWrappedRepository()) {
+            messages.add("wrapped repository missing");
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexedStorage.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedStorage.java
new file mode 100644
index 0000000..dd8b2b5
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexedStorage.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.PersistNoneException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.Trigger;
+import com.amazon.carbonado.UniqueConstraintException;
+
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.capability.IndexInfoCapability;
+
+import com.amazon.carbonado.filter.Filter;
+
+import com.amazon.carbonado.info.Direction;
+import com.amazon.carbonado.info.StorableInfo;
+import com.amazon.carbonado.info.StorableIntrospector;
+import com.amazon.carbonado.info.StorableIndex;
+
+import com.amazon.carbonado.cursor.MergeSortBuffer;
+
+import com.amazon.carbonado.spi.BaseQueryEngine;
+import com.amazon.carbonado.spi.BoundaryType;
+import com.amazon.carbonado.spi.RepairExecutor;
+import com.amazon.carbonado.spi.StorableIndexSet;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class IndexedStorage<S extends Storable> implements Storage<S> {
+    static <S extends Storable> StorableIndexSet<S> gatherRequiredIndexes(StorableInfo<S> info) {
+        StorableIndexSet<S> indexSet = new StorableIndexSet<S>();
+        indexSet.addIndexes(info);
+        indexSet.addAlternateKeys(info);
+        return indexSet;
+    }
+
+    final IndexedRepository mRepository;
+    final Storage<S> mMasterStorage;
+
+    private final Map<StorableIndex<S>, IndexInfo> mIndexInfoMap;
+
+    private final QueryEngine<S> mQueryEngine;
+
+    @SuppressWarnings("unchecked")
+    IndexedStorage(IndexedRepository repository, Storage<S> masterStorage)
+        throws RepositoryException
+    {
+        mRepository = repository;
+        mMasterStorage = masterStorage;
+        mIndexInfoMap = new IdentityHashMap<StorableIndex<S>, IndexInfo>();
+
+        StorableInfo<S> info = StorableIntrospector.examine(masterStorage.getStorableType());
+
+        // Determine what the set of indexes should be.
+        StorableIndexSet<S> newIndexSet = gatherRequiredIndexes(info);
+
+        // Mix in the indexes we get for free, but remove after reduce. A free
+        // index is one that the underlying storage is providing for us. We
+        // don't want to create redundant indexes.
+        IndexInfo[] infos = repository.getWrappedRepository()
+            .getCapability(IndexInfoCapability.class)
+            .getIndexInfo(masterStorage.getStorableType());
+
+        StorableIndex<S>[] freeIndexes = new StorableIndex[infos.length];
+        for (int i=0; i<infos.length; i++) {
+            try {
+                freeIndexes[i] = new StorableIndex<S>(masterStorage.getStorableType(), infos[i]);
+                newIndexSet.add(freeIndexes[i]);
+                mIndexInfoMap.put(freeIndexes[i], infos[i]);
+            } catch (IllegalArgumentException e) {
+                // Assume index is bogus, so ignore it.
+            }
+        }
+
+        newIndexSet.reduce(Direction.ASCENDING);
+
+        // Gather current indexes.
+        StorableIndexSet<S> currentIndexSet = new StorableIndexSet<S>();
+        // Gather indexes to remove.
+        StorableIndexSet<S> indexesToRemove = new StorableIndexSet<S>();
+
+        Query<StoredIndexInfo> query = repository.getWrappedRepository()
+            .storageFor(StoredIndexInfo.class)
+            // Primary key of StoredIndexInfo is an index descriptor, which
+            // starts with the storable type name. This emulates a "wildcard at
+            // the end" search.
+            .query("indexName >= ? & indexName < ?")
+            .with(getStorableType().getName() + '~')
+            .with(getStorableType().getName() + '~' + '\uffff');
+
+        for (StoredIndexInfo indexInfo : query.fetch().toList()) {
+            String name = indexInfo.getIndexName();
+            StorableIndex index;
+            try {
+                index = StorableIndex.parseNameDescriptor(name, info);
+            } catch (IllegalArgumentException e) {
+                // Remove unrecognized descriptors.
+                unregisterIndex(name);
+                continue;
+            }
+            if (index.getTypeDescriptor().equals(indexInfo.getIndexTypeDescriptor())) {
+                currentIndexSet.add(index);
+            } else {
+                indexesToRemove.add(index);
+            }
+        }
+
+        nonUniqueSearch: {
+            // If any current indexes are non-unique, then indexes are for an
+            // older version. For compatibility, don't uniquify the
+            // indexes. Otherwise, these indexes would need to be rebuilt.
+            for (StorableIndex<S> index : currentIndexSet) {
+                if (!index.isUnique()) {
+                    break nonUniqueSearch;
+                }
+            }
+
+            // The index implementation includes all primary key properties
+            // anyhow, so adding them here allows query analyzer to see these
+            // properties. As a side-effect of uniquify, all indexes are
+            // unique, and thus have 'U' in the descriptor. Each time
+            // nonUniqueSearch is run, it will not find any non-unique indexes.
+            newIndexSet.uniquify(info);
+        }
+
+        // Remove any old indexes.
+        {
+            indexesToRemove.addAll(currentIndexSet);
+
+            // Remove "free" indexes, since they don't need to be built.
+            for (int i=0; i<freeIndexes.length; i++) {
+                newIndexSet.remove(freeIndexes[i]);
+            }
+
+            indexesToRemove.removeAll(newIndexSet);
+
+            for (StorableIndex<S> index : indexesToRemove) {
+                removeIndex(index);
+            }
+        }
+
+        currentIndexSet = newIndexSet;
+
+        // Open all the indexes.
+        List<ManagedIndex<S>> managedIndexList = new ArrayList<ManagedIndex<S>>();
+        for (StorableIndex<S> index : currentIndexSet) {
+            IndexEntryGenerator<S> builder = IndexEntryGenerator.getInstance(index);
+            Class<? extends Storable> indexEntryClass = builder.getIndexEntryClass();
+            Storage<?> indexEntryStorage = repository.getIndexEntryStorageFor(indexEntryClass);
+            ManagedIndex managedIndex = new ManagedIndex<S>(index, builder, indexEntryStorage);
+
+            registerIndex(managedIndex);
+
+            mIndexInfoMap.put(index, managedIndex);
+            managedIndexList.add(managedIndex);
+        }
+
+        if (managedIndexList.size() > 0) {
+            // Add trigger to keep indexes up-to-date.
+            ManagedIndex<S>[] managedIndexes =
+                managedIndexList.toArray(new ManagedIndex[managedIndexList.size()]);
+
+            if (!addTrigger(new IndexesTrigger<S>(managedIndexes))) {
+                throw new RepositoryException("Unable to add trigger for managing indexes");
+            }
+        }
+
+        // Add "free" indexes back, in order for query engine to consider them.
+        for (int i=0; i<freeIndexes.length; i++) {
+            currentIndexSet.add(freeIndexes[i]);
+        }
+
+        mQueryEngine = new QueryEngine<S>(info, repository, this, currentIndexSet);
+    }
+
+    public Class<S> getStorableType() {
+        return mMasterStorage.getStorableType();
+    }
+
+    public S prepare() {
+        return mMasterStorage.prepare();
+    }
+
+    public Query<S> query() throws FetchException {
+        return mQueryEngine.getCompiledQuery();
+    }
+
+    public Query<S> query(String filter) throws FetchException {
+        return mQueryEngine.getCompiledQuery(filter);
+    }
+
+    public Query<S> query(Filter<S> filter) throws FetchException {
+        return mQueryEngine.getCompiledQuery(filter);
+    }
+
+    public boolean addTrigger(Trigger<? super S> trigger) {
+        return mMasterStorage.addTrigger(trigger);
+    }
+
+    public boolean removeTrigger(Trigger<? super S> trigger) {
+        return mMasterStorage.removeTrigger(trigger);
+    }
+
+    public IndexInfo[] getIndexInfo() {
+        IndexInfo[] infos = new IndexInfo[mIndexInfoMap.size()];
+        return mIndexInfoMap.values().toArray(infos);
+    }
+
+    @SuppressWarnings("unchecked")
+    public IndexEntryAccessor<S>[] getIndexEntryAccessors() {
+        List<IndexEntryAccessor<S>> accessors =
+            new ArrayList<IndexEntryAccessor<S>>(mIndexInfoMap.size());
+        for (IndexInfo info : mIndexInfoMap.values()) {
+            if (info instanceof IndexEntryAccessor) {
+                accessors.add((IndexEntryAccessor<S>) info);
+            }
+        }
+        return accessors.toArray(new IndexEntryAccessor[accessors.size()]);
+    }
+
+    Storage<S> getStorageFor(StorableIndex<S> index) {
+        if (mIndexInfoMap.get(index) instanceof ManagedIndex) {
+            // Index is managed by this storage, which is typical.
+            return this;
+        }
+        // Index is managed by master storage, most likely a primary key index.
+        return mMasterStorage;
+    }
+
+    ManagedIndex<S> getManagedIndex(StorableIndex<S> index) {
+        return (ManagedIndex<S>) mIndexInfoMap.get(index);
+    }
+
+    private void registerIndex(ManagedIndex<S> managedIndex)
+        throws RepositoryException
+    {
+        StorableIndex index = managedIndex.getIndex();
+
+        if (StoredIndexInfo.class.isAssignableFrom(getStorableType())) {
+            throw new IllegalStateException("StoredIndexInfo cannot have indexes");
+        }
+        StoredIndexInfo info = mRepository.getWrappedRepository()
+            .storageFor(StoredIndexInfo.class).prepare();
+        info.setIndexName(index.getNameDescriptor());
+
+        if (info.tryLoad()) {
+            // Index already exists and is registered.
+            return;
+        }
+
+        // New index, so populate it.
+        managedIndex.populateIndex(mRepository, mMasterStorage);
+
+        Transaction txn = mRepository.getWrappedRepository().enterTransaction();
+        try {
+            if (!info.tryLoad()) {
+                info.setIndexTypeDescriptor(index.getTypeDescriptor());
+                info.setCreationTimestamp(System.currentTimeMillis());
+                info.setVersionNumber(0);
+                info.insert();
+                txn.commit();
+            }
+        } finally {
+            txn.exit();
+        }
+    }
+
+    private void unregisterIndex(StorableIndex index) throws RepositoryException {
+        if (StoredIndexInfo.class.isAssignableFrom(getStorableType())) {
+            // Can't unregister when register wasn't allowed.
+            return;
+        }
+        unregisterIndex(index.getNameDescriptor());
+    }
+
+    private void unregisterIndex(String indexName) throws RepositoryException {
+        StoredIndexInfo info = mRepository.getWrappedRepository()
+            .storageFor(StoredIndexInfo.class).prepare();
+        info.setIndexName(indexName);
+        info.delete();
+    }
+
+    @SuppressWarnings("unchecked")
+    private void removeIndex(StorableIndex index) throws RepositoryException {
+        Log log = LogFactory.getLog(IndexedStorage.class);
+        if (log.isInfoEnabled()) {
+            StringBuilder b = new StringBuilder();
+            b.append("Removing index on ");
+            b.append(getStorableType().getName());
+            b.append(": ");
+            try {
+                index.appendTo(b);
+            } catch (java.io.IOException e) {
+                // Not gonna happen.
+            }
+            log.info(b.toString());
+        }
+
+        Class<? extends Storable> indexEntryClass =
+            IndexEntryGenerator.getInstance(index).getIndexEntryClass();
+
+        Storage<?> indexEntryStorage;
+        try {
+            indexEntryStorage = mRepository.getIndexEntryStorageFor(indexEntryClass);
+        } catch (Exception e) {
+            // Assume it doesn't exist.
+            unregisterIndex(index);
+            return;
+        }
+
+        // Doesn't completely remove the index, but it should free up space.
+        // TODO: when truncate method exists, call that instead
+        indexEntryStorage.query().deleteAll();
+        unregisterIndex(index);
+    }
+
+    private static class QueryEngine<S extends Storable> extends BaseQueryEngine<S> {
+
+        QueryEngine(StorableInfo<S> info,
+                    Repository repo,
+                    IndexedStorage<S> storage,
+                    StorableIndexSet<S> indexSet) {
+            super(info, repo, storage, null, indexSet);
+        }
+
+        @Override
+        protected Storage<S> getStorageFor(StorableIndex<S> index) {
+            return storage().getStorageFor(index);
+        }
+
+        protected Cursor<S> openCursor(StorableIndex<S> index,
+                                       Object[] exactValues,
+                                       BoundaryType rangeStartBoundary,
+                                       Object rangeStartValue,
+                                       BoundaryType rangeEndBoundary,
+                                       Object rangeEndValue,
+                                       boolean reverseRange,
+                                       boolean reverseOrder)
+            throws FetchException
+        {
+            // Note: this code ignores the reverseRange parameter to avoid
+            // double reversal. Only the lowest storage layer should examine
+            // this parameter.
+
+            ManagedIndex<S> indexInfo = storage().getManagedIndex(index);
+            Query<?> query = indexInfo.getIndexEntryQueryFor
+                (exactValues == null ? 0 : exactValues.length,
+                 rangeStartBoundary, rangeEndBoundary, reverseOrder);
+
+            if (exactValues != null) {
+                query = query.withValues(exactValues);
+            }
+
+            if (rangeStartBoundary != BoundaryType.OPEN) {
+                query = query.with(rangeStartValue);
+            }
+            if (rangeEndBoundary != BoundaryType.OPEN) {
+                query = query.with(rangeEndValue);
+            }
+
+            Cursor<? extends Storable> indexEntryCursor = query.fetch();
+
+            return new IndexedCursor<S>
+                (indexEntryCursor, storage(), indexInfo.getIndexEntryClassBuilder());
+        }
+
+        private IndexedStorage<S> storage() {
+            return (IndexedStorage<S>) super.getStorage();
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/IndexesTrigger.java b/src/main/java/com/amazon/carbonado/repo/indexed/IndexesTrigger.java
new file mode 100644
index 0000000..ab718e4
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/IndexesTrigger.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Trigger;
+import com.amazon.carbonado.UniqueConstraintException;
+
+/**
+ * Handles index updates.
+ *
+ * @author Brian S O'Neill
+ */
+class IndexesTrigger<S extends Storable> extends Trigger<S> {
+    private final ManagedIndex<S>[] mManagedIndexes;
+
+    /**
+     * @param managedIndexes all the indexes that need to be updated.
+     */
+    IndexesTrigger(ManagedIndex<S>[] managedIndexes) {
+        mManagedIndexes = managedIndexes;
+    }
+
+    @Override
+    public void afterInsert(S storable, Object state) throws PersistException {
+        for (ManagedIndex<S> managed : mManagedIndexes) {
+            if (!managed.insertIndexEntry(storable)) {
+                throw new UniqueConstraintException
+                    ("Alternate key constraint: " + storable.toString() +
+                     ", " + managed);
+            }
+        }
+    }
+
+    @Override
+    public void afterTryInsert(S storable, Object state) throws PersistException {
+        for (ManagedIndex<S> managed : mManagedIndexes) {
+            if (!managed.insertIndexEntry(storable)) {
+                throw abortTry();
+            }
+        }
+    }
+
+    @Override
+    public Object beforeUpdate(S storable) throws PersistException {
+        // Return old storable for afterUpdate.
+        S copy = (S) storable.copy();
+        try {
+            if (copy.tryLoad()) {
+                return copy;
+            }
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        }
+        // If this point is reached, then afterUpdate is not called because
+        // update will fail.
+        return null;
+    }
+
+    @Override
+    public void afterUpdate(S storable, Object state) throws PersistException {
+        // Cast old storable as provided by beforeUpdate.
+        S oldStorable = (S) state;
+        for (ManagedIndex<S> managed : mManagedIndexes) {
+            managed.updateIndexEntry(storable, oldStorable);
+        }
+    }
+
+    @Override
+    public Object beforeDelete(S storable) throws PersistException {
+        // Delete index entries referenced by existing storable.
+        S copy = (S) storable.copy();
+        try {
+            if (copy.tryLoad()) {
+                for (ManagedIndex<S> managed : mManagedIndexes) {
+                    managed.deleteIndexEntry(copy);
+                }
+            }
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/ManagedIndex.java b/src/main/java/com/amazon/carbonado/repo/indexed/ManagedIndex.java
new file mode 100644
index 0000000..107067c
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/ManagedIndex.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import java.util.Comparator;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.UniqueConstraintException;
+
+import com.amazon.carbonado.info.Direction;
+import com.amazon.carbonado.info.StorableIndex;
+
+import com.amazon.carbonado.cursor.MergeSortBuffer;
+
+import com.amazon.carbonado.spi.RepairExecutor;
+
+import com.amazon.carbonado.qe.BoundaryType;
+
+/**
+ * Encapsulates info and operations for a single index.
+ *
+ * @author Brian S O'Neill
+ */
+class ManagedIndex<S extends Storable> implements IndexEntryAccessor<S> {
+    private static final int POPULATE_BATCH_SIZE = 256;
+
+    private final StorableIndex mIndex;
+    private final IndexEntryGenerator<S> mGenerator;
+    private final Storage<?> mIndexEntryStorage;
+
+    private final Query<?>[] mQueryCache;
+
+    ManagedIndex(StorableIndex<S> index,
+                 IndexEntryGenerator<S> generator,
+                 Storage<?> indexEntryStorage)
+        throws SupportException
+    {
+        mIndex = index;
+        mGenerator = generator;
+        mIndexEntryStorage = indexEntryStorage;
+
+        // Cache keys are encoded as follows:
+        // bits 1..0: range end boundary
+        //            0=open boundary, 1=inclusive boundary, 2=exlusive boundary, 3=not used
+        // bits 3..2: range start boundary
+        //            0=open boundary, 1=inclusive boundary, 2=exlusive boundary, 3=not used
+        // bit     4: 0=forward order, 1=reverse
+        // bits n..5: exact property match count
+
+        // The size of the cache is dependent on the number of possible
+        // exactly matching properties, which is the property count of the
+        // index. If the index contained a huge number of properties, say
+        // 31, the cache size would be 1024.
+
+        int cacheSize = Integer.highestOneBit(index.getPropertyCount()) << (1 + 5);
+
+        mQueryCache = new Query[cacheSize];
+    }
+
+    public String getName() {
+        return mIndex.getNameDescriptor();
+    }
+
+    public String[] getPropertyNames() {
+        int i = mIndex.getPropertyCount();
+        String[] names = new String[i];
+        while (--i >= 0) {
+            names[i] = mIndex.getProperty(i).getName();
+        }
+        return names;
+    }
+
+    public Direction[] getPropertyDirections() {
+        int i = mIndex.getPropertyCount();
+        Direction[] directions = new Direction[i];
+        while (--i >= 0) {
+            directions[i] = mIndex.getPropertyDirection(i);
+        }
+        return directions;
+    }
+
+    public boolean isUnique() {
+        return mIndex.isUnique();
+    }
+
+    public boolean isClustered() {
+        return false;
+    }
+
+    public StorableIndex getIndex() {
+        return mIndex;
+    }
+
+    // Required by IndexEntryAccessor interface.
+    public Storage<?> getIndexEntryStorage() {
+        return mIndexEntryStorage;
+    }
+
+    // Required by IndexEntryAccessor interface.
+    public S loadMaster(Storable indexEntry) throws FetchException {
+        return mGenerator.loadMaster(indexEntry);
+    }
+
+    // Required by IndexEntryAccessor interface.
+    public void setAllProperties(Storable indexEntry, S master) {
+        mGenerator.setAllProperties(indexEntry, master);
+    }
+
+    // Required by IndexEntryAccessor interface.
+    public boolean isConsistent(Storable indexEntry, S master) {
+        return mGenerator.isConsistent(indexEntry, master);
+    }
+
+    // Required by IndexEntryAccessor interface.
+    public Comparator<? extends Storable> getComparator() {
+        return mGenerator.getComparator();
+    }
+
+    public IndexEntryGenerator<S> getIndexEntryClassBuilder() {
+        return mGenerator;
+    }
+
+    public Query<?> getIndexEntryQueryFor(int exactMatchCount,
+                                          BoundaryType rangeStartBoundary,
+                                          BoundaryType rangeEndBoundary,
+                                          boolean reverse)
+        throws FetchException
+    {
+        int key = exactMatchCount << 5;
+        if (rangeEndBoundary != BoundaryType.OPEN) {
+            if (rangeEndBoundary == BoundaryType.INCLUSIVE) {
+                key |= 0x01;
+            } else {
+                key |= 0x02;
+            }
+        }
+        if (rangeStartBoundary != BoundaryType.OPEN) {
+            if (rangeStartBoundary == BoundaryType.INCLUSIVE) {
+                key |= 0x04;
+            } else {
+                key |= 0x08;
+            }
+        }
+
+        if (reverse) {
+            key |= 0x10;
+        }
+
+        Query<?> query = mQueryCache[key];
+        if (query == null) {
+            StorableIndex index = mIndex;
+
+            StringBuilder filter = new StringBuilder();
+
+            int i;
+            for (i=0; i<exactMatchCount; i++) {
+                if (i > 0) {
+                    filter.append(" & ");
+                }
+                filter.append(index.getProperty(i).getName());
+                filter.append(" = ?");
+            }
+
+            boolean addOrderBy = false;
+
+            if (rangeStartBoundary != BoundaryType.OPEN) {
+                addOrderBy = true;
+                if (filter.length() > 0) {
+                    filter.append(" & ");
+                }
+                filter.append(index.getProperty(i).getName());
+                if (rangeStartBoundary == BoundaryType.INCLUSIVE) {
+                    filter.append(" >= ?");
+                } else {
+                    filter.append(" > ?");
+                }
+            }
+
+            if (rangeEndBoundary != BoundaryType.OPEN) {
+                addOrderBy = true;
+                if (filter.length() > 0) {
+                    filter.append(" & ");
+                }
+                filter.append(index.getProperty(i).getName());
+                if (rangeEndBoundary == BoundaryType.INCLUSIVE) {
+                    filter.append(" <= ?");
+                } else {
+                    filter.append(" < ?");
+                }
+            }
+
+            if (filter.length() == 0) {
+                query = mIndexEntryStorage.query();
+            } else {
+                query = mIndexEntryStorage.query(filter.toString());
+            }
+
+            if (addOrderBy || reverse) {
+                // Enforce ordering of properties for range searches and
+                // reverse ordering to work properly. Underlying repository
+                // should have ordered index properly, so this shouldn't
+                // cause a sort.
+                // TODO: should somehow warn if a sort is triggered
+                String[] orderProperties = new String[exactMatchCount + 1];
+                for (i=0; i<orderProperties.length; i++) {
+                    Direction dir = index.getPropertyDirection(i);
+                    if (reverse) {
+                        dir = dir.reverse();
+                    }
+                    orderProperties[i] = dir.toCharacter() + index.getProperty(i).getName();
+                }
+                query = query.orderBy(orderProperties);
+            }
+
+            mQueryCache[key] = query;
+        }
+
+        return query;
+    }
+
+    public String toString() {
+        StringBuilder b = new StringBuilder();
+        b.append("IndexInfo ");
+        try {
+            mIndex.appendTo(b);
+        } catch (java.io.IOException e) {
+            // Not gonna happen.
+        }
+        return b.toString();
+    }
+
+    /** Assumes caller is in a transaction */
+    boolean deleteIndexEntry(S userStorable) throws PersistException {
+        return makeIndexEntry(userStorable).tryDelete();
+    }
+
+    /** Assumes caller is in a transaction */
+    boolean insertIndexEntry(S userStorable) throws PersistException {
+        return insertIndexEntry(userStorable, makeIndexEntry(userStorable));
+    }
+
+    /** Assumes caller is in a transaction */
+    boolean updateIndexEntry(S userStorable, S oldUserStorable) throws PersistException {
+        Storable newIndexEntry = makeIndexEntry(userStorable);
+
+        if (oldUserStorable != null) {
+            Storable oldIndexEntry = makeIndexEntry(oldUserStorable);
+            if (oldIndexEntry.equalPrimaryKeys(newIndexEntry)) {
+                // Index entry didn't change, so nothing to do. If the
+                // index entry has a version, it will lag behind the
+                // master's version until the index entry changes, at which
+                // point the version will again match the master.
+                return true;
+            }
+
+            oldIndexEntry.tryDelete();
+        }
+
+        return insertIndexEntry(userStorable, newIndexEntry);
+    }
+
+    /**
+     * Populates the entire index, repairing as it goes.
+     *
+     * @param repo used to enter transactions
+     */
+    void populateIndex(Repository repo, Storage<S> masterStorage) throws RepositoryException {
+        Cursor<S> cursor = masterStorage.query().fetch();
+        if (!cursor.hasNext()) {
+            // Nothing exists in master, so nothing to populate.
+            cursor.close();
+            return;
+        }
+
+        Log log = LogFactory.getLog(IndexedStorage.class);
+        if (log.isInfoEnabled()) {
+            StringBuilder b = new StringBuilder();
+            b.append("Populating index on ");
+            b.append(masterStorage.getStorableType().getName());
+            b.append(": ");
+            try {
+                mIndex.appendTo(b);
+            } catch (java.io.IOException e) {
+                // Not gonna happen.
+            }
+            log.info(b.toString());
+        }
+
+        // Preload and sort all index entries for improved performance.
+
+        MergeSortBuffer buffer = new MergeSortBuffer(mIndexEntryStorage);
+        Comparator c = mGenerator.getComparator();
+        buffer.prepare(c);
+
+        while (cursor.hasNext()) {
+            buffer.add(makeIndexEntry(cursor.next()));
+        }
+
+        buffer.sort();
+
+        if (isUnique()) {
+            // If index is unique, scan buffer and check for duplicates
+            // _before_ inserting index entries. If there are duplicates,
+            // fail, since unique index cannot be built.
+
+            Object last = null;
+            for (Object obj : buffer) {
+                if (last != null) {
+                    if (c.compare(last, obj) == 0) {
+                        buffer.close();
+                        throw new UniqueConstraintException
+                            ("Cannot build unique index because duplicates exist: "
+                             + this);
+                    }
+                }
+                last = obj;
+            }
+        }
+
+        Transaction txn = repo.enterTransaction();
+        try {
+            int totalInserted = 0;
+            for (Object obj : buffer) {
+                Storable indexEntry = (Storable) obj;
+                if (!indexEntry.tryInsert()) {
+                    // repair
+                    indexEntry.tryDelete();
+                    indexEntry.tryInsert();
+                }
+                totalInserted++;
+                if (totalInserted % POPULATE_BATCH_SIZE == 0) {
+                    txn.commit();
+                    txn.exit();
+                    txn = repo.enterTransaction();
+                }
+            }
+            txn.commit();
+        } finally {
+            txn.exit();
+            buffer.close();
+        }
+    }
+
+    private Storable makeIndexEntry(S userStorable) {
+        Storable indexEntry = mIndexEntryStorage.prepare();
+        mGenerator.setAllProperties(indexEntry, userStorable);
+        return indexEntry;
+    }
+
+    /** Assumes caller is in a transaction */
+    private boolean insertIndexEntry(final S userStorable, final Storable indexEntry)
+        throws PersistException
+    {
+        if (indexEntry.tryInsert()) {
+            return true;
+        }
+
+        // If index entry already exists, then index might be corrupt.
+        {
+            Storable freshEntry = mIndexEntryStorage.prepare();
+            mGenerator.setAllProperties(freshEntry, userStorable);
+            indexEntry.copyVersionProperty(freshEntry);
+            if (freshEntry.equals(indexEntry)) {
+                // Existing entry is exactly what we expect. Return false
+                // exception if alternate key constraint, since this is
+                // user error.
+                return !isUnique();
+            }
+        }
+
+        // Run the repair outside a transaction.
+
+        RepairExecutor.execute(new Runnable() {
+            public void run() {
+                try {
+                    // Blow it away entry and re-insert. Don't simply update
+                    // the entry, since record version number may prevent
+                    // update.
+
+                    // Since we may be running outside transaction now, user
+                    // storable may have changed. Reload to get latest data.
+
+                    S freshUserStorable = (S) userStorable.copy();
+                    if (!freshUserStorable.tryLoad()) {
+                        // Gone now, nothing we can do. Assume index entry
+                        // was properly deleted.
+                        return;
+                    }
+
+                    Storable freshEntry = mIndexEntryStorage.prepare();
+                    mGenerator.setAllProperties(freshEntry, freshUserStorable);
+
+                    // Blow it away entry and re-insert. Don't simply update
+                    // the entry, since record version number may prevent
+                    // update.
+                    freshEntry.tryDelete();
+                    freshEntry.tryInsert();
+                } catch (FetchException fe) {
+                    LogFactory.getLog(IndexedStorage.class).warn
+                        ("Unable to check if repair is required: " +
+                         userStorable.toStringKeyOnly(), fe);
+                } catch (PersistException pe) {
+                    LogFactory.getLog(IndexedStorage.class).error
+                        ("Unable to repair index entry for " +
+                         userStorable.toStringKeyOnly(), pe);
+                }
+            }
+        });
+
+        return true;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/StoredIndexInfo.java b/src/main/java/com/amazon/carbonado/repo/indexed/StoredIndexInfo.java
new file mode 100644
index 0000000..915f90b
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/StoredIndexInfo.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+import com.amazon.carbonado.Nullable;
+import com.amazon.carbonado.PrimaryKey;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Version;
+
+import com.amazon.carbonado.layout.Unevolvable;
+
+/**
+ * Stores basic information about the indexes managed by IndexedRepository.
+ *
+ * <p>Note: This storable cannot have indexes defined, since it is used to
+ * discover information about indexes. It would create a cyclic dependency.
+ *
+ * @author Brian S O'Neill
+ */
+@PrimaryKey("indexName")
+public interface StoredIndexInfo extends Storable, Unevolvable, Unindexed {
+    /**
+     * Returns the index name, which is also a valid index name
+     * descriptor. This descriptor is defined by {@link
+     * com.amazon.carbonado.info.StorableIndex}. The name descriptor does not
+     * contain type information.
+     */
+    String getIndexName();
+
+    void setIndexName(String name);
+
+    /**
+     * Returns the types of the index properties. This descriptor is defined by
+     * {@link com.amazon.carbonado.info.StorableIndex}.
+     */
+    @Nullable
+    String getIndexTypeDescriptor();
+
+    void setIndexTypeDescriptor(String descriptor);
+
+    /**
+     * Returns the milliseconds from 1970-01-01T00:00:00Z when this record was
+     * created.
+     */
+    long getCreationTimestamp();
+
+    void setCreationTimestamp(long timestamp);
+
+    /**
+     * Record version number for this StoredIndexInfo instance. Some encoding
+     * strategies require a version number.
+     */
+    @Version
+    int getVersionNumber();
+
+    void setVersionNumber(int version);
+
+    /**
+     * Since this record cannot evolve, this property allows it to be extended
+     * without conflicting with existing records. This record cannot evolve
+     * because an evolution strategy likely depends on this interface remaining
+     * stable, avoiding a cyclic dependency.
+     */
+    @Nullable
+    byte[] getExtraData();
+
+    void setExtraData(byte[] data);
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/Unindexed.java b/src/main/java/com/amazon/carbonado/repo/indexed/Unindexed.java
new file mode 100644
index 0000000..e5a4cff
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/Unindexed.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.indexed;
+
+/**
+ * Marker interface for storables that are not allowed to have indexes.
+ *
+ * @author Brian S O'Neill
+ */
+public interface Unindexed {
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/indexed/package-info.java b/src/main/java/com/amazon/carbonado/repo/indexed/package-info.java
new file mode 100644
index 0000000..9f01bb9
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/indexed/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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.
+ */
+
+/**
+ * Repository implementation that adds index support for repositories that have
+ * little or no index support. The wrapped repository must support creation of
+ * new types.
+ *
+ * @see com.amazon.carbonado.repo.indexed.IndexedRepositoryBuilder
+ */
+package com.amazon.carbonado.repo.indexed;
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlob.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlob.java
new file mode 100644
index 0000000..44612bd
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlob.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.IOException;
+
+import java.sql.SQLException;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+
+import com.amazon.carbonado.lob.AbstractBlob;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCBlob extends AbstractBlob implements JDBCLob {
+    private static final int DEFAULT_BUFFER = 4000;
+
+    protected final JDBCRepository mRepo;
+    private java.sql.Blob mBlob;
+    private final JDBCBlobLoader mLoader;
+
+    JDBCBlob(JDBCRepository repo, java.sql.Blob blob, JDBCBlobLoader loader) {
+        super(repo);
+        mRepo = repo;
+        mBlob = blob;
+        mLoader = loader;
+    }
+
+    // FIXME: I/O streams must have embedded transaction
+
+    public InputStream openInputStream() throws FetchException {
+        try {
+            return getInternalBlobForFetch().getBinaryStream();
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public InputStream openInputStream(long pos) throws FetchException {
+        try {
+            if (pos == 0) {
+                return getInternalBlobForFetch().getBinaryStream();
+            }
+            return new Input(getInternalBlobForFetch(), DEFAULT_BUFFER);
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public InputStream openInputStream(long pos, int bufferSize) throws FetchException {
+        try {
+            if (pos == 0) {
+                return getInternalBlobForFetch().getBinaryStream();
+            }
+            if (bufferSize <= 0) {
+                bufferSize = DEFAULT_BUFFER;
+            }
+            return new Input(getInternalBlobForFetch(), bufferSize);
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public long getLength() throws FetchException {
+        try {
+            return getInternalBlobForFetch().length();
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public OutputStream openOutputStream() throws PersistException {
+        return openOutputStream(0);
+    }
+
+    public OutputStream openOutputStream(long pos) throws PersistException {
+        try {
+            return getInternalBlobForPersist().setBinaryStream(pos);
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    public OutputStream openOutputStream(long pos, int bufferSize) throws PersistException {
+        return openOutputStream(pos);
+    }
+
+    public void setLength(long length) throws PersistException {
+        // FIXME: Add special code to support increasing length
+        try {
+            getInternalBlobForPersist().truncate(length);
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    public void close() {
+        mBlob = null;
+    }
+
+    java.sql.Blob getInternalBlobForFetch() throws FetchException {
+        if (mBlob == null) {
+            if ((mBlob = mLoader.load(mRepo)) == null) {
+                throw new FetchException("Blob value is null");
+            }
+            try {
+                JDBCTransaction txn = mRepo.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(this);
+                }
+            } catch (Exception e) {
+                throw mRepo.toFetchException(e);
+            }
+        }
+        return mBlob;
+    }
+
+    java.sql.Blob getInternalBlobForPersist() throws PersistException {
+        if (mBlob == null) {
+            try {
+                if ((mBlob = mLoader.load(mRepo)) == null) {
+                    throw new PersistException("Blob value is null");
+                }
+                JDBCTransaction txn = mRepo.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(this);
+                }
+            } catch (Exception e) {
+                throw mRepo.toPersistException(e);
+            }
+        }
+        return mBlob;
+    }
+
+    private static class Input extends InputStream {
+        private final java.sql.Blob mBlob;
+        private final int mBufferSize;
+
+        private long mPos;
+        private byte[] mBuffer;
+        private int mBufferPos;
+
+        Input(java.sql.Blob blob, int bufferSize) {
+            mBlob = blob;
+            mBufferSize = bufferSize;
+        }
+
+        public int read() throws IOException {
+            if (fillBuffer() <= 0) {
+                return -1;
+            }
+            return mBuffer[mBufferPos++];
+        }
+
+        public int read(byte[] b, int off, int len) throws IOException {
+            int avail = fillBuffer();
+            if (avail <= 0) {
+                return -1;
+            }
+            if (len > avail) {
+                len = avail;
+            }
+            System.arraycopy(mBuffer, mBufferPos, b, off, len);
+            mBufferPos += len;
+            return len;
+        }
+
+        public long skip(long n) throws IOException {
+            if (n <= 0) {
+                return 0;
+            }
+            long newPos = mPos + n;
+            long length;
+            try {
+                length = mBlob.length();
+            } catch (SQLException e) {
+                IOException ioe = new IOException();
+                ioe.initCause(e);
+                throw ioe;
+            }
+            if (newPos >= length) {
+                newPos = length;
+                n = newPos - mPos;
+            }
+            long newBufferPos = mBufferPos + n;
+            if (mBuffer == null || newBufferPos >= mBuffer.length) {
+                mBuffer = null;
+                mBufferPos = 0;
+            } else {
+                mBufferPos = (int) newBufferPos;
+            }
+            mPos = newPos;
+            return n;
+        }
+
+        private int fillBuffer() throws IOException {
+            try {
+                if (mBuffer == null || mBufferPos >= mBuffer.length) {
+                    mBuffer = mBlob.getBytes(mPos, mBufferSize);
+                    mPos += mBuffer.length;
+                    mBufferPos = 0;
+                }
+                return mBuffer.length - mBufferPos;
+            } catch (SQLException e) {
+                IOException ioe = new IOException();
+                ioe.initCause(e);
+                throw ioe;
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlobLoader.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlobLoader.java
new file mode 100644
index 0000000..cd818c0
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCBlobLoader.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import com.amazon.carbonado.FetchException;
+
+/**
+ * Callback for reloading Blobs outside original transaction.
+ *
+ * @author Brian S O'Neill
+ */
+public interface JDBCBlobLoader {
+    /**
+     * @return Blob or null if missing
+     */
+    java.sql.Blob load(JDBCRepository jdbcRepo) throws FetchException;
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClob.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClob.java
new file mode 100644
index 0000000..95c8966
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClob.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.io.Reader;
+import java.io.Writer;
+import java.io.IOException;
+
+import java.sql.SQLException;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+
+import com.amazon.carbonado.lob.AbstractClob;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCClob extends AbstractClob implements JDBCLob {
+    private static final int DEFAULT_BUFFER = 4000;
+
+    protected final JDBCRepository mRepo;
+    private java.sql.Clob mClob;
+    private final JDBCClobLoader mLoader;
+
+    JDBCClob(JDBCRepository repo, java.sql.Clob clob, JDBCClobLoader loader) {
+        super(repo);
+        mRepo = repo;
+        mClob = clob;
+        mLoader = loader;
+    }
+
+    // FIXME: I/O streams must have embedded transaction
+
+    public Reader openReader() throws FetchException {
+        try {
+            return getInternalClobForFetch().getCharacterStream();
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public Reader openReader(long pos) throws FetchException {
+        try {
+            if (pos == 0) {
+                return getInternalClobForFetch().getCharacterStream();
+            }
+            return new Input(getInternalClobForFetch(), DEFAULT_BUFFER);
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public Reader openReader(long pos, int bufferSize) throws FetchException {
+        try {
+            if (pos == 0) {
+                return getInternalClobForFetch().getCharacterStream();
+            }
+            if (bufferSize <= 0) {
+                bufferSize = DEFAULT_BUFFER;
+            }
+            return new Input(getInternalClobForFetch(), bufferSize);
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public long getLength() throws FetchException {
+        try {
+            return getInternalClobForFetch().length();
+        } catch (SQLException e) {
+            throw mRepo.toFetchException(e);
+        }
+    }
+
+    public Writer openWriter() throws PersistException {
+        return openWriter(0);
+    }
+
+    public Writer openWriter(long pos) throws PersistException {
+        try {
+            return getInternalClobForPersist().setCharacterStream(pos);
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    public Writer openWriter(long pos, int bufferSize) throws PersistException {
+        return openWriter(pos);
+    }
+
+    public void setLength(long length) throws PersistException {
+        // FIXME: Add special code to support increasing length
+        try {
+            getInternalClobForPersist().truncate(length);
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    public void close() {
+        mClob = null;
+    }
+
+    java.sql.Clob getInternalClobForFetch() throws FetchException {
+        if (mClob == null) {
+            if ((mClob = mLoader.load(mRepo)) == null) {
+                throw new FetchException("Clob value is null");
+            }
+            try {
+                JDBCTransaction txn = mRepo.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(this);
+                }
+            } catch (Exception e) {
+                throw mRepo.toFetchException(e);
+            }
+        }
+        return mClob;
+    }
+
+    java.sql.Clob getInternalClobForPersist() throws PersistException {
+        if (mClob == null) {
+            try {
+                if ((mClob = mLoader.load(mRepo)) == null) {
+                    throw new PersistException("Clob value is null");
+                }
+                JDBCTransaction txn = mRepo.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(this);
+                }
+            } catch (Exception e) {
+                throw mRepo.toPersistException(e);
+            }
+        }
+        return mClob;
+    }
+
+    private static class Input extends Reader {
+        private final java.sql.Clob mClob;
+        private final int mBufferSize;
+
+        private long mPos;
+        private String mBuffer;
+        private int mBufferPos;
+
+        Input(java.sql.Clob clob, int bufferSize) {
+            mClob = clob;
+            mBufferSize = bufferSize;
+        }
+
+        public int read() throws IOException {
+            if (fillBuffer() <= 0) {
+                return -1;
+            }
+            return mBuffer.charAt(mBufferPos++);
+        }
+
+        public int read(char[] c, int off, int len) throws IOException {
+            int avail = fillBuffer();
+            if (avail <= 0) {
+                return -1;
+            }
+            if (len > avail) {
+                len = avail;
+            }
+            mBuffer.getChars(mBufferPos, mBufferPos + len, c, off);
+            mBufferPos += len;
+            return len;
+        }
+
+        public long skip(long n) throws IOException {
+            if (n <= 0) {
+                return 0;
+            }
+            long newPos = mPos + n;
+            long length;
+            try {
+                length = mClob.length();
+            } catch (SQLException e) {
+                IOException ioe = new IOException();
+                ioe.initCause(e);
+                throw ioe;
+            }
+            if (newPos >= length) {
+                newPos = length;
+                n = newPos - mPos;
+            }
+            long newBufferPos = mBufferPos + n;
+            if (mBuffer == null || newBufferPos >= mBuffer.length()) {
+                mBuffer = null;
+                mBufferPos = 0;
+            } else {
+                mBufferPos = (int) newBufferPos;
+            }
+            mPos = newPos;
+            return n;
+        }
+
+        public void close() {
+        }
+
+        private int fillBuffer() throws IOException {
+            try {
+                if (mBuffer == null || mBufferPos >= mBuffer.length()) {
+                    mBuffer = mClob.getSubString(mPos, mBufferSize);
+                    mPos += mBuffer.length();
+                    mBufferPos = 0;
+                }
+                return mBuffer.length() - mBufferPos;
+            } catch (SQLException e) {
+                IOException ioe = new IOException();
+                ioe.initCause(e);
+                throw ioe;
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClobLoader.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClobLoader.java
new file mode 100644
index 0000000..9e3470a
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCClobLoader.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import com.amazon.carbonado.FetchException;
+
+/**
+ * Callback for reloading Clobs outside original transaction.
+ *
+ * @author Brian S O'Neill
+ */
+public interface JDBCClobLoader {
+    /**
+     * @return Clob or null if missing
+     */
+    java.sql.Clob load(JDBCRepository jdbcRepo) throws FetchException;
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCConnectionCapability.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCConnectionCapability.java
new file mode 100644
index 0000000..dbde10e
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCConnectionCapability.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.capability.Capability;
+
+/**
+ * Capability to directly access the JDBC connection being used by the current
+ * transaction, which is thread-local. If no transaction is in progress, then
+ * the connection is in auto-commit mode.
+ *
+ * <p>All connections retrieved from this capability must be properly
+ * yielded. Do not close the connection directly, as this interferes with the
+ * transaction's ability to properly manage it.
+ *
+ * <p>It is perfectly okay for other Carbonado calls to be made while the
+ * connection is in use.  Also, it is okay to request more connections,
+ * although they will usually be the same instance. Failing to yield a
+ * connection has an undefined behavior.
+ *
+ * <pre>
+ * JDBCConnectionCapability cap = repo.getCapability(JDBCConnectionCapability.class);
+ * Transaction txn = repo.enterTransaction();
+ * try {
+ *     Connection con = cap.getConnection();
+ *     try {
+ *         ...
+ *     } finally {
+ *         cap.yieldConnection(con);
+ *     }
+ *     ...
+ *     txn.commit();
+ * } finally {
+ *     txn.exit();
+ * }
+ * </pre>
+ *
+ * @author Brian S O'Neill
+ */
+public interface JDBCConnectionCapability extends Capability {
+    /**
+     * Any connection returned by this method must be closed by calling
+     * yieldConnection.
+     */
+    Connection getConnection() throws FetchException;
+
+    /**
+     * Gives up a connection returned from getConnection. Connection must be
+     * yielded in same thread that retrieved it.
+     */
+    void yieldConnection(Connection con) throws FetchException;
+
+    /**
+     * Returns the name of the database product connected to.
+     */
+    String getDatabaseProductName();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCCursor.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCCursor.java
new file mode 100644
index 0000000..7ef2510
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCCursor.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.NoSuchElementException;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.cursor.AbstractCursor;
+
+/**
+ * Cursor implementation that queries a PreparedStatement.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCCursor<S extends Storable> extends AbstractCursor<S> {
+    private final JDBCStorage<S> mStorage;
+    private Connection mConnection;
+    private PreparedStatement mStatement;
+    private ResultSet mResultSet;
+
+    private boolean mHasNext;
+
+    JDBCCursor(JDBCStorage<S> storage,
+               Connection con,
+               PreparedStatement statement)
+        throws SQLException
+    {
+        mStorage = storage;
+        mConnection = con;
+        mStatement = statement;
+        mResultSet = statement.executeQuery();
+    }
+
+    public synchronized void close() throws FetchException {
+        if (mResultSet != null) {
+            try {
+                mResultSet.close();
+                mStatement.close();
+                mStorage.mRepository.yieldConnection(mConnection);
+            } catch (SQLException e) {
+                throw mStorage.getJDBCRepository().toFetchException(e);
+            } finally {
+                mResultSet = null;
+            }
+        }
+    }
+
+    public synchronized boolean hasNext() throws FetchException {
+        ResultSet rs = mResultSet;
+        if (rs == null) {
+            return false;
+        }
+        if (!mHasNext) {
+            try {
+                mHasNext = rs.next();
+            } catch (SQLException e) {
+                throw mStorage.getJDBCRepository().toFetchException(e);
+            }
+            if (!mHasNext) {
+                close();
+            }
+        }
+        return mHasNext;
+    }
+
+    public synchronized S next() throws FetchException, NoSuchElementException {
+        if (!hasNext()) {
+            throw new NoSuchElementException();
+        }
+        try {
+            S obj = mStorage.instantiate(mResultSet);
+            mHasNext = false;
+            return obj;
+        } catch (SQLException e) {
+            throw mStorage.getJDBCRepository().toFetchException(e);
+        }
+    }
+
+    public synchronized int skipNext(int amount) throws FetchException {
+        if (amount <= 0) {
+            if (amount < 0) {
+                throw new IllegalArgumentException("Cannot skip negative amount: " + amount);
+            }
+            return 0;
+        }
+
+        ResultSet rs = mResultSet;
+        if (rs == null) {
+            return 0;
+        }
+
+        mHasNext = true;
+
+        int actual = 0;
+        while (amount > 0) {
+            try {
+                if (rs.next()) {
+                    actual++;
+                } else {
+                    mHasNext = false;
+                    close();
+                    break;
+                }
+            } catch (SQLException e) {
+                throw mStorage.getJDBCRepository().toFetchException(e);
+            }
+        }
+
+        return actual;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCExceptionTransformer.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCExceptionTransformer.java
new file mode 100644
index 0000000..c40f08a
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCExceptionTransformer.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.SQLException;
+
+import com.amazon.carbonado.ConstraintException;
+import com.amazon.carbonado.PersistDeniedException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.UniqueConstraintException;
+import com.amazon.carbonado.spi.ExceptionTransformer;
+
+/**
+ * Custom exception transform rules.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCExceptionTransformer extends ExceptionTransformer {
+    // Getting actual SQLSTATE codes is quite difficult, unless you shell out
+    // cash for the proper manuals. SQLSTATE codes are five characters long,
+    // where the first two indicate error class. Although the following links
+    // are for DB2 SQLSTATE codes, the codes are fairly standard across all
+    // major database implementations.
+    //
+    // ftp://ftp.software.ibm.com/ps/products/db2/info/vr6/htm/db2m0/db2state.htm
+    // http://publib.boulder.ibm.com/infocenter/db2help/topic/com.ibm.db2.udb.doc/core/r0sttmsg.htm
+
+    /** Two digit SQLSTATE class prefix for all constraint violations */
+    public static String SQLSTATE_CONSTRAINT_VIOLATION_CLASS_CODE = "23";
+
+    /**
+     * Five digit SQLSTATE code for "A violation of the constraint imposed by a
+     * unique index or a unique constraint occurred"
+     */
+    public static String SQLSTATE_UNIQUE_CONSTRAINT_VIOLATION = "23505";
+
+    /**
+     * Examines the SQLSTATE code of the given SQL exception and determines if
+     * it is a generic constaint violation.
+     */
+    public boolean isConstraintError(SQLException e) {
+        if (e != null) {
+            String sqlstate = e.getSQLState();
+            if (sqlstate != null) {
+                return sqlstate.startsWith(SQLSTATE_CONSTRAINT_VIOLATION_CLASS_CODE);
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Examines the SQLSTATE code of the given SQL exception and determines if
+     * it is a unique constaint violation.
+     */
+    public boolean isUniqueConstraintError(SQLException e) {
+        if (isConstraintError(e)) {
+            String sqlstate = e.getSQLState();
+            return SQLSTATE_UNIQUE_CONSTRAINT_VIOLATION.equals(sqlstate);
+        }
+        return false;
+    }
+
+    /**
+     * Examines the SQLSTATE code of the given SQL exception and determines if
+     * it indicates insufficient privileges.
+     */
+    public boolean isInsufficientPrivilegesError(SQLException e) {
+        return false;
+    }
+
+    JDBCExceptionTransformer() {
+    }
+
+    @Override
+    protected PersistException transformIntoPersistException(Throwable e) {
+        PersistException pe = super.transformIntoPersistException(e);
+        if (pe != null) {
+            return pe;
+        }
+        if (e instanceof SQLException) {
+            SQLException se = (SQLException) e;
+            if (isUniqueConstraintError(se)) {
+                return new UniqueConstraintException(e);
+            }
+            if (isConstraintError(se)) {
+                return new ConstraintException(e);
+            }
+            if (isInsufficientPrivilegesError(se)) {
+                return new PersistDeniedException(e);
+            }
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCLob.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCLob.java
new file mode 100644
index 0000000..a4f09b2
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCLob.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+interface JDBCLob extends com.amazon.carbonado.lob.Lob {
+    void close();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepository.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepository.java
new file mode 100644
index 0000000..6e53354
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepository.java
@@ -0,0 +1,665 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.SQLException;
+import java.util.Map;
+import java.util.IdentityHashMap;
+
+import javax.sql.DataSource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.cojen.util.WeakIdentityMap;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.MalformedTypeException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Repository;
+import static com.amazon.carbonado.RepositoryBuilder.RepositoryReference;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.UnsupportedTypeException;
+
+import com.amazon.carbonado.capability.Capability;
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.capability.IndexInfoCapability;
+import com.amazon.carbonado.capability.ShutdownCapability;
+import com.amazon.carbonado.capability.StorableInfoCapability;
+
+import com.amazon.carbonado.info.StorableProperty;
+
+/**
+ * Repository implementation backed by a JDBC accessible database.
+ * JDBCRepository is not independent of the underlying database schema, and so
+ * it requires matching tables and columns in the database. It will not alter
+ * or create tables. Use the {@link com.amazon.carbonado.Alias Alias} annotation to
+ * control precisely which tables and columns must be matched up.
+ *
+ * @author Brian S O'Neill
+ * @see JDBCRepositoryBuilder
+ */
+// Note: this class must be public because auto-generated code needs access to it
+public class JDBCRepository
+    implements Repository,
+               IndexInfoCapability,
+               ShutdownCapability,
+               StorableInfoCapability,
+               JDBCConnectionCapability
+{
+
+    static IsolationLevel mapIsolationLevelFromJdbc(int jdbcLevel) {
+        switch (jdbcLevel) {
+        case Connection.TRANSACTION_READ_UNCOMMITTED: default:
+            return IsolationLevel.READ_UNCOMMITTED;
+        case Connection.TRANSACTION_READ_COMMITTED:
+            return IsolationLevel.READ_COMMITTED;
+        case Connection.TRANSACTION_REPEATABLE_READ:
+            return IsolationLevel.REPEATABLE_READ;
+        case Connection.TRANSACTION_SERIALIZABLE:
+            return IsolationLevel.SERIALIZABLE;
+        }
+    }
+
+    static int mapIsolationLevelToJdbc(IsolationLevel level) {
+        switch (level) {
+        case READ_UNCOMMITTED: default:
+            return Connection.TRANSACTION_READ_UNCOMMITTED;
+        case READ_COMMITTED:
+            return Connection.TRANSACTION_READ_COMMITTED;
+        case REPEATABLE_READ:
+            return Connection.TRANSACTION_REPEATABLE_READ;
+        case SERIALIZABLE:
+            return Connection.TRANSACTION_SERIALIZABLE;
+        }
+    }
+
+    /**
+     * Returns the highest supported level for the given desired level.
+     *
+     * @return null if not supported
+     */
+    private static IsolationLevel selectIsolationLevel(DatabaseMetaData md,
+                                                       IsolationLevel desiredLevel)
+        throws SQLException, RepositoryException
+    {
+        while (!md.supportsTransactionIsolationLevel(mapIsolationLevelToJdbc(desiredLevel))) {
+            switch (desiredLevel) {
+            case READ_UNCOMMITTED:
+                desiredLevel = IsolationLevel.READ_COMMITTED;
+                break;
+            case READ_COMMITTED:
+                desiredLevel = IsolationLevel.REPEATABLE_READ;
+                break;
+            case REPEATABLE_READ:
+                desiredLevel = IsolationLevel.SERIALIZABLE;
+                break;
+            case SERIALIZABLE: default:
+                return null;
+            }
+        }
+        return desiredLevel;
+    }
+
+    private final Log mLog = LogFactory.getLog(getClass());
+
+    private final String mName;
+    final boolean mIsMaster;
+    private final RepositoryReference mRootRef;
+    private final String mDatabaseProductName;
+    private final DataSource mDataSource;
+    private final String mCatalog;
+    private final String mSchema;
+    private final Map<Class<?>, JDBCStorage<?>> mStorages;
+
+    // Track all open connections so that they can be closed when this
+    // repository is closed.
+    private Map<Connection, Object> mOpenConnections;
+
+    private final ThreadLocal<JDBCTransactionManager> mCurrentTxnMgr;
+
+    // Weakly tracks all JDBCTransactionManager instances for shutdown.
+    private final Map<JDBCTransactionManager, ?> mAllTxnMgrs;
+
+    private final boolean mSupportsSavepoints;
+    private final boolean mSupportsSelectForUpdate;
+
+    private final IsolationLevel mDefaultIsolationLevel;
+    private final int mJdbcDefaultIsolationLevel;
+
+    private final JDBCSupportStrategy mSupportStrategy;
+    private JDBCExceptionTransformer mExceptionTransformer;
+
+    // Mappings from IsolationLevel to best matching supported level.
+    final IsolationLevel mReadUncommittedLevel;
+    final IsolationLevel mReadCommittedLevel;
+    final IsolationLevel mRepeatableReadLevel;
+    final IsolationLevel mSerializableLevel;
+
+    /**
+     * @param name name to give repository instance
+     * @param isMaster when true, storables in this repository must manage
+     * version properties and sequence properties
+     * @param dataSource provides JDBC database connections
+     * @param catalog optional catalog to search for tables -- actual meaning
+     * is database independent
+     * @param schema optional schema to search for tables -- actual meaning is
+     * database independent
+     */
+    @SuppressWarnings("unchecked")
+    JDBCRepository(RepositoryReference rootRef,
+                   String name, boolean isMaster,
+                   DataSource dataSource, String catalog, String schema)
+        throws RepositoryException
+    {
+        if (name == null || dataSource == null) {
+            throw new IllegalArgumentException();
+        }
+        mName = name;
+        mIsMaster = isMaster;
+        mRootRef = rootRef;
+        mDataSource = dataSource;
+        mCatalog = catalog;
+        mSchema = schema;
+        mStorages = new IdentityHashMap<Class<?>, JDBCStorage<?>>();
+        mOpenConnections = new IdentityHashMap<Connection, Object>();
+        mCurrentTxnMgr = new ThreadLocal<JDBCTransactionManager>();
+        mAllTxnMgrs = new WeakIdentityMap();
+
+        // Temporarily set to generic one, in case there's a problem during initialization.
+        mExceptionTransformer = new JDBCExceptionTransformer();
+
+        // Test connectivity and get some info on transaction isolation levels.
+        Connection con = getConnection();
+        try {
+            DatabaseMetaData md = con.getMetaData();
+            if (md == null || !md.supportsTransactions()) {
+                throw new RepositoryException("Database does not support transactions");
+            }
+
+            mDatabaseProductName = md.getDatabaseProductName();
+
+            boolean supportsSavepoints;
+            try {
+                supportsSavepoints = md.supportsSavepoints();
+            } catch (AbstractMethodError e) {
+                supportsSavepoints = false;
+            }
+
+            if (supportsSavepoints) {
+                con.setAutoCommit(false);
+                // Some JDBC drivers (HSQLDB) lie about their savepoint support.
+                try {
+                    con.setSavepoint();
+                } catch (SQLException e) {
+                    mLog.warn("JDBC driver for " + mDatabaseProductName +
+                              " reports supporting savepoints, but it " +
+                              "doesn't appear to work: " + e);
+                    supportsSavepoints = false;
+                } finally {
+                    con.rollback();
+                    con.setAutoCommit(true);
+                }
+            }
+
+            mSupportsSavepoints = supportsSavepoints;
+            mSupportsSelectForUpdate = md.supportsSelectForUpdate();
+
+            mJdbcDefaultIsolationLevel = md.getDefaultTransactionIsolation();
+            mDefaultIsolationLevel = mapIsolationLevelFromJdbc(mJdbcDefaultIsolationLevel);
+
+            mReadUncommittedLevel = selectIsolationLevel(md, IsolationLevel.READ_UNCOMMITTED);
+            mReadCommittedLevel   = selectIsolationLevel(md, IsolationLevel.READ_COMMITTED);
+            mRepeatableReadLevel  = selectIsolationLevel(md, IsolationLevel.REPEATABLE_READ);
+            mSerializableLevel    = selectIsolationLevel(md, IsolationLevel.SERIALIZABLE);
+
+        } catch (SQLException e) {
+            throw toRepositoryException(e);
+        } finally {
+            forceYieldConnection(con);
+        }
+
+        mSupportStrategy = JDBCSupportStrategy.createStrategy(this);
+        mExceptionTransformer = mSupportStrategy.createExceptionTransformer();
+    }
+
+    public DataSource getDataSource() {
+        return mDataSource;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    @SuppressWarnings("unchecked")
+    public <S extends Storable> Storage<S> storageFor(Class<S> type) throws RepositoryException {
+        // Lock on mAllTxnMgrs to prevent databases from being opened during shutdown.
+        synchronized (mAllTxnMgrs) {
+            JDBCStorage<S> storage = (JDBCStorage<S>) mStorages.get(type);
+            if (storage == null) {
+                // Examine and throw exception early if there is a problem.
+                JDBCStorableInfo<S> info = examineStorable(type);
+
+                if (!info.isSupported()) {
+                    throw new UnsupportedTypeException(type);
+                }
+
+                storage = new JDBCStorage<S>(this, info);
+                mStorages.put(type, storage);
+            }
+            return storage;
+        }
+    }
+
+    public Transaction enterTransaction() {
+        return openTransactionManager().enter(null);
+    }
+
+    public Transaction enterTransaction(IsolationLevel level) {
+        return openTransactionManager().enter(level);
+    }
+
+    public Transaction enterTopTransaction(IsolationLevel level) {
+        return openTransactionManager().enterTop(level);
+    }
+
+    public IsolationLevel getTransactionIsolationLevel() {
+        return openTransactionManager().getIsolationLevel();
+    }
+
+    /**
+     * Returns true if a transaction is in progress and it is for update.
+     */
+    public boolean isTransactionForUpdate() {
+        return openTransactionManager().isForUpdate();
+    }
+
+    /**
+     * Convenience method that calls into {@link JDBCStorableIntrospector}.
+     *
+     * @param type Storable type to examine
+     * @throws MalformedTypeException if Storable type is not well-formed
+     * @throws RepositoryException if there was a problem in accessing the database
+     * @throws IllegalArgumentException if type is null
+     */
+    public <S extends Storable> JDBCStorableInfo<S> examineStorable(Class<S> type)
+        throws RepositoryException, SupportException
+    {
+        try {
+            return JDBCStorableIntrospector.examine(type, mDataSource, mCatalog, mSchema);
+        } catch (SQLException e) {
+            throw toRepositoryException(e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    public <C extends Capability> C getCapability(Class<C> capabilityType) {
+        if (capabilityType.isInstance(this)) {
+            return (C) this;
+        }
+        return null;
+    }
+
+    public <S extends Storable> IndexInfo[] getIndexInfo(Class<S> storableType)
+        throws RepositoryException
+    {
+        return ((JDBCStorage) storageFor(storableType)).getIndexInfo();
+    }
+
+    public String[] getUserStorableTypeNames() {
+        // We don't register Storable types persistently, so just return what
+        // we know right now.
+        synchronized (mAllTxnMgrs) {
+            String[] names = new String[mStorages.size()];
+            int i = 0;
+            for (Class<?> type : mStorages.keySet()) {
+                names[i++] = type.getName();
+            }
+            return names;
+        }
+    }
+
+    public boolean isSupported(Class<Storable> type) {
+        if (type == null) {
+            return false;
+        }
+        try {
+            examineStorable(type);
+            return true;
+        } catch (RepositoryException e) {
+            return false;
+        }
+    }
+
+    public boolean isPropertySupported(Class<Storable> type, String name) {
+        if (type == null || name == null) {
+            return false;
+        }
+        try {
+            JDBCStorableProperty<?> prop = examineStorable(type).getAllProperties().get(name);
+            return prop == null ? false : prop.isSupported();
+        } catch (RepositoryException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Convenience method to convert a regular StorableProperty into a
+     * JDBCStorableProperty.
+     *
+     * @throws UnsupportedOperationException if JDBCStorableProperty is not supported
+     */
+    <S extends Storable> JDBCStorableProperty<S>
+        getJDBCStorableProperty(StorableProperty<S> property)
+        throws RepositoryException, SupportException
+    {
+        JDBCStorableInfo<S> info = examineStorable(property.getEnclosingType());
+        JDBCStorableProperty<S> jProperty = info.getAllProperties().get(property.getName());
+        if (!jProperty.isSupported()) {
+            throw new UnsupportedOperationException
+                ("Property is not supported: " + property.getName());
+        }
+        return jProperty;
+    }
+
+    /**
+     * Returns the thread-local JDBCTransactionManager instance, creating it if
+     * needed.
+     */
+    JDBCTransactionManager openTransactionManager() {
+        JDBCTransactionManager txnMgr = mCurrentTxnMgr.get();
+        if (txnMgr == null) {
+            synchronized (mAllTxnMgrs) {
+                txnMgr = new JDBCTransactionManager(this);
+                mCurrentTxnMgr.set(txnMgr);
+                mAllTxnMgrs.put(txnMgr, null);
+            }
+        }
+        return txnMgr;
+    }
+
+    public void close() {
+        shutdown(false);
+    }
+
+    public boolean isAutoShutdownEnabled() {
+        return false;
+    }
+
+    public void setAutoShutdownEnabled(boolean enabled) {
+    }
+
+    public void shutdown() {
+        shutdown(true);
+    }
+
+    private void shutdown(boolean suspendThreads) {
+        synchronized (mAllTxnMgrs) {
+            // Close transactions and cursors.
+            for (JDBCTransactionManager txnMgr : mAllTxnMgrs.keySet()) {
+                if (suspendThreads) {
+                    // Lock transaction manager but don't release it. This
+                    // prevents other threads from beginning work during
+                    // shutdown, which will likely fail along the way.
+                    txnMgr.getLock().lock();
+                }
+                try {
+                    txnMgr.close();
+                } catch (Throwable e) {
+                    getLog().error(null, e);
+                }
+            }
+
+            // Now close all open connections.
+            if (mOpenConnections != null) {
+                for (Connection con : mOpenConnections.keySet()) {
+                    try {
+                        con.close();
+                    } catch (SQLException e) {
+                        getLog().warn(null, e);
+                    }
+                }
+                mOpenConnections = null;
+            }
+        }
+    }
+
+    protected Log getLog() {
+        return mLog;
+    }
+
+    public String getDatabaseProductName() {
+        return mDatabaseProductName;
+    }
+
+    /**
+     * Any connection returned by this method must be closed by calling
+     * yieldConnection on this repository.
+     */
+    // Note: This method must be public for auto-generated code to access it.
+    public Connection getConnection() throws FetchException {
+        try {
+            if (mOpenConnections == null) {
+                throw new FetchException("Repository is closed");
+            }
+
+            JDBCTransaction txn = openTransactionManager().getTxn();
+            if (txn != null) {
+                // Return the connection used by the current transaction.
+                return txn.getConnection();
+            }
+
+            // Get connection outside synchronized section since it may block.
+            Connection con = mDataSource.getConnection();
+            con.setAutoCommit(true);
+
+            synchronized (mAllTxnMgrs) {
+                if (mOpenConnections == null) {
+                    con.close();
+                    throw new FetchException("Repository is closed");
+                }
+                mOpenConnections.put(con, null);
+            }
+
+            return con;
+        } catch (Exception e) {
+            throw toFetchException(e);
+        }
+    }
+
+    /**
+     * Called by JDBCTransactionManager.
+     */
+    Connection getConnectionForTxn(IsolationLevel level) throws FetchException {
+        try {
+            if (mOpenConnections == null) {
+                throw new FetchException("Repository is closed");
+            }
+
+            // Get connection outside synchronized section since it may block.
+            Connection con = mDataSource.getConnection();
+            con.setAutoCommit(false);
+            if (level != mDefaultIsolationLevel) {
+                con.setTransactionIsolation(mapIsolationLevelToJdbc(level));
+            }
+
+            synchronized (mAllTxnMgrs) {
+                if (mOpenConnections == null) {
+                    con.close();
+                    throw new FetchException("Repository is closed");
+                }
+                mOpenConnections.put(con, null);
+            }
+
+            return con;
+        } catch (Exception e) {
+            throw toFetchException(e);
+        }
+    }
+
+    /**
+     * Gives up a connection returned from getConnection. Connection must be
+     * yielded in same thread that retrieved it.
+     */
+    // Note: This method must be public for auto-generated code to access it.
+    public void yieldConnection(Connection con) throws FetchException {
+        try {
+            if (con.getAutoCommit()) {
+                synchronized (mAllTxnMgrs) {
+                    if (mOpenConnections != null) {
+                        mOpenConnections.remove(con);
+                    }
+                }
+                // Close connection outside synchronized section since it may block.
+                if (con.getTransactionIsolation() != mJdbcDefaultIsolationLevel) {
+                    con.setTransactionIsolation(mJdbcDefaultIsolationLevel);
+                }
+                con.close();
+            }
+
+            // Connections which aren't auto-commit are in a transaction.
+            // When transaction is finished, JDBCTransactionManager switches
+            // connection back to auto-commit and calls yieldConnection.
+        } catch (Exception e) {
+            throw toFetchException(e);
+        }
+    }
+
+    /**
+     * Yields connection without attempting to restore isolation level. Ignores
+     * any exceptions too.
+     */
+    private void forceYieldConnection(Connection con) {
+        synchronized (mAllTxnMgrs) {
+            if (mOpenConnections != null) {
+                mOpenConnections.remove(con);
+            }
+        }
+        // Close connection outside synchronized section since it may block.
+        try {
+            con.close();
+        } catch (SQLException e) {
+            // Don't care.
+        }
+    }
+
+    boolean supportsSavepoints() {
+        return mSupportsSavepoints;
+    }
+
+    boolean supportsSelectForUpdate() {
+        return mSupportsSelectForUpdate;
+    }
+
+    /**
+     * Returns the highest supported level for the given desired level.
+     *
+     * @return null if not supported
+     */
+    IsolationLevel selectIsolationLevel(Transaction parent, IsolationLevel desiredLevel) {
+        if (desiredLevel == null) {
+            if (parent == null) {
+                desiredLevel = mDefaultIsolationLevel;
+            } else {
+                desiredLevel = parent.getIsolationLevel();
+            }
+        } else if (parent != null) {
+            IsolationLevel parentLevel = parent.getIsolationLevel();
+            // Can promote to higher level, but not lower.
+            if (parentLevel.compareTo(desiredLevel) >= 0) {
+                desiredLevel = parentLevel;
+            } else {
+                return null;
+            }
+        }
+
+        switch (desiredLevel) {
+        case READ_UNCOMMITTED:
+            return mReadUncommittedLevel;
+        case READ_COMMITTED:
+            return mReadCommittedLevel;
+        case REPEATABLE_READ:
+            return mRepeatableReadLevel;
+        case SERIALIZABLE:
+            return mSerializableLevel;
+        }
+
+        return null;
+    }
+
+    JDBCSupportStrategy getSupportStrategy() {
+        return mSupportStrategy;
+    }
+
+    Repository getRootRepository() {
+        return mRootRef.get();
+    }
+
+    /**
+     * Transforms the given throwable into an appropriate fetch exception. If
+     * it already is a fetch exception, it is simply casted.
+     *
+     * @param e required exception to transform
+     * @return FetchException, never null
+     */
+    public FetchException toFetchException(Throwable e) {
+        return mExceptionTransformer.toFetchException(e);
+    }
+
+    /**
+     * Transforms the given throwable into an appropriate persist exception. If
+     * it already is a persist exception, it is simply casted.
+     *
+     * @param e required exception to transform
+     * @return PersistException, never null
+     */
+    public PersistException toPersistException(Throwable e) {
+        return mExceptionTransformer.toPersistException(e);
+    }
+
+    /**
+     * Transforms the given throwable into an appropriate repository
+     * exception. If it already is a repository exception, it is simply casted.
+     *
+     * @param e required exception to transform
+     * @return RepositoryException, never null
+     */
+    public RepositoryException toRepositoryException(Throwable e) {
+        return mExceptionTransformer.toRepositoryException(e);
+    }
+
+    /**
+     * Examines the SQLSTATE code of the given SQL exception and determines if
+     * it is a unique constaint violation.
+     */
+    public boolean isUniqueConstraintError(SQLException e) {
+        return mExceptionTransformer.isUniqueConstraintError(e);
+    }
+
+    JDBCExceptionTransformer getExceptionTransformer() {
+        return mExceptionTransformer;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepositoryBuilder.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepositoryBuilder.java
new file mode 100644
index 0000000..507d70a
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCRepositoryBuilder.java
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.SQLException;
+import java.util.Collection;
+
+import javax.sql.DataSource;
+
+import com.amazon.carbonado.ConfigurationException;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.spi.AbstractRepositoryBuilder;
+
+/**
+ * Builds a repository instance backed by a JDBC accessible database.
+ * JDBCRepository is not independent of the underlying database schema, and so
+ * it requires matching tables and columns in the database. It will not alter
+ * or create tables. Use the {@link com.amazon.carbonado.Alias Alias}
+ * annotation to control precisely which tables and columns must be matched up.
+ *
+ * <p>Note: The current JDBC repository implementation makes certain
+ * assumptions about the database it is accessing. It must support transactions
+ * and multiple statements per connection. If it doesn't support savepoints,
+ * then nested transactions are faked -- rollback of inner transaction will
+ * appear to do nothing.
+ *
+ * <p>
+ * The following extra capabilities are supported:
+ * <ul>
+ * <li>{@link com.amazon.carbonado.capability.IndexInfoCapability IndexInfoCapability}
+ * <li>{@link com.amazon.carbonado.capability.StorableInfoCapability StorableInfoCapability}
+ * <li>{@link com.amazon.carbonado.capability.ShutdownCapability ShutdownCapability}
+ * <li>{@link JDBCConnectionCapability JDBCConnectionCapability}
+ * </ul>
+ *
+ * @author Brian S O'Neill
+ */
+public class JDBCRepositoryBuilder extends AbstractRepositoryBuilder {
+    private String mName;
+    private boolean mIsMaster = true;
+    private DataSource mDataSource;
+    private boolean mDataSourceLogging;
+    private String mCatalog;
+    private String mSchema;
+    private String mDriverClassName;
+    private String mURL;
+    private String mUsername;
+    private String mPassword;
+
+    public JDBCRepositoryBuilder() {
+    }
+
+    public JDBCRepository build(RepositoryReference rootRef) throws RepositoryException {
+        assertReady();
+        JDBCRepository repo = new JDBCRepository
+            (rootRef, getName(), isMaster(), getDataSource(), mCatalog, mSchema);
+        rootRef.set(repo);
+        return repo;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public void setName(String name) {
+        mName = name;
+    }
+
+    public boolean isMaster() {
+        return mIsMaster;
+    }
+
+    public void setMaster(boolean b) {
+        mIsMaster = b;
+    }
+
+    /**
+     * Set the source of JDBC connections, overriding all other database
+     * connectivity configuration in this object.
+     */
+    public void setDataSource(DataSource dataSource) {
+        mDataSource = dataSource;
+        mDriverClassName = null;
+        mURL = null;
+        mUsername = null;
+        mPassword = null;
+    }
+
+    /**
+     * Returns the source of JDBC connections, which defaults to a non-pooling
+     * source if driver class, driver URL, username, and password are all
+     * supplied.
+     *
+     * @throws ConfigurationException if driver class wasn't found
+     */
+    public DataSource getDataSource() throws ConfigurationException {
+        if (mDataSource == null) {
+            if (mDriverClassName != null && mURL != null) {
+                try {
+                    mDataSource = new SimpleDataSource
+                        (mDriverClassName, mURL, mUsername, mPassword);
+                } catch (SQLException e) {
+                    Throwable cause = e.getCause();
+                    if (cause == null) {
+                        cause = e;
+                    }
+                    throw new ConfigurationException(cause);
+                }
+            }
+        }
+
+        DataSource ds = mDataSource;
+        if (getDataSourceLogging() && !(ds instanceof LoggingDataSource)) {
+            ds = LoggingDataSource.create(ds);
+        }
+
+        return ds;
+    }
+
+    /**
+     * Pass true to enable debug logging. By default, it is false.
+     *
+     * @see LoggingDataSource
+     */
+    public void setDataSourceLogging(boolean b) {
+        mDataSourceLogging = b;
+    }
+
+    /**
+     * Returns true if debug logging is enabled.
+     *
+     * @see LoggingDataSource
+     */
+    public boolean getDataSourceLogging() {
+        return mDataSourceLogging;
+    }
+
+    /**
+     * Optionally set the catalog to search for metadata.
+     */
+    public void setCatalog(String catalog) {
+        mCatalog = catalog;
+    }
+
+    /**
+     * Returns the optional catalog to search for metadata.
+     */
+    public String getCatalog() {
+        return mCatalog;
+    }
+
+    /**
+     * Optionally set the schema to search for metadata.
+     */
+    public void setSchema(String schema) {
+        mSchema = schema;
+    }
+
+    /**
+     * Returns the optional schema to search for metadata.
+     */
+    public String getSchema() {
+        return mSchema;
+    }
+
+    /**
+     * Set the JDBC driver class name, which is required if a DataSource was not provided.
+     */
+    public void setDriverClassName(String driverClassName) {
+        mDriverClassName = driverClassName;
+    }
+
+    /**
+     * Returns the driver class name, which may be null if a DataSource was provided.
+     */
+    public String getDriverClassName() {
+        return mDriverClassName;
+    }
+
+    /**
+     * Set the JDBC connection URL, which is required if a DataSource was not
+     * provided.
+     */
+    public void setDriverURL(String url) {
+        mURL = url;
+    }
+
+    /**
+     * Returns the connection URL, which may be null if a DataSource was
+     * provided.
+     */
+    public String getDriverURL() {
+        return mURL;
+    }
+
+    /**
+     * Optionally set the username to use with DataSource.
+     */
+    public void setUserName(String username) {
+        mUsername = username;
+    }
+
+    /**
+     * Returns the optional username to use with DataSource.
+     */
+    public String getUserName() {
+        return mUsername;
+    }
+
+    /**
+     * Optionally set the password to use with DataSource.
+     */
+    public void setPassword(String password) {
+        mPassword = password;
+    }
+
+    /**
+     * Returns the optional password to use with DataSource.
+     */
+    public String getPassword() {
+        return mPassword;
+    }
+
+    public void errorCheck(Collection<String> messages) throws ConfigurationException {
+        super.errorCheck(messages);
+        if (mDataSource == null) {
+            if (mDriverClassName == null) {
+                messages.add("driverClassName missing");
+            }
+            if (mURL == null) {
+                messages.add("driverURL missing");
+            }
+            if (messages.size() == 0) {
+                // Verify driver exists, only if no other errors.
+                getDataSource();
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSequenceValueProducer.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSequenceValueProducer.java
new file mode 100644
index 0000000..f98bd1e
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSequenceValueProducer.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+import com.amazon.carbonado.PersistException;
+
+import com.amazon.carbonado.spi.AbstractSequenceValueProducer;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCSequenceValueProducer extends AbstractSequenceValueProducer {
+    private final JDBCRepository mRepo;
+    private final String mQuery;
+
+    JDBCSequenceValueProducer(JDBCRepository repo, String sequenceQuery) {
+        mRepo = repo;
+        mQuery = sequenceQuery;
+    }
+
+    public long nextLongValue() throws PersistException {
+        try {
+            Connection con = mRepo.getConnection();
+            try {
+                Statement st = con.createStatement();
+                try {
+                    ResultSet rs = st.executeQuery(mQuery);
+                    try {
+                        if (rs.next()) {
+                            return rs.getLong(1);
+                        }
+                        throw new PersistException("No results from sequence query: " + mQuery);
+                    } finally {
+                        rs.close();
+                    }
+                } finally {
+                    st.close();
+                }
+            } finally {
+                mRepo.yieldConnection(con);
+            }
+        } catch (Exception e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableGenerator.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableGenerator.java
new file mode 100644
index 0000000..bb851ab
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableGenerator.java
@@ -0,0 +1,1892 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.UndeclaredThrowableException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import org.cojen.classfile.ClassFile;
+import org.cojen.classfile.CodeBuilder;
+import org.cojen.classfile.Label;
+import org.cojen.classfile.LocalVariable;
+import org.cojen.classfile.MethodInfo;
+import org.cojen.classfile.Modifiers;
+import org.cojen.classfile.Opcode;
+import org.cojen.classfile.TypeDesc;
+import org.cojen.util.ClassInjector;
+import org.cojen.util.SoftValuedHashMap;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.OptimisticLockException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+
+import com.amazon.carbonado.lob.Lob;
+
+import com.amazon.carbonado.info.StorablePropertyAdapter;
+
+import com.amazon.carbonado.spi.CodeBuilderUtil;
+import com.amazon.carbonado.spi.MasterFeature;
+import com.amazon.carbonado.spi.MasterStorableGenerator;
+import com.amazon.carbonado.spi.MasterSupport;
+import com.amazon.carbonado.spi.StorableGenerator;
+import com.amazon.carbonado.spi.TriggerSupport;
+
+import static com.amazon.carbonado.spi.CommonMethodNames.*;
+
+/**
+ * Generates concrete implementations of {@link Storable} types for
+ * {@link JDBCRepository}.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCStorableGenerator<S extends Storable> {
+    // These method names end in "$" to prevent name collisions with any
+    // inherited methods.
+    private static final String EXTRACT_ALL_METHOD_NAME = "extractAll$";
+    private static final String EXTRACT_DATA_METHOD_NAME = "extractData$";
+    private static final String LOB_LOADER_FIELD_PREFIX = "lobLoader$";
+
+    // Initial StringBuilder capactity for update statement.
+    private static final int INITIAL_UPDATE_BUFFER_SIZE = 100;
+
+    private static final Map<Class<?>, Class<? extends Storable>> cCache;
+
+    static {
+        cCache = new SoftValuedHashMap();
+    }
+
+    static <S extends Storable> Class<? extends S> getGeneratedClass(JDBCStorableInfo<S> info)
+        throws SupportException
+    {
+        Class<S> type = info.getStorableType();
+        synchronized (cCache) {
+            Class<? extends S> generatedClass = (Class<? extends S>) cCache.get(type);
+            if (generatedClass != null) {
+                return generatedClass;
+            }
+            generatedClass = new JDBCStorableGenerator<S>(info).generateAndInjectClass();
+            cCache.put(type, generatedClass);
+            return generatedClass;
+        }
+    }
+
+    private final Class<S> mStorableType;
+    private final JDBCStorableInfo<S> mInfo;
+    private final Map<String, ? extends JDBCStorableProperty<S>> mAllProperties;
+
+    private final ClassLoader mParentClassLoader;
+    private final ClassInjector mClassInjector;
+    private final ClassFile mClassFile;
+
+    private JDBCStorableGenerator(JDBCStorableInfo<S> info) throws SupportException {
+        mStorableType = info.getStorableType();
+        mInfo = info;
+        mAllProperties = mInfo.getAllProperties();
+
+        EnumSet<MasterFeature> features = EnumSet
+            .of(MasterFeature.INSERT_SEQUENCES,
+                MasterFeature.INSERT_TXN, MasterFeature.UPDATE_TXN);
+
+        final Class<? extends S> abstractClass =
+            MasterStorableGenerator.getAbstractClass(mStorableType, features);
+
+        mParentClassLoader = abstractClass.getClassLoader();
+        mClassInjector = ClassInjector.create(mStorableType.getName(), mParentClassLoader);
+
+        mClassFile = new ClassFile(mClassInjector.getClassName(), abstractClass);
+        mClassFile.markSynthetic();
+        mClassFile.setSourceFile(JDBCStorableGenerator.class.getName());
+        mClassFile.setTarget("1.5");
+    }
+
+    private Class<? extends S> generateAndInjectClass() {
+        // We'll need these "inner classes" which serve as Lob loading
+        // callbacks. Lob's need to be reloaded if the original transaction has
+        // been committed.
+        final Map<JDBCStorableProperty<S>, Class<?>> lobLoaderMap = generateLobLoaders();
+
+        // Declare some types.
+        final TypeDesc storageType = TypeDesc.forClass(Storage.class);
+        final TypeDesc jdbcRepoType = TypeDesc.forClass(JDBCRepository.class);
+        final TypeDesc jdbcSupportType = TypeDesc.forClass(JDBCSupport.class);
+        final TypeDesc resultSetType = TypeDesc.forClass(ResultSet.class);
+        final TypeDesc connectionType = TypeDesc.forClass(Connection.class);
+        final TypeDesc preparedStatementType = TypeDesc.forClass(PreparedStatement.class);
+        final TypeDesc lobArrayType = TypeDesc.forClass(Lob.class).toArrayType();
+        final TypeDesc masterSupportType = TypeDesc.forClass(MasterSupport.class);
+        final TypeDesc classType = TypeDesc.forClass(Class.class);
+
+        if (lobLoaderMap.size() > 0) {
+            // Add static initializer to save references to Lob
+            // loaders. Otherwise, classes might get unloaded before they are
+            // used for the first time.
+
+            MethodInfo mi = mClassFile.addInitializer();
+            CodeBuilder b = new CodeBuilder(mi);
+
+            int i = 0;
+            for (Class<?> loaderClass : lobLoaderMap.values()) {
+                String fieldName = LOB_LOADER_FIELD_PREFIX + i;
+                mClassFile.addField
+                    (Modifiers.PRIVATE.toStatic(true).toFinal(true), fieldName, classType);
+                b.loadConstant(TypeDesc.forClass(loaderClass));
+                b.storeStaticField(fieldName, classType);
+                i++;
+            }
+
+            b.returnVoid();
+        }
+
+        // Add constructor that accepts a JDBCSupport.
+        {
+            TypeDesc[] params = {jdbcSupportType};
+            MethodInfo mi = mClassFile.addConstructor(Modifiers.PUBLIC, params);
+            CodeBuilder b = new CodeBuilder(mi);
+            b.loadThis();
+            b.loadLocal(b.getParameter(0));
+            b.checkCast(masterSupportType);
+            b.invokeSuperConstructor(new TypeDesc[] {masterSupportType});
+            b.returnVoid();
+        }
+
+        // Add constructor that accepts a JDBCSupport and a ResultSet row.
+        {
+            TypeDesc[] params = {jdbcSupportType, resultSetType, TypeDesc.INT};
+            MethodInfo mi = mClassFile.addConstructor(Modifiers.PUBLIC, params);
+            CodeBuilder b = new CodeBuilder(mi);
+            b.loadThis();
+            b.loadLocal(b.getParameter(0));
+            b.checkCast(masterSupportType);
+            b.invokeSuperConstructor(new TypeDesc[] {masterSupportType});
+
+            // Call extractAll method to fill in properties.
+            b.loadThis();
+            b.loadLocal(b.getParameter(1));
+            b.loadLocal(b.getParameter(2));
+            b.invokePrivate(EXTRACT_ALL_METHOD_NAME, null,
+                            new TypeDesc[] {resultSetType, TypeDesc.INT});
+
+            // Indicate that object is clean by calling markAllPropertiesClean.
+            b.loadThis();
+            b.invokeVirtual(MARK_ALL_PROPERTIES_CLEAN, null, null);
+
+            b.returnVoid();
+        }
+
+        // Add private method to extract all properties from a ResultSet row.
+        defineExtractAllMethod(lobLoaderMap);
+        // Add private method to extract non-pk properties from a ResultSet row.
+        defineExtractDataMethod(lobLoaderMap);
+
+        // For all unsupported properties, override get/set method to throw
+        // UnsupportedOperationException.
+        {
+            for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+                if (property.isJoin() || property.isSupported()) {
+                    continue;
+                }
+                String message = "Independent property \"" + property.getName() +
+                    "\" is not supported by the SQL schema: ";
+                message += mInfo.getTableName();
+                CodeBuilder b = new CodeBuilder(mClassFile.addMethod(property.getReadMethod()));
+                CodeBuilderUtil.throwException(b, UnsupportedOperationException.class, message);
+                b = new CodeBuilder(mClassFile.addMethod(property.getWriteMethod()));
+                CodeBuilderUtil.throwException(b, UnsupportedOperationException.class, message);
+            }
+        }
+
+        // Add required protected doTryLoad method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PROTECTED,
+                 MasterStorableGenerator.DO_TRY_LOAD_MASTER_METHOD_NAME, TypeDesc.BOOLEAN, null);
+            mi.addException(TypeDesc.forClass(FetchException.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            LocalVariable repoVar = getJDBCRepository(b);
+            Label tryBeforeCon = b.createLabel().setLocation();
+            LocalVariable conVar = getConnection(b, repoVar);
+            Label tryAfterCon = b.createLabel().setLocation();
+
+            b.loadThis();
+            b.loadLocal(repoVar);
+            b.loadLocal(conVar);
+            b.loadNull(); // No Lobs to update
+            b.invokeVirtual(MasterStorableGenerator.DO_TRY_LOAD_MASTER_METHOD_NAME,
+                            TypeDesc.BOOLEAN,
+                            new TypeDesc[] {jdbcRepoType, connectionType, lobArrayType});
+            LocalVariable resultVar = b.createLocalVariable("result", TypeDesc.BOOLEAN);
+            b.storeLocal(resultVar);
+
+            yieldConAndHandleException(b, repoVar, tryBeforeCon, conVar, tryAfterCon, false);
+
+            b.loadLocal(resultVar);
+            b.returnValue(TypeDesc.BOOLEAN);
+        }
+
+        // Now define doTryLoad(JDBCRepositry, Connection, Lob[]). The Lob array argument
+        // is optional, and it indicates which (large) Lobs should be updated upon load.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PROTECTED,
+                 MasterStorableGenerator.DO_TRY_LOAD_MASTER_METHOD_NAME, TypeDesc.BOOLEAN,
+                 new TypeDesc[] {jdbcRepoType, connectionType, lobArrayType});
+            mi.addException(TypeDesc.forClass(Exception.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            StringBuilder selectBuilder = null;
+            for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+                // Along with unsupported properties and joins, primary keys are not loaded.
+                // This is because they are included in the where clause.
+                if (!property.isSelectable() || property.isPrimaryKeyMember()) {
+                    continue;
+                }
+                if (selectBuilder == null) {
+                    selectBuilder = new StringBuilder();
+                    selectBuilder.append("SELECT ");
+                } else {
+                    selectBuilder.append(',');
+                }
+                selectBuilder.append(property.getColumnName());
+            }
+
+            if (selectBuilder == null) {
+                // All properties are pks. A select still needs to be
+                // performed, but just discard the results. The select needs to
+                // be performed in order to verify that a record exists, since
+                // we need to return true or false.
+                selectBuilder = new StringBuilder();
+                selectBuilder.append("SELECT ");
+                selectBuilder.append
+                    (mInfo.getPrimaryKeyProperties().values().iterator().next().getColumnName());
+            }
+
+            selectBuilder.append(" FROM ");
+            selectBuilder.append(mInfo.getQualifiedTableName());
+
+            LocalVariable psVar = b.createLocalVariable("ps", preparedStatementType);
+
+            Label tryAfterPs = buildWhereClauseAndPreparedStatement
+                (b, selectBuilder, b.getParameter(1), psVar, b.getParameter(0), null);
+
+            b.loadLocal(psVar);
+            b.invokeInterface(preparedStatementType, "executeQuery", resultSetType, null);
+            LocalVariable rsVar = b.createLocalVariable("rs", resultSetType);
+            b.storeLocal(rsVar);
+            Label tryAfterRs = b.createLabel().setLocation();
+
+            // If no results, then return false. Otherwise, there must be
+            // exactly one result.
+
+            LocalVariable resultVar = b.createLocalVariable("result", TypeDesc.BOOLEAN);
+            b.loadLocal(rsVar);
+            b.invokeInterface(resultSetType, "next", TypeDesc.BOOLEAN, null);
+            b.storeLocal(resultVar);
+            b.loadLocal(resultVar);
+            Label noResults = b.createLabel();
+            b.ifZeroComparisonBranch(noResults, "==");
+
+            b.loadThis();
+            b.loadLocal(rsVar);
+            b.loadConstant(1);
+            b.loadLocal(b.getParameter(2)); // Pass Lobs to update
+            b.invokePrivate(EXTRACT_DATA_METHOD_NAME, null,
+                            new TypeDesc[] {resultSetType, TypeDesc.INT, lobArrayType});
+
+            noResults.setLocation();
+
+            closeResultSet(b, rsVar, tryAfterRs);
+            closeStatement(b, psVar, tryAfterPs);
+
+            b.loadLocal(resultVar);
+            b.returnValue(TypeDesc.BOOLEAN);
+        }
+
+        // Unlike the other methods, doTryInsert is allowed to throw an
+        // SQLException. Override insert and tryInsert to catch SQLException.
+        // The tryInsert method must also decide if it is a unique constraint
+        // exception and returns false instead. This design allows the original
+        // SQLException to be passed with the UniqueConstraintException,
+        // providing more context.
+
+        // Override insert method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PUBLIC, INSERT_METHOD_NAME, null, null);
+            mi.addException(TypeDesc.forClass(PersistException.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            Label tryStart = b.createLabel().setLocation();
+            b.loadThis();
+            b.invokeSuper(mClassFile.getSuperClassName(), INSERT_METHOD_NAME, null, null);
+            Label tryEnd = b.createLabel().setLocation();
+            b.returnVoid();
+
+            b.exceptionHandler(tryStart, tryEnd, Exception.class.getName());
+            pushJDBCRepository(b);
+            // Swap exception object and JDBCRepository instance.
+            b.swap();
+            TypeDesc[] params = {TypeDesc.forClass(Throwable.class)};
+            b.invokeVirtual(jdbcRepoType, "toPersistException",
+                            TypeDesc.forClass(PersistException.class), params);
+            b.throwObject();
+        }
+
+        // Override tryInsert method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PUBLIC, TRY_INSERT_METHOD_NAME, TypeDesc.BOOLEAN, null);
+            mi.addException(TypeDesc.forClass(PersistException.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            Label tryStart = b.createLabel().setLocation();
+            b.loadThis();
+            b.invokeSuper(mClassFile.getSuperClassName(),
+                          TRY_INSERT_METHOD_NAME, TypeDesc.BOOLEAN, null);
+            Label innerTryEnd = b.createLabel().setLocation();
+            b.returnValue(TypeDesc.BOOLEAN);
+
+            b.exceptionHandler(tryStart, innerTryEnd, SQLException.class.getName());
+            b.dup(); // dup the SQLException
+            pushJDBCRepository(b);
+            b.swap(); // swap the dup'ed SQLException to pass to method
+            b.invokeVirtual(jdbcRepoType, "isUniqueConstraintError",
+                            TypeDesc.BOOLEAN,
+                            new TypeDesc[] {TypeDesc.forClass(SQLException.class)});
+            Label notConstraint = b.createLabel();
+            b.ifZeroComparisonBranch(notConstraint, "==");
+            // Return false to indicate unique constraint violation.
+            b.loadConstant(false);
+            b.returnValue(TypeDesc.BOOLEAN);
+
+            notConstraint.setLocation();
+            // Re-throw SQLException, since it is not a unique constraint violation.
+            b.throwObject();
+
+            Label outerTryEnd = b.createLabel().setLocation();
+
+            b.exceptionHandler(tryStart, outerTryEnd, Exception.class.getName());
+            pushJDBCRepository(b);
+            // Swap exception object and JDBCRepository instance.
+            b.swap();
+            TypeDesc[] params = {TypeDesc.forClass(Throwable.class)};
+            b.invokeVirtual(jdbcRepoType, "toPersistException",
+                            TypeDesc.forClass(PersistException.class), params);
+            b.throwObject();
+        }
+
+        // Add required protected doTryInsert method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PROTECTED,
+                 MasterStorableGenerator.DO_TRY_INSERT_MASTER_METHOD_NAME, TypeDesc.BOOLEAN, null);
+            mi.addException(TypeDesc.forClass(PersistException.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            LocalVariable repoVar = getJDBCRepository(b);
+            LocalVariable conVar = getConnection(b, repoVar);
+            Label tryAfterCon = b.createLabel().setLocation();
+
+            // Push connection in preparation for preparing a statement.
+            b.loadLocal(conVar);
+
+            // Only insert version property if DIRTY. Create two insert
+            // statements, with and without the version property.
+            StringBuilder sb = new StringBuilder();
+            createInsertStatement(sb, false);
+            String noVersion = sb.toString();
+
+            sb.setLength(0);
+            int versionPropNumber = createInsertStatement(sb, true);
+
+            LocalVariable includeVersion = null;
+
+            if (versionPropNumber < 0) {
+                // No version property at all, so no need to determine which
+                // statement to execute.
+                b.loadConstant(noVersion);
+            } else {
+                includeVersion = b.createLocalVariable(null, TypeDesc.BOOLEAN);
+
+                Label isDirty = b.createLabel();
+                branchIfDirty(b, versionPropNumber, isDirty, true);
+
+                // Version not dirty, so don't insert it. Assume database
+                // creates an initial version instead.
+                b.loadConstant(false);
+                b.storeLocal(includeVersion);
+                b.loadConstant(noVersion);
+                Label cont = b.createLabel();
+                b.branch(cont);
+
+                isDirty.setLocation();
+                // Including version property in statement.
+                b.loadConstant(true);
+                b.storeLocal(includeVersion);
+                b.loadConstant(sb.toString());
+
+                cont.setLocation();
+            }
+
+            // At this point, the stack contains a connection and a SQL
+            // statement String.
+
+            LocalVariable psVar = b.createLocalVariable("ps", preparedStatementType);
+            b.invokeInterface(connectionType, "prepareStatement", preparedStatementType,
+                              new TypeDesc[] {TypeDesc.STRING});
+            b.storeLocal(psVar);
+
+            Label tryAfterPs = b.createLabel().setLocation();
+
+            // Now fill in parameters with property values.
+
+            JDBCStorableProperty<S> versionProperty = null;
+
+            // Gather all Lob properties to track if a post-insert update is required.
+            Map<JDBCStorableProperty<S>, Integer> lobIndexMap = findLobs();
+            LocalVariable lobArrayVar = null;
+            if (lobIndexMap.size() != 0) {
+                // Create array to track which Lobs are too large and need extra work.
+                lobArrayVar = b.createLocalVariable(null, lobArrayType);
+                b.loadConstant(lobIndexMap.size());
+                b.newObject(lobArrayType);
+                b.storeLocal(lobArrayVar);
+            }
+
+            int ordinal = 0;
+            for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+                if (!property.isSelectable()) {
+                    continue;
+                }
+                if (property.isVersion()) {
+                    if (includeVersion != null) {
+                        // Fill in version later, but check against boolean
+                        // local variable to decide if it is was dirty.
+                        versionProperty = property;
+                    }
+                    continue;
+                }
+
+                b.loadLocal(psVar);
+                b.loadConstant(++ordinal);
+
+                setPreparedStatementValue
+                    (b, property, repoVar, null, lobArrayVar, lobIndexMap.get(property));
+            }
+
+            if (versionProperty != null) {
+                // Fill in version property now, but only if was dirty.
+                b.loadLocal(includeVersion);
+                Label skipVersion = b.createLabel();
+                b.ifZeroComparisonBranch(skipVersion, "==");
+
+                b.loadLocal(psVar);
+                b.loadConstant(++ordinal);
+                setPreparedStatementValue(b, versionProperty, repoVar, null, null, null);
+
+                skipVersion.setLocation();
+            }
+
+            // Execute the statement.
+            b.loadLocal(psVar);
+            b.invokeInterface(preparedStatementType, "executeUpdate", TypeDesc.INT, null);
+            b.pop();
+            closeStatement(b, psVar, tryAfterPs);
+
+            // Immediately reload object, to ensure that any database supplied
+            // default values are properly retrieved. Since INSERT_TXN is
+            // enabled, superclass ensures that transaction is still in
+            // progress at this point.
+
+            b.loadThis();
+            b.loadLocal(repoVar);
+            b.loadLocal(conVar);
+            if (lobArrayVar == null) {
+                b.loadNull();
+            } else {
+                b.loadLocal(lobArrayVar);
+            }
+            b.invokeVirtual(MasterStorableGenerator.DO_TRY_LOAD_MASTER_METHOD_NAME,
+                            TypeDesc.BOOLEAN,
+                            new TypeDesc[] {jdbcRepoType, connectionType, lobArrayType});
+            b.pop();
+
+            // Note: yieldConAndHandleException is not called, allowing any
+            // SQLException to be thrown. The insert or tryInsert methods must handle it.
+            yieldCon(b, repoVar, conVar, tryAfterCon);
+
+            b.loadConstant(true);
+            b.returnValue(TypeDesc.BOOLEAN);
+        }
+
+        // Add required protected doTryUpdate method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PROTECTED,
+                 MasterStorableGenerator.DO_TRY_UPDATE_MASTER_METHOD_NAME,
+                 TypeDesc.BOOLEAN, null);
+            mi.addException(TypeDesc.forClass(PersistException.class));
+
+            CodeBuilder b = new CodeBuilder(mi);
+
+            // Only update properties with state DIRTY. Therefore, update
+            // statement is always dynamic.
+
+            LocalVariable repoVar = getJDBCRepository(b);
+            Label tryBeforeCon = b.createLabel().setLocation();
+            LocalVariable conVar = getConnection(b, repoVar);
+            Label tryAfterCon = b.createLabel().setLocation();
+
+            // Load connection in preparation for creating statement.
+            b.loadLocal(conVar);
+
+            TypeDesc stringBuilderType = TypeDesc.forClass(StringBuilder.class);
+            b.newObject(stringBuilderType);
+            b.dup();
+            b.loadConstant(INITIAL_UPDATE_BUFFER_SIZE);
+            b.invokeConstructor(stringBuilderType, new TypeDesc[] {TypeDesc.INT});
+
+            // Methods on StringBuilder.
+            final Method appendStringMethod;
+            final Method appendCharMethod;
+            final Method toStringMethod;
+            try {
+                appendStringMethod = StringBuilder.class.getMethod("append", String.class);
+                appendCharMethod = StringBuilder.class.getMethod("append", char.class);
+                toStringMethod = StringBuilder.class.getMethod("toString", (Class[]) null);
+            } catch (NoSuchMethodException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+
+            {
+                StringBuilder sqlBuilder = new StringBuilder();
+                sqlBuilder.append("UPDATE ");
+                sqlBuilder.append(mInfo.getQualifiedTableName());
+                sqlBuilder.append(" SET ");
+
+                b.loadConstant(sqlBuilder.toString());
+                b.invoke(appendStringMethod); // method leaves StringBuilder on stack
+            }
+
+            // Iterate over the properties, appending a set parameter for each
+            // that is dirty.
+
+            LocalVariable countVar = b.createLocalVariable("count", TypeDesc.INT);
+            b.loadConstant(0);
+            b.storeLocal(countVar);
+
+            int propNumber = -1;
+            for (JDBCStorableProperty property : mAllProperties.values()) {
+                propNumber++;
+
+                if (property.isSelectable() && !property.isPrimaryKeyMember()) {
+                    if (property.isVersion()) {
+                        // TODO: Support option where version property is
+                        // updated on the Carbonado side rather than relying on
+                        // SQL trigger.
+                        continue;
+                    }
+
+                    Label isNotDirty = b.createLabel();
+                    branchIfDirty(b, propNumber, isNotDirty, false);
+
+                    b.loadLocal(countVar);
+                    Label isZero = b.createLabel();
+                    b.ifZeroComparisonBranch(isZero, "==");
+                    b.loadConstant(',');
+                    b.invoke(appendCharMethod);
+
+                    isZero.setLocation();
+                    b.loadConstant(property.getColumnName());
+                    b.invoke(appendStringMethod);
+                    b.loadConstant("=?");
+                    b.invoke(appendStringMethod);
+
+                    b.integerIncrement(countVar, 1);
+
+                    isNotDirty.setLocation();
+                }
+            }
+
+            Collection<JDBCStorableProperty<S>> whereProperties =
+                mInfo.getPrimaryKeyProperties().values();
+
+            JDBCStorableProperty<S> versionProperty = mInfo.getVersionProperty();
+            if (versionProperty != null && versionProperty.isSupported()) {
+                // Include version property in WHERE clause to support optimistic locking.
+                List<JDBCStorableProperty<S>> list =
+                    new ArrayList<JDBCStorableProperty<S>>(whereProperties);
+                list.add(versionProperty);
+                whereProperties = list;
+            }
+
+            // If no dirty properties, a valid update statement must still be
+            // created. Just update the first "where" property to itself.
+            {
+                b.loadLocal(countVar);
+                Label notZero = b.createLabel();
+                b.ifZeroComparisonBranch(notZero, "!=");
+
+                b.loadConstant(whereProperties.iterator().next().getColumnName());
+                b.invoke(appendStringMethod);
+                b.loadConstant("=?");
+                b.invoke(appendStringMethod);
+
+                notZero.setLocation();
+            }
+
+            b.loadConstant(" WHERE ");
+            b.invoke(appendStringMethod);
+
+            int ordinal = 0;
+            for (JDBCStorableProperty<S> property : whereProperties) {
+                if (ordinal > 0) {
+                    b.loadConstant(" AND ");
+                    b.invoke(appendStringMethod);
+                }
+                b.loadConstant(property.getColumnName());
+                b.invoke(appendStringMethod);
+                if (property.isNullable()) {
+                    // FIXME
+                    throw new UnsupportedOperationException();
+                } else {
+                    b.loadConstant("=?");
+                    b.invoke(appendStringMethod);
+                }
+                ordinal++;
+            }
+
+            // Convert StringBuilder value to a String.
+            b.invoke(toStringMethod);
+
+            // At this point, the stack contains a connection and a SQL
+            // statement String.
+
+            LocalVariable psVar = b.createLocalVariable("ps", preparedStatementType);
+            b.invokeInterface(connectionType, "prepareStatement", preparedStatementType,
+                              new TypeDesc[] {TypeDesc.STRING});
+            b.storeLocal(psVar);
+            Label tryAfterPs = b.createLabel().setLocation();
+
+            // Walk through dirty properties again, setting values on statement.
+
+            LocalVariable indexVar = b.createLocalVariable("index", TypeDesc.INT);
+            // First prepared statement index is always one, so says JDBC.
+            b.loadConstant(1);
+            b.storeLocal(indexVar);
+
+            // Gather all Lob properties to track if a post-update update is required.
+            Map<JDBCStorableProperty<S>, Integer> lobIndexMap = findLobs();
+            LocalVariable lobArrayVar = null;
+            if (lobIndexMap.size() != 0) {
+                // Create array to track which Lobs are too large and need extra work.
+                lobArrayVar = b.createLocalVariable(null, lobArrayType);
+                b.loadConstant(lobIndexMap.size());
+                b.newObject(lobArrayType);
+                b.storeLocal(lobArrayVar);
+            }
+
+            // If no dirty properties, fill in extra property from before.
+            {
+                b.loadLocal(countVar);
+                Label notZero = b.createLabel();
+                b.ifZeroComparisonBranch(notZero, "!=");
+
+                JDBCStorableProperty property = whereProperties.iterator().next();
+
+                b.loadLocal(psVar);
+                b.loadLocal(indexVar);
+                setPreparedStatementValue
+                    (b, property, repoVar, null, lobArrayVar, lobIndexMap.get(property));
+
+                b.integerIncrement(indexVar, 1);
+
+                notZero.setLocation();
+            }
+
+            propNumber = -1;
+            for (JDBCStorableProperty property : mAllProperties.values()) {
+                propNumber++;
+
+                if (property.isSelectable() && !property.isPrimaryKeyMember()) {
+                    if (property.isVersion()) {
+                        // TODO: Support option where version property is
+                        // updated on the Carbonado side rather than relying on
+                        // SQL trigger. Just add one to the value.
+                        continue;
+                    }
+
+                    Label isNotDirty = b.createLabel();
+                    branchIfDirty(b, propNumber, isNotDirty, false);
+
+                    b.loadLocal(psVar);
+                    b.loadLocal(indexVar);
+                    setPreparedStatementValue
+                        (b, property, repoVar, null, lobArrayVar, lobIndexMap.get(property));
+
+                    b.integerIncrement(indexVar, 1);
+
+                    isNotDirty.setLocation();
+                }
+            }
+
+            // Walk through where clause properties again, setting values on
+            // statement.
+
+            for (JDBCStorableProperty<S> property : whereProperties) {
+                if (property.isNullable()) {
+                    // FIXME
+                    throw new UnsupportedOperationException();
+                } else {
+                    b.loadLocal(psVar);
+                    b.loadLocal(indexVar);
+                    setPreparedStatementValue(b, property, repoVar, null, null, null);
+                }
+
+                b.integerIncrement(indexVar, 1);
+            }
+
+            // Execute the update statement.
+
+            b.loadLocal(psVar);
+            LocalVariable updateCount = b.createLocalVariable("updateCount", TypeDesc.INT);
+            b.invokeInterface(preparedStatementType, "executeUpdate", TypeDesc.INT, null);
+            b.storeLocal(updateCount);
+
+            closeStatement(b, psVar, tryAfterPs);
+
+            Label doReload = b.createLabel();
+            Label skipReload = b.createLabel();
+
+            if (versionProperty == null) {
+                b.loadLocal(updateCount);
+                b.ifZeroComparisonBranch(skipReload, "==");
+            } else {
+                // If update count is zero, either the record was deleted or
+                // the version doesn't match. To distinguish these two cases,
+                // select record version. If not found, return
+                // false. Otherwise, throw OptimisticLockException.
+
+                b.loadLocal(updateCount);
+                b.ifZeroComparisonBranch(doReload, "!=");
+
+                StringBuilder selectBuilder = new StringBuilder();
+                selectBuilder.append("SELECT ");
+                selectBuilder.append(versionProperty.getColumnName());
+                selectBuilder.append(" FROM ");
+                selectBuilder.append(mInfo.getQualifiedTableName());
+
+                LocalVariable countPsVar = b.createLocalVariable("ps", preparedStatementType);
+
+                Label tryAfterCountPs = buildWhereClauseAndPreparedStatement
+                    (b, selectBuilder, conVar, countPsVar, null, null);
+
+                b.loadLocal(countPsVar);
+                b.invokeInterface(preparedStatementType, "executeQuery", resultSetType, null);
+                LocalVariable rsVar = b.createLocalVariable("rs", resultSetType);
+                b.storeLocal(rsVar);
+                Label tryAfterRs = b.createLabel().setLocation();
+
+                b.loadLocal(rsVar);
+                b.invokeInterface(resultSetType, "next", TypeDesc.BOOLEAN, null);
+                // Record missing, return false.
+                b.ifZeroComparisonBranch(skipReload, "==");
+
+                b.loadLocal(rsVar);
+                b.loadConstant(1); // column 1
+                b.invokeInterface(resultSetType, "getLong",
+                                  TypeDesc.LONG, new TypeDesc[] {TypeDesc.INT});
+                LocalVariable actualVersion = b.createLocalVariable(null, TypeDesc.LONG);
+                b.storeLocal(actualVersion);
+
+                closeResultSet(b, rsVar, tryAfterRs);
+                closeStatement(b, countPsVar, tryAfterCountPs);
+
+                // Throw exception.
+                {
+                    TypeDesc desc = TypeDesc.forClass(OptimisticLockException.class);
+                    b.newObject(desc);
+                    b.dup();
+                    b.loadThis();
+                    // Pass expected version number for exception message.
+                    TypeDesc propertyType = TypeDesc.forClass(versionProperty.getType());
+                    b.loadField(versionProperty.getName(), propertyType);
+                    b.convert(propertyType, TypeDesc.LONG.toObjectType());
+                    b.loadLocal(actualVersion);
+                    b.convert(TypeDesc.LONG, TypeDesc.LONG.toObjectType());
+                    b.invokeConstructor(desc, new TypeDesc[] {TypeDesc.OBJECT, TypeDesc.OBJECT});
+                    b.throwObject();
+                }
+            }
+
+            // Immediately reload object, to ensure that any database supplied
+            // default values are properly retrieved. Since UPDATE_TXN is
+            // enabled, superclass ensures that transaction is still in
+            // progress at this point.
+
+            doReload.setLocation();
+            b.loadThis();
+            b.loadLocal(repoVar);
+            b.loadLocal(conVar);
+            if (lobArrayVar == null) {
+                b.loadNull();
+            } else {
+                b.loadLocal(lobArrayVar);
+            }
+            b.invokeVirtual(MasterStorableGenerator.DO_TRY_LOAD_MASTER_METHOD_NAME,
+                            TypeDesc.BOOLEAN,
+                            new TypeDesc[] {jdbcRepoType, connectionType, lobArrayType});
+            // Even though a boolean is returned, the actual value for true and
+            // false is an int, 1 or 0.
+            b.storeLocal(updateCount);
+
+            skipReload.setLocation();
+
+            yieldConAndHandleException(b, repoVar, tryBeforeCon, conVar, tryAfterCon, true);
+
+            b.loadLocal(updateCount);
+            b.returnValue(TypeDesc.BOOLEAN);
+        }
+
+        // Add required protected doTryDelete method.
+        {
+            MethodInfo mi = mClassFile.addMethod
+                (Modifiers.PROTECTED,
+                 MasterStorableGenerator.DO_TRY_DELETE_MASTER_METHOD_NAME, TypeDesc.BOOLEAN, null);
+            mi.addException(TypeDesc.forClass(PersistException.class));
+            CodeBuilder b = new CodeBuilder(mi);
+
+            StringBuilder deleteBuilder = new StringBuilder();
+            deleteBuilder.append("DELETE FROM ");
+            deleteBuilder.append(mInfo.getQualifiedTableName());
+
+            LocalVariable repoVar = getJDBCRepository(b);
+            Label tryBeforeCon = b.createLabel().setLocation();
+            LocalVariable conVar = getConnection(b, repoVar);
+            Label tryAfterCon = b.createLabel().setLocation();
+
+            LocalVariable psVar = b.createLocalVariable("ps", preparedStatementType);
+
+            Label tryAfterPs = buildWhereClauseAndPreparedStatement
+                (b, deleteBuilder, conVar, psVar, null, null);
+
+            b.loadLocal(psVar);
+            b.invokeInterface(preparedStatementType, "executeUpdate", TypeDesc.INT, null);
+
+            // Return false if count is zero, true otherwise. Just return the
+            // int as if it were boolean.
+
+            LocalVariable resultVar = b.createLocalVariable("result", TypeDesc.INT);
+            b.storeLocal(resultVar);
+
+            closeStatement(b, psVar, tryAfterPs);
+            yieldConAndHandleException(b, repoVar, tryBeforeCon, conVar, tryAfterCon, true);
+
+            b.loadLocal(resultVar);
+            b.returnValue(TypeDesc.BOOLEAN);
+        }
+
+        Class<? extends S> generatedClass = mClassInjector.defineClass(mClassFile);
+
+        // Touch lobLoaderMap to ensure reference to these classes are kept
+        // until after storable class is generated. Otherwise, these classes
+        // might get unloaded.
+        lobLoaderMap.size();
+
+        return generatedClass;
+    }
+
+    /**
+     * Finds all Lob properties and maps them to a zero-based index. This
+     * information is used to update large Lobs after an insert or update.
+     */
+    private Map<JDBCStorableProperty<S>, Integer>findLobs() {
+        Map<JDBCStorableProperty<S>, Integer> lobIndexMap =
+            new IdentityHashMap<JDBCStorableProperty<S>, Integer>();
+
+        int lobIndex = 0;
+
+        for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+            if (!property.isSelectable() || property.isVersion()) {
+                continue;
+            }
+
+            Class psClass = property.getPreparedStatementSetMethod().getParameterTypes()[1];
+
+            if (Lob.class.isAssignableFrom(property.getType()) ||
+                java.sql.Blob.class.isAssignableFrom(psClass) ||
+                java.sql.Clob.class.isAssignableFrom(psClass)) {
+
+                lobIndexMap.put(property, lobIndex++);
+            }
+        }
+
+        return lobIndexMap;
+    }
+
+    /**
+     * Generates code to get the JDBCRepository instance and store it in a
+     * local variable.
+     */
+    private LocalVariable getJDBCRepository(CodeBuilder b) {
+        pushJDBCRepository(b);
+        LocalVariable repoVar =
+            b.createLocalVariable("repo", TypeDesc.forClass(JDBCRepository.class));
+        b.storeLocal(repoVar);
+        return repoVar;
+    }
+
+    /**
+     * Generates code to push the JDBCRepository instance on the stack.
+     */
+    private void pushJDBCRepository(CodeBuilder b) {
+        pushJDBCSupport(b);
+        b.invokeInterface(TypeDesc.forClass(JDBCSupport.class), "getJDBCRepository",
+                          TypeDesc.forClass(JDBCRepository.class), null);
+    }
+
+    /**
+     * Generates code to push the JDBCSupport instance on the stack.
+     */
+    private void pushJDBCSupport(CodeBuilder b) {
+        b.loadThis();
+        b.loadField(StorableGenerator.SUPPORT_FIELD_NAME, TypeDesc.forClass(TriggerSupport.class));
+        b.checkCast(TypeDesc.forClass(JDBCSupport.class));
+    }
+
+    /**
+     * Generates code to get connection from JDBCRepository and store it in a local variable.
+     *
+     * @param repoVar reference to JDBCRepository
+     */
+    private LocalVariable getConnection(CodeBuilder b, LocalVariable repoVar) {
+        b.loadLocal(repoVar);
+        b.invokeVirtual(TypeDesc.forClass(JDBCRepository.class),
+                        "getConnection", TypeDesc.forClass(Connection.class), null);
+        LocalVariable conVar = b.createLocalVariable("con", TypeDesc.forClass(Connection.class));
+        b.storeLocal(conVar);
+        return conVar;
+    }
+
+    /**
+     * Generates code which emulates this:
+     *
+     *     // May throw FetchException
+     *     JDBCRepository.yieldConnection(con);
+     *
+     * @param repoVar required reference to JDBCRepository
+     * @param conVar optional connection to yield
+     */
+    private void yieldConnection(CodeBuilder b, LocalVariable repoVar, LocalVariable conVar) {
+        if (conVar != null) {
+            b.loadLocal(repoVar);
+            b.loadLocal(conVar);
+            b.invokeVirtual(TypeDesc.forClass(JDBCRepository.class),
+                            "yieldConnection", null,
+                            new TypeDesc[] {TypeDesc.forClass(Connection.class)});
+        }
+    }
+
+    /**
+     * Generates code that finishes the given SQL statement by appending a
+     * WHERE clause. Prepared statement is then created and all parameters are
+     * filled in.
+     *
+     * @param sqlBuilder contains SQL statement right before the WHERE clause
+     * @param conVar local variable referencing connection
+     * @param psVar declared local variable which will receive PreparedStatement
+     * @param jdbcRepoVar when non-null, check transaction if SELECT should be FOR UPDATE
+     * @param instanceVar when null, assume properties are contained in
+     * "this". Otherwise, invoke property access methods on storable referenced
+     * in var.
+     * @return label right after prepared statement was created, which is to be
+     * used as the start of a try block that ensures the prepared statement is
+     * closed.
+     */
+    private Label buildWhereClauseAndPreparedStatement
+        (CodeBuilder b,
+         StringBuilder sqlBuilder,
+         LocalVariable conVar,
+         LocalVariable psVar,
+         LocalVariable jdbcRepoVar,
+         LocalVariable instanceVar)
+    {
+        final TypeDesc superType = TypeDesc.forClass(mClassFile.getSuperClassName());
+        final Iterable<? extends JDBCStorableProperty<?>> properties =
+            mInfo.getPrimaryKeyProperties().values();
+
+        sqlBuilder.append(" WHERE ");
+
+        List<JDBCStorableProperty> nullableProperties = new ArrayList<JDBCStorableProperty>();
+        int ordinal = 0;
+        for (JDBCStorableProperty property : properties) {
+            if (!property.isSelectable()) {
+                continue;
+            }
+            if (property.isNullable()) {
+                // Nullable properties need to alter the SQL where clause
+                // syntax at runtime, taking the forms "=?" or "IS NULL".
+                nullableProperties.add(property);
+                continue;
+            }
+            if (ordinal > 0) {
+                sqlBuilder.append(" AND ");
+            }
+            sqlBuilder.append(property.getColumnName());
+            sqlBuilder.append("=?");
+            ordinal++;
+        }
+
+        // Push connection in preparation for preparing a statement.
+        b.loadLocal(conVar);
+
+        if (nullableProperties.size() == 0) {
+            b.loadConstant(sqlBuilder.toString());
+
+            // Determine at runtime if SELECT should be " FOR UPDATE".
+            if (jdbcRepoVar != null) {
+                b.loadLocal(jdbcRepoVar);
+                b.invokeVirtual
+                    (jdbcRepoVar.getType(), "isTransactionForUpdate", TypeDesc.BOOLEAN, null);
+                Label notForUpdate = b.createLabel();
+                b.ifZeroComparisonBranch(notForUpdate, "==");
+
+                b.loadConstant(" FOR UPDATE");
+                b.invokeVirtual(TypeDesc.STRING, "concat",
+                                TypeDesc.STRING, new TypeDesc[] {TypeDesc.STRING});
+
+                notForUpdate.setLocation();
+            }
+        } else {
+            // Finish select statement at runtime, since we don't know if the
+            // properties are null or not.
+            if (ordinal > 0) {
+                sqlBuilder.append(" AND ");
+            }
+
+            // Make runtime buffer capacity large enough to hold all "IS NULL" phrases.
+            int capacity = sqlBuilder.length() + 7 * nullableProperties.size();
+            if (nullableProperties.size() > 1) {
+                // Account for all the appended " AND " phrases.
+                capacity += 5 * (nullableProperties.size() - 1);
+            }
+            for (JDBCStorableProperty property : nullableProperties) {
+                // Account for property names.
+                capacity += property.getColumnName().length();
+            }
+
+            TypeDesc stringBuilderType = TypeDesc.forClass(StringBuilder.class);
+            b.newObject(stringBuilderType);
+            b.dup();
+            b.loadConstant(capacity);
+            b.invokeConstructor(stringBuilderType, new TypeDesc[] {TypeDesc.INT});
+
+            // Methods on StringBuilder.
+            final Method appendStringMethod;
+            final Method toStringMethod;
+            try {
+                appendStringMethod = StringBuilder.class.getMethod("append", String.class);
+                toStringMethod = StringBuilder.class.getMethod("toString", (Class[]) null);
+            } catch (NoSuchMethodException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+
+            b.loadConstant(sqlBuilder.toString());
+            b.invoke(appendStringMethod); // method leaves StringBuilder on stack
+
+            ordinal = 0;
+            for (JDBCStorableProperty property : nullableProperties) {
+                if (ordinal > 0) {
+                    b.loadConstant(" AND ");
+                    b.invoke(appendStringMethod);
+                }
+
+                b.loadConstant(property.getColumnName());
+                b.invoke(appendStringMethod);
+
+                b.loadThis();
+
+                final TypeDesc propertyType = TypeDesc.forClass(property.getType());
+                b.loadField(superType, property.getName(), propertyType);
+
+                Label notNull = b.createLabel();
+                b.ifNullBranch(notNull, false);
+                b.loadConstant("IS NULL");
+                b.invoke(appendStringMethod);
+                Label next = b.createLabel();
+                b.branch(next);
+
+                notNull.setLocation();
+                b.loadConstant("=?");
+                b.invoke(appendStringMethod);
+
+                next.setLocation();
+                ordinal++;
+            }
+
+            // Determine at runtime if SELECT should be " FOR UPDATE".
+            if (jdbcRepoVar != null) {
+                b.loadLocal(jdbcRepoVar);
+                b.invokeVirtual
+                    (jdbcRepoVar.getType(), "isTransactionForUpdate", TypeDesc.BOOLEAN, null);
+                Label notForUpdate = b.createLabel();
+                b.ifZeroComparisonBranch(notForUpdate, "==");
+
+                b.loadConstant(" FOR UPDATE");
+                b.invoke(appendStringMethod);
+
+                notForUpdate.setLocation();
+            }
+
+            // Convert StringBuilder to String.
+            b.invoke(toStringMethod);
+        }
+
+        // At this point, the stack contains a connection and a SQL statement String.
+
+        final TypeDesc connectionType = TypeDesc.forClass(Connection.class);
+        final TypeDesc preparedStatementType = TypeDesc.forClass(PreparedStatement.class);
+
+        b.invokeInterface(connectionType, "prepareStatement", preparedStatementType,
+                          new TypeDesc[] {TypeDesc.STRING});
+        b.storeLocal(psVar);
+        Label tryAfterPs = b.createLabel().setLocation();
+
+        // Now set where clause parameters.
+        ordinal = 0;
+        for (JDBCStorableProperty property : properties) {
+            if (!property.isSelectable()) {
+                continue;
+            }
+
+            Label skipProperty = b.createLabel();
+
+            final TypeDesc propertyType = TypeDesc.forClass(property.getType());
+
+            if (!property.isNullable()) {
+                b.loadLocal(psVar);
+                b.loadConstant(++ordinal);
+            } else {
+                // Nullable properties are dynamically added to where clause,
+                // and are at the end of the prepared statement. If value is
+                // null, then skip to the next property, since the statement
+                // was appended earlier with "IS NULL".
+                b.loadThis();
+                b.loadField(superType, property.getName(), propertyType);
+                b.ifNullBranch(skipProperty, true);
+            }
+
+            setPreparedStatementValue(b, property, null, instanceVar, null, null);
+
+            skipProperty.setLocation();
+        }
+
+        return tryAfterPs;
+    }
+
+    /**
+     * Generates code to call a PreparedStatement.setXxx(int, Xxx) method, with
+     * the value of the given property. Assumes that PreparedStatement and int
+     * index are on the stack.
+     *
+     * If the property is a Lob, then pass in the optional lobTooLargeVar to
+     * track if it was too large to insert/update. The type of lobTooLargeVar
+     * must be the carbonado lob type. At runtime, if the variable's value is
+     * not null, then lob was too large to insert. The value of the variable is
+     * the original lob. An update statement needs to be issued after the load
+     * to insert/update the large value.
+     *
+     * @param instanceVar when null, assume properties are contained in
+     * "this". Otherwise, invoke property access methods on storable referenced
+     * in var.
+     * @param lobArrayVar optional, used for lob properties
+     * @param lobIndex optional, used for lob properties
+     */
+    private void setPreparedStatementValue
+        (CodeBuilder b, JDBCStorableProperty<?> property, LocalVariable repoVar,
+         LocalVariable instanceVar,
+         LocalVariable lobArrayVar, Integer lobIndex)
+    {
+        if (instanceVar == null) {
+            b.loadThis();
+        } else {
+            b.loadLocal(instanceVar);
+        }
+
+        Class psClass = property.getPreparedStatementSetMethod().getParameterTypes()[1];
+        TypeDesc psType = TypeDesc.forClass(psClass);
+        TypeDesc propertyType = TypeDesc.forClass(property.getType());
+
+        StorablePropertyAdapter adapter = property.getAppliedAdapter();
+        TypeDesc fromType;
+        if (adapter == null) {
+            // Get protected field directly, since no adapter.
+            if (instanceVar == null) {
+                b.loadField(property.getName(), propertyType);
+            } else {
+                b.loadField(instanceVar.getType(), property.getName(), propertyType);
+            }
+            fromType = propertyType;
+        } else {
+            Class toClass = psClass;
+            if (java.sql.Blob.class.isAssignableFrom(toClass)) {
+                toClass = com.amazon.carbonado.lob.Blob.class;
+            } else if (java.sql.Clob.class.isAssignableFrom(toClass)) {
+                toClass = com.amazon.carbonado.lob.Clob.class;
+            }
+            Method adaptMethod = adapter.findAdaptMethod(property.getType(), toClass);
+            TypeDesc adaptType = TypeDesc.forClass(adaptMethod.getReturnType());
+            // Invoke special inherited protected method that gets the field
+            // and invokes the adapter. Method was generated by
+            // StorableGenerator.
+            String methodName = property.getReadMethodName() + '$';
+            if (instanceVar == null) {
+                b.invokeVirtual(methodName, adaptType, null);
+            } else {
+                b.invokeVirtual (instanceVar.getType(), methodName, adaptType, null);
+            }
+            fromType = adaptType;
+        }
+
+        Label done = b.createLabel();
+
+        if (!fromType.isPrimitive()) {
+            // Handle case where property value is null.
+            b.dup();
+            Label notNull = b.createLabel();
+            b.ifNullBranch(notNull, false);
+            // Invoke setNull method instead.
+            b.pop(); // discard duplicate null.
+            b.loadConstant(property.getDataType());
+            b.invokeInterface(TypeDesc.forClass(PreparedStatement.class), "setNull",
+                              null, new TypeDesc[] {TypeDesc.INT, TypeDesc.INT});
+            b.branch(done);
+            notNull.setLocation();
+        }
+
+        if (Lob.class.isAssignableFrom(fromType.toClass())) {
+            // Run special conversion.
+
+            LocalVariable lobVar = b.createLocalVariable(null, fromType);
+            b.storeLocal(lobVar);
+            LocalVariable columnVar = b.createLocalVariable(null, TypeDesc.INT);
+            b.storeLocal(columnVar);
+            LocalVariable psVar = b.createLocalVariable
+                ("ps", TypeDesc.forClass(PreparedStatement.class));
+            b.storeLocal(psVar);
+
+            if (lobArrayVar != null && lobIndex != null) {
+                // Prepare for update result. If too large, then array entry is not null.
+                b.loadLocal(lobArrayVar);
+                b.loadConstant(lobIndex);
+            }
+
+            pushJDBCSupport(b);
+            b.loadLocal(psVar);
+            b.loadLocal(columnVar);
+            b.loadLocal(lobVar);
+
+            // Stack looks like this: JDBCSupport, PreparedStatement, int (column), Lob
+
+            Method setValueMethod;
+            try {
+                String name = fromType.toClass().getName();
+                name = "set" + name.substring(name.lastIndexOf('.') + 1) + "Value";
+                setValueMethod = JDBCSupport.class.getMethod
+                    (name, PreparedStatement.class, int.class, fromType.toClass());
+            } catch (NoSuchMethodException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+
+            b.invoke(setValueMethod);
+
+            if (lobArrayVar == null || lobIndex == null) {
+                b.pop();
+            } else {
+                b.storeToArray(TypeDesc.OBJECT);
+            }
+        } else {
+            b.convert(fromType, psType);
+            b.invoke(property.getPreparedStatementSetMethod());
+        }
+
+        done.setLocation();
+    }
+
+    /**
+     * Generates code which emulates this:
+     *
+     * ...
+     * } finally {
+     *     JDBCRepository.yieldConnection(con);
+     * }
+     *
+     * @param repoVar required reference to JDBCRepository
+     * @param conVar optional connection variable
+     * @param tryAfterCon label right after connection acquisition
+     */
+    private void yieldCon
+        (CodeBuilder b,
+         LocalVariable repoVar,
+         LocalVariable conVar,
+         Label tryAfterCon)
+    {
+        Label endFinallyLabel = b.createLabel().setLocation();
+        Label contLabel = b.createLabel();
+
+        yieldConnection(b, repoVar, conVar);
+        b.branch(contLabel);
+
+        b.exceptionHandler(tryAfterCon, endFinallyLabel, null);
+        yieldConnection(b, repoVar, conVar);
+        b.throwObject();
+
+        contLabel.setLocation();
+    }
+
+    /**
+     * Generates code which emulates this:
+     *
+     * ...
+     *     } finally {
+     *         JDBCRepository.yieldConnection(con);
+     *     }
+     * } catch (Exception e) {
+     *     throw JDBCRepository.toFetchException(e);
+     * }
+     *
+     * @param repoVar required reference to JDBCRepository
+     * @param txnVar optional transaction variable to commit/exit
+     * @param tryBeforeCon label right before connection acquisition
+     * @param conVar optional connection variable
+     * @param tryAfterCon label right after connection acquisition
+     */
+    private void yieldConAndHandleException
+        (CodeBuilder b,
+         LocalVariable repoVar,
+         Label tryBeforeCon, LocalVariable conVar, Label tryAfterCon,
+         boolean forPersist)
+    {
+        Label endFinallyLabel = b.createLabel().setLocation();
+        Label contLabel = b.createLabel();
+
+        yieldConnection(b, repoVar, conVar);
+        b.branch(contLabel);
+
+        b.exceptionHandler(tryAfterCon, endFinallyLabel, null);
+        yieldConnection(b, repoVar, conVar);
+        b.throwObject();
+
+        b.exceptionHandler
+            (tryBeforeCon, b.createLabel().setLocation(), Exception.class.getName());
+        b.loadLocal(repoVar);
+        // Swap exception object and JDBCRepository instance.
+        b.swap();
+        TypeDesc[] params = {TypeDesc.forClass(Throwable.class)};
+        if (forPersist) {
+            b.invokeVirtual(TypeDesc.forClass(JDBCRepository.class), "toPersistException",
+                            TypeDesc.forClass(PersistException.class), params);
+        } else {
+            b.invokeVirtual(TypeDesc.forClass(JDBCRepository.class), "toFetchException",
+                            TypeDesc.forClass(FetchException.class), params);
+        }
+        b.throwObject();
+
+        contLabel.setLocation();
+    }
+
+    /**
+     * Generates code which emulates this:
+     *
+     * ...
+     * } finally {
+     *     statement.close();
+     * }
+     *
+     * @param statementVar Statement variable
+     * @param tryAfterStatement label right after Statement acquisition
+     */
+    private void closeStatement
+        (CodeBuilder b, LocalVariable statementVar, Label tryAfterStatement)
+    {
+        Label contLabel = b.createLabel();
+        Label endFinallyLabel = b.createLabel().setLocation();
+
+        b.loadLocal(statementVar);
+        b.invokeInterface(TypeDesc.forClass(Statement.class), "close", null, null);
+        b.branch(contLabel);
+
+        b.exceptionHandler(tryAfterStatement, endFinallyLabel, null);
+        b.loadLocal(statementVar);
+        b.invokeInterface(TypeDesc.forClass(Statement.class), "close", null, null);
+        b.throwObject();
+
+        contLabel.setLocation();
+    }
+
+    /**
+     * Generates code which emulates this:
+     *
+     * ...
+     * } finally {
+     *     rs.close();
+     * }
+     *
+     * @param rsVar ResultSet variable
+     * @param tryAfterRs label right after ResultSet acquisition
+     */
+    private void closeResultSet
+        (CodeBuilder b, LocalVariable rsVar, Label tryAfterRs)
+    {
+        Label contLabel = b.createLabel();
+        Label endFinallyLabel = b.createLabel().setLocation();
+
+        b.loadLocal(rsVar);
+        b.invokeInterface(TypeDesc.forClass(ResultSet.class), "close", null, null);
+        b.branch(contLabel);
+
+        b.exceptionHandler(tryAfterRs, endFinallyLabel, null);
+        b.loadLocal(rsVar);
+        b.invokeInterface(TypeDesc.forClass(ResultSet.class), "close", null, null);
+        b.throwObject();
+
+        contLabel.setLocation();
+    }
+
+    /**
+     * Generates code to branch if a property is dirty.
+     *
+     * @param propNumber property number from all properties map
+     * @param target branch target
+     * @param when true, branch if dirty; when false, branch when not dirty
+     */
+    private void branchIfDirty(CodeBuilder b, int propNumber,
+                               Label target, boolean branchIfDirty)
+    {
+        String stateFieldName = StorableGenerator.PROPERTY_STATE_FIELD_NAME + (propNumber >> 4);
+        b.loadThis();
+        b.loadField(stateFieldName, TypeDesc.INT);
+
+        int shift = (propNumber & 0xf) * 2;
+        b.loadConstant(StorableGenerator.PROPERTY_STATE_MASK << shift);
+        b.math(Opcode.IAND);
+        b.loadConstant(StorableGenerator.PROPERTY_STATE_DIRTY << shift);
+
+        b.ifComparisonBranch(target, branchIfDirty ? "==" : "!=");
+    }
+
+    private void defineExtractAllMethod(Map<JDBCStorableProperty<S>, Class<?>> lobLoaderMap) {
+        MethodInfo mi = mClassFile.addMethod
+            (Modifiers.PRIVATE, EXTRACT_ALL_METHOD_NAME, null,
+             new TypeDesc[] {TypeDesc.forClass(ResultSet.class), TypeDesc.INT});
+        CodeBuilder b = new CodeBuilder(mi);
+
+        defineExtract(b, b.getParameter(0), b.getParameter(1), null,
+                      mInfo.getPrimaryKeyProperties().values(), lobLoaderMap);
+
+        // Invoke extract data method to do the rest.
+        b.loadThis();
+        // Load the ResultSet var.
+        b.loadLocal(b.getParameter(0));
+        // The offset variable has already been incremented by code generated
+        // by defineExtract, except for the last property.
+        b.loadLocal(b.getParameter(1));
+        b.loadConstant(1);
+        b.math(Opcode.IADD);
+        b.loadNull(); // No Lobs to update
+        b.invokePrivate(EXTRACT_DATA_METHOD_NAME, null,
+                        new TypeDesc[] {TypeDesc.forClass(ResultSet.class), TypeDesc.INT,
+                                        TypeDesc.forClass(Lob.class).toArrayType()});
+
+        b.returnVoid();
+    }
+
+    private void defineExtractDataMethod(Map<JDBCStorableProperty<S>, Class<?>> lobLoaderMap) {
+        MethodInfo mi = mClassFile.addMethod
+            (Modifiers.PRIVATE, EXTRACT_DATA_METHOD_NAME, null,
+             new TypeDesc[] {TypeDesc.forClass(ResultSet.class), TypeDesc.INT,
+                             TypeDesc.forClass(Lob.class).toArrayType()});
+        CodeBuilder b = new CodeBuilder(mi);
+        defineExtract(b, b.getParameter(0), b.getParameter(1), b.getParameter(2),
+                      mInfo.getDataProperties().values(), lobLoaderMap);
+        b.returnVoid();
+    }
+
+    private void defineExtract
+        (CodeBuilder b,
+         LocalVariable rsVar, LocalVariable initialOffsetVar, LocalVariable lobArrayVar,
+         Iterable<JDBCStorableProperty<S>> properties,
+         Map<JDBCStorableProperty<S>, Class<?>> lobLoaderMap)
+    {
+        LocalVariable offsetVar = null;
+        int lobIndex = 0;
+
+        for (JDBCStorableProperty<S> property : properties) {
+            if (!property.isSelectable()) {
+                continue;
+            }
+
+            // Push this in preparation for calling setXxx method.
+            b.loadThis();
+
+            b.loadLocal(rsVar);
+            if (offsetVar == null) {
+                offsetVar = initialOffsetVar;
+            } else {
+                b.integerIncrement(offsetVar, 1);
+            }
+            b.loadLocal(offsetVar);
+            Method resultSetGetMethod = property.getResultSetGetMethod();
+            b.invoke(resultSetGetMethod);
+
+            TypeDesc resultSetType = TypeDesc.forClass(resultSetGetMethod.getReturnType());
+
+            Label wasNull = b.createLabel();
+            if (resultSetType.isPrimitive() && property.isNullable()) {
+                b.loadLocal(rsVar);
+                b.invokeInterface
+                    (TypeDesc.forClass(ResultSet.class), "wasNull", TypeDesc.BOOLEAN, null);
+                Label wasNotNull = b.createLabel();
+                // boolean value is false (==0) when was not null.
+                b.ifZeroComparisonBranch(wasNotNull, "==");
+
+                // Discard result and replace with null.
+                if (resultSetType.isDoubleWord()) {
+                    b.pop2();
+                } else {
+                    b.pop();
+                }
+                b.loadNull();
+                b.branch(wasNull);
+
+                wasNotNull.setLocation();
+            }
+
+            if (Lob.class.isAssignableFrom(property.getType()) ||
+                java.sql.Blob.class.isAssignableFrom(resultSetType.toClass()) ||
+                java.sql.Clob.class.isAssignableFrom(resultSetType.toClass())) {
+
+                // Run special conversion and then lie about the result set type.
+
+                boolean isClob =
+                    com.amazon.carbonado.lob.Clob.class.isAssignableFrom(property.getType()) ||
+                    java.sql.Clob.class.isAssignableFrom(resultSetType.toClass());
+
+                String lobTypeName = isClob ? "Clob" : "Blob";
+
+                Method convertMethod;
+                try {
+                    String loaderName =
+                        "com.amazon.carbonado.repo.jdbc.JDBC" + lobTypeName + "Loader";
+                    convertMethod = JDBCSupport.class.getMethod
+                        ("convert".concat(lobTypeName),
+                         resultSetType.toClass(), Class.forName(loaderName));
+                } catch (ClassNotFoundException e) {
+                    throw new UndeclaredThrowableException(e);
+                } catch (NoSuchMethodException e) {
+                    throw new UndeclaredThrowableException(e);
+                }
+
+                pushJDBCSupport(b);
+                b.swap();
+
+                // Instantiate loader, which may be used later to reload the
+                // lob. Loader is passed to convert method, where it is saved
+                // inside the converted lob for future use.
+                TypeDesc lobLoaderType = TypeDesc.forClass(lobLoaderMap.get(property));
+                b.newObject(lobLoaderType);
+                b.dup();
+                b.loadThis();
+                b.invokeConstructor(lobLoaderType, new TypeDesc[] {mClassFile.getType()});
+
+                b.invoke(convertMethod);
+                resultSetType = TypeDesc.forClass(convertMethod.getReturnType());
+
+                if (lobArrayVar != null) {
+                    // Add code to check if Lob needs to be updated.
+                    b.loadLocal(lobArrayVar);
+                    Label noUpdateLob = b.createLabel();
+                    b.ifNullBranch(noUpdateLob, true);
+
+                    b.loadLocal(lobArrayVar);
+                    b.loadConstant(lobIndex);
+                    b.loadFromArray(TypeDesc.OBJECT);
+                    b.ifNullBranch(noUpdateLob, true);
+
+                    // The Lob in the array represents the new value. What is
+                    // currently on the stack (as converted above) is the old
+                    // value currently in the database. Call the JDBCRepository
+                    // updateXlob method, which stuffs the new blob contents
+                    // into the old blob, thus updating it.
+
+                    TypeDesc lobType = TypeDesc.forClass(convertMethod.getReturnType());
+                    LocalVariable lob = b.createLocalVariable(null, lobType);
+                    b.storeLocal(lob);
+
+                    pushJDBCSupport(b);
+                    b.loadLocal(lob);
+
+                    b.loadLocal(lobArrayVar);
+                    b.loadConstant(lobIndex);
+                    b.loadFromArray(TypeDesc.OBJECT);
+                    b.checkCast(lobType);
+
+                    TypeDesc[] params = {lobType, lobType};
+                    b.invokeInterface(TypeDesc.forClass(JDBCSupport.class),
+                                      "update".concat(lobTypeName), null, params);
+
+                    // Lob content now updated.
+                    b.loadLocal(lob);
+
+                    noUpdateLob.setLocation();
+
+                    lobIndex++;
+                }
+            }
+
+            TypeDesc superType = TypeDesc.forClass(mClassFile.getSuperClassName());
+
+            StorablePropertyAdapter adapter = property.getAppliedAdapter();
+            if (adapter == null) {
+                TypeDesc propertyType = TypeDesc.forClass(property.getType());
+                b.convert(resultSetType, propertyType);
+                wasNull.setLocation();
+                // Set protected field directly, since no adapter.
+                b.storeField(superType, property.getName(), propertyType);
+            } else {
+                Method adaptMethod = adapter.findAdaptMethod
+                    (resultSetType.toClass(), property.getType());
+                TypeDesc adaptType = TypeDesc.forClass(adaptMethod.getParameterTypes()[0]);
+                b.convert(resultSetType, adaptType);
+                wasNull.setLocation();
+                // Invoke special inherited protected method that invokes the
+                // adapter and sets the field. Method was generated by StorableGenerator.
+                b.invokeVirtual(superType,
+                                property.getWriteMethodName() + '$',
+                                null, new TypeDesc[] {adaptType});
+            }
+        }
+    }
+
+    /**
+     * @param b builder to receive statement
+     * @param withVersion when false, ignore any version property
+     * @return version property number, or -1 if none
+     */
+    private int createInsertStatement(StringBuilder b, boolean withVersion) {
+        b.append("INSERT INTO ");
+        b.append(mInfo.getQualifiedTableName());
+        b.append(" (");
+
+        JDBCStorableProperty<?> versionProperty = null;
+        int versionPropNumber = -1;
+
+        int ordinal = 0;
+        int propNumber = -1;
+        for (JDBCStorableProperty<?> property : mInfo.getAllProperties().values()) {
+            propNumber++;
+            if (!property.isSelectable()) {
+                continue;
+            }
+            if (property.isVersion()) {
+                if (withVersion) {
+                    versionProperty = property;
+                    versionPropNumber = propNumber;
+                }
+                continue;
+            }
+            if (ordinal > 0) {
+                b.append(',');
+            }
+            b.append(property.getColumnName());
+            ordinal++;
+        }
+
+        // Insert version property at end, to make things easier for when the
+        // proper insert statement is selected.
+        if (versionProperty != null) {
+            if (ordinal > 0) {
+                b.append(',');
+            }
+            b.append(versionProperty.getColumnName());
+            ordinal++;
+        }
+
+        b.append(") VALUES (");
+
+        for (int i=0; i<ordinal; i++) {
+            if (i > 0) {
+                b.append(',');
+            }
+            b.append('?');
+        }
+
+        b.append(')');
+
+        return versionPropNumber;
+    }
+
+    private Map<JDBCStorableProperty<S>, Class<?>> generateLobLoaders() {
+        Map<JDBCStorableProperty<S>, Class<?>> lobLoaderMap =
+            new IdentityHashMap<JDBCStorableProperty<S>, Class<?>>();
+
+        for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+            if (!property.isSelectable() || property.isVersion()) {
+                continue;
+            }
+
+            Class psClass = property.getPreparedStatementSetMethod().getParameterTypes()[1];
+
+            Class<?> lobLoader;
+
+            if (com.amazon.carbonado.lob.Blob.class.isAssignableFrom(property.getType()) ||
+                java.sql.Blob.class.isAssignableFrom(psClass)) {
+
+                lobLoader = generateLobLoader(property, JDBCBlobLoader.class);
+            } else if (com.amazon.carbonado.lob.Clob.class.isAssignableFrom(property.getType()) ||
+                       java.sql.Clob.class.isAssignableFrom(psClass)) {
+
+                lobLoader = generateLobLoader(property, JDBCClobLoader.class);
+            } else {
+                continue;
+            }
+
+            lobLoaderMap.put(property, lobLoader);
+        }
+
+        return lobLoaderMap;
+    }
+
+    /**
+     * Generates an inner class conforming to JDBCBlobLoader or JDBCClobLoader.
+     *
+     * @param loaderType either JDBCBlobLoader or JDBCClobLoader
+     */
+    private Class<?> generateLobLoader(JDBCStorableProperty<S> property, Class<?> loaderType) {
+        ClassInjector ci = ClassInjector.create
+            (property.getEnclosingType().getName(), mParentClassLoader);
+
+        ClassFile cf = new ClassFile(ci.getClassName());
+        cf.markSynthetic();
+        cf.setSourceFile(JDBCStorableGenerator.class.getName());
+        cf.setTarget("1.5");
+        cf.addInterface(loaderType);
+
+        boolean isClob = loaderType == JDBCClobLoader.class;
+
+        final TypeDesc jdbcRepoType = TypeDesc.forClass(JDBCRepository.class);
+        final TypeDesc resultSetType = TypeDesc.forClass(ResultSet.class);
+        final TypeDesc preparedStatementType = TypeDesc.forClass(PreparedStatement.class);
+        final TypeDesc sqlLobType = TypeDesc.forClass
+            (isClob ? java.sql.Clob.class : java.sql.Blob.class);
+
+        final String enclosingFieldName = "enclosing";
+        final TypeDesc enclosingType = mClassFile.getType();
+
+        cf.addField(Modifiers.PRIVATE, enclosingFieldName, enclosingType);
+
+        // Add constructor that accepts reference to enclosing storable.
+        {
+            MethodInfo mi = cf.addConstructor(Modifiers.PUBLIC, new TypeDesc[] {enclosingType});
+            CodeBuilder b = new CodeBuilder(mi);
+            b.loadThis();
+            b.invokeSuperConstructor(null);
+            b.loadThis();
+            b.loadLocal(b.getParameter(0));
+            b.storeField(enclosingFieldName, enclosingType);
+            b.returnVoid();
+        }
+
+        MethodInfo mi = cf.addMethod
+            (Modifiers.PUBLIC, "load", sqlLobType, new TypeDesc[] {jdbcRepoType});
+        mi.addException(TypeDesc.forClass(FetchException.class));
+        CodeBuilder b = new CodeBuilder(mi);
+
+        LocalVariable repoVar = b.getParameter(0);
+
+        Label tryBeforeCon = b.createLabel().setLocation();
+        LocalVariable conVar = getConnection(b, repoVar);
+        Label tryAfterCon = b.createLabel().setLocation();
+
+        StringBuilder selectBuilder = new StringBuilder();
+        selectBuilder.append("SELECT ");
+        selectBuilder.append(property.getColumnName());
+        selectBuilder.append(" FROM ");
+        selectBuilder.append(mInfo.getQualifiedTableName());
+
+        LocalVariable psVar = b.createLocalVariable("ps", preparedStatementType);
+
+        LocalVariable instanceVar = b.createLocalVariable(null, enclosingType);
+        b.loadThis();
+        b.loadField(enclosingFieldName, enclosingType);
+        b.storeLocal(instanceVar);
+
+        Label tryAfterPs = buildWhereClauseAndPreparedStatement
+            (b, selectBuilder, conVar, psVar, repoVar, instanceVar);
+
+        b.loadLocal(psVar);
+        b.invokeInterface(preparedStatementType, "executeQuery", resultSetType, null);
+        LocalVariable rsVar = b.createLocalVariable("rs", resultSetType);
+        b.storeLocal(rsVar);
+        Label tryAfterRs = b.createLabel().setLocation();
+
+        // If no results, then return null. Otherwise, there must be exactly
+        // one result.
+
+        LocalVariable resultVar = b.createLocalVariable(null, sqlLobType);
+        b.loadNull();
+        b.storeLocal(resultVar);
+
+        b.loadLocal(rsVar);
+        b.invokeInterface(resultSetType, "next", TypeDesc.BOOLEAN, null);
+        Label noResults = b.createLabel();
+        b.ifZeroComparisonBranch(noResults, "==");
+
+        b.loadLocal(rsVar);
+        b.loadConstant(1);
+        b.invokeInterface(resultSetType, isClob ? "getClob" : "getBlob",
+                          sqlLobType, new TypeDesc[] {TypeDesc.INT});
+        b.storeLocal(resultVar);
+
+        noResults.setLocation();
+
+        closeResultSet(b, rsVar, tryAfterRs);
+        closeStatement(b, psVar, tryAfterPs);
+        yieldConAndHandleException(b, repoVar, tryBeforeCon, conVar, tryAfterCon, false);
+
+        b.loadLocal(resultVar);
+        b.returnValue(sqlLobType);
+
+        return ci.defineClass(cf);
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableInfo.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableInfo.java
new file mode 100644
index 0000000..6275c09
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableInfo.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.util.Map;
+
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.Storable;
+
+import com.amazon.carbonado.info.StorableInfo;
+
+/**
+ * Contains all the metadata describing a specific {@link Storable} type as
+ * needed by JDBCRepository. It extends the regular {@link StorableInfo} with
+ * information gathered from the database.
+ *
+ * @author Brian S O'Neill
+ * @see JDBCStorableIntrospector
+ */
+public interface JDBCStorableInfo<S extends Storable> extends StorableInfo<S> {
+    /**
+     * Returns false only if storable type is {@link com.amazon.carbonado.Independent independent}
+     * and no matching table was found.
+     */
+    boolean isSupported();
+
+    /**
+     * Returns the optional catalog name for the Storable. Some databases use a
+     * catalog name to fully qualify the table name.
+     */
+    String getCatalogName();
+
+    /**
+     * Returns the optional schema name for the Storable. Some databases use a
+     * schema name to fully qualify the table name.
+     */
+    String getSchemaName();
+
+    /**
+     * Returns the table name for the Storable or null if unsupported.
+     */
+    String getTableName();
+
+    /**
+     * Returns the qualified table name for the Storable or null if
+     * unsupported. Is used by SQL statements.
+     */
+    String getQualifiedTableName();
+
+    IndexInfo[] getIndexInfo();
+
+    Map<String, JDBCStorableProperty<S>> getAllProperties();
+
+    Map<String, JDBCStorableProperty<S>> getPrimaryKeyProperties();
+
+    Map<String, JDBCStorableProperty<S>> getDataProperties();
+
+    JDBCStorableProperty<S> getVersionProperty();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableIntrospector.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableIntrospector.java
new file mode 100644
index 0000000..7ea8ef4
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableIntrospector.java
@@ -0,0 +1,1365 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.lang.reflect.UndeclaredThrowableException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+import java.math.BigDecimal;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import static java.sql.Types.*;
+
+import javax.sql.DataSource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.cojen.classfile.TypeDesc;
+import org.cojen.util.KeyFactory;
+import org.cojen.util.SoftValuedHashMap;
+
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.MalformedTypeException;
+import com.amazon.carbonado.MismatchException;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.SupportException;
+
+import com.amazon.carbonado.info.Direction;
+import com.amazon.carbonado.info.OrderedProperty;
+import com.amazon.carbonado.info.StorableInfo;
+import com.amazon.carbonado.info.StorableIntrospector;
+import com.amazon.carbonado.info.StorableIndex;
+import com.amazon.carbonado.info.StorableKey;
+import com.amazon.carbonado.info.StorableProperty;
+import com.amazon.carbonado.info.StorablePropertyAdapter;
+import com.amazon.carbonado.info.StorablePropertyConstraint;
+
+import com.amazon.carbonado.spi.IndexInfoImpl;
+
+/**
+ * Provides additional metadata for a {@link Storable} type needed by
+ * JDBCRepository. The storable type must match to a table in an external
+ * database. All examined data is cached, so repeat examinations are fast,
+ * unless the examination failed.
+ *
+ * @author Brian S O'Neill
+ */
+public class JDBCStorableIntrospector extends StorableIntrospector {
+    // Maps compound keys to softly referenced JDBCStorableInfo objects.
+    @SuppressWarnings("unchecked")
+    private static Map<Object, JDBCStorableInfo<?>> cCache = new SoftValuedHashMap();
+
+    /**
+     * Examines the given class and returns a JDBCStorableInfo describing it. A
+     * MalformedTypeException is thrown for a variety of reasons if the given
+     * class is not a well-defined Storable type or if it can't match up with
+     * an entity in the external database.
+     *
+     * @param type Storable type to examine
+     * @param ds source of JDBC connections to use for matching to a table
+     * @param catalog optional catalog to search
+     * @param schema optional schema to search
+     * @throws MalformedTypeException if Storable type is not well-formed
+     * @throws RepositoryException if there was a problem in accessing the database
+     * @throws IllegalArgumentException if type is null
+     */
+    @SuppressWarnings("unchecked")
+    public static <S extends Storable> JDBCStorableInfo<S> examine
+        (Class<S> type, DataSource ds, String catalog, String schema)
+        throws SQLException, SupportException
+    {
+        Object key = KeyFactory.createKey(new Object[] {type, ds, catalog, schema});
+
+        synchronized (cCache) {
+            JDBCStorableInfo<S> jInfo = (JDBCStorableInfo<S>) cCache.get(key);
+            if (jInfo != null) {
+                return jInfo;
+            }
+
+            // Call superclass for most info.
+            StorableInfo<S> mainInfo = examine(type);
+            Connection con = ds.getConnection();
+            try {
+                jInfo = examine(mainInfo, con, catalog, schema);
+            } finally {
+                try {
+                    con.close();
+                } catch (SQLException e) {
+                    // Don't care.
+                }
+            }
+
+            cCache.put(key, jInfo);
+
+            // Finish resolving join properties, after properties have been
+            // added to cache. This makes it possible for joins to (directly or
+            // indirectly) reference their own enclosing type.
+            for (JDBCStorableProperty<S> jProperty : jInfo.getAllProperties().values()) {
+                ((JProperty<S>) jProperty).fillInternalJoinElements(ds, catalog, schema);
+                ((JProperty<S>) jProperty).fillExternalJoinElements(ds, catalog, schema);
+            }
+
+            return jInfo;
+        }
+    }
+
+    /**
+     * Uses the given database connection to query database metadata. This is
+     * used to bind storables to tables, and properties to columns. Other
+     * checks are performed to ensure that storable type matches well with the
+     * definition in the database.
+     */
+    private static <S extends Storable> JDBCStorableInfo<S> examine
+        (StorableInfo<S> mainInfo, Connection con,
+         final String searchCatalog, final String searchSchema)
+        throws SQLException, SupportException
+    {
+        DatabaseMetaData meta = con.getMetaData();
+
+        final String databaseProductName = meta.getDatabaseProductName();
+        final String userName = meta.getUserName();
+
+        String[] tableAliases;
+        if (mainInfo.getAliasCount() > 0) {
+            tableAliases = mainInfo.getAliases();
+        } else {
+            String name = mainInfo.getStorableType().getName();
+            int index = name.lastIndexOf('.');
+            if (index >= 0) {
+                name = name.substring(index + 1);
+            }
+            tableAliases = generateAliases(name);
+        }
+
+        // Try to find matching table from aliases.
+        String catalog = null, schema = null, tableName = null, tableType = null;
+        findName: {
+            // The call to getTables may return several matching tables. This
+            // map defines the "best" table type we'd like to use. The higher
+            // the number the better.
+            Map<String, Integer> fitnessMap = new HashMap<String, Integer>();
+            fitnessMap.put("LOCAL TEMPORARY", 1);
+            fitnessMap.put("GLOBAL TEMPORARY", 2);
+            fitnessMap.put("VIEW", 3);
+            fitnessMap.put("SYSTEM TABLE", 4);
+            fitnessMap.put("TABLE", 5);
+            fitnessMap.put("ALIAS", 6);
+            fitnessMap.put("SYNONYM", 7);
+
+            for (int i=0; i<tableAliases.length; i++) {
+                ResultSet rs = meta.getTables(searchCatalog, searchSchema, tableAliases[i], null);
+                try {
+                    int bestFitness = 0;
+                    while (rs.next()) {
+                        String type = rs.getString("TABLE_TYPE");
+                        Integer fitness = fitnessMap.get(type);
+                        if (fitness != null) {
+                            String rsSchema = rs.getString("TABLE_SCHEM");
+
+                            if (searchSchema == null) {
+                                if (userName != null && userName.equalsIgnoreCase(rsSchema)) {
+                                    // Favor entities whose schema name matches
+                                    // the user name.
+                                    fitness += 7;
+                                }
+                            }
+
+                            if (fitness > bestFitness) {
+                                bestFitness = fitness;
+                                catalog = rs.getString("TABLE_CAT");
+                                schema = rsSchema;
+                                tableName = rs.getString("TABLE_NAME");
+                                tableType = type;
+                            }
+                        }
+                    }
+                } finally {
+                    rs.close();
+                }
+            }
+        }
+
+        if (tableName == null && !mainInfo.isIndependent()) {
+            StringBuilder buf = new StringBuilder();
+            buf.append("Unable to find matching table name for type \"");
+            buf.append(mainInfo.getStorableType().getName());
+            buf.append("\" by looking for ");
+            appendToSentence(buf, tableAliases);
+            buf.append(" with catalog " + searchCatalog + " and schema " + searchSchema);
+            throw new MismatchException(buf.toString());
+        }
+
+        String qualifiedTableName = tableName;
+        String resolvedTableName = tableName;
+
+        // Oracle specific stuff...
+        // TODO: Migrate this to OracleSupportStrategy.
+        if (tableName != null && databaseProductName.toUpperCase().contains("ORACLE")) {
+            if ("TABLE".equals(tableType) && searchSchema != null) {
+                // Qualified table name references the schema. Used by SQL statements.
+                qualifiedTableName = searchSchema + '.' + tableName;
+            } else if ("SYNONYM".equals(tableType)) {
+                // Try to get the real schema. This call is Oracle specific, however.
+                String select = "SELECT TABLE_OWNER,TABLE_NAME " +
+                    "FROM ALL_SYNONYMS " +
+                    "WHERE OWNER=? AND SYNONYM_NAME=?";
+                PreparedStatement ps = con.prepareStatement(select);
+                ps.setString(1, schema); // in Oracle, schema is the owner
+                ps.setString(2, tableName);
+                try {
+                    ResultSet rs = ps.executeQuery();
+                    try {
+                        if (rs.next()) {
+                            schema = rs.getString("TABLE_OWNER");
+                            resolvedTableName = rs.getString("TABLE_NAME");
+                        }
+                    } finally {
+                        rs.close();
+                    }
+                } finally {
+                    ps.close();
+                }
+            }
+        }
+
+        // Gather information on all columns such that metadata only needs to
+        // be retrieved once.
+        Map<String, ColumnInfo> columnMap =
+            new TreeMap<String, ColumnInfo>(String.CASE_INSENSITIVE_ORDER);
+
+        if (resolvedTableName != null) {
+            ResultSet rs = meta.getColumns(catalog, schema, resolvedTableName, null);
+            try {
+                while (rs.next()) {
+                    ColumnInfo info = new ColumnInfo(rs);
+                    columnMap.put(info.columnName, info);
+                }
+            } finally {
+                rs.close();
+            }
+        }
+
+        // Make sure that all properties have a corresponding column.
+        Map<String, ? extends StorableProperty<S>> mainProperties = mainInfo.getAllProperties();
+        Map<String, String> columnToProperty = new HashMap<String, String>();
+        Map<String, JDBCStorableProperty<S>> jProperties =
+            new LinkedHashMap<String, JDBCStorableProperty<S>>(mainProperties.size());
+
+        ArrayList<String> errorMessages = new ArrayList<String>();
+
+        for (StorableProperty<S> mainProperty : mainProperties.values()) {
+            if (mainProperty.isJoin() || tableName == null) {
+                jProperties.put(mainProperty.getName(), new JProperty<S>(mainProperty));
+                continue;
+            }
+
+            String[] columnAliases;
+            if (mainProperty.getAliasCount() > 0) {
+                columnAliases = mainProperty.getAliases();
+            } else {
+                columnAliases = generateAliases(mainProperty.getName());
+            }
+
+            JDBCStorableProperty<S> jProperty = null;
+            boolean addedError = false;
+
+            findName: for (int i=0; i<columnAliases.length; i++) {
+                ColumnInfo columnInfo = columnMap.get(columnAliases[i]);
+                if (columnInfo != null) {
+                    AccessInfo accessInfo = getAccessInfo
+                        (mainProperty,
+                         columnInfo.dataType, columnInfo.dataTypeName,
+                         columnInfo.columnSize, columnInfo.decimalDigits);
+
+                    if (accessInfo == null) {
+                        TypeDesc propertyType = TypeDesc.forClass(mainProperty.getType());
+                        String message =
+                            "Property \"" + mainProperty.getName() +
+                            "\" has type \"" + propertyType.getFullName() +
+                            "\" which is incompatible with database type \"" +
+                            columnInfo.dataTypeName + '"';
+
+                        if (columnInfo.decimalDigits > 0) {
+                            message += " (decimal digits = " + columnInfo.decimalDigits + ')';
+                        }
+
+                        errorMessages.add(message);
+                        addedError = true;
+                        break findName;
+                    }
+
+                    if (columnInfo.nullable) {
+                        if (!mainProperty.isNullable()) {
+                            errorMessages.add
+                                ("Property \"" + mainProperty.getName() +
+                                 "\" must have a Nullable annotation");
+                        }
+                    } else {
+                        if (mainProperty.isNullable()) {
+                            errorMessages.add
+                                ("Property \"" + mainProperty.getName() +
+                                 "\" must not have a Nullable annotation");
+                        }
+                    }
+
+                    jProperty = new JProperty<S>(mainProperty, columnInfo,
+                                                 accessInfo.mResultSetGet,
+                                                 accessInfo.mPreparedStatementSet,
+                                                 accessInfo.getAdapter());
+
+                    break findName;
+                }
+            }
+
+            if (jProperty != null) {
+                jProperties.put(mainProperty.getName(), jProperty);
+                columnToProperty.put(jProperty.getColumnName(), jProperty.getName());
+            } else {
+                if (mainProperty.isIndependent()) {
+                    jProperties.put(mainProperty.getName(), new JProperty<S>(mainProperty));
+                } else if (!addedError) {
+                    StringBuilder buf = new StringBuilder();
+                    buf.append("Unable to find matching database column for property \"");
+                    buf.append(mainProperty.getName());
+                    buf.append("\" by looking for ");
+                    appendToSentence(buf, columnAliases);
+                    errorMessages.add(buf.toString());
+                }
+            }
+        }
+
+        if (errorMessages.size() > 0) {
+            throw new MismatchException(errorMessages);
+        }
+
+        // Gather index info...
+        IndexInfo[] indexInfo;
+        boolean hasIndexInfo = false;
+
+        gatherIndexInfo: {
+            if (resolvedTableName == null) {
+                indexInfo = new IndexInfo[0];
+                break gatherIndexInfo;
+            }
+
+            ResultSet rs;
+            try {
+                rs = meta.getIndexInfo(catalog, schema, resolvedTableName, false, true);
+            } catch (SQLException e) {
+                getLog().info
+                    ("Unable to get index info for table \"" + resolvedTableName +
+                     "\" with catalog " + catalog + " and schema " + schema + ": " + e);
+                indexInfo = new IndexInfo[0];
+                break gatherIndexInfo;
+            }
+
+            List<IndexInfo> infoList = new ArrayList<IndexInfo>();
+
+            try {
+                String indexName = null;
+                boolean unique = false;
+                boolean clustered = false;
+                List<String> indexProperties = new ArrayList<String>();
+                List<Direction> directions = new ArrayList<Direction>();
+
+                while (rs.next()) {
+                    if (rs.getInt("TYPE") == DatabaseMetaData.tableIndexStatistic) {
+                        // Ignore this type.
+                        continue;
+                    }
+
+                    String propertyName = columnToProperty.get(rs.getString("COLUMN_NAME"));
+                    if (propertyName == null) {
+                        // Ignore indexes on unknown columns.
+                        continue;
+                    }
+
+                    String nextName = rs.getString("INDEX_NAME");
+
+                    if (indexName != null && !indexName.equals(nextName)) {
+                        infoList.add(new IndexInfoImpl(indexName, unique, clustered,
+                                                       indexProperties.toArray(new String[0]),
+                                                       directions.toArray(new Direction[0])));
+                        indexProperties.clear();
+                        directions.clear();
+                    }
+
+                    indexName = nextName;
+                    unique = !rs.getBoolean("NON_UNIQUE");
+                    clustered = rs.getInt("TYPE") == DatabaseMetaData.tableIndexClustered;
+
+                    String ascOrDesc = rs.getString("ASC_OR_DESC");
+                    Direction direction = Direction.UNSPECIFIED;
+                    if ("A".equals(ascOrDesc)) {
+                        direction = Direction.ASCENDING;
+                    } else if ("D".equals(ascOrDesc)) {
+                        direction = Direction.DESCENDING;
+                    }
+
+                    indexProperties.add(propertyName);
+                    directions.add(direction);
+                }
+
+                if (indexProperties.size() > 0) {
+                    infoList.add(new IndexInfoImpl(indexName, unique, clustered,
+                                                   indexProperties.toArray(new String[0]),
+                                                   directions.toArray(new Direction[0])));
+                }
+            } finally {
+                rs.close();
+            }
+
+            indexInfo = infoList.toArray(new IndexInfo[0]);
+            hasIndexInfo = true;
+        }
+
+        // Now verify that primary keys match.
+
+        // As primary keys are found, remove from columnToProperty map.
+
+        if (resolvedTableName != null) checkPrimaryKey: {
+            ResultSet rs;
+            try {
+                rs = meta.getPrimaryKeys(catalog, schema, resolvedTableName);
+            } catch (SQLException e) {
+                getLog().info
+                    ("Unable to get primary keys for table \"" + resolvedTableName +
+                     "\" with catalog " + catalog + " and schema " + schema + ": " + e);
+                break checkPrimaryKey;
+            }
+
+            try {
+                while (rs.next()) {
+                    String columnName = rs.getString("COLUMN_NAME");
+                    String propertyName = columnToProperty.remove(columnName);
+                    StorableProperty mainProperty = mainProperties.get(propertyName);
+
+                    if (!mainProperty.isPrimaryKeyMember()) {
+                        errorMessages.add
+                            ("Property \"" + propertyName +
+                             "\" must have a PrimaryKey annotation");
+                    }
+                }
+            } finally {
+                rs.close();
+            }
+
+            // All remaining properties must not have a primary key annotation.
+            for (String propertyName : columnToProperty.values()) {
+                StorableProperty mainProperty = mainProperties.get(propertyName);
+
+                if (mainProperty.isPrimaryKeyMember()) {
+                    errorMessages.add
+                        ("Property \"" + propertyName + "\" cannot have a PrimaryKey annotation");
+                }
+            }
+        }
+
+        // Verify AlternateKey annotations are backed by unique indexes. Unlike
+        // for primary keys, there is no requirement that unique indexes must
+        // have an AlternateKey annotation.
+        if (hasIndexInfo) {
+            // Note the deep nesting of loops. I hope the index sets are small.
+
+            int altKeyCount = mainInfo.getAlternateKeyCount();
+            altKeyScan:
+            for (int i=altKeyCount; --i>=0; ) {
+                StorableKey<S> altKey = mainInfo.getAlternateKey(i);
+                Set<? extends OrderedProperty<S>> altKeyProps = altKey.getProperties();
+                indexMatch:
+                for (int j=indexInfo.length; --j>=0; ) {
+                    IndexInfo ii = indexInfo[j];
+                    if (ii.isUnique()) {
+                        String[] indexPropNames = ii.getPropertyNames();
+                        if (indexPropNames.length == altKeyProps.size()) {
+                            propertyMatch:
+                            for (OrderedProperty<S> orderedProp : altKeyProps) {
+                                StorableProperty<S> altKeyProp =
+                                    orderedProp.getChainedProperty().getPrimeProperty();
+                                String keyPropName = altKeyProp.getName();
+                                for (int k=indexPropNames.length; --k>=0; ) {
+                                    if (indexPropNames[k].equals(keyPropName)) {
+                                        // This property matched...
+                                        continue propertyMatch;
+                                    }
+                                }
+                                // Didn't match a property, so move on to next index.
+                                continue indexMatch;
+                            }
+                            // Fully matched an index, move on to next alt key.
+                            continue altKeyScan;
+                        }
+                    }
+                }
+                // No indexes match, so error.
+                StringBuilder buf = new StringBuilder();
+                buf.append("No matching unique index for alternate key: ");
+                try {
+                    altKey.appendTo(buf);
+                } catch (IOException e) {
+                    // Not gonna happen.
+                }
+                errorMessages.add(buf.toString());
+            }
+        }
+
+        if (errorMessages.size() > 0) {
+            throw new MismatchException(errorMessages);
+        }
+
+        return new JInfo<S>
+            (mainInfo, catalog, schema, tableName, qualifiedTableName, indexInfo, jProperties);
+    }
+
+    private static Log getLog() {
+        return LogFactory.getLog(JDBCStorableIntrospector.class);
+    }
+
+    /**
+     * Figures out how to best access the given property, or returns null if
+     * not supported. An adapter may be applied.
+     *
+     * @return null if not supported
+     */
+    private static AccessInfo getAccessInfo
+        (StorableProperty property,
+         int dataType, String dataTypeName, int columnSize, int decimalDigits)
+    {
+        AccessInfo info = getAccessInfo
+            (property.getType(), dataType, dataTypeName, columnSize, decimalDigits);
+        if (info != null) {
+            return info;
+        }
+
+        // See if an appropriate adapter exists.
+        StorablePropertyAdapter adapter = property.getAdapter();
+        if (adapter != null) {
+            Method[] toMethods = adapter.findAdaptMethodsTo(property.getType());
+            for (Method toMethod : toMethods) {
+                Class fromType = toMethod.getParameterTypes()[0];
+                // Verify that reverse adapt method exists as well...
+                if (adapter.findAdaptMethod(property.getType(), fromType) != null) {
+                    // ...and try to get access info for fromType.
+                    info = getAccessInfo
+                        (fromType, dataType, dataTypeName, columnSize, decimalDigits);
+                    if (info != null) {
+                        info.setAdapter(adapter);
+                        return info;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Figures out how to best access the given property, or returns null if
+     * not supported. An adapter is not be applied.
+     *
+     * @return null if not supported
+     */
+    private static AccessInfo getAccessInfo
+        (Class desiredClass,
+         int dataType, String dataTypeName, int columnSize, int decimalDigits)
+    {
+        if (!desiredClass.isPrimitive()) {
+            TypeDesc desiredType = TypeDesc.forClass(desiredClass);
+            if (desiredType.toPrimitiveType() != null) {
+                desiredType = desiredType.toPrimitiveType();
+                desiredClass = desiredType.toClass();
+            }
+        }
+
+        Class actualClass;
+        String suffix;
+
+        switch (dataType) {
+        default:
+            return null;
+
+        case BIT:
+        case BOOLEAN:
+            if (desiredClass == boolean.class) {
+                actualClass = boolean.class;
+                suffix = "Boolean";
+            } else {
+                return null;
+            }
+            break;
+
+        case TINYINT:
+            if (desiredClass == byte.class) {
+                actualClass = byte.class;
+                suffix = "Byte";
+            } else {
+                return null;
+            }
+            break;
+
+        case SMALLINT:
+            if (desiredClass == short.class) {
+                actualClass = short.class;
+                suffix = "Short";
+            } else {
+                return null;
+            }
+            break;
+
+        case INTEGER:
+            if (desiredClass == int.class) {
+                actualClass = int.class;
+                suffix = "Int";
+            } else {
+                return null;
+            }
+            break;
+
+        case BIGINT:
+            if (desiredClass == long.class) {
+                actualClass = long.class;
+                suffix = "Long";
+            } else {
+                return null;
+            }
+            break;
+
+        case FLOAT:
+            if (desiredClass == float.class) {
+                actualClass = float.class;
+                suffix = "Float";
+            } else {
+                return null;
+            }
+            break;
+
+        case DOUBLE:
+        case REAL:
+            if (desiredClass == double.class) {
+                actualClass = double.class;
+                suffix = "Double";
+            } else {
+                return null;
+            }
+            break;
+
+        case NUMERIC:
+        case DECIMAL:
+            if (desiredClass == int.class) {
+                if (decimalDigits == 0) {
+                    actualClass = int.class;
+                    suffix = "Int";
+                } else {
+                    return null;
+                }
+            } else if (desiredClass == long.class) {
+                if (decimalDigits == 0) {
+                    actualClass = long.class;
+                    suffix = "Long";
+                } else {
+                    return null;
+                }
+            } else if (desiredClass == double.class) {
+                actualClass = double.class;
+                suffix = "Double";
+            } else if (desiredClass == BigDecimal.class) {
+                actualClass = BigDecimal.class;
+                suffix = "BigDecimal";
+            } else {
+                return null;
+            }
+            break;
+
+        case CHAR:
+        case VARCHAR:
+        case LONGVARCHAR:
+            if (desiredClass == String.class) {
+                actualClass = String.class;
+                suffix = "String";
+            } else {
+                return null;
+            }
+            break;
+
+        case DATE:
+            // Treat Date as a Timestamp since some databases make no
+            // distinction.  The DateTimeAdapter can be used to provide
+            // more control over the desired precision.
+            if (desiredClass == Date.class || desiredClass == java.sql.Date.class) {
+                actualClass = java.sql.Timestamp.class;
+                suffix = "Timestamp";
+            } else {
+                return null;
+            }
+            break;
+
+        case TIME:
+            if (desiredClass == Date.class || desiredClass == java.sql.Time.class) {
+                actualClass = java.sql.Time.class;
+                suffix = "Time";
+            } else {
+                return null;
+            }
+            break;
+
+        case TIMESTAMP:
+            if (desiredClass == Date.class || desiredClass == java.sql.Timestamp.class) {
+                actualClass = java.sql.Timestamp.class;
+                suffix = "Timestamp";
+            } else {
+                return null;
+            }
+            break;
+
+        case BINARY:
+        case VARBINARY:
+        case LONGVARBINARY:
+            if (desiredClass == byte[].class) {
+                actualClass = byte[].class;
+                suffix = "Bytes";
+            } else {
+                return null;
+            }
+            break;
+
+        case BLOB:
+            if (desiredClass == com.amazon.carbonado.lob.Blob.class) {
+                actualClass = java.sql.Blob.class;
+                suffix = "Blob";
+            } else {
+                return null;
+            }
+            break;
+
+        case CLOB:
+            if (desiredClass == com.amazon.carbonado.lob.Clob.class) {
+                actualClass = java.sql.Clob.class;
+                suffix = "Clob";
+            } else {
+                return null;
+            }
+            break;
+        }
+
+        return new AccessInfo(suffix, actualClass);
+    }
+
+    /**
+     * Appends words to a sentence as an "or" list.
+     */
+    private static void appendToSentence(StringBuilder buf, String[] names) {
+        for (int i=0; i<names.length; i++) {
+            if (i > 0) {
+                if (i + 1 >= names.length) {
+                    buf.append(" or ");
+                } else {
+                    buf.append(", ");
+                }
+            }
+            buf.append('"');
+            buf.append(names[i]);
+            buf.append('"');
+        }
+    }
+
+    /**
+     * Generates aliases for the given name, converting camel case form into
+     * various underscore forms.
+     */
+    static String[] generateAliases(String base) {
+        int length = base.length();
+        if (length <= 1) {
+            return new String[]{base.toUpperCase(), base.toLowerCase()};
+        }
+
+        ArrayList<String> aliases = new ArrayList<String>(4);
+
+        StringBuilder buf = new StringBuilder();
+
+        int i;
+        for (i=0; i<length; ) {
+            char c = base.charAt(i++);
+            if (c == '_' || !Character.isJavaIdentifierPart(c)) {
+                // Keep scanning for first letter.
+                buf.append(c);
+            } else {
+                buf.append(Character.toUpperCase(c));
+                break;
+            }
+        }
+
+        boolean canSeparate = false;
+        boolean appendedIdentifierPart = false;
+
+        for (; i<length; i++) {
+            char c = base.charAt(i);
+            if (c == '_' || !Character.isJavaIdentifierPart(c)) {
+                canSeparate = false;
+                appendedIdentifierPart = false;
+            } else if (Character.isLowerCase(c)) {
+                canSeparate = true;
+                appendedIdentifierPart = true;
+            } else {
+                if (appendedIdentifierPart &&
+                    i + 1 < length && Character.isLowerCase(base.charAt(i + 1))) {
+                    canSeparate = true;
+                }
+                if (canSeparate) {
+                    buf.append('_');
+                }
+                canSeparate = false;
+                appendedIdentifierPart = true;
+            }
+            buf.append(c);
+        }
+
+        String derived = buf.toString();
+
+        addToSet(aliases, derived.toUpperCase());
+        addToSet(aliases, derived.toLowerCase());
+        addToSet(aliases, derived);
+        addToSet(aliases, base);
+
+        return aliases.toArray(new String[aliases.size()]);
+    }
+
+    private static void addToSet(ArrayList<String> list, String value) {
+        if (!list.contains(value)) {
+            list.add(value);
+        }
+    }
+
+    static String intern(String str) {
+        return str == null ? null : str.intern();
+    }
+
+    private static class ColumnInfo {
+        final String columnName;
+        final int dataType;
+        final String dataTypeName;
+        final int columnSize;
+        final int decimalDigits;
+        final boolean nullable;
+        final int charOctetLength;
+        final int ordinalPosition;
+
+        ColumnInfo(ResultSet rs) throws SQLException {
+            columnName = intern(rs.getString("COLUMN_NAME"));
+            dataTypeName = intern(rs.getString("TYPE_NAME"));
+            columnSize = rs.getInt("COLUMN_SIZE");
+            decimalDigits = rs.getInt("DECIMAL_DIGITS");
+            nullable = rs.getInt("NULLABLE") == DatabaseMetaData.columnNullable;
+            charOctetLength = rs.getInt("CHAR_OCTET_LENGTH");
+            ordinalPosition = rs.getInt("ORDINAL_POSITION");
+
+            int dt = rs.getInt("DATA_TYPE");
+            if (dt == OTHER) {
+                if ("BLOB".equalsIgnoreCase(dataTypeName)) {
+                    dt = BLOB;
+                } else if ("CLOB".equalsIgnoreCase(dataTypeName)) {
+                    dt = CLOB;
+                } else if ("FLOAT".equalsIgnoreCase(dataTypeName)) {
+                    dt = FLOAT;
+                } else if ("TIMESTAMP".equalsIgnoreCase(dataTypeName)) {
+                    dt = TIMESTAMP;
+                } else if (dataTypeName.toUpperCase().contains("TIMESTAMP")) {
+                    dt = TIMESTAMP;
+                }
+            }
+
+            dataType = dt;
+        }
+    }
+
+    private static class AccessInfo {
+        // ResultSet get method, never null.
+        final Method mResultSetGet;
+
+        // PreparedStatement set method, never null.
+        final Method mPreparedStatementSet;
+
+        // Is null if no adapter needed.
+        private StorablePropertyAdapter mAdapter;
+
+        AccessInfo(String suffix, Class actualClass) {
+            try {
+                mResultSetGet = ResultSet.class.getMethod("get" + suffix, int.class);
+                mPreparedStatementSet = PreparedStatement.class.getMethod
+                    ("set" + suffix, int.class, actualClass);
+            } catch (NoSuchMethodException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+        }
+
+        StorablePropertyAdapter getAdapter() {
+            return mAdapter;
+        }
+
+        void setAdapter(StorablePropertyAdapter adapter) {
+            mAdapter = adapter;
+        }
+    }
+
+    /**
+     * Implementation of JDBCStorableInfo. The 'J' prefix is just a shorthand
+     * to disambiguate the class name.
+     */
+    private static class JInfo<S extends Storable> implements JDBCStorableInfo<S> {
+        private final StorableInfo<S> mMainInfo;
+        private final String mCatalogName;
+        private final String mSchemaName;
+        private final String mTableName;
+        private final String mQualifiedTableName;
+        private final IndexInfo[] mIndexInfo;
+        private final Map<String, JDBCStorableProperty<S>> mAllProperties;
+
+        private transient Map<String, JDBCStorableProperty<S>> mPrimaryKeyProperties;
+        private transient Map<String, JDBCStorableProperty<S>> mDataProperties;
+        private transient JDBCStorableProperty<S> mVersionProperty;
+
+        JInfo(StorableInfo<S> mainInfo,
+              String catalogName, String schemaName, String tableName, String qualifiedTableName,
+              IndexInfo[] indexInfo,
+              Map<String, JDBCStorableProperty<S>> allProperties)
+        {
+            mMainInfo = mainInfo;
+            mCatalogName = intern(catalogName);
+            mSchemaName = intern(schemaName);
+            mTableName = intern(tableName);
+            mQualifiedTableName = intern(qualifiedTableName);
+            mIndexInfo = indexInfo;
+            mAllProperties = Collections.unmodifiableMap(allProperties);
+        }
+
+        public String getName() {
+            return mMainInfo.getName();
+        }
+
+        public Class<S> getStorableType() {
+            return mMainInfo.getStorableType();
+        }
+
+        public StorableKey<S> getPrimaryKey() {
+            return mMainInfo.getPrimaryKey();
+        }
+
+        public int getAlternateKeyCount() {
+            return mMainInfo.getAlternateKeyCount();
+        }
+
+        public StorableKey<S> getAlternateKey(int index) {
+            return mMainInfo.getAlternateKey(index);
+        }
+
+        public StorableKey<S>[] getAlternateKeys() {
+            return mMainInfo.getAlternateKeys();
+        }
+
+        public int getAliasCount() {
+            return mMainInfo.getAliasCount();
+        }
+
+        public String getAlias(int index) {
+            return mMainInfo.getAlias(index);
+        }
+
+        public String[] getAliases() {
+            return mMainInfo.getAliases();
+        }
+
+        public int getIndexCount() {
+            return mMainInfo.getIndexCount();
+        }
+
+        public StorableIndex<S> getIndex(int index) {
+            return mMainInfo.getIndex(index);
+        }
+
+        public StorableIndex<S>[] getIndexes() {
+            return mMainInfo.getIndexes();
+        }
+
+        public boolean isIndependent() {
+            return mMainInfo.isIndependent();
+        }
+
+        public boolean isSupported() {
+            return mTableName != null;
+        }
+
+        public String getCatalogName() {
+            return mCatalogName;
+        }
+
+        public String getSchemaName() {
+            return mSchemaName;
+        }
+
+        public String getTableName() {
+            return mTableName;
+        }
+
+        public String getQualifiedTableName() {
+            return mQualifiedTableName;
+        }
+
+        public IndexInfo[] getIndexInfo() {
+            return mIndexInfo.clone();
+        }
+
+        public Map<String, JDBCStorableProperty<S>> getAllProperties() {
+            return mAllProperties;
+        }
+
+        public Map<String, JDBCStorableProperty<S>> getPrimaryKeyProperties() {
+            if (mPrimaryKeyProperties == null) {
+                Map<String, JDBCStorableProperty<S>> pkProps =
+                    new LinkedHashMap<String, JDBCStorableProperty<S>>(mAllProperties.size());
+                for (Map.Entry<String, JDBCStorableProperty<S>> entry : mAllProperties.entrySet()){
+                    JDBCStorableProperty<S> property = entry.getValue();
+                    if (property.isPrimaryKeyMember()) {
+                        pkProps.put(entry.getKey(), property);
+                    }
+                }
+                mPrimaryKeyProperties = Collections.unmodifiableMap(pkProps);
+            }
+            return mPrimaryKeyProperties;
+        }
+
+        public Map<String, JDBCStorableProperty<S>> getDataProperties() {
+            if (mDataProperties == null) {
+                Map<String, JDBCStorableProperty<S>> dataProps =
+                    new LinkedHashMap<String, JDBCStorableProperty<S>>(mAllProperties.size());
+                for (Map.Entry<String, JDBCStorableProperty<S>> entry : mAllProperties.entrySet()){
+                    JDBCStorableProperty<S> property = entry.getValue();
+                    if (!property.isPrimaryKeyMember() && !property.isJoin()) {
+                        dataProps.put(entry.getKey(), property);
+                    }
+                }
+                mDataProperties = Collections.unmodifiableMap(dataProps);
+            }
+            return mDataProperties;
+        }
+
+        public JDBCStorableProperty<S> getVersionProperty() {
+            if (mVersionProperty == null) {
+                for (JDBCStorableProperty<S> property : mAllProperties.values()) {
+                    if (property.isVersion()) {
+                        mVersionProperty = property;
+                        break;
+                    }
+                }
+            }
+            return mVersionProperty;
+        }
+    }
+
+    /**
+     * Implementation of JDBCStorableProperty. The 'J' prefix is just a
+     * shorthand to disambiguate the class name.
+     */
+    private static class JProperty<S extends Storable> implements JDBCStorableProperty<S> {
+        private final StorableProperty<S> mMainProperty;
+        private final String mColumnName;
+        private final Integer mDataType;
+        private final String mDataTypeName;
+        private final Method mResultSetGet;
+        private final Method mPreparedStatementSet;
+        private final StorablePropertyAdapter mAdapter;
+        private final Integer mColumnSize;
+        private final Integer mDecimalDigits;
+        private final Integer mCharOctetLength;
+        private final Integer mOrdinalPosition;
+
+        private JDBCStorableProperty<S>[] mInternal;
+        private JDBCStorableProperty<?>[] mExternal;
+
+        /**
+         * Join properties need to be filled in later.
+         */
+        JProperty(StorableProperty<S> mainProperty, ColumnInfo columnInfo,
+                  Method resultSetGet, Method preparedStatementSet,
+                  StorablePropertyAdapter adapter) {
+            mMainProperty = mainProperty;
+            mColumnName = columnInfo.columnName;
+            mDataType = columnInfo.dataType;
+            mDataTypeName = columnInfo.dataTypeName;
+            mResultSetGet = resultSetGet;
+            mPreparedStatementSet = preparedStatementSet;
+            mAdapter = adapter;
+            mColumnSize = columnInfo.columnSize;
+            mDecimalDigits = columnInfo.decimalDigits;
+            mCharOctetLength = columnInfo.charOctetLength;
+            mOrdinalPosition = columnInfo.ordinalPosition;
+        }
+
+        JProperty(StorableProperty<S> mainProperty) {
+            mMainProperty = mainProperty;
+            mColumnName = null;
+            mDataType = null;
+            mDataTypeName = null;
+            mResultSetGet = null;
+            mPreparedStatementSet = null;
+            mAdapter = null;
+            mColumnSize = null;
+            mDecimalDigits = null;
+            mCharOctetLength = null;
+            mOrdinalPosition = null;
+        }
+
+        public String getName() {
+            return mMainProperty.getName();
+        }
+
+        public Class<?> getType() {
+            return mMainProperty.getType();
+        }
+
+        public Class<S> getEnclosingType() {
+            return mMainProperty.getEnclosingType();
+        }
+
+        public Method getReadMethod() {
+            return mMainProperty.getReadMethod();
+        }
+
+        public String getReadMethodName() {
+            return mMainProperty.getReadMethodName();
+        }
+
+        public Method getWriteMethod() {
+            return mMainProperty.getWriteMethod();
+        }
+
+        public String getWriteMethodName() {
+            return mMainProperty.getWriteMethodName();
+        }
+
+        public boolean isNullable() {
+            return mMainProperty.isNullable();
+        }
+
+        public boolean isPrimaryKeyMember() {
+            return mMainProperty.isPrimaryKeyMember();
+        }
+
+        public boolean isAlternateKeyMember() {
+            return mMainProperty.isAlternateKeyMember();
+        }
+
+        public int getAliasCount() {
+            return mMainProperty.getAliasCount();
+        }
+
+        public String getAlias(int index) {
+            return mMainProperty.getAlias(index);
+        }
+
+        public String[] getAliases() {
+            return mMainProperty.getAliases();
+        }
+
+        public boolean isJoin() {
+            return mMainProperty.isJoin();
+        }
+
+        public Class<? extends Storable> getJoinedType() {
+            return mMainProperty.getJoinedType();
+        }
+
+        public int getJoinElementCount() {
+            return mMainProperty.getJoinElementCount();
+        }
+
+        public boolean isQuery() {
+            return mMainProperty.isQuery();
+        }
+
+        public int getConstraintCount() {
+            return mMainProperty.getConstraintCount();
+        }
+
+        public StorablePropertyConstraint getConstraint(int index) {
+            return mMainProperty.getConstraint(index);
+        }
+
+        public StorablePropertyConstraint[] getConstraints() {
+            return mMainProperty.getConstraints();
+        }
+
+        public StorablePropertyAdapter getAdapter() {
+            return mMainProperty.getAdapter();
+        }
+
+        public String getSequenceName() {
+            return mMainProperty.getSequenceName();
+        }
+
+        public boolean isVersion() {
+            return mMainProperty.isVersion();
+        }
+
+        public boolean isIndependent() {
+            return mMainProperty.isIndependent();
+        }
+
+        public boolean isSupported() {
+            if (isJoin()) {
+                // TODO: Check if joined type is supported
+                return true;
+            } else {
+                return mColumnName != null;
+            }
+        }
+
+        public boolean isSelectable() {
+            return mColumnName != null && !isJoin();
+        }
+
+        public String getColumnName() {
+            return mColumnName;
+        }
+
+        public Integer getDataType() {
+            return mDataType;
+        }
+
+        public String getDataTypeName() {
+            return mDataTypeName;
+        }
+
+        public Method getResultSetGetMethod() {
+            return mResultSetGet;
+        }
+
+        public Method getPreparedStatementSetMethod() {
+            return mPreparedStatementSet;
+        }
+
+        public StorablePropertyAdapter getAppliedAdapter() {
+            return mAdapter;
+        }
+
+        public Integer getColumnSize() {
+            return mColumnSize;
+        }
+
+        public Integer getDecimalDigits() {
+            return mDecimalDigits;
+        }
+
+        public Integer getCharOctetLength() {
+            return mCharOctetLength;
+        }
+
+        public Integer getOrdinalPosition() {
+            return mOrdinalPosition;
+        }
+
+        public JDBCStorableProperty<S> getInternalJoinElement(int index) {
+            if (mInternal == null) {
+                throw new IndexOutOfBoundsException();
+            }
+            return mInternal[index];
+        }
+
+        @SuppressWarnings("unchecked")
+        public JDBCStorableProperty<S>[] getInternalJoinElements() {
+            if (mInternal == null) {
+                return new JDBCStorableProperty[0];
+            }
+            return mInternal.clone();
+        }
+
+        public JDBCStorableProperty<?> getExternalJoinElement(int index) {
+            if (mExternal == null) {
+                throw new IndexOutOfBoundsException();
+            }
+            return mExternal[index];
+        }
+
+        public JDBCStorableProperty<?>[] getExternalJoinElements() {
+            if (mExternal == null) {
+                return new JDBCStorableProperty[0];
+            }
+            return mExternal.clone();
+        }
+
+        public String toString() {
+            return mMainProperty.toString();
+        }
+
+        public void appendTo(Appendable app) throws IOException {
+            mMainProperty.appendTo(app);
+        }
+
+        @SuppressWarnings("unchecked")
+        void fillInternalJoinElements(DataSource ds, String catalog, String schema)
+            throws SQLException, SupportException
+        {
+            StorableProperty<S>[] mainInternal = mMainProperty.getInternalJoinElements();
+            if (mainInternal.length == 0) {
+                mInternal = null;
+                return;
+            }
+
+            JDBCStorableInfo<S> info = examine(getEnclosingType(), ds, catalog, schema);
+
+            JDBCStorableProperty<S>[] internal = new JDBCStorableProperty[mainInternal.length];
+            for (int i=mainInternal.length; --i>=0; ) {
+                internal[i] = info.getAllProperties().get(mainInternal[i].getName());
+            }
+            mInternal = internal;
+        }
+
+        void fillExternalJoinElements(DataSource ds, String catalog, String schema)
+            throws SQLException, SupportException
+        {
+            StorableProperty<?>[] mainExternal = mMainProperty.getExternalJoinElements();
+            if (mainExternal.length == 0) {
+                mExternal = null;
+                return;
+            }
+
+            JDBCStorableInfo<?> info = examine(getJoinedType(), ds, catalog, schema);
+
+            JDBCStorableProperty<?>[] external = new JDBCStorableProperty[mainExternal.length];
+            for (int i=mainExternal.length; --i>=0; ) {
+                external[i] = info.getAllProperties().get(mainExternal[i].getName());
+            }
+            mExternal = external;
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableProperty.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableProperty.java
new file mode 100644
index 0000000..b900e69
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorableProperty.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.lang.reflect.Method;
+
+import com.amazon.carbonado.Storable;
+
+import com.amazon.carbonado.info.StorableProperty;
+import com.amazon.carbonado.info.StorablePropertyAdapter;
+
+/**
+ * Contains all the metadata describing a property of a specific {@link
+ * Storable} type as needed by JDBCRepository.
+ *
+ * @author Brian S O'Neill
+ * @see JDBCStorableIntrospector
+ */
+public interface JDBCStorableProperty<S extends Storable> extends StorableProperty<S> {
+    /**
+     * Returns false only if property is independent and no matching column was
+     * found.
+     */
+    boolean isSupported();
+
+    /**
+     * Returns true if property is both supported and not a join. Simply put,
+     * it can appear in a select statement.
+     */
+    boolean isSelectable();
+
+    /**
+     * Returns the table column for this property.
+     *
+     * @return null if property is unsupported
+     */
+    String getColumnName();
+
+    /**
+     * Returns the data type as defined by {@link java.sql.Types}.
+     *
+     * @return null if property is unsupported
+     */
+    Integer getDataType();
+
+    /**
+     * Returns the data type name.
+     *
+     * @return null if property is unsupported
+     */
+    String getDataTypeName();
+
+    /**
+     * Returns the method to use to access this property (by index) from a
+     * ResultSet.
+     *
+     * @return null if property is unsupported
+     */
+    Method getResultSetGetMethod();
+
+    /**
+     * Returns the method to use to set this property (by index) into a
+     * PreparedStatement.
+     *
+     * @return null if property is unsupported
+     */
+    Method getPreparedStatementSetMethod();
+
+    /**
+     * Returns the adapter that needs to be applied to properties returned from
+     * ResultSets and set into PreparedStatements. Is null if not needed.
+     *
+     * @return null if property is unsupported or if not needed.
+     */
+    StorablePropertyAdapter getAppliedAdapter();
+
+    /**
+     * The column size is either the maximum number of characters or the
+     * numeric precision.
+     *
+     * @return null if property is unsupported
+     */
+    Integer getColumnSize();
+
+    /**
+     * Returns the amount of fractional decimal digits.
+     *
+     * @return null if property is unsupported
+     */
+    Integer getDecimalDigits();
+
+    /**
+     * Returns the maximum amount of bytes for property value.
+     *
+     * @return null if property is unsupported
+     */
+    Integer getCharOctetLength();
+
+    /**
+     * Returns the one-based index of the column in the table.
+     *
+     * @return null if property is unsupported
+     */
+    Integer getOrdinalPosition();
+
+    JDBCStorableProperty<S> getInternalJoinElement(int index);
+
+    JDBCStorableProperty<S>[] getInternalJoinElements();
+
+    JDBCStorableProperty<?> getExternalJoinElement(int index);
+
+    JDBCStorableProperty<?>[] getExternalJoinElements();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorage.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorage.java
new file mode 100644
index 0000000..99c4bea
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCStorage.java
@@ -0,0 +1,1129 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+import java.io.IOException;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.UndeclaredThrowableException;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.LinkedHashMap;
+
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Trigger;
+import com.amazon.carbonado.capability.IndexInfo;
+
+import com.amazon.carbonado.filter.AndFilter;
+import com.amazon.carbonado.filter.Filter;
+import com.amazon.carbonado.filter.FilterValues;
+import com.amazon.carbonado.filter.OrFilter;
+import com.amazon.carbonado.filter.PropertyFilter;
+import com.amazon.carbonado.filter.RelOp;
+import com.amazon.carbonado.filter.Visitor;
+
+import com.amazon.carbonado.info.ChainedProperty;
+import com.amazon.carbonado.info.Direction;
+import com.amazon.carbonado.info.OrderedProperty;
+import com.amazon.carbonado.info.StorableProperty;
+import com.amazon.carbonado.info.StorablePropertyAdapter;
+
+import com.amazon.carbonado.spi.BaseQuery;
+import com.amazon.carbonado.spi.BaseQueryCompiler;
+import com.amazon.carbonado.spi.SequenceValueProducer;
+import com.amazon.carbonado.spi.TriggerManager;
+
+import com.amazon.carbonado.util.QuickConstructorGenerator;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCStorage<S extends Storable> extends BaseQueryCompiler<S>
+    implements Storage<S>, JDBCSupport<S>
+{
+    private static final String TABLE_ALIAS_PREFIX = "T";
+    private static final int FIRST_RESULT_INDEX = 1;
+
+    final JDBCRepository mRepository;
+    final JDBCSupportStrategy mSupportStrategy;
+    final JDBCStorableInfo<S> mInfo;
+    final InstanceFactory mInstanceFactory;
+
+    final TriggerManager<S> mTriggerManager;
+
+    JDBCStorage(JDBCRepository repository, JDBCStorableInfo<S> info)
+        throws SupportException
+    {
+        super(info);
+        mRepository = repository;
+        mSupportStrategy = repository.getSupportStrategy();
+        mInfo = info;
+
+        Class<? extends S> generatedStorableClass = JDBCStorableGenerator.getGeneratedClass(info);
+        mInstanceFactory = QuickConstructorGenerator
+            .getInstance(generatedStorableClass, InstanceFactory.class);
+
+        mTriggerManager = new TriggerManager<S>();
+    }
+
+    public Class<S> getStorableType() {
+        return mInfo.getStorableType();
+    }
+
+    public S prepare() {
+        return (S) mInstanceFactory.instantiate(this);
+    }
+
+    public Query<S> query() throws FetchException {
+        return getCompiledQuery();
+    }
+
+    public Query<S> query(String filter) throws FetchException {
+        return getCompiledQuery(filter);
+    }
+
+    public Query<S> query(Filter<S> filter) throws FetchException {
+        return getCompiledQuery(filter);
+    }
+
+    public JDBCRepository getJDBCRepository() {
+        return mRepository;
+    }
+
+    public Repository getRootRepository() {
+        return mRepository.getRootRepository();
+    }
+
+    public boolean isPropertySupported(String propertyName) {
+        JDBCStorableProperty<S> property = mInfo.getAllProperties().get(propertyName);
+        return property != null && property.isSupported();
+    }
+
+    public boolean addTrigger(Trigger<? super S> trigger) {
+        return mTriggerManager.addTrigger(trigger);
+    }
+
+    public boolean removeTrigger(Trigger<? super S> trigger) {
+        return mTriggerManager.removeTrigger(trigger);
+    }
+
+    public IndexInfo[] getIndexInfo() {
+        return mInfo.getIndexInfo();
+    }
+
+    public SequenceValueProducer getSequenceValueProducer(String name) throws PersistException {
+        return mSupportStrategy.getSequenceValueProducer(name);
+    }
+
+    public Trigger<? super S> getInsertTrigger() {
+        return mTriggerManager.getInsertTrigger();
+    }
+
+    public Trigger<? super S> getUpdateTrigger() {
+        return mTriggerManager.getUpdateTrigger();
+    }
+
+    public Trigger<? super S> getDeleteTrigger() {
+        return mTriggerManager.getDeleteTrigger();
+    }
+
+    /**
+     * @param loader used to reload Blob outside original transaction
+     */
+    public com.amazon.carbonado.lob.Blob convertBlob(java.sql.Blob blob, JDBCBlobLoader loader)
+        throws FetchException
+    {
+        JDBCBlob jblob = mSupportStrategy.convertBlob(blob, loader);
+
+        if (jblob != null) {
+            try {
+                JDBCTransaction txn = mRepository.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(jblob);
+                }
+            } catch (Exception e) {
+                throw mRepository.toFetchException(e);
+            }
+        }
+
+        return jblob;
+    }
+
+    /**
+     * @param loader used to reload Clob outside original transaction
+     */
+    public com.amazon.carbonado.lob.Clob convertClob(java.sql.Clob clob, JDBCClobLoader loader)
+        throws FetchException
+    {
+        JDBCClob jclob = mSupportStrategy.convertClob(clob, loader);
+
+        if (jclob != null) {
+            try {
+                JDBCTransaction txn = mRepository.openTransactionManager().getTxn();
+                if (txn != null) {
+                    txn.register(jclob);
+                }
+            } catch (Exception e) {
+                throw mRepository.toFetchException(e);
+            }
+        }
+
+        return jclob;
+    }
+
+    /**
+     * @return original blob if too large and post-insert update is required, null otherwise
+     * @throws PersistException instead of FetchException since this code is
+     * called during an insert operation
+     */
+    public com.amazon.carbonado.lob.Blob setBlobValue(PreparedStatement ps, int column,
+                                                      com.amazon.carbonado.lob.Blob blob)
+        throws PersistException
+    {
+        return mSupportStrategy.setBlobValue(ps, column, blob);
+    }
+
+    /**
+     * @return original clob if too large and post-insert update is required, null otherwise
+     * @throws PersistException instead of FetchException since this code is
+     * called during an insert operation
+     */
+    public com.amazon.carbonado.lob.Clob setClobValue(PreparedStatement ps, int column,
+                                                      com.amazon.carbonado.lob.Clob clob)
+        throws PersistException
+    {
+        return mSupportStrategy.setClobValue(ps, column, clob);
+    }
+
+    public void updateBlob(com.amazon.carbonado.lob.Blob oldBlob,
+                           com.amazon.carbonado.lob.Blob newBlob)
+        throws PersistException
+    {
+        mSupportStrategy.updateBlob(oldBlob, newBlob);
+    }
+
+    public void updateClob(com.amazon.carbonado.lob.Clob oldClob,
+                           com.amazon.carbonado.lob.Clob newClob)
+        throws PersistException
+    {
+        mSupportStrategy.updateClob(oldClob, newClob);
+    }
+
+    protected JDBCStorableInfo<S> getStorableInfo() {
+        return mInfo;
+    }
+
+    protected Query<S> compileQuery(FilterValues<S> values, OrderedProperty<S>[] orderings)
+        throws FetchException, UnsupportedOperationException
+    {
+        JoinNode jn;
+        try {
+            JoinNodeBuilder jnb = new JoinNodeBuilder();
+            if (values == null) {
+                jn = new JoinNode(getStorableInfo(), null);
+            } else {
+                values.getFilter().accept(jnb, null);
+                jn = jnb.getRootJoinNode();
+            }
+            jnb.captureOrderings(orderings);
+        } catch (UndeclaredThrowableException e) {
+            throw mRepository.toFetchException(e);
+        }
+
+        StatementBuilder selectBuilder = new StatementBuilder();
+        selectBuilder.append("SELECT ");
+
+        // Don't bother using a table alias for one table. With just one table,
+        // there's no need to disambiguate.
+        String alias = jn.hasAnyJoins() ? jn.getAlias() : null;
+
+        Map<String, JDBCStorableProperty<S>> properties = getStorableInfo().getAllProperties();
+        int ordinal = 0;
+        for (JDBCStorableProperty<S> property : properties.values()) {
+            if (!property.isSelectable()) {
+                continue;
+            }
+            if (ordinal > 0) {
+                selectBuilder.append(',');
+            }
+            if (alias != null) {
+                selectBuilder.append(alias);
+                selectBuilder.append('.');
+            }
+            selectBuilder.append(property.getColumnName());
+            ordinal++;
+        }
+
+        selectBuilder.append(" FROM");
+
+        StatementBuilder fromWhereBuilder = new StatementBuilder();
+        fromWhereBuilder.append(" FROM");
+
+        if (alias == null) {
+            // Don't bother defining a table alias for one table.
+            jn.appendTableNameTo(selectBuilder);
+            jn.appendTableNameTo(fromWhereBuilder);
+        } else {
+            jn.appendFullJoinTo(selectBuilder);
+            jn.appendFullJoinTo(fromWhereBuilder);
+        }
+
+        PropertyFilter<S>[] propertyFilters;
+        boolean[] propertyFilterNullable;
+
+        if (values == null) {
+            propertyFilters = null;
+            propertyFilterNullable = null;
+        } else {
+            // Build the WHERE clause only if anything to filter on.
+            selectBuilder.append(" WHERE ");
+            fromWhereBuilder.append(" WHERE ");
+
+            WhereBuilder wb = new WhereBuilder(selectBuilder, alias == null ? null : jn);
+            FetchException e = values.getFilter().accept(wb, null);
+            if (e != null) {
+                throw e;
+            }
+
+            propertyFilters = wb.getPropertyFilters();
+            propertyFilterNullable = wb.getPropertyFilterNullable();
+
+            wb = new WhereBuilder(fromWhereBuilder, alias == null ? null : jn);
+            e = values.getFilter().accept(wb, null);
+            if (e != null) {
+                throw e;
+            }
+        }
+
+        // Append order-by clause.
+        if (orderings != null && orderings.length != 0) {
+            selectBuilder.append(" ORDER BY ");
+            ordinal = 0;
+            for (OrderedProperty<S> orderedProperty : orderings) {
+                if (ordinal > 0) {
+                    selectBuilder.append(',');
+                }
+                selectBuilder.appendColumn(alias == null ? null : jn,
+                                           orderedProperty.getChainedProperty());
+                if (orderedProperty.getDirection() == Direction.DESCENDING) {
+                    selectBuilder.append(" DESC");
+                }
+                ordinal++;
+            }
+        }
+
+        try {
+            CursorFactory factory = new CursorFactory(selectBuilder.build(),
+                                                      fromWhereBuilder.build(),
+                                                      propertyFilters,
+                                                      propertyFilterNullable);
+            return new JDBCQuery(factory, values, orderings);
+        } catch (RepositoryException e) {
+            throw mRepository.toFetchException(e);
+        }
+    }
+
+    public S instantiate(ResultSet rs) throws SQLException {
+        return (S) mInstanceFactory.instantiate(this, rs, FIRST_RESULT_INDEX);
+    }
+
+    public static interface InstanceFactory {
+        Storable instantiate(JDBCSupport storage);
+
+        Storable instantiate(JDBCSupport storage, ResultSet rs, int offset) throws SQLException;
+    }
+
+    private class CursorFactory {
+        private final Statement<S> mSelectStatement;
+        private final int mMaxSelectStatementLength;
+        private final Statement<S> mFromWhereStatement;
+        private final int mMaxFromWhereStatementLength;
+
+        // The following arrays all have the same length, or they may all be null.
+
+        private final PropertyFilter<S>[] mPropertyFilters;
+        private final boolean[] mPropertyFilterNullable;
+
+        private final Method[] mPreparedStatementSetMethods;
+
+        // Some entries may be null if no adapter required.
+        private final Method[] mAdapterMethods;
+
+        // Some entries may be null if no adapter required.
+        private final Object[] mAdapterInstances;
+
+        CursorFactory(Statement<S> selectStatement,
+                      Statement<S> fromWhereStatement,
+                      PropertyFilter<S>[] propertyFilters,
+                      boolean[] propertyFilterNullable)
+            throws RepositoryException
+        {
+            mSelectStatement = selectStatement;
+            mMaxSelectStatementLength = selectStatement.maxLength();
+            mFromWhereStatement = fromWhereStatement;
+            mMaxFromWhereStatementLength = fromWhereStatement.maxLength();
+
+            if (propertyFilters == null) {
+                mPropertyFilters = null;
+                mPropertyFilterNullable = null;
+                mPreparedStatementSetMethods = null;
+                mAdapterMethods = null;
+                mAdapterInstances = null;
+            } else {
+                mPropertyFilters = propertyFilters;
+                mPropertyFilterNullable = propertyFilterNullable;
+
+                int length = propertyFilters.length;
+
+                mPreparedStatementSetMethods = new Method[length];
+                mAdapterMethods = new Method[length];
+                mAdapterInstances = new Object[length];
+
+                gatherAdapterMethods(propertyFilters);
+            }
+        }
+
+        private void gatherAdapterMethods(PropertyFilter<S>[] filters)
+            throws RepositoryException
+        {
+            for (int i=0; i<filters.length; i++) {
+                PropertyFilter<S> filter = filters[i];
+                ChainedProperty<S> chained = filter.getChainedProperty();
+                StorableProperty<?> property = chained.getLastProperty();
+                JDBCStorableProperty<?> jProperty =
+                    mRepository.getJDBCStorableProperty(property);
+
+                Method psSetMethod = jProperty.getPreparedStatementSetMethod();
+                mPreparedStatementSetMethods[i] = psSetMethod;
+
+                StorablePropertyAdapter adapter = jProperty.getAppliedAdapter();
+                if (adapter != null) {
+                    Class toType = psSetMethod.getParameterTypes()[1];
+                    mAdapterMethods[i] = adapter.findAdaptMethod(jProperty.getType(), toType);
+                    mAdapterInstances[i] = adapter.getAdapterInstance();
+                }
+            }
+        }
+
+        JDBCCursor<S> openCursor(FilterValues<S> filterValues, boolean forUpdate)
+            throws FetchException
+        {
+            Connection con = mRepository.getConnection();
+            try {
+                PreparedStatement ps =
+                    con.prepareStatement(prepareSelect(filterValues, forUpdate));
+
+                setParameters(ps, filterValues);
+                return new JDBCCursor<S>(JDBCStorage.this, con, ps);
+            } catch (Exception e) {
+                throw mRepository.toFetchException(e);
+            }
+        }
+
+        /**
+         * Delete operation is included in cursor factory for ease of implementation.
+         */
+        int executeDelete(FilterValues<S> filterValues) throws PersistException {
+            Connection con;
+            try {
+                con = mRepository.getConnection();
+            } catch (FetchException e) {
+                throw e.toPersistException();
+            }
+            try {
+                PreparedStatement ps = con.prepareStatement(prepareDelete(filterValues));
+                setParameters(ps, filterValues);
+                return ps.executeUpdate();
+            } catch (Exception e) {
+                throw mRepository.toPersistException(e);
+            } finally {
+                try {
+                    mRepository.yieldConnection(con);
+                } catch (FetchException e) {
+                    throw e.toPersistException();
+                }
+            }
+        }
+
+        /**
+         * Count operation is included in cursor factory for ease of implementation.
+         */
+        long executeCount(FilterValues<S> filterValues) throws FetchException {
+            Connection con = mRepository.getConnection();
+            try {
+                PreparedStatement ps = con.prepareStatement(prepareCount(filterValues));
+                setParameters(ps, filterValues);
+                ResultSet rs = ps.executeQuery();
+                try {
+                    rs.next();
+                    return rs.getLong(1);
+                } finally {
+                    rs.close();
+                }
+            } catch (Exception e) {
+                throw mRepository.toFetchException(e);
+            } finally {
+                mRepository.yieldConnection(con);
+            }
+        }
+
+        String prepareSelect(FilterValues<S> filterValues, boolean forUpdate) {
+            if (!forUpdate) {
+                return mSelectStatement.buildStatement(mMaxSelectStatementLength, filterValues);
+            }
+
+            // Allocate with extra room for " FOR UPDATE"
+            StringBuilder b = new StringBuilder(mMaxSelectStatementLength + 11);
+            mSelectStatement.appendTo(b, filterValues);
+            b.append(" FOR UPDATE");
+            return b.toString();
+        }
+
+        String prepareDelete(FilterValues<S> filterValues) {
+            // Allocate with extra room for "DELETE"
+            StringBuilder b = new StringBuilder(6 + mMaxFromWhereStatementLength);
+            b.append("DELETE");
+            mFromWhereStatement.appendTo(b, filterValues);
+            return b.toString();
+        }
+
+        String prepareCount(FilterValues<S> filterValues) {
+            // Allocate with extra room for "SELECT COUNT(*)"
+            StringBuilder b = new StringBuilder(15 + mMaxFromWhereStatementLength);
+            b.append("SELECT COUNT(*)");
+            mFromWhereStatement.appendTo(b, filterValues);
+            return b.toString();
+        }
+
+        private void setParameters(PreparedStatement ps, FilterValues<S> filterValues)
+            throws Exception
+        {
+            PropertyFilter<S>[] propertyFilters = mPropertyFilters;
+
+            if (propertyFilters == null) {
+                return;
+            }
+
+            boolean[] propertyFilterNullable = mPropertyFilterNullable;
+            Method[] psSetMethods = mPreparedStatementSetMethods;
+            Method[] adapterMethods = mAdapterMethods;
+            Object[] adapterInstances = mAdapterInstances;
+
+            int ordinal = 0;
+            int psOrdinal = 1; // Start at one since JDBC ordinals are one-based.
+            for (PropertyFilter<S> filter : propertyFilters) {
+                setValue: {
+                    Object value = filterValues.getAssignedValue(filter);
+
+                    if (value == null && propertyFilterNullable[ordinal]) {
+                        // No '?' parameter to fill since value "IS NULL" or "IS NOT NULL"
+                        break setValue;
+                    }
+
+                    Method adapter = adapterMethods[ordinal];
+                    if (adapter != null) {
+                        value = adapter.invoke(adapterInstances[ordinal], value);
+                    }
+
+                    psSetMethods[ordinal].invoke(ps, psOrdinal, value);
+                    psOrdinal++;
+                }
+
+                ordinal++;
+            }
+        }
+    }
+
+    private class JDBCQuery extends BaseQuery<S> {
+        private final CursorFactory mCursorFactory;
+
+        JDBCQuery(CursorFactory factory,
+                  FilterValues<S> values,
+                  OrderedProperty<S>[] orderings)
+        {
+            super(mRepository, JDBCStorage.this, values, orderings);
+            mCursorFactory = factory;
+        }
+
+        JDBCQuery(CursorFactory factory,
+                  FilterValues<S> values,
+                  String[] orderings)
+        {
+            super(mRepository, JDBCStorage.this, values, orderings);
+            mCursorFactory = factory;
+        }
+
+        public Query<S> orderBy(String property)
+            throws FetchException, UnsupportedOperationException
+        {
+            return JDBCStorage.this.getOrderedQuery(getFilterValues(), property);
+        }
+
+        public Query<S> orderBy(String... properties)
+            throws FetchException, UnsupportedOperationException
+        {
+            return JDBCStorage.this.getOrderedQuery(getFilterValues(), properties);
+        }
+
+        public Cursor<S> fetch() throws FetchException {
+            boolean forUpdate = mRepository.openTransactionManager().isForUpdate();
+            return mCursorFactory.openCursor(getFilterValues(), forUpdate);
+        }
+
+        public void deleteAll() throws PersistException {
+            if (mTriggerManager.getDeleteTrigger() != null) {
+                // Super implementation loads one at time and calls
+                // delete. This allows delete trigger to be invoked on each.
+                super.deleteAll();
+            } else {
+                mCursorFactory.executeDelete(getFilterValues());
+            }
+        }
+
+        public long count() throws FetchException {
+            return mCursorFactory.executeCount(getFilterValues());
+        }
+
+        public boolean printNative(Appendable app, int indentLevel) throws IOException {
+            indent(app, indentLevel);
+            boolean forUpdate = mRepository.openTransactionManager().isForUpdate();
+            app.append(mCursorFactory.prepareSelect(getFilterValues(), forUpdate));
+            app.append('\n');
+            return true;
+        }
+
+        public boolean printPlan(Appendable app, int indentLevel) throws IOException {
+            try {
+                boolean forUpdate = mRepository.openTransactionManager().isForUpdate();
+                String statement = mCursorFactory.prepareSelect(getFilterValues(), forUpdate);
+                return mRepository.getSupportStrategy().printPlan(app, indentLevel, statement);
+            } catch (FetchException e) {
+                LogFactory.getLog(JDBCStorage.class).error(null, e);
+                return false;
+            }
+        }
+
+        protected BaseQuery<S> newInstance(FilterValues<S> values) {
+            return new JDBCQuery(mCursorFactory, values, getOrderings());
+        }
+
+        protected BaseQuery<S> cachedInstance(Filter<S> filter) throws FetchException {
+            return (BaseQuery<S>) JDBCStorage.this.getCompiledQuery(filter);
+        }
+    }
+
+    /**
+     * Node in a tree structure describing how tables are joined together.
+     */
+    private class JoinNode {
+        // Joined property which led to this node. For root node, it is null.
+        private final JDBCStorableProperty<?> mProperty;
+
+        private final JDBCStorableInfo<?> mInfo;
+        private final String mAlias;
+
+        private final Map<String, JoinNode> mSubNodes;
+
+        /**
+         * @param alias table alias in SQL statement, i.e. "T1"
+         */
+        JoinNode(JDBCStorableInfo<?> info, String alias) {
+            this(null, info, alias);
+        }
+
+        private JoinNode(JDBCStorableProperty<?> property, JDBCStorableInfo<?> info, String alias)
+        {
+            mProperty = property;
+            mInfo = info;
+            mAlias = alias;
+            mSubNodes = new LinkedHashMap<String, JoinNode>();
+        }
+
+        /**
+         * Returns the table alias to use in SQL statement, i.e. "T1"
+         */
+        public String getAlias() {
+            return mAlias;
+        }
+
+        public String findAliasFor(ChainedProperty<?> chained) {
+            return findAliasFor(chained, 0);
+        }
+
+        private String findAliasFor(ChainedProperty<?> chained, int offset) {
+            if ((chained.getChainCount() - offset) <= 0) {
+                // At this point in the chain, there are no more joins.
+                return mAlias;
+            }
+            StorableProperty<?> property;
+            if (offset == 0) {
+                property = chained.getPrimeProperty();
+            } else {
+                property = chained.getChainedProperty(offset - 1);
+            }
+            String name = property.getName();
+            JoinNode subNode = mSubNodes.get(name);
+            if (subNode != null) {
+                return subNode.findAliasFor(chained, offset + 1);
+            }
+            return null;
+        }
+
+        public boolean hasAnyJoins() {
+            return mSubNodes.size() > 0;
+        }
+
+        /**
+         * Appends table name to the given FROM clause builder.
+         */
+        public void appendTableNameTo(StatementBuilder fromClause) {
+            fromClause.append(' ');
+            fromClause.append(mInfo.getQualifiedTableName());
+        }
+
+        /**
+         * Appends table names, aliases, and joins to the given FROM clause
+         * builder.
+         */
+        public void appendFullJoinTo(StatementBuilder fromClause) {
+            appendTableNameTo(fromClause);
+            fromClause.append(' ');
+            fromClause.append(mAlias);
+            for (JoinNode jn : mSubNodes.values()) {
+                // TODO: By default, joins are all inner. A join could become
+                // LEFT OUTER JOIN if the query filter has a term like this:
+                // "address = ? | address.state = ?", and the runtime value of
+                // address is null. Because of DNF transformation and lack of
+                // short-circuit ops, this syntax might be difficult to parse.
+                // This might be a better way of expressing an outer join:
+                // "address(.)state = ?".
+
+                fromClause.append(" INNER JOIN");
+                jn.appendFullJoinTo(fromClause);
+                fromClause.append(" ON ");
+                int count = jn.mProperty.getJoinElementCount();
+                for (int i=0; i<count; i++) {
+                    if (i > 0) {
+                        fromClause.append(" AND ");
+                    }
+                    fromClause.append(mAlias);
+                    fromClause.append('.');
+                    fromClause.append(jn.mProperty.getInternalJoinElement(i).getColumnName());
+                    fromClause.append('=');
+                    fromClause.append(jn.mAlias);
+                    fromClause.append('.');
+                    fromClause.append(jn.mProperty.getExternalJoinElement(i).getColumnName());
+                }
+            }
+        }
+
+        /**
+         * @return new value for aliasCounter
+         */
+        public int addJoin(ChainedProperty<?> chained, int aliasCounter)
+            throws RepositoryException
+        {
+            return addJoin(chained, aliasCounter, 0);
+        }
+
+        private int addJoin(ChainedProperty<?> chained, int aliasCounter, int offset)
+            throws RepositoryException
+        {
+            if ((chained.getChainCount() - offset) <= 0) {
+                // At this point in the chain, there are no more joins.
+                return aliasCounter;
+            }
+            StorableProperty<?> property;
+            if (offset == 0) {
+                property = chained.getPrimeProperty();
+            } else {
+                property = chained.getChainedProperty(offset - 1);
+            }
+            String name = property.getName();
+            JoinNode subNode = mSubNodes.get(name);
+            if (subNode == null) {
+                JDBCStorableInfo<?> info = mRepository.examineStorable(property.getJoinedType());
+                JDBCStorableProperty<?> jProperty = mRepository.getJDBCStorableProperty(property);
+                subNode = new JoinNode(jProperty, info, TABLE_ALIAS_PREFIX + (++aliasCounter));
+                mSubNodes.put(name, subNode);
+            }
+            return subNode.addJoin(chained, aliasCounter, offset + 1);
+        }
+
+        public String toString() {
+            StringBuilder b = new StringBuilder();
+            b.append("{table=");
+            b.append(mInfo.getQualifiedTableName());
+            b.append(", alias=");
+            b.append(mAlias);
+            if (mSubNodes.size() > 0) {
+                b.append(", subNodes=");
+                b.append(mSubNodes);
+            }
+            b.append('}');
+            return b.toString();
+        }
+    }
+
+    /**
+     * Filter visitor that constructs a JoinNode tree.
+     */
+    private class JoinNodeBuilder extends Visitor<S, Object, Object> {
+        private JoinNode mRootJoinNode;
+        private int mAliasCounter;
+
+        JoinNodeBuilder() {
+            mAliasCounter = 1;
+            mRootJoinNode = new JoinNode(getStorableInfo(), TABLE_ALIAS_PREFIX + mAliasCounter);
+        }
+
+        public JoinNode getRootJoinNode() {
+            return mRootJoinNode;
+        }
+
+        /**
+         * Processes the given property orderings and ensures that they are
+         * part of the JoinNode tree.
+         *
+         * @throws UndeclaredThrowableException wraps a RepositoryException
+         */
+        public void captureOrderings(OrderedProperty<?>[] orderings) {
+            try {
+                if (orderings != null) {
+                    for (OrderedProperty<?> orderedProperty : orderings) {
+                        ChainedProperty<?> chained = orderedProperty.getChainedProperty();
+                        mAliasCounter = mRootJoinNode.addJoin(chained, mAliasCounter);
+                    }
+                }
+            } catch (RepositoryException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+        }
+
+        /**
+         * @throws UndeclaredThrowableException wraps a RepositoryException
+         * since RepositoryException cannot be thrown directly
+         */
+        public Object visit(PropertyFilter<S> filter, Object param) {
+            try {
+                visit(filter);
+                return null;
+            } catch (RepositoryException e) {
+                throw new UndeclaredThrowableException(e);
+            }
+        }
+
+        private void visit(PropertyFilter<S> filter) throws RepositoryException {
+            ChainedProperty<S> chained = filter.getChainedProperty();
+            mAliasCounter = mRootJoinNode.addJoin(chained, mAliasCounter);
+        }
+    }
+
+    /**
+     * Simple DOM representing a SQL statement.
+     */
+    private static abstract class Statement<S extends Storable> {
+        public abstract int maxLength();
+
+        /**
+         * Builds a statement string from the given values.
+         *
+         * @param initialCapacity expected size of finished string
+         * length. Should be value returned from maxLength.
+         * @param filterValues values may be needed to build complete statement
+         */
+        public String buildStatement(int initialCapacity, FilterValues<S> filterValues) {
+            StringBuilder b = new StringBuilder(initialCapacity);
+            this.appendTo(b, filterValues);
+            return b.toString();
+        }
+
+        public abstract void appendTo(StringBuilder b, FilterValues<S> filterValues);
+
+        /**
+         * Just used for debugging.
+         */
+        public String toString() {
+            StringBuilder b = new StringBuilder();
+            appendTo(b, null);
+            return b.toString();
+        }
+    }
+
+    private static class LiteralStatement<S extends Storable> extends Statement<S> {
+        private final String mStr;
+
+        LiteralStatement(String str) {
+            mStr = str;
+        }
+
+        public int maxLength() {
+            return mStr.length();
+        }
+
+        public String buildStatement(int initialCapacity, FilterValues<S> filterValues) {
+            return mStr;
+        }
+
+        public void appendTo(StringBuilder b, FilterValues<S> filterValues) {
+            b.append(mStr);
+        }
+
+        /**
+         * Returns the literal value.
+         */
+        public String toString() {
+            return mStr;
+        }
+    }
+
+    private static class NullablePropertyStatement<S extends Storable> extends Statement<S> {
+        private final PropertyFilter<S> mFilter;
+        private final boolean mIsNullOp;
+
+        NullablePropertyStatement(PropertyFilter<S> filter, boolean isNullOp) {
+            mFilter = filter;
+            mIsNullOp = isNullOp;
+        }
+
+        public int maxLength() {
+            return mIsNullOp ? 8 : 12; // for " IS NULL" or " IS NOT NULL"
+        }
+
+        public void appendTo(StringBuilder b, FilterValues<S> filterValues) {
+            if (filterValues != null
+                && filterValues.getValue(mFilter) == null
+                && filterValues.isAssigned(mFilter))
+            {
+                if (mIsNullOp) {
+                    b.append(" IS NULL");
+                } else {
+                    b.append(" IS NOT NULL");
+                }
+            } else {
+                if (mIsNullOp) {
+                    b.append("=?");
+                } else {
+                    b.append("<>?");
+                }
+            }
+        }
+    }
+
+    private static class CompositeStatement<S extends Storable> extends Statement<S> {
+        private final Statement<S>[] mStatements;
+
+        @SuppressWarnings("unchecked")
+        CompositeStatement(List<Statement<S>> statements) {
+            mStatements = statements.toArray(new Statement[statements.size()]);
+        }
+
+        public int maxLength() {
+            int max = 0;
+            for (Statement<S> statement : mStatements) {
+                max += statement.maxLength();
+            }
+            return max;
+        }
+
+        public void appendTo(StringBuilder b, FilterValues<S> filterValues) {
+            for (Statement<S> statement : mStatements) {
+                statement.appendTo(b, filterValues);
+            }
+        }
+    }
+
+    private class StatementBuilder {
+        private List<Statement<S>> mStatements;
+        private StringBuilder mLiteralBuilder;
+
+        StatementBuilder() {
+            mStatements = new ArrayList<Statement<S>>();
+            mLiteralBuilder = new StringBuilder();
+        }
+
+        public Statement<S> build() {
+            if (mStatements.size() == 0 || mLiteralBuilder.length() > 0) {
+                mStatements.add(new LiteralStatement<S>(mLiteralBuilder.toString()));
+                mLiteralBuilder.setLength(0);
+            }
+            if (mStatements.size() == 1) {
+                return mStatements.get(0);
+            } else {
+                return new CompositeStatement<S>(mStatements);
+            }
+        }
+
+        public void append(char c) {
+            mLiteralBuilder.append(c);
+        }
+
+        public void append(String str) {
+            mLiteralBuilder.append(str);
+        }
+
+        public void append(LiteralStatement<S> statement) {
+            append(statement.toString());
+        }
+
+        public void append(Statement<S> statement) {
+            if (statement instanceof LiteralStatement) {
+                append((LiteralStatement<S>) statement);
+            } else {
+                mStatements.add(new LiteralStatement<S>(mLiteralBuilder.toString()));
+                mLiteralBuilder.setLength(0);
+                mStatements.add(statement);
+            }
+        }
+
+        public void appendColumn(JoinNode jn, ChainedProperty<?> chained)
+            throws FetchException
+        {
+            String alias;
+            if (jn == null) {
+                alias = null;
+            } else {
+                alias = jn.findAliasFor(chained);
+            }
+            if (alias != null) {
+                mLiteralBuilder.append(alias);
+                mLiteralBuilder.append('.');
+            }
+            StorableProperty<?> property = chained.getLastProperty();
+            JDBCStorableProperty<?> jProperty;
+            try {
+                jProperty = mRepository.getJDBCStorableProperty(property);
+            } catch (RepositoryException e) {
+                throw mRepository.toFetchException(e);
+            }
+            if (jProperty.isJoin()) {
+                throw new UnsupportedOperationException
+                    ("Join property doesn't have a corresponding column: " + chained);
+            }
+            mLiteralBuilder.append(jProperty.getColumnName());
+        }
+    }
+
+    private class WhereBuilder extends Visitor<S, FetchException, Object> {
+        private final StatementBuilder mStatementBuilder;
+        private final JoinNode mJoinNode;
+
+        private List<PropertyFilter<S>> mPropertyFilters;
+        private List<Boolean> mPropertyFilterNullable;
+
+        WhereBuilder(StatementBuilder statementBuilder, JoinNode jn) {
+            mStatementBuilder = statementBuilder;
+            mJoinNode = jn;
+            mPropertyFilters = new ArrayList<PropertyFilter<S>>();
+            mPropertyFilterNullable = new ArrayList<Boolean>();
+        }
+
+        @SuppressWarnings("unchecked")
+        public PropertyFilter<S>[] getPropertyFilters() {
+            return mPropertyFilters.toArray(new PropertyFilter[mPropertyFilters.size()]);
+        }
+
+        public boolean[] getPropertyFilterNullable() {
+            boolean[] array = new boolean[mPropertyFilterNullable.size()];
+            for (int i=0; i<array.length; i++) {
+                array[i] = mPropertyFilterNullable.get(i);
+            }
+            return array;
+        }
+
+        public FetchException visit(OrFilter<S> filter, Object param) {
+            FetchException e;
+            mStatementBuilder.append('(');
+            e = filter.getLeftFilter().accept(this, null);
+            if (e != null) {
+                return e;
+            }
+            mStatementBuilder.append(" OR ");
+            e = filter.getRightFilter().accept(this, null);
+            if (e != null) {
+                return e;
+            }
+            mStatementBuilder.append(')');
+            return null;
+        }
+
+        public FetchException visit(AndFilter<S> filter, Object param) {
+            FetchException e;
+            mStatementBuilder.append('(');
+            e = filter.getLeftFilter().accept(this, null);
+            if (e != null) {
+                return e;
+            }
+            mStatementBuilder.append(" AND ");
+            e = filter.getRightFilter().accept(this, null);
+            if (e != null) {
+                return e;
+            }
+            mStatementBuilder.append(')');
+            return null;
+        }
+
+        public FetchException visit(PropertyFilter<S> filter, Object param) {
+            try {
+                mStatementBuilder.appendColumn(mJoinNode, filter.getChainedProperty());
+            } catch (FetchException e) {
+                return e;
+            }
+
+            mPropertyFilters.add(filter);
+
+            RelOp op = filter.getOperator();
+            StorableProperty<?> property = filter.getChainedProperty().getLastProperty();
+
+            if (property.isNullable() && (op == RelOp.EQ || op == RelOp.NE)) {
+                mPropertyFilterNullable.add(true);
+                mStatementBuilder.append(new NullablePropertyStatement<S>(filter, op == RelOp.EQ));
+            } else {
+                mPropertyFilterNullable.add(false);
+                if (op == RelOp.NE) {
+                    mStatementBuilder.append("<>");
+                } else {
+                    mStatementBuilder.append(op.toString());
+                }
+                mStatementBuilder.append('?');
+            }
+
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupport.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupport.java
new file mode 100644
index 0000000..f178b7d
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupport.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.PreparedStatement;
+
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+
+import com.amazon.carbonado.spi.MasterSupport;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+public interface JDBCSupport<S extends Storable> extends MasterSupport<S> {
+    public JDBCRepository getJDBCRepository();
+
+    /**
+     * @param loader used to reload Blob outside original transaction
+     */
+    public com.amazon.carbonado.lob.Blob convertBlob(java.sql.Blob blob, JDBCBlobLoader loader)
+        throws FetchException;
+
+    /**
+     * @param loader used to reload Clob outside original transaction
+     */
+    public com.amazon.carbonado.lob.Clob convertClob(java.sql.Clob clob, JDBCClobLoader loader)
+        throws FetchException;
+
+    /**
+     * @return original blob if too large and post-insert update is required, null otherwise
+     * @throws PersistException instead of FetchException since this code is
+     * called during an insert operation
+     */
+    public com.amazon.carbonado.lob.Blob setBlobValue(PreparedStatement ps, int column,
+                                                      com.amazon.carbonado.lob.Blob blob)
+        throws PersistException;
+
+    /**
+     * @return original clob if too large and post-insert update is required, null otherwise
+     * @throws PersistException instead of FetchException since this code is
+     * called during an insert operation
+     */
+    public com.amazon.carbonado.lob.Clob setClobValue(PreparedStatement ps, int column,
+                                                      com.amazon.carbonado.lob.Clob clob)
+        throws PersistException;
+
+    public void updateBlob(com.amazon.carbonado.lob.Blob oldBlob,
+                           com.amazon.carbonado.lob.Blob newBlob)
+        throws PersistException;
+
+    public void updateClob(com.amazon.carbonado.lob.Clob oldClob,
+                           com.amazon.carbonado.lob.Clob newClob)
+        throws PersistException;
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupportStrategy.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupportStrategy.java
new file mode 100644
index 0000000..b5d900a
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCSupportStrategy.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.io.Writer;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+
+import com.amazon.carbonado.util.ThrowUnchecked;
+
+import com.amazon.carbonado.spi.SequenceValueProducer;
+
+/**
+ * Allows database product specific features to be abstracted.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCSupportStrategy {
+    private static final int BLOB_BUFFER_SIZE = 4000;
+    private static final int CLOB_BUFFER_SIZE = 2000;
+
+    static JDBCSupportStrategy createStrategy(JDBCRepository repo) {
+        String databaseProductName = repo.getDatabaseProductName();
+        if (databaseProductName != null && databaseProductName.length() > 0) {
+            String className =
+                "com.amazon.carbonado.repo.jdbc." +
+                Character.toUpperCase(databaseProductName.charAt(0)) +
+                databaseProductName.substring(1).toLowerCase() +
+                "SupportStrategy";
+            try {
+                Class<JDBCSupportStrategy> clazz =
+                    (Class<JDBCSupportStrategy>) Class.forName(className);
+                return clazz.getDeclaredConstructor(JDBCRepository.class).newInstance(repo);
+            } catch (ClassNotFoundException e) {
+                // just use default strategy
+            } catch (Exception e) {
+                ThrowUnchecked.fireFirstDeclaredCause(e);
+            }
+        }
+
+        return new JDBCSupportStrategy(repo);
+    }
+
+    protected final JDBCRepository mRepo;
+
+    private Map<String, SequenceValueProducer> mSequences;
+
+    protected JDBCSupportStrategy(JDBCRepository repo) {
+        mRepo = repo;
+    }
+
+    JDBCExceptionTransformer createExceptionTransformer() {
+        return new JDBCExceptionTransformer();
+    }
+
+    /**
+     * Utility method used by generated storables to get sequence values during
+     * an insert operation.
+     *
+     * @param sequenceName name of sequence
+     * @throws PersistException instead of FetchException since this code is
+     * called during an insert operation
+     */
+    synchronized SequenceValueProducer getSequenceValueProducer(String sequenceName)
+        throws PersistException
+    {
+        SequenceValueProducer sequence = mSequences == null ? null : mSequences.get(sequenceName);
+
+        if (sequence == null) {
+            String sequenceQuery = createSequenceQuery(sequenceName);
+            sequence = new JDBCSequenceValueProducer(mRepo, sequenceQuery);
+            if (mSequences == null) {
+                mSequences = new HashMap<String, SequenceValueProducer>();
+            }
+            mSequences.put(sequenceName, sequence);
+        }
+
+        return sequence;
+    }
+
+    String createSequenceQuery(String sequenceName) {
+        throw new UnsupportedOperationException
+            ("Sequences are not supported by default JDBC support strategy. " +
+             "If \"" + mRepo.getDatabaseProductName() + "\" actually does support sequences, " +
+             "then a custom support strategy might be available in a separate jar. " +
+             "If so, simply add it to your classpath.");
+    }
+
+    /**
+     * @param loader used to reload Blob outside original transaction
+     */
+    JDBCBlob convertBlob(java.sql.Blob blob, JDBCBlobLoader loader) {
+        return blob == null ? null: new JDBCBlob(mRepo, blob, loader);
+    }
+
+    /**
+     * @param loader used to reload Clob outside original transaction
+     */
+    JDBCClob convertClob(java.sql.Clob clob, JDBCClobLoader loader) {
+        return clob == null ? null : new JDBCClob(mRepo, clob, loader);
+    }
+
+    /**
+     * @return original blob if too large and post-insert update is required, null otherwise
+     */
+    com.amazon.carbonado.lob.Blob setBlobValue(PreparedStatement ps, int column,
+                                               com.amazon.carbonado.lob.Blob blob)
+        throws PersistException
+    {
+        try {
+            if (blob instanceof JDBCBlob) {
+                ps.setBlob(column, ((JDBCBlob) blob).getInternalBlobForPersist());
+                return null;
+            }
+
+            long length = blob.getLength();
+
+            if (((long) ((int) length)) != length) {
+                throw new PersistException("BLOB length is too long: " + length);
+            }
+
+            ps.setBinaryStream(column, blob.openInputStream(), (int) length);
+            return null;
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        }
+    }
+
+    /**
+     * @return original clob if too large and post-insert update is required, null otherwise
+     */
+    com.amazon.carbonado.lob.Clob setClobValue(PreparedStatement ps, int column,
+                                               com.amazon.carbonado.lob.Clob clob)
+        throws PersistException
+    {
+        try {
+            if (clob instanceof JDBCClob) {
+                ps.setClob(column, ((JDBCClob) clob).getInternalClobForPersist());
+                return null;
+            }
+
+            long length = clob.getLength();
+
+            if (((long) ((int) length)) != length) {
+                throw new PersistException("CLOB length is too long: " + length);
+            }
+
+            ps.setCharacterStream(column, clob.openReader(), (int) length);
+            return null;
+        } catch (SQLException e) {
+            throw mRepo.toPersistException(e);
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        }
+    }
+
+    void updateBlob(com.amazon.carbonado.lob.Blob oldBlob, com.amazon.carbonado.lob.Blob newBlob)
+        throws PersistException
+    {
+        try {
+            OutputStream out = oldBlob.openOutputStream();
+            InputStream in = newBlob.openInputStream();
+            byte[] buf = new byte[BLOB_BUFFER_SIZE];
+            int amt;
+            while ((amt = in.read(buf)) > 0) {
+                out.write(buf, 0, amt);
+            }
+            in.close();
+            out.close();
+            oldBlob.setLength(newBlob.getLength());
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        } catch (IOException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    void updateClob(com.amazon.carbonado.lob.Clob oldClob, com.amazon.carbonado.lob.Clob newClob)
+        throws PersistException
+    {
+        try {
+            Writer out = oldClob.openWriter();
+            Reader in = newClob.openReader();
+            char[] buf = new char[CLOB_BUFFER_SIZE];
+            int amt;
+            while ((amt = in.read(buf)) > 0) {
+                out.write(buf, 0, amt);
+            }
+            in.close();
+            out.close();
+            oldClob.setLength(newClob.getLength());
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        } catch (IOException e) {
+            throw mRepo.toPersistException(e);
+        }
+    }
+
+    boolean printPlan(Appendable app, int indentLevel, String statement)
+        throws FetchException, IOException
+    {
+        return false;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransaction.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransaction.java
new file mode 100644
index 0000000..d92228d
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransaction.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import java.sql.Connection;
+import java.sql.Savepoint;
+import java.sql.SQLException;
+
+import com.amazon.carbonado.IsolationLevel;
+
+/**
+ * JDBCTransaction is just a wrapper around a connection and (optionally) a
+ * savepoint.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCTransaction {
+    private final Connection mConnection;
+    // Use TRANSACTION_NONE as a magic value to indicate that the isolation
+    // level need not be changed when the transaction ends. This is a little
+    // optimization to avoid a round trip call to the remote database.
+    private final int mOriginalLevel;
+    private Savepoint mSavepoint;
+
+    private List<JDBCLob> mRegisteredLobs;
+
+    JDBCTransaction(Connection con) {
+        mConnection = con;
+        // Don't change level upon abort.
+        mOriginalLevel = Connection.TRANSACTION_NONE;
+    }
+
+    /**
+     * Construct a nested transaction.
+     */
+    JDBCTransaction(JDBCTransaction parent, IsolationLevel level) throws SQLException {
+        mConnection = parent.mConnection;
+
+        if (level == null) {
+            // Don't change level upon abort.
+            mOriginalLevel = Connection.TRANSACTION_NONE;
+        } else {
+            int newLevel = JDBCRepository.mapIsolationLevelToJdbc(level);
+            int originalLevel = mConnection.getTransactionIsolation();
+            if (newLevel == originalLevel) {
+                // Don't change level upon abort.
+                mOriginalLevel = Connection.TRANSACTION_NONE;
+            } else {
+                // Don't change level upon abort.
+                mOriginalLevel = originalLevel;
+                mConnection.setTransactionIsolation(newLevel);
+            }
+        }
+
+        mSavepoint = mConnection.setSavepoint();
+    }
+
+    Connection getConnection() {
+        return mConnection;
+    }
+
+    void commit() throws SQLException {
+        if (mSavepoint == null) {
+            mConnection.commit();
+        } else {
+            // Don't commit, make a new savepoint. Root transaction has no
+            // savepoint, and so it will do the real commit.
+            mSavepoint = mConnection.setSavepoint();
+        }
+    }
+
+    /**
+     * @return connection to close, or null if not ready to because this was a
+     * nested transaction
+     */
+    Connection abort() throws SQLException {
+        if (mRegisteredLobs != null) {
+            for (JDBCLob lob : mRegisteredLobs) {
+                lob.close();
+            }
+            mRegisteredLobs = null;
+        }
+        if (mSavepoint == null) {
+            mConnection.rollback();
+            mConnection.setAutoCommit(true);
+            return mConnection;
+        } else {
+            mConnection.rollback(mSavepoint);
+            if (mOriginalLevel != Connection.TRANSACTION_NONE) {
+                mConnection.setTransactionIsolation(mOriginalLevel);
+            }
+            mSavepoint = null;
+            return null;
+        }
+    }
+
+    void register(JDBCLob lob) {
+        if (mRegisteredLobs == null) {
+            mRegisteredLobs = new ArrayList<JDBCLob>(4);
+        }
+        mRegisteredLobs.add(lob);
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransactionManager.java b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransactionManager.java
new file mode 100644
index 0000000..7fa3fcc
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/JDBCTransactionManager.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.lang.ref.WeakReference;
+import java.sql.Connection;
+import java.sql.SQLException;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.spi.TransactionManager;
+
+/**
+ * Manages transactions for JDBCRepository. Only one instance is allocated per
+ * thread.
+ *
+ * @author Brian S O'Neill
+ */
+class JDBCTransactionManager extends TransactionManager<JDBCTransaction> {
+    // Weakly reference repository because thread locals are not cleaned up
+    // very quickly.
+    private final WeakReference<JDBCRepository> mRepositoryRef;
+
+    JDBCTransactionManager(JDBCRepository repository) {
+        super(repository.getExceptionTransformer());
+        mRepositoryRef = new WeakReference<JDBCRepository>(repository);
+    }
+
+    @Override
+    public boolean isForUpdate() {
+        return super.isForUpdate() && mRepositoryRef.get().supportsSelectForUpdate();
+    }
+
+    protected IsolationLevel selectIsolationLevel(Transaction parent, IsolationLevel level) {
+        JDBCRepository repo = mRepositoryRef.get();
+        if (repo == null) {
+            throw new IllegalStateException("Repository closed");
+        }
+        return repo.selectIsolationLevel(parent, level);
+    }
+
+    protected JDBCTransaction createTxn(JDBCTransaction parent, IsolationLevel level)
+        throws SQLException, FetchException
+    {
+        JDBCRepository repo = mRepositoryRef.get();
+        if (repo == null) {
+            throw new IllegalStateException("Repository closed");
+        }
+
+        if (parent != null) {
+            if (!repo.supportsSavepoints()) {
+                // No support for nested transactions, so fake it.
+                return parent;
+            }
+            return new JDBCTransaction(parent, level);
+        }
+
+        return new JDBCTransaction(repo.getConnectionForTxn(level));
+    }
+
+    protected boolean commitTxn(JDBCTransaction txn) throws SQLException {
+        txn.commit();
+        return true;
+    }
+
+    protected void abortTxn(JDBCTransaction txn) throws SQLException, FetchException {
+        Connection con;
+        if ((con = txn.abort()) != null) {
+            JDBCRepository repo = mRepositoryRef.get();
+            if (repo == null) {
+                con.close();
+            } else {
+                repo.yieldConnection(con);
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingCallableStatement.java b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingCallableStatement.java
new file mode 100644
index 0000000..86ac2e0
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingCallableStatement.java
@@ -0,0 +1,392 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.math.BigDecimal;
+import java.util.Calendar;
+import java.sql.*;
+
+import org.apache.commons.logging.Log;
+
+/**
+ * CallableStatement returned by LoggingConnection;
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingCallableStatement extends LoggingPreparedStatement implements CallableStatement {
+    LoggingCallableStatement(Log log, Connection con, CallableStatement ps, String sql) {
+        super(log, con, ps, sql);
+    }
+
+    public void registerOutParameter(int parameterIndex, int sqlType) throws SQLException {
+        cs().registerOutParameter(parameterIndex, sqlType);
+    }
+
+    public void registerOutParameter(int parameterIndex, int sqlType, int scale)
+        throws SQLException
+    {
+        cs().registerOutParameter(parameterIndex, sqlType, scale);
+    }
+
+    public boolean wasNull() throws SQLException {
+        return cs().wasNull();
+    }
+
+    public String getString(int parameterIndex) throws SQLException {
+        return cs().getString(parameterIndex);
+    }
+
+    public boolean getBoolean(int parameterIndex) throws SQLException {
+        return cs().getBoolean(parameterIndex);
+    }
+
+    public byte getByte(int parameterIndex) throws SQLException {
+        return cs().getByte(parameterIndex);
+    }
+
+    public short getShort(int parameterIndex) throws SQLException {
+        return cs().getShort(parameterIndex);
+    }
+
+    public int getInt(int parameterIndex) throws SQLException {
+        return cs().getInt(parameterIndex);
+    }
+
+    public long getLong(int parameterIndex) throws SQLException {
+        return cs().getLong(parameterIndex);
+    }
+
+    public float getFloat(int parameterIndex) throws SQLException {
+        return cs().getFloat(parameterIndex);
+    }
+
+    public double getDouble(int parameterIndex) throws SQLException {
+        return cs().getDouble(parameterIndex);
+    }
+
+    @Deprecated
+    public BigDecimal getBigDecimal(int parameterIndex, int scale) throws SQLException {
+        return cs().getBigDecimal(parameterIndex, scale);
+    }
+
+    public byte[] getBytes(int parameterIndex) throws SQLException {
+        return cs().getBytes(parameterIndex);
+    }
+
+    public java.sql.Date getDate(int parameterIndex) throws SQLException {
+        return cs().getDate(parameterIndex);
+    }
+
+    public java.sql.Time getTime(int parameterIndex) throws SQLException {
+        return cs().getTime(parameterIndex);
+    }
+
+    public java.sql.Timestamp getTimestamp(int parameterIndex)
+        throws SQLException
+    {
+        return cs().getTimestamp(parameterIndex);
+    }
+
+    public Object getObject(int parameterIndex) throws SQLException {
+        return cs().getObject(parameterIndex);
+    }
+
+    public BigDecimal getBigDecimal(int parameterIndex) throws SQLException {
+        return cs().getBigDecimal(parameterIndex);
+    }
+
+    public Object getObject(int i, java.util.Map<String,Class<?>> map) throws SQLException {
+        return cs().getObject(i, map);
+    }
+
+    public Ref getRef(int i) throws SQLException {
+        return cs().getRef(i);
+    }
+
+    public Blob getBlob(int i) throws SQLException {
+        return cs().getBlob(i);
+    }
+
+    public Clob getClob(int i) throws SQLException {
+        return cs().getClob(i);
+    }
+
+    public Array getArray(int i) throws SQLException {
+        return cs().getArray(i);
+    }
+
+    public java.sql.Date getDate(int parameterIndex, Calendar cal) throws SQLException {
+        return cs().getDate(parameterIndex, cal);
+    }
+
+    public java.sql.Time getTime(int parameterIndex, Calendar cal) throws SQLException {
+        return cs().getTime(parameterIndex, cal);
+    }
+
+    public java.sql.Timestamp getTimestamp(int parameterIndex, Calendar cal) throws SQLException {
+        return cs().getTimestamp(parameterIndex, cal);
+    }
+
+    public void registerOutParameter(int paramIndex, int sqlType, String typeName)
+        throws SQLException
+    {
+        cs().registerOutParameter(paramIndex, sqlType, typeName);
+    }
+
+    public void registerOutParameter(String parameterName, int sqlType) throws SQLException {
+        cs().registerOutParameter(parameterName, sqlType);
+    }
+
+    public void registerOutParameter(String parameterName, int sqlType, int scale)
+        throws SQLException
+    {
+        cs().registerOutParameter(parameterName, sqlType, scale);
+    }
+
+    public void registerOutParameter(String parameterName, int sqlType, String typeName)
+        throws SQLException
+    {
+        cs().registerOutParameter(parameterName, sqlType, typeName);
+    }
+
+    public java.net.URL getURL(int parameterIndex) throws SQLException {
+        return cs().getURL(parameterIndex);
+    }
+
+    public void setURL(String parameterName, java.net.URL val) throws SQLException {
+        cs().setURL(parameterName, val);
+    }
+
+    public void setNull(String parameterName, int sqlType) throws SQLException {
+        cs().setNull(parameterName, sqlType);
+    }
+
+    public void setBoolean(String parameterName, boolean x) throws SQLException {
+        cs().setBoolean(parameterName, x);
+    }
+
+    public void setByte(String parameterName, byte x) throws SQLException {
+        cs().setByte(parameterName, x);
+    }
+
+    public void setShort(String parameterName, short x) throws SQLException {
+        cs().setShort(parameterName, x);
+    }
+
+    public void setInt(String parameterName, int x) throws SQLException {
+        cs().setInt(parameterName, x);
+    }
+
+    public void setLong(String parameterName, long x) throws SQLException {
+        cs().setLong(parameterName, x);
+    }
+
+    public void setFloat(String parameterName, float x) throws SQLException {
+        cs().setFloat(parameterName, x);
+    }
+
+    public void setDouble(String parameterName, double x) throws SQLException {
+        cs().setDouble(parameterName, x);
+    }
+
+    public void setBigDecimal(String parameterName, BigDecimal x) throws SQLException {
+        cs().setBigDecimal(parameterName, x);
+    }
+
+    public void setString(String parameterName, String x) throws SQLException {
+        cs().setString(parameterName, x);
+    }
+
+    public void setBytes(String parameterName, byte x[]) throws SQLException {
+        cs().setBytes(parameterName, x);
+    }
+
+    public void setDate(String parameterName, java.sql.Date x) throws SQLException {
+        cs().setDate(parameterName, x);
+    }
+
+    public void setTime(String parameterName, java.sql.Time x) throws SQLException {
+        cs().setTime(parameterName, x);
+    }
+
+    public void setTimestamp(String parameterName, java.sql.Timestamp x) throws SQLException {
+        cs().setTimestamp(parameterName, x);
+    }
+
+    public void setAsciiStream(String parameterName, java.io.InputStream x, int length)
+        throws SQLException
+    {
+        cs().setAsciiStream(parameterName, x, length);
+    }
+
+    public void setBinaryStream(String parameterName, java.io.InputStream x,
+                                int length)
+        throws SQLException
+    {
+        cs().setBinaryStream(parameterName, x, length);
+    }
+
+    public void setObject(String parameterName, Object x, int targetSqlType, int scale)
+        throws SQLException
+    {
+        cs().setObject(parameterName, x, targetSqlType, scale);
+    }
+
+    public void setObject(String parameterName, Object x, int targetSqlType)
+        throws SQLException
+    {
+        cs().setObject(parameterName, x, targetSqlType);
+    }
+
+    public void setObject(String parameterName, Object x) throws SQLException {
+        cs().setObject(parameterName, x);
+    }
+
+    public void setCharacterStream(String parameterName,
+                                   java.io.Reader reader,
+                                   int length)
+        throws SQLException
+    {
+        cs().setCharacterStream(parameterName, reader, length);
+    }
+
+    public void setDate(String parameterName, java.sql.Date x, Calendar cal)
+        throws SQLException
+    {
+        cs().setDate(parameterName, x, cal);
+    }
+
+    public void setTime(String parameterName, java.sql.Time x, Calendar cal)
+        throws SQLException
+    {
+        cs().setTime(parameterName, x, cal);
+    }
+
+    public void setTimestamp(String parameterName, java.sql.Timestamp x, Calendar cal)
+        throws SQLException
+    {
+        cs().setTimestamp(parameterName, x, cal);
+    }
+
+    public void setNull(String parameterName, int sqlType, String typeName)
+        throws SQLException
+    {
+        cs().setNull(parameterName, sqlType, typeName);
+    }
+
+    public String getString(String parameterName) throws SQLException {
+        return cs().getString(parameterName);
+    }
+
+    public boolean getBoolean(String parameterName) throws SQLException {
+        return cs().getBoolean(parameterName);
+    }
+
+    public byte getByte(String parameterName) throws SQLException {
+        return cs().getByte(parameterName);
+    }
+
+    public short getShort(String parameterName) throws SQLException {
+        return cs().getShort(parameterName);
+    }
+
+    public int getInt(String parameterName) throws SQLException {
+        return cs().getInt(parameterName);
+    }
+
+    public long getLong(String parameterName) throws SQLException {
+        return cs().getLong(parameterName);
+    }
+
+    public float getFloat(String parameterName) throws SQLException {
+        return cs().getFloat(parameterName);
+    }
+
+    public double getDouble(String parameterName) throws SQLException {
+        return cs().getDouble(parameterName);
+    }
+
+    public byte[] getBytes(String parameterName) throws SQLException {
+        return cs().getBytes(parameterName);
+    }
+
+    public java.sql.Date getDate(String parameterName) throws SQLException {
+        return cs().getDate(parameterName);
+    }
+
+    public java.sql.Time getTime(String parameterName) throws SQLException {
+        return cs().getTime(parameterName);
+    }
+
+    public java.sql.Timestamp getTimestamp(String parameterName) throws SQLException {
+        return cs().getTimestamp(parameterName);
+    }
+
+    public Object getObject(String parameterName) throws SQLException {
+        return cs().getObject(parameterName);
+    }
+
+    public BigDecimal getBigDecimal(String parameterName) throws SQLException {
+        return cs().getBigDecimal(parameterName);
+    }
+
+    public Object getObject(String parameterName, java.util.Map<String,Class<?>> map)
+        throws SQLException
+    {
+        return cs().getObject(parameterName, map);
+    }
+
+    public Ref getRef(String parameterName) throws SQLException {
+        return cs().getRef(parameterName);
+    }
+
+    public Blob getBlob(String parameterName) throws SQLException {
+        return cs().getBlob(parameterName);
+    }
+
+    public Clob getClob(String parameterName) throws SQLException {
+        return cs().getClob(parameterName);
+    }
+
+    public Array getArray(String parameterName) throws SQLException {
+        return cs().getArray(parameterName);
+    }
+
+    public java.sql.Date getDate(String parameterName, Calendar cal) throws SQLException {
+        return cs().getDate(parameterName, cal);
+    }
+
+    public java.sql.Time getTime(String parameterName, Calendar cal) throws SQLException {
+        return cs().getTime(parameterName, cal);
+    }
+
+    public java.sql.Timestamp getTimestamp(String parameterName, Calendar cal)
+        throws SQLException
+    {
+        return cs().getTimestamp(parameterName, cal);
+    }
+
+    public java.net.URL getURL(String parameterName) throws SQLException {
+        return cs().getURL(parameterName);
+    }
+
+    private CallableStatement cs() {
+        return (CallableStatement) mStatement;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingConnection.java b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingConnection.java
new file mode 100644
index 0000000..5d3327e
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingConnection.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.*;
+
+import org.apache.commons.logging.Log;
+
+/**
+ * Connection returned by LoggingDataSource.
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingConnection implements Connection {
+    private final Log mLog;
+    private final Connection mCon;
+
+    LoggingConnection(Log log, Connection con) {
+        mLog = log;
+        mCon = con;
+    }
+
+    public Statement createStatement() throws SQLException {
+        return new LoggingStatement(mLog, this, mCon.createStatement());
+    }
+
+    public Statement createStatement(int resultSetType, int resultSetConcurrency)
+        throws SQLException
+    {
+        return new LoggingStatement
+            (mLog, this, mCon.createStatement(resultSetType, resultSetConcurrency));
+    }
+
+    public Statement createStatement(int resultSetType, int resultSetConcurrency,
+                                     int resultSetHoldability)
+        throws SQLException
+    {
+        return new LoggingStatement(mLog, this,
+                                    mCon.createStatement(resultSetType, resultSetConcurrency,
+                                                         resultSetHoldability));
+    }
+
+    public PreparedStatement prepareStatement(String sql) throws SQLException {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql), sql);
+    }
+
+    public PreparedStatement prepareStatement(String sql, int resultSetType,
+                                              int resultSetConcurrency)
+        throws SQLException
+    {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql, resultSetType, resultSetConcurrency), sql);
+    }
+
+    public PreparedStatement prepareStatement(String sql, int resultSetType,
+                                              int resultSetConcurrency, int resultSetHoldability)
+        throws SQLException
+    {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql, resultSetType,
+                                               resultSetConcurrency, resultSetHoldability), sql);
+    }
+
+    public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys)
+        throws SQLException
+    {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql, autoGeneratedKeys), sql);
+    }
+
+    public PreparedStatement prepareStatement(String sql, int columnIndexes[])
+        throws SQLException
+    {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql, columnIndexes), sql);
+    }
+
+    public PreparedStatement prepareStatement(String sql, String columnNames[])
+        throws SQLException
+    {
+        return new LoggingPreparedStatement
+            (mLog, this, mCon.prepareStatement(sql, columnNames), sql);
+    }
+
+    public CallableStatement prepareCall(String sql) throws SQLException {
+        return new LoggingCallableStatement(mLog, this, mCon.prepareCall(sql), sql);
+    }
+
+    public CallableStatement prepareCall(String sql, int resultSetType,
+                                         int resultSetConcurrency)
+        throws SQLException
+    {
+        return new LoggingCallableStatement
+            (mLog, this, mCon.prepareCall(sql, resultSetType, resultSetConcurrency), sql);
+    }
+
+    public CallableStatement prepareCall(String sql, int resultSetType,
+                                         int resultSetConcurrency,
+                                         int resultSetHoldability)
+        throws SQLException
+    {
+        return new LoggingCallableStatement
+            (mLog, this, mCon.prepareCall(sql, resultSetType,
+                                          resultSetConcurrency, resultSetHoldability), sql);
+    }
+
+    public String nativeSQL(String sql) throws SQLException {
+        return mCon.nativeSQL(sql);
+    }
+
+    public void setAutoCommit(boolean autoCommit) throws SQLException {
+        mCon.setAutoCommit(autoCommit);
+    }
+
+    public boolean getAutoCommit() throws SQLException {
+        return mCon.getAutoCommit();
+    }
+
+    public void commit() throws SQLException {
+        mLog.debug("Connection.commit()");
+        mCon.commit();
+    }
+
+    public void rollback() throws SQLException {
+        mLog.debug("Connection.rollback()");
+        mCon.rollback();
+    }
+
+    public void close() throws SQLException {
+        mLog.debug("Connection.close()");
+        mCon.close();
+    }
+
+    public boolean isClosed() throws SQLException {
+        return mCon.isClosed();
+    }
+
+    public DatabaseMetaData getMetaData() throws SQLException {
+        mLog.debug("Connection.getMetaData()");
+        return mCon.getMetaData();
+    }
+
+    public void setReadOnly(boolean readOnly) throws SQLException {
+        mCon.setReadOnly(readOnly);
+    }
+
+    public boolean isReadOnly() throws SQLException {
+        return mCon.isReadOnly();
+    }
+
+    public void setCatalog(String catalog) throws SQLException {
+        mCon.setCatalog(catalog);
+    }
+
+    public String getCatalog() throws SQLException {
+        return mCon.getCatalog();
+    }
+
+    public void setTransactionIsolation(int level) throws SQLException {
+        mCon.setTransactionIsolation(level);
+    }
+
+    public int getTransactionIsolation() throws SQLException {
+        return mCon.getTransactionIsolation();
+    }
+
+    public SQLWarning getWarnings() throws SQLException {
+        return mCon.getWarnings();
+    }
+
+    public void clearWarnings() throws SQLException {
+        mCon.clearWarnings();
+    }
+
+    public java.util.Map<String,Class<?>> getTypeMap() throws SQLException {
+        return mCon.getTypeMap();
+    }
+
+    public void setTypeMap(java.util.Map<String,Class<?>> map) throws SQLException {
+        mCon.setTypeMap(map);
+    }
+
+    public void setHoldability(int holdability) throws SQLException {
+        mCon.setHoldability(holdability);
+    }
+
+    public int getHoldability() throws SQLException {
+        return mCon.getHoldability();
+    }
+
+    public Savepoint setSavepoint() throws SQLException {
+        mLog.debug("Connection.setSavepoint()");
+        return mCon.setSavepoint();
+    }
+
+    public Savepoint setSavepoint(String name) throws SQLException {
+        mLog.debug("Connection.setSavepoint(name)");
+        return mCon.setSavepoint(name);
+    }
+
+    public void rollback(Savepoint savepoint) throws SQLException {
+        mLog.debug("Connection.rollback(savepoint)");
+        mCon.rollback(savepoint);
+    }
+
+    public void releaseSavepoint(Savepoint savepoint) throws SQLException {
+        mLog.debug("Connection.releaseSavepoint(savepoint)");
+        mCon.releaseSavepoint(savepoint);
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingDataSource.java b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingDataSource.java
new file mode 100644
index 0000000..f612956
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingDataSource.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.io.PrintWriter;
+import javax.sql.DataSource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+/**
+ * Wraps another DataSource such that all SQL statements are logged as debug.
+ *
+ * @author Brian S O'Neill
+ */
+public class LoggingDataSource implements DataSource {
+    /**
+     * Wraps the given DataSource which logs to the default log. If debug
+     * logging is disabled, the original DataSource is returned.
+     */
+    public static DataSource create(DataSource ds) {
+        return create(ds, null);
+    }
+
+    /**
+     * Wraps the given DataSource which logs to the given log. If debug logging
+     * is disabled, the original DataSource is returned.
+     */
+    public static DataSource create(DataSource ds, Log log) {
+        if (ds == null) {
+            throw new IllegalArgumentException();
+        }
+        if (log == null) {
+            log = LogFactory.getLog(LoggingDataSource.class);
+        }
+        if (!log.isDebugEnabled()) {
+            return ds;
+        }
+        return new LoggingDataSource(log, ds);
+    }
+
+    private final Log mLog;
+    private final DataSource mDataSource;
+
+    private LoggingDataSource(Log log, DataSource ds) {
+        mLog = log;
+        mDataSource = ds;
+    }
+
+    public Connection getConnection() throws SQLException {
+        mLog.debug("DataSource.getConnection()");
+        return new LoggingConnection(mLog, mDataSource.getConnection());
+    }
+
+    public Connection getConnection(String username, String password) throws SQLException {
+        mLog.debug("DataSource.getConnection(username, password)");
+        return new LoggingConnection(mLog, mDataSource.getConnection(username, password));
+    }
+
+    public PrintWriter getLogWriter() throws SQLException {
+        return mDataSource.getLogWriter();
+    }
+
+    public void setLogWriter(PrintWriter writer) throws SQLException {
+        mDataSource.setLogWriter(writer);
+    }
+
+    public void setLoginTimeout(int seconds) throws SQLException {
+        mDataSource.setLoginTimeout(seconds);
+    }
+
+    public int getLoginTimeout() throws SQLException {
+        return mDataSource.getLoginTimeout();
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingPreparedStatement.java b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingPreparedStatement.java
new file mode 100644
index 0000000..be2c48c
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingPreparedStatement.java
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.math.BigDecimal;
+import java.util.Calendar;
+import java.sql.*;
+
+import org.apache.commons.logging.Log;
+
+/**
+ * PreparedStatement returned by LoggingConnection;
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingPreparedStatement extends LoggingStatement implements PreparedStatement {
+    protected final String mSQL;
+
+    LoggingPreparedStatement(Log log, Connection con, PreparedStatement ps, String sql) {
+        super(log, con, ps);
+        mSQL = sql;
+    }
+
+    public ResultSet executeQuery() throws SQLException {
+        mLog.debug(mSQL);
+        return ps().executeQuery();
+    }
+
+    public int executeUpdate() throws SQLException {
+        mLog.debug(mSQL);
+        return ps().executeUpdate();
+    }
+
+    public boolean execute() throws SQLException {
+        mLog.debug(mSQL);
+        return ps().execute();
+    }
+
+    public void addBatch() throws SQLException {
+        // TODO: add local batch
+        ps().addBatch();
+    }
+
+    public void clearParameters() throws SQLException {
+        // TODO: clear locally
+        ps().clearParameters();
+    }
+
+    public void setNull(int parameterIndex, int sqlType) throws SQLException {
+        // TODO: set locally
+        ps().setNull(parameterIndex, sqlType);
+    }
+
+    public void setBoolean(int parameterIndex, boolean x) throws SQLException {
+        // TODO: set locally
+        ps().setBoolean(parameterIndex, x);
+    }
+
+    public void setByte(int parameterIndex, byte x) throws SQLException {
+        // TODO: set locally
+        ps().setByte(parameterIndex, x);
+    }
+
+    public void setShort(int parameterIndex, short x) throws SQLException {
+        // TODO: set locally
+        ps().setShort(parameterIndex, x);
+    }
+
+    public void setInt(int parameterIndex, int x) throws SQLException {
+        // TODO: set locally
+        ps().setInt(parameterIndex, x);
+    }
+
+    public void setLong(int parameterIndex, long x) throws SQLException {
+        // TODO: set locally
+        ps().setLong(parameterIndex, x);
+    }
+
+    public void setFloat(int parameterIndex, float x) throws SQLException {
+        // TODO: set locally
+        ps().setFloat(parameterIndex, x);
+    }
+
+    public void setDouble(int parameterIndex, double x) throws SQLException {
+        // TODO: set locally
+        ps().setDouble(parameterIndex, x);
+    }
+
+    public void setBigDecimal(int parameterIndex, BigDecimal x) throws SQLException {
+        // TODO: set locally
+        ps().setBigDecimal(parameterIndex, x);
+    }
+
+    public void setString(int parameterIndex, String x) throws SQLException {
+        // TODO: set locally
+        ps().setString(parameterIndex, x);
+    }
+
+    public void setBytes(int parameterIndex, byte x[]) throws SQLException {
+        // TODO: set locally
+        ps().setBytes(parameterIndex, x);
+    }
+
+    public void setDate(int parameterIndex, java.sql.Date x)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setDate(parameterIndex, x);
+    }
+
+    public void setTime(int parameterIndex, java.sql.Time x)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setTime(parameterIndex, x);
+    }
+
+    public void setTimestamp(int parameterIndex, java.sql.Timestamp x)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setTimestamp(parameterIndex, x);
+    }
+
+    public void setAsciiStream(int parameterIndex, java.io.InputStream x, int length)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setAsciiStream(parameterIndex, x, length);
+    }
+
+    @Deprecated
+    public void setUnicodeStream(int parameterIndex, java.io.InputStream x, int length)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setUnicodeStream(parameterIndex, x, length);
+    }
+
+    public void setBinaryStream(int parameterIndex, java.io.InputStream x, int length)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setBinaryStream(parameterIndex, x, length);
+    }
+
+    public void setObject(int parameterIndex, Object x, int targetSqlType, int scale)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setObject(parameterIndex, x, targetSqlType, scale);
+    }
+
+    public void setObject(int parameterIndex, Object x, int targetSqlType)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setObject(parameterIndex, x, targetSqlType);
+    }
+
+    public void setObject(int parameterIndex, Object x) throws SQLException {
+        // TODO: set locally
+        ps().setObject(parameterIndex, x);
+    }
+
+    public void setCharacterStream(int parameterIndex,
+                                   java.io.Reader reader,
+                                   int length)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setCharacterStream(parameterIndex, reader, length);
+    }
+
+    public void setRef(int i, Ref x) throws SQLException {
+        // TODO: set locally
+        ps().setRef(i, x);
+    }
+
+    public void setBlob(int i, Blob x) throws SQLException {
+        // TODO: set locally
+        ps().setBlob(i, x);
+    }
+
+    public void setClob(int i, Clob x) throws SQLException {
+        // TODO: set locally
+        ps().setClob(i, x);
+    }
+
+    public void setArray(int i, Array x) throws SQLException {
+        // TODO: set locally
+        ps().setArray(i, x);
+    }
+
+    public void setDate(int parameterIndex, java.sql.Date x, Calendar cal)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setDate(parameterIndex, x, cal);
+    }
+
+    public void setTime(int parameterIndex, java.sql.Time x, Calendar cal)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setTime(parameterIndex, x, cal);
+    }
+
+    public void setTimestamp(int parameterIndex, java.sql.Timestamp x, Calendar cal)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setTimestamp(parameterIndex, x, cal);
+    }
+
+    public void setNull(int paramIndex, int sqlType, String typeName)
+        throws SQLException
+    {
+        // TODO: set locally
+        ps().setNull(paramIndex, sqlType, typeName);
+    }
+
+    public void setURL(int parameterIndex, java.net.URL x) throws SQLException {
+        // TODO: set locally
+        ps().setURL(parameterIndex, x);
+    }
+
+    public ResultSetMetaData getMetaData() throws SQLException {
+        return ps().getMetaData();
+    }
+
+    public ParameterMetaData getParameterMetaData() throws SQLException {
+        return ps().getParameterMetaData();
+    }
+
+    private PreparedStatement ps() {
+        return (PreparedStatement) mStatement;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingStatement.java b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingStatement.java
new file mode 100644
index 0000000..790778f
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/LoggingStatement.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.*;
+
+import org.apache.commons.logging.Log;
+
+/**
+ * Statement returned by LoggingConnection;
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingStatement implements Statement {
+    private final Connection mCon;
+    protected final Log mLog;
+    protected final Statement mStatement;
+
+    LoggingStatement(Log log, Connection con, Statement statement) {
+        mLog = log;
+        mCon = con;
+        mStatement = statement;
+    }
+
+    public ResultSet executeQuery(String sql) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.executeQuery(sql);
+    }
+
+    public int executeUpdate(String sql) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.executeUpdate(sql);
+    }
+
+    public boolean execute(String sql) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.execute(sql);
+    }
+
+    public int executeUpdate(String sql, int autoGeneratedKeys) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.executeUpdate(sql, autoGeneratedKeys);
+    }
+
+    public int executeUpdate(String sql, int columnIndexes[]) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.executeUpdate(sql, columnIndexes);
+    }
+
+    public int executeUpdate(String sql, String columnNames[]) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.executeUpdate(sql, columnNames);
+    }
+
+    public boolean execute(String sql, int autoGeneratedKeys) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.execute(sql, autoGeneratedKeys);
+    }
+
+    public boolean execute(String sql, int columnIndexes[]) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.execute(sql, columnIndexes);
+    }
+
+    public boolean execute(String sql, String columnNames[]) throws SQLException {
+        mLog.debug(sql);
+        return mStatement.execute(sql, columnNames);
+    }
+
+    public void addBatch(String sql) throws SQLException {
+        // TODO: save locally
+        mStatement.addBatch(sql);
+    }
+
+    public void clearBatch() throws SQLException {
+        // TODO: clear locally
+        mStatement.clearBatch();
+    }
+
+    public int[] executeBatch() throws SQLException {
+        // TODO: log locally recorded batch
+        return mStatement.executeBatch();
+    }
+
+    public void close() throws SQLException {
+        mStatement.close();
+    }
+
+    public int getMaxFieldSize() throws SQLException {
+        return mStatement.getMaxFieldSize();
+    }
+
+    public void setMaxFieldSize(int max) throws SQLException {
+        mStatement.setMaxFieldSize(max);
+    }
+
+    public int getMaxRows() throws SQLException {
+        return mStatement.getMaxRows();
+    }
+
+    public void setMaxRows(int max) throws SQLException {
+        mStatement.setMaxRows(max);
+    }
+
+    public void setEscapeProcessing(boolean enable) throws SQLException {
+        mStatement.setEscapeProcessing(enable);
+    }
+
+    public int getQueryTimeout() throws SQLException {
+        return mStatement.getQueryTimeout();
+    }
+
+    public void setQueryTimeout(int seconds) throws SQLException {
+        mStatement.setQueryTimeout(seconds);
+    }
+
+    public void cancel() throws SQLException {
+        mStatement.cancel();
+    }
+
+    public SQLWarning getWarnings() throws SQLException {
+        return mStatement.getWarnings();
+    }
+
+    public void clearWarnings() throws SQLException {
+        mStatement.clearWarnings();
+    }
+
+    public void setCursorName(String name) throws SQLException {
+        mStatement.setCursorName(name);
+    }
+
+    public ResultSet getResultSet() throws SQLException {
+        return mStatement.getResultSet();
+    }
+
+    public int getUpdateCount() throws SQLException {
+        return mStatement.getUpdateCount();
+    }
+
+    public boolean getMoreResults() throws SQLException {
+        return mStatement.getMoreResults();
+    }
+
+    public void setFetchDirection(int direction) throws SQLException {
+        mStatement.setFetchDirection(direction);
+    }
+
+    public int getFetchDirection() throws SQLException {
+        return mStatement.getFetchDirection();
+    }
+
+    public void setFetchSize(int rows) throws SQLException {
+        mStatement.setFetchSize(rows);
+    }
+
+    public int getFetchSize() throws SQLException {
+        return mStatement.getFetchSize();
+    }
+
+    public int getResultSetConcurrency() throws SQLException {
+        return mStatement.getResultSetConcurrency();
+    }
+
+    public int getResultSetType()  throws SQLException {
+        return mStatement.getResultSetType();
+    }
+
+    public Connection getConnection() {
+        return mCon;
+    }
+
+    public boolean getMoreResults(int current) throws SQLException {
+        return mStatement.getMoreResults(current);
+    }
+
+    public ResultSet getGeneratedKeys() throws SQLException {
+        return mStatement.getGeneratedKeys();
+    }
+
+    public int getResultSetHoldability() throws SQLException {
+        return mStatement.getResultSetHoldability();
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/SimpleDataSource.java b/src/main/java/com/amazon/carbonado/repo/jdbc/SimpleDataSource.java
new file mode 100644
index 0000000..9c93fec
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/SimpleDataSource.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.jdbc;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.io.PrintWriter;
+import java.util.Properties;
+
+import javax.sql.DataSource;
+
+/**
+ * SimpleDataSource does not implement any connection pooling.
+ *
+ * @author Brian S O'Neill
+ */
+public class SimpleDataSource implements DataSource {
+    private final String mURL;
+    private final Properties mProperties;
+
+    private PrintWriter mLogWriter;
+
+    public SimpleDataSource(String driverClass, String driverURL, String username, String password)
+        throws SQLException
+    {
+        try {
+            Class.forName(driverClass);
+        } catch (ClassNotFoundException e) {
+            SQLException e2 = new SQLException();
+            e2.initCause(e);
+            throw e2;
+        }
+        mURL = driverURL;
+        mProperties = new Properties();
+        if (username != null) {
+            mProperties.put("user", username);
+        }
+        if (password != null) {
+            mProperties.put("password", password);
+        }
+    }
+
+    public Connection getConnection() throws SQLException {
+        return DriverManager.getConnection(mURL, mProperties);
+    }
+
+    public Connection getConnection(String username, String password) throws SQLException {
+        Properties props = new Properties();
+        if (username != null) {
+            props.put("user", username);
+        }
+        if (password != null) {
+            props.put("password", password);
+        }
+        return DriverManager.getConnection(mURL, props);
+    }
+
+    public PrintWriter getLogWriter() throws SQLException {
+        return mLogWriter;
+    }
+
+    public void setLogWriter(PrintWriter writer) throws SQLException {
+        mLogWriter = writer;
+    }
+
+    public void setLoginTimeout(int seconds) throws SQLException {
+    }
+
+    public int getLoginTimeout() throws SQLException {
+        return 0;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/jdbc/package-info.java b/src/main/java/com/amazon/carbonado/repo/jdbc/package-info.java
new file mode 100644
index 0000000..e7300a5
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/jdbc/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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.
+ */
+
+/**
+ * Repository implementation that connects to an external SQL database via
+ * JDBC. JDBC repository is not independent of the underlying database schema,
+ * and so it requires matching tables and columns in the database. It will not
+ * alter or create tables. Use the {@link com.amazon.carbonado.Alias Alias}
+ * annotation to control precisely which tables and columns must be matched up.
+ *
+ * @see com.amazon.carbonado.repo.jdbc.JDBCRepositoryBuilder
+ */
+package com.amazon.carbonado.repo.jdbc;
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/CommonsLog.java b/src/main/java/com/amazon/carbonado/repo/logging/CommonsLog.java
new file mode 100644
index 0000000..f3fca4b
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/CommonsLog.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+/**
+ * Log implementation that uses Jakarta Commons Logging at debug level.
+ *
+ * @author Brian S O'Neill
+ */
+public class CommonsLog implements Log {
+    private final org.apache.commons.logging.Log mLog;
+
+    public CommonsLog(org.apache.commons.logging.Log log) {
+        mLog = log;
+    }
+
+    public CommonsLog(Class clazz) {
+        mLog = org.apache.commons.logging.LogFactory.getLog(clazz);
+    }
+
+    public boolean isEnabled() {
+        return mLog.isDebugEnabled();
+    }
+
+    public void write(String message) {
+        mLog.debug(message);
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/Log.java b/src/main/java/com/amazon/carbonado/repo/logging/Log.java
new file mode 100644
index 0000000..d5ac74e
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/Log.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+/**
+ * Very simple Log interface.
+ *
+ * @author Brian S O'Neill
+ */
+public interface Log {
+    boolean isEnabled();
+
+    void write(String message);
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LogAccessCapability.java b/src/main/java/com/amazon/carbonado/repo/logging/LogAccessCapability.java
new file mode 100644
index 0000000..fe3a59a
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LogAccessCapability.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import com.amazon.carbonado.capability.Capability;
+
+/**
+ * Provides access to the Log.
+ *
+ * @author Brian S O'Neill
+ */
+public interface LogAccessCapability extends Capability {
+    Log getLog();
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LoggingQuery.java b/src/main/java/com/amazon/carbonado/repo/logging/LoggingQuery.java
new file mode 100644
index 0000000..2535c74
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LoggingQuery.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+
+import com.amazon.carbonado.spi.WrappedQuery;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingQuery<S extends Storable> extends WrappedQuery<S> {
+    private final LoggingStorage<S> mStorage;
+
+    LoggingQuery(LoggingStorage<S> storage, Query<S> query) {
+        super(query);
+        mStorage = storage;
+    }
+
+    public Cursor<S> fetch() throws FetchException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.fetch() on " + this);
+        }
+        return super.fetch();
+    }
+
+    public S loadOne() throws FetchException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.loadOne() on " + this);
+        }
+        return super.loadOne();
+    }
+
+    public S tryLoadOne() throws FetchException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.tryLoadOne() on " + this);
+        }
+        return super.tryLoadOne();
+    }
+
+    public void deleteOne() throws PersistException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.deleteOne() on " + this);
+        }
+        super.deleteOne();
+    }
+
+    public boolean tryDeleteOne() throws PersistException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.tryDeleteOne() on " + this);
+        }
+        return super.tryDeleteOne();
+    }
+
+    public void deleteAll() throws PersistException {
+        Log log = mStorage.mRepo.getLog();
+        if (log.isEnabled()) {
+            log.write("Query.deleteAll() on " + this);
+        }
+        super.deleteAll();
+    }
+
+    protected S wrap(S storable) {
+        return mStorage.wrap(storable);
+    }
+
+    protected WrappedQuery<S> newInstance(Query<S> query) {
+        return new LoggingQuery<S>(mStorage, query);
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepository.java b/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepository.java
new file mode 100644
index 0000000..e44e3b7
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepository.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.Repository;
+import static com.amazon.carbonado.RepositoryBuilder.RepositoryReference;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Transaction;
+
+import com.amazon.carbonado.capability.Capability;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingRepository implements Repository, LogAccessCapability {
+    private final RepositoryReference mRootRef;
+    private final Repository mRepo;
+    private final Log mLog;
+
+    // Map of storages by storable class
+    private final Map<Class<?>, LoggingStorage<?>> mStorages;
+
+    LoggingRepository(RepositoryReference rootRef, Repository actual, Log log) {
+        mRootRef = rootRef;
+        mRepo = actual;
+        mLog = log;
+
+        mStorages = new IdentityHashMap<Class<?>, LoggingStorage<?>>();
+    }
+
+    public String getName() {
+        return mRepo.getName();
+    }
+
+    public <S extends Storable> Storage<S> storageFor(Class<S> type)
+        throws SupportException, RepositoryException
+    {
+        synchronized (mStorages) {
+            LoggingStorage storage = mStorages.get(type);
+            if (storage == null) {
+                storage = new LoggingStorage(this, mRepo.storageFor(type));
+                mStorages.put(type, storage);
+            }
+            return storage;
+        }
+    }
+
+    public Transaction enterTransaction() {
+        mLog.write("Repository.enterTransaction()");
+        return new LoggingTransaction(mLog, mRepo.enterTransaction());
+    }
+
+    public Transaction enterTransaction(IsolationLevel level) {
+        if (mLog.isEnabled()) {
+            mLog.write("Repository.enterTransaction(" + level + ')');
+        }
+        return new LoggingTransaction(mLog, mRepo.enterTransaction(level));
+    }
+
+    public Transaction enterTopTransaction(IsolationLevel level) {
+        if (mLog.isEnabled()) {
+            mLog.write("Repository.enterTopTransaction(" + level + ')');
+        }
+        return new LoggingTransaction(mLog, mRepo.enterTopTransaction(level));
+    }
+
+    public IsolationLevel getTransactionIsolationLevel() {
+        return mRepo.getTransactionIsolationLevel();
+    }
+
+    public <C extends Capability> C getCapability(Class<C> capabilityType) {
+        if (capabilityType.isInstance(this)) {
+            return (C) this;
+        }
+        return mRepo.getCapability(capabilityType);
+    }
+
+    public void close() {
+        mRepo.close();
+    }
+
+    public Log getLog() {
+        return mLog;
+    }
+
+    Repository getRootRepository() {
+        return mRootRef.get();
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepositoryBuilder.java b/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepositoryBuilder.java
new file mode 100644
index 0000000..5f06cd5
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LoggingRepositoryBuilder.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import java.util.Collection;
+
+import com.amazon.carbonado.ConfigurationException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryBuilder;
+import com.amazon.carbonado.RepositoryException;
+
+import com.amazon.carbonado.spi.AbstractRepositoryBuilder;
+
+/**
+ * Repository implementation which logs activity against it. By default, all
+ * logged messages are at the debug level.
+ *
+ * <p>
+ * The following extra capabilities are supported:
+ * <ul>
+ * <li>{@link LogAccessCapability}
+ * </ul>
+ *
+ * Example:
+ *
+ * <pre>
+ * LoggingRepositoryBuilder loggingBuilder = new LoggingRepositoryBuilder();
+ * loggingBuilder.setActualRepositoryBuilder(...);
+ * Repository repo = loggingBuilder.build();
+ * </pre>
+ *
+ * @author Brian S O'Neill
+ */
+public class LoggingRepositoryBuilder extends AbstractRepositoryBuilder {
+    private String mName;
+    private Boolean mMaster;
+    private Log mLog;
+    private RepositoryBuilder mRepoBuilder;
+
+    public LoggingRepositoryBuilder() {
+    }
+
+    public Repository build(RepositoryReference rootRef) throws RepositoryException {
+        if (mName == null) {
+            if (mRepoBuilder != null) {
+                mName = mRepoBuilder.getName();
+            }
+        }
+
+        assertReady();
+
+        if (mLog == null) {
+            mLog = new CommonsLog(LoggingRepository.class);
+        }
+
+        String originalName = mRepoBuilder.getName();
+        boolean originalIsMaster = mRepoBuilder.isMaster();
+
+        boolean enabled = mLog.isEnabled();
+        boolean master = mMaster != null ? mMaster : originalIsMaster;
+
+        Repository actual;
+        try {
+            if (enabled) {
+                mRepoBuilder.setName("Logging " + mName);
+            }
+            mRepoBuilder.setMaster(master);
+            actual = mRepoBuilder.build(rootRef);
+        } finally {
+            mRepoBuilder.setName(originalName);
+            mRepoBuilder.setMaster(originalIsMaster);
+        }
+
+        if (!enabled) {
+            return actual;
+        }
+
+        Repository repo = new LoggingRepository(rootRef, actual, mLog);
+        rootRef.set(repo);
+        return repo;
+    }
+
+    public void setName(String name) {
+        mName = name;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public void setMaster(boolean master) {
+        mMaster = master;
+    }
+
+    public boolean isMaster() {
+        return mMaster;
+    }
+
+    /**
+     * Set the Log to use. If null, use default. Log must be enabled when build
+     * is called, or else no logging is ever performed.
+     */
+    public void setLog(Log log) {
+        mLog = log;
+    }
+
+    /**
+     * Return the Log to use. If null, use default.
+     */
+    public Log getLog() {
+        return mLog;
+    }
+
+    /**
+     * Set the Repository to wrap all calls to.
+     */
+    public void setActualRepositoryBuilder(RepositoryBuilder builder) {
+        mRepoBuilder = builder;
+    }
+
+    /**
+     * Returns the Repository that all calls are wrapped to.
+     */
+    public RepositoryBuilder getActualRepositoryBuilder() {
+        return mRepoBuilder;
+    }
+
+    public void errorCheck(Collection<String> messages) throws ConfigurationException {
+        super.errorCheck(messages);
+        if (mRepoBuilder == null) {
+            messages.add("Actual repository builder must be set");
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LoggingStorage.java b/src/main/java/com/amazon/carbonado/repo/logging/LoggingStorage.java
new file mode 100644
index 0000000..93b5a33
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LoggingStorage.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+
+import com.amazon.carbonado.spi.WrappedStorage;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingStorage<S extends Storable> extends WrappedStorage<S> {
+    final LoggingRepository mRepo;
+
+    LoggingStorage(LoggingRepository repo, Storage<S> storage) {
+        super(storage);
+        mRepo = repo;
+    }
+
+    protected S wrap(S storable) {
+        return super.wrap(storable);
+    }
+
+    protected Query<S> wrap(Query<S> query) {
+        return new LoggingQuery<S>(this, query);
+    }
+
+    protected Support createSupport(S storable) {
+        return new Handler(storable);
+    }
+
+    private class Handler extends Support {
+        private final S mStorable;
+
+        Handler(S storable) {
+            mStorable = storable;
+        }
+
+        public Repository getRootRepository() {
+            return mRepo.getRootRepository();
+        }
+
+        public boolean isPropertySupported(String propertyName) {
+            return mStorable.isPropertySupported(propertyName);
+        }
+
+        public void load() throws FetchException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.load() on " + mStorable.toStringKeyOnly());
+            }
+            mStorable.load();
+        }
+
+        public boolean tryLoad() throws FetchException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.tryLoad() on " + mStorable.toStringKeyOnly());
+            }
+            return mStorable.tryLoad();
+        }
+
+        public void insert() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.insert() on " + mStorable.toString());
+            }
+            mStorable.insert();
+        }
+
+        public boolean tryInsert() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.tryInsert() on " + mStorable.toString());
+            }
+            return mStorable.tryInsert();
+        }
+
+        public void update() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.update() on " + mStorable.toString());
+            }
+            mStorable.update();
+        }
+
+        public boolean tryUpdate() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.tryUpdate() on " + mStorable.toString());
+            }
+            return mStorable.tryUpdate();
+        }
+
+        public void delete() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.delete() on " + mStorable.toStringKeyOnly());
+            }
+            mStorable.delete();
+        }
+
+        public boolean tryDelete() throws PersistException {
+            Log log = mRepo.getLog();
+            if (log.isEnabled()) {
+                log.write("Storable.tryDelete() on " + mStorable.toStringKeyOnly());
+            }
+            return mStorable.tryDelete();
+        }
+
+        public Support createSupport(S storable) {
+            return new Handler(storable);
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/LoggingTransaction.java b/src/main/java/com/amazon/carbonado/repo/logging/LoggingTransaction.java
new file mode 100644
index 0000000..7deac92
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/LoggingTransaction.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.logging;
+
+import java.util.concurrent.TimeUnit;
+
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.Transaction;
+
+/**
+ *
+ *
+ * @author Brian S O'Neill
+ */
+class LoggingTransaction implements Transaction {
+    private final Log mLog;
+    private final Transaction mTxn;
+
+    LoggingTransaction(Log log, Transaction txn) {
+        mLog = log;
+        mTxn = txn;
+    }
+
+    public void commit() throws PersistException {
+        mLog.write("Transaction.commit()");
+        mTxn.commit();
+    }
+
+    public void exit() throws PersistException {
+        mLog.write("Transaction.exit()");
+        mTxn.exit();
+    }
+
+    public void setForUpdate(boolean forUpdate) {
+        if (mLog.isEnabled()) {
+            mLog.write("Transaction.setForUpdate(" + forUpdate + ')');
+        }
+        mTxn.setForUpdate(forUpdate);
+    }
+
+    public boolean isForUpdate() {
+        return mTxn.isForUpdate();
+    }
+
+    public void setDesiredLockTimeout(int timeout, TimeUnit unit) {
+        mTxn.setDesiredLockTimeout(timeout, unit);
+    }
+
+    public IsolationLevel getIsolationLevel() {
+        return mTxn.getIsolationLevel();
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/logging/package-info.java b/src/main/java/com/amazon/carbonado/repo/logging/package-info.java
new file mode 100644
index 0000000..860a9ae
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/logging/package-info.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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.
+ */
+
+/**
+ * Repository implementation which logs activity against it. By default, all
+ * logged messages are at the debug level.
+ *
+ * @see com.amazon.carbonado.repo.logging.LoggingRepositoryBuilder
+ */
+package com.amazon.carbonado.repo.logging;
diff --git a/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepository.java b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepository.java
new file mode 100644
index 0000000..5003053
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepository.java
@@ -0,0 +1,583 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.replicated;
+
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.cojen.util.BeanComparator;
+
+import com.amazon.carbonado.Cursor;
+import com.amazon.carbonado.FetchInterruptedException;
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.MalformedTypeException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.Transaction;
+
+import com.amazon.carbonado.capability.Capability;
+import com.amazon.carbonado.capability.IndexInfo;
+import com.amazon.carbonado.capability.IndexInfoCapability;
+import com.amazon.carbonado.capability.ResyncCapability;
+import com.amazon.carbonado.capability.ShutdownCapability;
+import com.amazon.carbonado.capability.StorableInfoCapability;
+
+import com.amazon.carbonado.info.Direction;
+import com.amazon.carbonado.info.StorableIntrospector;
+
+import com.amazon.carbonado.spi.TransactionPair;
+
+import com.amazon.carbonado.util.Throttle;
+
+/**
+ * A ReplicatedRepository binds two repositories together.  One will be used
+ * for reading (the replica), and the other will be used for writing; changes
+ * to the master repository will be copied to the replica.
+ *
+ * @author Don Schneider
+ */
+class ReplicatedRepository
+    implements Repository,
+               ResyncCapability,
+               ShutdownCapability,
+               StorableInfoCapability
+{
+    // Constants used by resync method.
+    private static final int RESYNC_QUEUE_SIZE = 1000;
+    private static final long RESYNC_QUEUE_TIMEOUT_MS = 30000;
+
+    /**
+     * Utility method to select the natural ordering of a storage, by looking
+     * for a clustered index on the primary key. Returns null if no clustered
+     * index was found.
+     *
+     * TODO: Try to incorporate this into standard storage interface somehow.
+     */
+    private static String[] selectNaturalOrder(Repository repo, Class<? extends Storable> type)
+        throws RepositoryException
+    {
+        IndexInfoCapability capability = repo.getCapability(IndexInfoCapability.class);
+        if (capability == null) {
+            return null;
+        }
+        IndexInfo info = null;
+        for (IndexInfo candidate : capability.getIndexInfo(type)) {
+            if (candidate.isClustered()) {
+                info = candidate;
+                break;
+            }
+        }
+        if (info == null) {
+            return null;
+        }
+
+        // Verify index is part of primary key.
+        Set<String> pkSet = StorableIntrospector.examine(type).getPrimaryKeyProperties().keySet();
+
+        String[] propNames = info.getPropertyNames();
+        for (String prop : propNames) {
+            if (!pkSet.contains(prop)) {
+                return null;
+            }
+        }
+
+        String[] orderBy = new String[pkSet.size()];
+
+        Direction[] directions = info.getPropertyDirections();
+
+        // Clone to remove elements.
+        pkSet = new LinkedHashSet<String>(pkSet);
+
+        int i;
+        for (i=0; i<propNames.length; i++) {
+            orderBy[i] = ((directions[i] == Direction.DESCENDING) ? "-" : "+") + propNames[i];
+            pkSet.remove(propNames[i]);
+        }
+
+        // Append any remaining pk properties, to ensure complete ordering.
+        if (pkSet.size() > 0) {
+            for (String prop : pkSet) {
+                orderBy[i++] = prop;
+            }
+        }
+
+        return orderBy;
+    }
+
+    private String mName;
+    private Repository mReplicaRepository;
+    private Repository mMasterRepository;
+
+    // Map of storages by storable class
+    private final Map<Class<?>, ReplicatedStorage<?>> mStorages;
+
+    ReplicatedRepository(String aName,
+                         Repository aReplicaRepository,
+                         Repository aMasterRepository) {
+        mName = aName;
+        mReplicaRepository = aReplicaRepository;
+        mMasterRepository = aMasterRepository;
+
+        mStorages = new IdentityHashMap<Class<?>, ReplicatedStorage<?>>();
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    Repository getReplicaRepository() {
+        return mReplicaRepository;
+    }
+
+    Repository getMasterRepository() {
+        return mMasterRepository;
+    }
+
+    @SuppressWarnings("unchecked")
+    public <S extends Storable> Storage<S> storageFor(Class<S> type)
+        throws MalformedTypeException, SupportException, RepositoryException
+    {
+        synchronized (mStorages) {
+            ReplicatedStorage storage = mStorages.get(type);
+            if (storage == null) {
+                // Examine and throw exception if there is a problem.
+                StorableIntrospector.examine(type);
+
+                storage = createStorage(type);
+                mStorages.put(type, storage);
+            }
+            return storage;
+        }
+    }
+
+    private <S extends Storable> ReplicatedStorage<S> createStorage(Class<S> type)
+        throws SupportException, RepositoryException
+    {
+        return new ReplicatedStorage<S>(this, type);
+    }
+
+    public Transaction enterTransaction() {
+        return new TransactionPair(mMasterRepository.enterTransaction(),
+                                   mReplicaRepository.enterTransaction());
+    }
+
+    public Transaction enterTransaction(IsolationLevel level) {
+        return new TransactionPair(mMasterRepository.enterTransaction(level),
+                                   mReplicaRepository.enterTransaction(level));
+    }
+
+    public Transaction enterTopTransaction(IsolationLevel level) {
+        return new TransactionPair(mMasterRepository.enterTopTransaction(level),
+                                   mReplicaRepository.enterTopTransaction(level));
+    }
+
+    public IsolationLevel getTransactionIsolationLevel() {
+        IsolationLevel replicaLevel = mReplicaRepository.getTransactionIsolationLevel();
+        if (replicaLevel == null) {
+            return null;
+        }
+        IsolationLevel masterLevel = mMasterRepository.getTransactionIsolationLevel();
+        if (masterLevel == null) {
+            return null;
+        }
+        return replicaLevel.lowestCommon(masterLevel);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <C extends Capability> C getCapability(Class<C> capabilityType) {
+        if (capabilityType.isInstance(this)) {
+            if (ShutdownCapability.class.isAssignableFrom(capabilityType)) {
+                if (mReplicaRepository.getCapability(capabilityType) == null &&
+                    mMasterRepository.getCapability(capabilityType) == null) {
+
+                    return null;
+                }
+            }
+            return (C) this;
+        }
+
+        C cap = mMasterRepository.getCapability(capabilityType);
+        if (cap == null) {
+            cap = mReplicaRepository.getCapability(capabilityType);
+        }
+
+        return cap;
+    }
+
+    public void close() {
+        mReplicaRepository.close();
+        mMasterRepository.close();
+    }
+
+    public String[] getUserStorableTypeNames() throws RepositoryException {
+        StorableInfoCapability replicaCap =
+            mReplicaRepository.getCapability(StorableInfoCapability.class);
+        StorableInfoCapability masterCap =
+            mMasterRepository.getCapability(StorableInfoCapability.class);
+
+        if (replicaCap == null) {
+            if (masterCap == null) {
+                return new String[0];
+            }
+            return masterCap.getUserStorableTypeNames();
+        } else if (masterCap == null) {
+            return replicaCap.getUserStorableTypeNames();
+        }
+
+        // Merge the two sets together.
+        Set<String> names = new LinkedHashSet<String>();
+        for (String name : replicaCap.getUserStorableTypeNames()) {
+            names.add(name);
+        }
+        for (String name : masterCap.getUserStorableTypeNames()) {
+            names.add(name);
+        }
+
+        return names.toArray(new String[names.size()]);
+    }
+
+    public boolean isSupported(Class<Storable> type) {
+        StorableInfoCapability replicaCap =
+            mReplicaRepository.getCapability(StorableInfoCapability.class);
+        StorableInfoCapability masterCap =
+            mMasterRepository.getCapability(StorableInfoCapability.class);
+
+        return (masterCap == null || masterCap.isSupported(type))
+            && (replicaCap == null || replicaCap.isSupported(type));
+    }
+
+    public boolean isPropertySupported(Class<Storable> type, String name) {
+        StorableInfoCapability replicaCap =
+            mReplicaRepository.getCapability(StorableInfoCapability.class);
+        StorableInfoCapability masterCap =
+            mMasterRepository.getCapability(StorableInfoCapability.class);
+
+        return (masterCap == null || masterCap.isPropertySupported(type, name))
+            && (replicaCap == null || replicaCap.isPropertySupported(type, name));
+    }
+
+    public boolean isAutoShutdownEnabled() {
+        ShutdownCapability cap = mReplicaRepository.getCapability(ShutdownCapability.class);
+        if (cap != null && cap.isAutoShutdownEnabled()) {
+            return true;
+        }
+        cap = mMasterRepository.getCapability(ShutdownCapability.class);
+        if (cap != null && cap.isAutoShutdownEnabled()) {
+            return true;
+        }
+        return false;
+    }
+
+    public void setAutoShutdownEnabled(boolean enabled) {
+        ShutdownCapability cap = mReplicaRepository.getCapability(ShutdownCapability.class);
+        if (cap != null) {
+            cap.setAutoShutdownEnabled(enabled);
+        }
+        cap = mMasterRepository.getCapability(ShutdownCapability.class);
+        if (cap != null) {
+            cap.setAutoShutdownEnabled(enabled);
+        }
+    }
+
+    public void shutdown() {
+        ShutdownCapability cap = mReplicaRepository.getCapability(ShutdownCapability.class);
+        if (cap != null) {
+            cap.shutdown();
+        } else {
+            mReplicaRepository.close();
+        }
+        cap = mMasterRepository.getCapability(ShutdownCapability.class);
+        if (cap != null) {
+            cap.shutdown();
+        } else {
+            mMasterRepository.close();
+        }
+    }
+
+    /**
+     * Repairs replicated storables by synchronizing the replica repository
+     * against the master repository.
+     *
+     * @param type type of storable to re-sync
+     * @param desiredSpeed throttling parameter - 1.0 = full speed, 0.5 = half
+     * speed, 0.1 = one-tenth speed, etc
+     * @param filter optional query filter to limit which objects get re-sync'ed
+     * @param filterValues filter values for optional filter
+     */
+    public <S extends Storable> void resync(Class<S> type,
+                                            double desiredSpeed,
+                                            String filter,
+                                            Object... filterValues)
+        throws RepositoryException
+    {
+        Storage<S> replicaStorage, masterStorage;
+        replicaStorage = mReplicaRepository.storageFor(type);
+        masterStorage = mMasterRepository.storageFor(type);
+
+        Query<S> replicaQuery, masterQuery;
+        if (filter == null) {
+            replicaQuery = replicaStorage.query();
+            masterQuery = masterStorage.query();
+        } else {
+            replicaQuery = replicaStorage.query(filter).withValues(filterValues);
+            masterQuery = masterStorage.query(filter).withValues(filterValues);
+        }
+
+        // Order both queries the same so that they can be run in parallel.
+        String[] orderBy = selectNaturalOrder(mMasterRepository, type);
+        if (orderBy == null) {
+            orderBy = selectNaturalOrder(mReplicaRepository, type);
+            if (orderBy == null) {
+                Set<String> pkSet =
+                    StorableIntrospector.examine(type).getPrimaryKeyProperties().keySet();
+                orderBy = pkSet.toArray(new String[0]);
+            }
+        }
+
+        BeanComparator bc = BeanComparator.forClass(type);
+        for (String order : orderBy) {
+            bc = bc.orderBy(order);
+            bc = bc.caseSensitive();
+        }
+
+        replicaQuery = replicaQuery.orderBy(orderBy);
+        masterQuery = masterQuery.orderBy(orderBy);
+
+        Throttle throttle;
+        if (desiredSpeed >= 1.0) {
+            throttle = null;
+        } else {
+            if (desiredSpeed < 0.0) {
+                desiredSpeed = 0.0;
+            }
+            // 50 samples
+            throttle = new Throttle(50);
+        }
+
+        Cursor<S> replicaCursor = replicaQuery.fetch();
+        try {
+            Cursor<S> masterCursor = masterQuery.fetch();
+            try {
+                resync(((ReplicatedStorage) storageFor(type)).getTrigger(),
+                       replicaCursor,
+                       masterCursor,
+                       throttle, desiredSpeed,
+                       bc);
+            } finally {
+                masterCursor.close();
+            }
+        } finally {
+            replicaCursor.close();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private <S extends Storable> void resync(ReplicationTrigger<S> trigger,
+                                             Cursor<S> replicaCursor,
+                                             Cursor<S> masterCursor,
+                                             Throttle throttle, double desiredSpeed,
+                                             Comparator comparator)
+        throws RepositoryException
+    {
+        // Enqueue resyncs to a separate thread since open cursors hold locks
+        // on currently referenced entries.
+        BlockingQueue<Runnable> resyncQueue =
+            new ArrayBlockingQueue<Runnable>(RESYNC_QUEUE_SIZE, true);
+        ResyncThread resyncThread = new ResyncThread(resyncQueue);
+        resyncThread.start();
+
+        try {
+            S replicaEntry = null;
+            S masterEntry = null;
+
+            while (true) {
+                if (throttle != null) {
+                    try {
+                        // 100 millisecond clock precision
+                        throttle.throttle(desiredSpeed, 100);
+                    } catch (InterruptedException e) {
+                        throw new FetchInterruptedException(e);
+                    }
+                }
+
+                if (replicaEntry == null && replicaCursor.hasNext()) {
+                    replicaEntry = replicaCursor.next();
+                }
+
+                if (masterEntry == null && masterCursor.hasNext()) {
+                    masterEntry = masterCursor.next();
+                }
+
+                // Comparator should treat null as high.
+                int compare = comparator.compare(replicaEntry, masterEntry);
+
+                if (compare < 0) {
+                    // Bogus exists only in replica so delete it.
+                    resyncThread.addResyncTask(trigger, replicaEntry, null);
+                    // Allow replica to advance.
+                    replicaEntry = null;
+                } else if (compare > 0) {
+                    // Replica cursor is missing an entry so copy it.
+                    resyncThread.addResyncTask(trigger, null, masterEntry);
+                    // Allow master to advance.
+                    masterEntry = null;
+                } else {
+                    if (replicaEntry == null && masterEntry == null) {
+                        // Both cursors exhausted -- resync is complete.
+                        break;
+                    }
+
+                    if (!replicaEntry.equalProperties(masterEntry)) {
+                        // Replica is stale.
+                        resyncThread.addResyncTask(trigger, replicaEntry, masterEntry);
+                    }
+
+                    // Entries are synchronized so allow both cursors to advance.
+                    replicaEntry = null;
+                    masterEntry = null;
+                }
+            }
+        } finally {
+            resyncThread.waitUntilDone();
+        }
+    }
+
+    // TODO: Use TaskQueueThread
+
+    private static class ResyncThread extends Thread {
+        private static final int
+            STATE_RUNNING = 0,
+            STATE_SHOULD_STOP = 1,
+            STATE_STOPPED = 2;
+
+        private static final Runnable STOP_TASK = new Runnable() {public void run() {}};
+
+        private final BlockingQueue<Runnable> mQueue;
+
+        private int mState = STATE_RUNNING;
+
+        ResyncThread(BlockingQueue<Runnable> queue) {
+            super("ReplicatedRepository Resync");
+            mQueue = queue;
+        }
+
+        public void run() {
+            try {
+                while (true) {
+                    boolean isStopping;
+                    synchronized (this) {
+                        isStopping = mState != STATE_RUNNING;
+                    }
+
+                    Runnable task;
+                    if (isStopping) {
+                        // Poll the queue so this thread doesn't block when it
+                        // should be stopping.
+                        task = mQueue.poll();
+                    } else {
+                        try {
+                            task = mQueue.take();
+                        } catch (InterruptedException e) {
+                            break;
+                        }
+                    }
+
+                    if (task == null || task == STOP_TASK) {
+                        // Marker to indicate we should stop.
+                        break;
+                    }
+
+                    task.run();
+                }
+            } finally {
+                synchronized (this) {
+                    mState = STATE_STOPPED;
+                    notifyAll();
+                }
+            }
+        }
+
+        <S extends Storable> void addResyncTask(final ReplicationTrigger<S> trigger,
+                                                final S replicaEntry,
+                                                final S masterEntry)
+            throws RepositoryException
+        {
+            if (replicaEntry == null && masterEntry == null) {
+                // If both are null, then there's nothing to do, is there?
+                // Note: Caller shouldn't have passed double nulls to
+                // addResyncTask in the first place.
+                return;
+            }
+
+            Runnable task = new Runnable() {
+                public void run() {
+                    try {
+                        trigger.resyncEntries(replicaEntry, masterEntry);
+                    } catch (Exception e) {
+                        LogFactory.getLog(ReplicatedRepository.class).error(null, e);
+                    }
+                }
+            };
+
+            addResyncTask(task);
+        }
+
+
+        <S extends Storable> void addResyncTask(Runnable task)
+            throws RepositoryException
+        {
+            try {
+                if (!mQueue.offer(task, RESYNC_QUEUE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+                    throw new RepositoryException("Unable to enqueue resync task");
+                }
+            } catch (InterruptedException e) {
+                throw new RepositoryException(e);
+            }
+        }
+
+        synchronized void waitUntilDone() throws RepositoryException {
+            if (mState == STATE_STOPPED) {
+                return;
+            }
+            mState = STATE_SHOULD_STOP;
+            try {
+                // Inject stop task into the queue so it knows to stop.
+                mQueue.offer(STOP_TASK, RESYNC_QUEUE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+                while (mState != STATE_STOPPED) {
+                    wait();
+                }
+            } catch (InterruptedException e) {
+                throw new RepositoryException(e);
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepositoryBuilder.java b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepositoryBuilder.java
new file mode 100644
index 0000000..8d5cc75
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedRepositoryBuilder.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.replicated;
+
+import java.util.Collection;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.ConfigurationException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.RepositoryBuilder;
+import com.amazon.carbonado.RepositoryException;
+
+import com.amazon.carbonado.spi.AbstractRepositoryBuilder;
+import com.amazon.carbonado.spi.BelatedRepositoryCreator;
+
+/**
+ * Repository builder for the replicated repository.
+ * <p>
+ * The following extra capabilities are supported:
+ * <ul>
+ * <li>{@link com.amazon.carbonado.capability.ResyncCapability ResyncCapability}
+ * </ul>
+ *
+ * @author Don Schneider
+ * @author Brian S O'Neill
+ */
+public class ReplicatedRepositoryBuilder extends AbstractRepositoryBuilder {
+    static final int DEFAULT_MASTER_TIMEOUT_MILLIS = 15000;
+    static final int DEFAULT_RETRY_MILLIS = 30000;
+
+    private String mName;
+    private boolean mIsMaster = true;
+    private RepositoryBuilder mReplicaRepositoryBuilder;
+    private RepositoryBuilder mMasterRepositoryBuilder;
+
+    public ReplicatedRepositoryBuilder() {
+    }
+
+    public Repository build(RepositoryReference rootRef) throws RepositoryException {
+        assertReady();
+
+        Repository replica, master;
+
+        {
+            boolean originalOption = mReplicaRepositoryBuilder.isMaster();
+            try {
+                mReplicaRepositoryBuilder.setMaster(false);
+                replica = mReplicaRepositoryBuilder.build(rootRef);
+            } finally {
+                mReplicaRepositoryBuilder.setMaster(originalOption);
+            }
+        }
+
+        {
+            // Create master using BelatedRepositoryCreator such that we can
+            // start up and read from replica even if master is down.
+
+            final boolean originalOption = mMasterRepositoryBuilder.isMaster();
+            mMasterRepositoryBuilder.setMaster(mIsMaster);
+
+            Log log = LogFactory.getLog(ReplicatedRepositoryBuilder.class);
+            BelatedRepositoryCreator creator = new BelatedRepositoryCreator
+                (log, mMasterRepositoryBuilder, rootRef, DEFAULT_RETRY_MILLIS) {
+
+                protected void createdNotification(Repository repo) {
+                    // Don't need builder any more so restore it.
+                    mMasterRepositoryBuilder.setMaster(originalOption);
+                }
+            };
+
+            master = creator.get(DEFAULT_MASTER_TIMEOUT_MILLIS);
+        }
+
+        Repository repo = new ReplicatedRepository(getName(), replica, master);
+        rootRef.set(repo);
+        return repo;
+    }
+
+    public String getName() {
+        String name = mName;
+        if (name == null) {
+            if (mReplicaRepositoryBuilder != null && mReplicaRepositoryBuilder.getName() != null) {
+                name = mReplicaRepositoryBuilder.getName();
+            } else if (mMasterRepositoryBuilder != null) {
+                name = mMasterRepositoryBuilder.getName();
+            }
+        }
+        return name;
+    }
+
+    public void setName(String name) {
+        mName = name;
+    }
+
+    public boolean isMaster() {
+        return mIsMaster;
+    }
+
+    public void setMaster(boolean b) {
+        mIsMaster = b;
+    }
+
+    /**
+     * @return "replica" respository to replicate to.
+     */
+    public RepositoryBuilder getReplicaRepositoryBuilder() {
+        return mReplicaRepositoryBuilder;
+    }
+
+    /**
+     * Set "replica" respository to replicate to, which is required. This builder
+     * automatically sets the master option of the given repository builder to
+     * false.
+     */
+    public void setReplicaRepositoryBuilder(RepositoryBuilder replicaRepositoryBuilder) {
+        mReplicaRepositoryBuilder = replicaRepositoryBuilder;
+    }
+
+    /**
+     * @return "master" respository to replicate from.
+     */
+    public RepositoryBuilder getMasterRepositoryBuilder() {
+        return mMasterRepositoryBuilder;
+    }
+
+    /**
+     * Set "master" respository to replicate from, which is required. This
+     * builder automatically sets the master option of the given repository to
+     * true.
+     */
+    public void setMasterRepositoryBuilder(RepositoryBuilder masterRepositoryBuilder) {
+        mMasterRepositoryBuilder = masterRepositoryBuilder;
+    }
+
+    public void errorCheck(Collection<String> messages) throws ConfigurationException {
+        super.errorCheck(messages);
+        if (null == getReplicaRepositoryBuilder()) {
+            messages.add("replicaRepositoryBuilder missing");
+        }
+        if (null == getMasterRepositoryBuilder()) {
+            messages.add("masterRepositoryBuilder missing");
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedStorage.java b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedStorage.java
new file mode 100644
index 0000000..cf30e5d
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicatedStorage.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.replicated;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.OptimisticLockException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.PersistNoneException;
+import com.amazon.carbonado.Query;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.SupportException;
+import com.amazon.carbonado.RepositoryException;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.Trigger;
+import com.amazon.carbonado.UniqueConstraintException;
+import com.amazon.carbonado.UnsupportedTypeException;
+
+import com.amazon.carbonado.filter.Filter;
+
+import com.amazon.carbonado.spi.BelatedStorageCreator;
+import com.amazon.carbonado.spi.RepairExecutor;
+
+/**
+ * ReplicatedStorage
+ *
+ * @author Don Schneider
+ * @author Brian S O'Neill
+ */
+class ReplicatedStorage<S extends Storable> implements Storage<S> {
+    final Storage<S> mReplicaStorage;
+    final ReplicationTrigger<S> mTrigger;
+
+    public ReplicatedStorage(ReplicatedRepository aRepository, Class<S> aType)
+        throws SupportException, RepositoryException
+    {
+        mReplicaStorage = aRepository.getReplicaRepository().storageFor(aType);
+
+        // Create master using BelatedStorageCreator such that we can start up
+        // and read from replica even if master is down.
+
+        Log log = LogFactory.getLog(getClass());
+        BelatedStorageCreator<S> creator = new BelatedStorageCreator<S>
+            (log, aRepository.getMasterRepository(), aType,
+             ReplicatedRepositoryBuilder.DEFAULT_RETRY_MILLIS);
+
+        Storage<S> masterStorage;
+        try {
+            masterStorage = creator.get(ReplicatedRepositoryBuilder.DEFAULT_MASTER_TIMEOUT_MILLIS);
+        } catch (UnsupportedTypeException e) {
+            // Master doesn't support Storable, but it is marked as Independent.
+            masterStorage = null;
+        }
+
+        mTrigger = new ReplicationTrigger<S>(aRepository, mReplicaStorage, masterStorage);
+        addTrigger(mTrigger);
+    }
+
+    /**
+     * For testing only.
+     */
+    ReplicatedStorage(Repository aRepository,
+                      Storage<S> replicaStorage,
+                      Storage<S> masterStorage)
+    {
+        mReplicaStorage = replicaStorage;
+        mTrigger = new ReplicationTrigger<S>(aRepository, mReplicaStorage, masterStorage);
+        addTrigger(mTrigger);
+    }
+
+    public Class<S> getStorableType() {
+        return mReplicaStorage.getStorableType();
+    }
+
+    public S prepare() {
+        return mReplicaStorage.prepare();
+    }
+
+    public Query<S> query() throws FetchException {
+        return mReplicaStorage.query();
+    }
+
+    public Query<S> query(String filter) throws FetchException {
+        return mReplicaStorage.query(filter);
+    }
+
+    public Query<S> query(Filter<S> filter) throws FetchException {
+        return mReplicaStorage.query(filter);
+    }
+
+    public boolean addTrigger(Trigger<? super S> trigger) {
+        return mReplicaStorage.addTrigger(trigger);
+    }
+
+    public boolean removeTrigger(Trigger<? super S> trigger) {
+        return mReplicaStorage.removeTrigger(trigger);
+    }
+
+    ReplicationTrigger<S> getTrigger() {
+        return mTrigger;
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/replicated/ReplicationTrigger.java b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicationTrigger.java
new file mode 100644
index 0000000..19915b7
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/replicated/ReplicationTrigger.java
@@ -0,0 +1,398 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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 com.amazon.carbonado.repo.replicated;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import com.amazon.carbonado.FetchDeadlockException;
+import com.amazon.carbonado.FetchException;
+import com.amazon.carbonado.FetchNoneException;
+import com.amazon.carbonado.IsolationLevel;
+import com.amazon.carbonado.OptimisticLockException;
+import com.amazon.carbonado.PersistDeadlockException;
+import com.amazon.carbonado.PersistException;
+import com.amazon.carbonado.PersistNoneException;
+import com.amazon.carbonado.Repository;
+import com.amazon.carbonado.Storable;
+import com.amazon.carbonado.Storage;
+import com.amazon.carbonado.Transaction;
+import com.amazon.carbonado.Trigger;
+import com.amazon.carbonado.UniqueConstraintException;
+
+import com.amazon.carbonado.spi.RepairExecutor;
+
+/**
+ * All inserts/updates/deletes are first committed to the master storage, then
+ * duplicated and committed to the replica.
+ *
+ * @author Don Schneider
+ * @author Brian S O'Neill
+ */
+class ReplicationTrigger<S extends Storable> extends Trigger<S> {
+    private final Repository mRepository;
+    private final Storage<S> mReplicaStorage;
+    private final Storage<S> mMasterStorage;
+
+    private final ThreadLocal<AtomicInteger> mDisabled = new ThreadLocal<AtomicInteger>();
+
+    ReplicationTrigger(Repository repository,
+                       Storage<S> replicaStorage,
+                       Storage<S> masterStorage)
+    {
+        mRepository = repository;
+        mReplicaStorage = replicaStorage;
+        mMasterStorage = masterStorage;
+    }
+
+    @Override
+    public Object beforeInsert(S replica) throws PersistException {
+        return beforeInsert(replica, false);
+    }
+
+    @Override
+    public Object beforeTryInsert(S replica) throws PersistException {
+        return beforeInsert(replica, true);
+    }
+
+    private Object beforeInsert(S replica, boolean forTry) throws PersistException {
+        if (isReplicationDisabled()) {
+            return null;
+        }
+
+        final S master = mMasterStorage.prepare();
+        replica.copyAllProperties(master);
+
+        try {
+            if (forTry) {
+                if (!master.tryInsert()) {
+                    throw abortTry();
+                }
+            } else {
+                master.insert();
+            }
+        } catch (UniqueConstraintException e) {
+            // This may be caused by an inconsistency between replica and
+            // master. Here's one scenerio: user called tryLoad and saw the
+            // entry does not exist. So instead of calling update, he/she calls
+            // insert. If the master entry exists, then there is an
+            // inconsistency. The code below checks for this specific kind of
+            // error and repairs it by inserting a record in the replica.
+
+            // Here's another scenerio: Unique constraint was caused by an
+            // inconsistency with the values of the alternate keys. User
+            // expected alternate keys to have unique values, as indicated by
+            // replica.
+
+            repair(replica);
+
+            // Throw exception since we don't know what the user's intentions
+            // really are.
+            throw e;
+        }
+
+        // Master may have applied sequences to unitialized primary keys, so
+        // copy primary keys to replica. Mark properties as dirty to allow
+        // primary key to be changed.
+        replica.markPropertiesDirty();
+
+        // Copy all properties in order to trigger constraints that
+        // master should have resolved.
+        master.copyAllProperties(replica);
+
+        return null;
+    }
+
+    @Override
+    public Object beforeUpdate(S replica) throws PersistException {
+        return beforeUpdate(replica, false);
+    }
+
+    @Override
+    public Object beforeTryUpdate(S replica) throws PersistException {
+        return beforeUpdate(replica, true);
+    }
+
+    private Object beforeUpdate(S replica, boolean forTry) throws PersistException {
+        if (isReplicationDisabled()) {
+            return null;
+        }
+
+        final S master = mMasterStorage.prepare();
+        replica.copyPrimaryKeyProperties(master);
+
+        if (!replica.hasDirtyProperties()) {
+            // Nothing to update, but must load from master anyhow, since
+            // update must always perform a fresh load as a side-effect. We
+            // cannot simply call update on the master, since it may need a
+            // version property to be set. Setting the version has the
+            // side-effect of making the storable look dirty, so the master
+            // will perform an update. This in turn causes the version to
+            // increase for no reason.
+            try {
+                if (forTry) {
+                    if (!master.tryLoad()) {
+                        // Master record does not exist. To ensure consistency,
+                        // delete record from replica.
+                        deleteReplica(replica);
+                        throw abortTry();
+                    }
+                } else {
+                    try {
+                        master.load();
+                    } catch (FetchNoneException e) {
+                        // Master record does not exist. To ensure consistency,
+                        // delete record from replica.
+                        deleteReplica(replica);
+                        throw e;
+                    }
+                }
+            } catch (FetchException e) {
+                throw e.toPersistException
+                    ("Could not load master object for update: " + master.toStringKeyOnly());
+            }
+        } else {
+            replica.copyVersionProperty(master);
+            replica.copyDirtyProperties(master);
+
+            try {
+                if (forTry) {
+                    if (!master.tryUpdate()) {
+                        // Master record does not exist. To ensure consistency,
+                        // delete record from replica.
+                        deleteReplica(replica);
+                        throw abortTry();
+                    }
+                } else {
+                    try {
+                        master.update();
+                    } catch (PersistNoneException e) {
+                        // Master record does not exist. To ensure consistency,
+                        // delete record from replica.
+                        deleteReplica(replica);
+                        throw e;
+                    }
+                }
+            } catch (OptimisticLockException e) {
+                // This may be caused by an inconsistency between replica and
+                // master.
+
+                repair(replica);
+
+                // Throw original exception since we don't know what the user's
+                // intentions really are.
+                throw e;
+            }
+        }
+
+        // Copy master properties back, since its repository may have
+        // altered property values as a side effect.
+        master.copyUnequalProperties(replica);
+
+        return null;
+    }
+
+    @Override
+    public Object beforeDelete(S replica) throws PersistException {
+        if (isReplicationDisabled()) {
+            return null;
+        }
+
+        S master = mMasterStorage.prepare();
+        replica.copyPrimaryKeyProperties(master);
+
+        // If this fails to delete anything, don't care. Any delete failure
+        // will be detected when the replica is deleted. If there was an
+        // inconsistency, it is resolved after the replica is deleted.
+        master.tryDelete();
+
+        return null;
+    }
+
+    /**
+     * Re-sync the replica to the master. The primary keys of both entries are
+     * assumed to match.
+     *
+     * @param replicaEntry current replica entry, or null if none
+     * @param masterEntry current master entry, or null if none
+     */
+    void resyncEntries(S replicaEntry, S masterEntry) throws FetchException, PersistException {
+        if (replicaEntry == null && masterEntry == null) {
+            return;
+        }
+
+        Log log = LogFactory.getLog(ReplicatedRepository.class);
+
+        setReplicationDisabled(true);
+        try {
+            Transaction txn = mRepository.enterTransaction();
+            try {
+                if (replicaEntry != null) {
+                    if (masterEntry == null) {
+                        log.info("Deleting bogus entry: " + replicaEntry);
+                    }
+                    replicaEntry.tryDelete();
+                }
+                if (masterEntry != null) {
+                    Storable newReplicaEntry = mReplicaStorage.prepare();
+                    if (replicaEntry == null) {
+                        masterEntry.copyAllProperties(newReplicaEntry);
+                        log.info("Adding missing entry: " + newReplicaEntry);
+                    } else {
+                        if (replicaEntry.equalProperties(masterEntry)) {
+                            return;
+                        }
+                        // First copy from old replica to preserve values of
+                        // any independent properties. Be sure not to copy
+                        // nulls from old replica to new replica, in case new
+                        // non-nullable properties have been added. This is why
+                        // copyUnequalProperties is called instead of
+                        // copyAllProperties.
+                        replicaEntry.copyUnequalProperties(newReplicaEntry);
+                        // Calling copyAllProperties will skip unsupported
+                        // independent properties in master, thus preserving
+                        // old independent property values.
+                        masterEntry.copyAllProperties(newReplicaEntry);
+                        log.info("Replacing stale entry with: " + newReplicaEntry);
+                    }
+                    if (!newReplicaEntry.tryInsert()) {
+                        // Try to correct bizarre corruption.
+                        newReplicaEntry.tryDelete();
+                        newReplicaEntry.tryInsert();
+                    }
+                }
+                txn.commit();
+            } finally {
+                txn.exit();
+            }
+        } finally {
+            setReplicationDisabled(false);
+        }
+    }
+
+    /**
+     * Runs a repair in a background thread. This is done for two reasons: It
+     * allows repair to not be hindered by locks acquired by transactions and
+     * repairs don't get rolled back when culprit exception is thrown. Culprit
+     * may be UniqueConstraintException or OptimisticLockException.
+     */
+    private void repair(S replica) throws PersistException {
+        replica = (S) replica.copy();
+        S master = mMasterStorage.prepare();
+        replica.copyPrimaryKeyProperties(master);
+
+        try {
+            if (replica.tryLoad()) {
+                if (master.tryLoad()) {
+                    if (replica.equalProperties(master)) {
+                        // Both are equal -- no repair needed.
+                        return;
+                    }
+                }
+            } else if (!master.tryLoad()) {
+                // Both are missing -- no repair needed.
+                return;
+            }
+        } catch (FetchException e) {
+            throw e.toPersistException();
+        }
+
+        final S finalReplica = replica;
+        final S finalMaster = master;
+
+        RepairExecutor.execute(new Runnable() {
+            public void run() {
+                try {
+                    Transaction txn = mRepository.enterTransaction();
+                    try {
+                        txn.setForUpdate(true);
+                        if (finalReplica.tryLoad()) {
+                            if (finalMaster.tryLoad()) {
+                                resyncEntries(finalReplica, finalMaster);
+                            } else {
+                                resyncEntries(finalReplica, null);
+                            }
+                        } else if (finalMaster.tryLoad()) {
+                            resyncEntries(null, finalMaster);
+                        }
+                        txn.commit();
+                    } finally {
+                        txn.exit();
+                    }
+                } catch (FetchException fe) {
+                    Log log = LogFactory.getLog(ReplicatedRepository.class);
+                    log.warn("Unable to check if repair is required for " +
+                             finalReplica.toStringKeyOnly(), fe);
+                } catch (PersistException pe) {
+                    Log log = LogFactory.getLog(ReplicatedRepository.class);
+                    log.error("Unable to repair entry " +
+                              finalReplica.toStringKeyOnly(), pe);
+                }
+            }
+        });
+    }
+
+    /**
+     * Deletes the replica entry with replication disabled.
+     */
+    private void deleteReplica(S replica) throws PersistException {
+        // Disable replication to prevent trigger from being invoked by
+        // deleting replica.
+        setReplicationDisabled(true);
+        try {
+            replica.tryDelete();
+        } finally {
+            setReplicationDisabled(false);
+        }
+    }
+
+    /**
+     * Returns true if replication is disabled for the current thread.
+     */
+    private boolean isReplicationDisabled() {
+        // Count indicates how many times disabled (nested)
+        AtomicInteger i = mDisabled.get();
+        return i != null && i.get() > 0;
+    }
+
+    /**
+     * By default, replication is enabled for the current thread. Pass true to
+     * disable during re-sync operations.
+     */
+    private void setReplicationDisabled(boolean disabled) {
+        // Using a count allows this method call to be nested. Based on the
+        // current implementation, it should never be nested, so this extra
+        // work is just a safeguard.
+        AtomicInteger i = mDisabled.get();
+        if (disabled) {
+            if (i == null) {
+                i = new AtomicInteger(1);
+                mDisabled.set(i);
+            } else {
+                i.incrementAndGet();
+            }
+        } else {
+            if (i != null) {
+                i.decrementAndGet();
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/amazon/carbonado/repo/replicated/package-info.java b/src/main/java/com/amazon/carbonado/repo/replicated/package-info.java
new file mode 100644
index 0000000..44d8124
--- /dev/null
+++ b/src/main/java/com/amazon/carbonado/repo/replicated/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2006 Amazon Technologies, Inc. or its affiliates.
+ * Amazon, Amazon.com and Carbonado are trademarks or registered trademarks
+ * of Amazon Technologies, Inc. or its affiliates.  All rights reserved.
+ *
+ * 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.
+ */
+
+/**
+ * Repository implementation that supports replication between two
+ * repositories. One repository is the replica, and the other is the
+ * master. Read operations are served by the replica, and the master is
+ * consulted when writing. Changes to the master are copied to the replica.
+ *
+ * @see com.amazon.carbonado.repo.replicated.ReplicatedRepositoryBuilder
+ */
+package com.amazon.carbonado.repo.replicated;
-- 
cgit v1.2.3