com.couchbase.lite.store.SQLiteViewStore.java Source code

Java tutorial

Introduction

Here is the source code for com.couchbase.lite.store.SQLiteViewStore.java

Source

/**
 * Copyright (c) 2016 Couchbase, Inc. 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.couchbase.lite.store;

import com.couchbase.lite.CouchbaseLiteException;
import com.couchbase.lite.Emitter;
import com.couchbase.lite.Manager;
import com.couchbase.lite.Mapper;
import com.couchbase.lite.Predicate;
import com.couchbase.lite.QueryOptions;
import com.couchbase.lite.QueryRow;
import com.couchbase.lite.Reducer;
import com.couchbase.lite.Status;
import com.couchbase.lite.TransactionalTask;
import com.couchbase.lite.View;
import com.couchbase.lite.internal.InterfaceAudience;
import com.couchbase.lite.internal.RevisionInternal;
import com.couchbase.lite.internal.database.ContentValues;
import com.couchbase.lite.storage.Cursor;
import com.couchbase.lite.storage.SQLException;
import com.couchbase.lite.storage.SQLiteStorageEngine;
import com.couchbase.lite.support.JsonDocument;
import com.couchbase.lite.util.CountDown;
import com.couchbase.lite.util.Log;
import com.couchbase.lite.util.SQLiteUtils;
import com.fasterxml.jackson.core.JsonProcessingException;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

public class SQLiteViewStore implements ViewStore, QueryRowStore {
    public static String TAG = Log.TAG_VIEW;

    private abstract class AbstractMapEmitBlock implements Emitter {
        protected long sequence = 0;

        void setSequence(long sequence) {
            this.sequence = sequence;
        }
    }

    private static final int REDUCE_BATCH_SIZE = 100;

    // public
    private String name;
    private ViewStoreDelegate delegate;

    // private
    private SQLiteStore store;
    private int viewID;
    private View.TDViewCollation collation;
    private String _mapTableName;
    private SQLiteViewStore curView; // Current view used when update index

    ///////////////////////////////////////////////////////////////////////////
    // Constructor
    ///////////////////////////////////////////////////////////////////////////

    protected SQLiteViewStore(SQLiteStore store, String name, boolean create) throws CouchbaseLiteException {
        this.store = store;
        this.name = name;
        this.viewID = -1; // means 'unknown'
        this.collation = View.TDViewCollation.TDViewCollationUnicode;
        if (!create && getViewID() <= 0)
            throw new CouchbaseLiteException(Status.NOT_FOUND);
    }

    ///////////////////////////////////////////////////////////////////////////
    // Implementation of ViewStorage
    ///////////////////////////////////////////////////////////////////////////

    @Override
    public String getName() {
        return name;
    }

    @Override
    public ViewStoreDelegate getDelegate() {
        return delegate;
    }

    @Override
    public void setDelegate(ViewStoreDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    public void setCollation(View.TDViewCollation collation) {
        this.collation = collation;
    }

    @Override
    public void close() {
        store = null;
        viewID = -1;
    }

    @Override
    public void deleteIndex() {
        if (getViewID() <= 0) {
            return;
        }
        String sql = "DROP TABLE IF EXISTS 'maps_#'; "
                + "UPDATE views SET lastSequence=0, total_docs=0 WHERE view_id=#";
        if (!runStatements(sql))
            Log.w(TAG, "Couldn't delete view _index `%s`", name);
    }

    @Override
    public void deleteView() {
        store.runInTransaction(new TransactionalTask() {
            @Override
            public boolean run() {
                deleteIndex();
                String[] whereArgs = { name };
                int rowsAffected = store.getStorageEngine().delete("views", "name=?", whereArgs);
                return rowsAffected > 0 ? true : false;
            }
        });
        viewID = 0;
    }

    /**
     * Updates the version of the view. A change in version means the delegate's map block has
     * changed its semantics, so the _index should be deleted.
     */
    @Override
    public boolean setVersion(String version) {
        // Update the version column in the database. This is a little weird looking because we want
        // to avoid modifying the database if the version didn't change, and because the row might
        // not exist yet.
        SQLiteStorageEngine storage = store.getStorageEngine();
        boolean hasView;
        Cursor cursor = null;
        try {
            String sql = "SELECT name, version FROM views WHERE name=?";
            String[] args = { name };
            cursor = storage.rawQuery(sql, args);
            hasView = cursor.moveToNext();
        } catch (SQLException e) {
            Log.e(Log.TAG_VIEW, "Error querying existing view name " + name, e);
            return false;
        } finally {
            if (cursor != null)
                cursor.close();
        }

        if (!hasView) {
            // no such record, so insert
            ContentValues insertValues = new ContentValues();
            insertValues.put("name", name);
            insertValues.put("version", version);
            insertValues.put("total_docs", 0);
            storage.insert("views", null, insertValues);
            createIndex();
            return true; // created new view
        }

        ContentValues updateValues = new ContentValues();
        updateValues.put("version", version);
        updateValues.put("lastSequence", 0);
        updateValues.put("total_docs", 0);
        String[] whereArgs = { name, version };
        int rowsAffected = storage.update("views", updateValues, "name=? AND version!=?", whereArgs);
        return (rowsAffected > 0);
    }

    @Override
    public int getTotalRows() {
        SQLiteStorageEngine storageEngine = store.getStorageEngine();
        String sql = "SELECT total_docs FROM views WHERE name=?";
        String[] args = { name };
        int totalRows = SQLiteUtils.intForQuery(storageEngine, sql, args);
        if (totalRows == -1) { // means unknown
            createIndex();
            totalRows = countTotalRows();
            updateTotalRows(totalRows);
        }
        return totalRows;
    }

    private int countTotalRows() {
        SQLiteStorageEngine storageEngine = store.getStorageEngine();
        String sql = queryString("SELECT COUNT(*) FROM 'maps_#'");
        return SQLiteUtils.intForQuery(storageEngine, sql, null);
    }

    /**
     * The last sequence number that has been indexed.
     */
    @Override
    public long getLastSequenceIndexed() {
        String sql = "SELECT lastSequence FROM views WHERE name=?";
        String[] args = { name };
        Cursor cursor = null;
        long result = -1;
        try {
            cursor = store.getStorageEngine().rawQuery(sql, args);
            if (cursor.moveToNext()) {
                result = cursor.getLong(0);
            }
        } catch (Exception e) {
            Log.e(Log.TAG_VIEW, "Error getting last sequence indexed", e);
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        return result;
    }

    @Override
    public long getLastSequenceChangedAt() {
        // TODO: Implement
        return 0;
    }

    /**
     * Updates the indexes of one or more views in parallel.
     *
     * @param inputViews An array of ViewStore instances, always including the receiver.
     * @return Status OK if updated or NOT_MODIFIED if already up-to-date.
     * @throws CouchbaseLiteException
     */
    @Override
    @InterfaceAudience.Private
    public Status updateIndexes(List<ViewStore> inputViews) throws CouchbaseLiteException {
        Log.v(Log.TAG_VIEW, "Re-indexing view: %s", name);
        if (getViewID() <= 0) {
            String msg = "getViewID() < 0";
            throw new CouchbaseLiteException(msg, new Status(Status.NOT_FOUND));
        }

        store.beginTransaction();
        boolean success = false;
        Cursor cursor = null;
        try {
            // If the view the update is for doesn't need any update, don't do anything:
            final long dbMaxSequence = store.getLastSequence();
            final long forViewLastSequence = getLastSequenceIndexed();
            if (forViewLastSequence >= dbMaxSequence) {
                success = true;
                return new Status(Status.NOT_MODIFIED);
            }

            // Check whether we need to update at all,
            // and remove obsolete emitted results from the 'maps' table:
            long minLastSequence = dbMaxSequence;
            final long[] viewLastSequence = new long[inputViews.size()];
            int deletedCount = 0;
            int i = 0;
            final HashSet<String> docTypes = new HashSet<String>();
            HashMap<String, String> viewDocTypes = null;
            boolean allDocTypes = false;
            final HashMap<Integer, Integer> viewTotalRows = new HashMap<Integer, Integer>();
            final ArrayList<SQLiteViewStore> views = new ArrayList<SQLiteViewStore>();
            final ArrayList<Mapper> mapBlocks = new ArrayList<Mapper>();

            for (ViewStore v : inputViews) {
                assert (v != null);
                SQLiteViewStore view = (SQLiteViewStore) v;
                ViewStoreDelegate delegate = view.getDelegate();
                Mapper map = delegate != null ? delegate.getMap() : null;
                if (map == null) {
                    if (view == this) {
                        String msg = String.format(Locale.ENGLISH,
                                "Cannot index view %s: " + "no map block registered", view.getName());
                        Log.e(Log.TAG_VIEW, msg);
                        throw new CouchbaseLiteException(msg, new Status(Status.BAD_REQUEST));
                    }
                    Log.v(Log.TAG_VIEW, "    %s has no map block; skipping it", view.getName());
                    continue;
                }

                views.add(view);
                mapBlocks.add(map);

                int viewID = view.getViewID();
                if (viewID <= 0) {
                    String message = String.format(Locale.ENGLISH, "View '%s' not found in database",
                            view.getName());
                    Log.e(Log.TAG_VIEW, message);
                    throw new CouchbaseLiteException(message, new Status(Status.NOT_FOUND));
                }

                int totalRows = view.getTotalRows();
                viewTotalRows.put(viewID, totalRows);

                long last = view == this ? forViewLastSequence : view.getLastSequenceIndexed();
                viewLastSequence[i++] = last;
                if (last < 0) {
                    String msg = String.format(Locale.ENGLISH, "last < 0 (%d)", last);
                    throw new CouchbaseLiteException(msg, new Status(Status.INTERNAL_SERVER_ERROR));
                } else if (last < dbMaxSequence) {
                    if (last == 0)
                        view.createIndex();
                    minLastSequence = Math.min(minLastSequence, last);
                    Log.v(Log.TAG_VIEW, "    %s last indexed at #%d", view.getName(), last);

                    String docType = delegate.getDocumentType();
                    if (docType != null) {
                        docTypes.add(docType);
                        if (viewDocTypes == null)
                            viewDocTypes = new HashMap<String, String>();
                        viewDocTypes.put(view.getName(), docType);
                    } else {
                        allDocTypes = true;
                    }

                    int changes = 0;
                    if (last == 0) {
                        changes = store.getStorageEngine().delete(view.queryString("maps_#"), null, null);
                    } else {
                        store.optimizeSQLIndexes();
                        String[] args = { Long.toString(last), Long.toString(last) };
                        changes = store.getStorageEngine().delete(view.queryString("maps_#"),
                                "sequence IN (SELECT parent FROM revs "
                                        + "WHERE sequence>? AND +parent>0 AND +parent<=?)",
                                args);
                    }

                    // Update #deleted rows:
                    deletedCount += changes;

                    // Only count these deletes as changes if this isn't a view reset to 0
                    if (last != 0) {
                        int newTotalRows = viewTotalRows.get(viewID) - changes;
                        viewTotalRows.put(viewID, newTotalRows);
                    }
                }
            }

            if (minLastSequence == dbMaxSequence) {
                Log.v(Log.TAG_VIEW, "minLastSequence (%d) == dbMaxSequence (%d), nothing to do", minLastSequence,
                        dbMaxSequence);
                success = true;
                return new Status(Status.NOT_MODIFIED);
            }

            Log.v(Log.TAG_VIEW, "Updating indexes of (%s) from #%d to #%d ...", viewNames(views), minLastSequence,
                    dbMaxSequence);

            // This is the emit() block, which gets called from within the user-defined map() block
            // that's called down below.
            final AtomicInteger insertedCount = new AtomicInteger(0);
            AbstractMapEmitBlock emitBlock = new AbstractMapEmitBlock() {
                @Override
                public void emit(Object key, Object value) {
                    if (key == null) {
                        Log.w(Log.TAG_VIEW, "emit() called with nil key; ignoring");
                        return;
                    }
                    try {
                        curView.emit(key, value, this.sequence); // emit block's sequence
                        int curViewID = curView.getViewID();
                        viewTotalRows.put(curViewID, viewTotalRows.get(curViewID) + 1);
                    } catch (Exception e) {
                        Log.e(Log.TAG_VIEW, "Error emitting", e);
                        throw new RuntimeException(e);
                    }
                }
            };

            // Now scan every revision added since the last time the view was indexed:

            // NOTE: Below is original Query. In case query result uses a lot of memory,
            //       Android SQLiteDatabase causes null value column. Then it causes the missing
            //       _index data because following logic skip result if column is null.
            //       To avoid the issue, retrieving json field is isolated from original query.
            //       Because json field could be large, maximum size is 2MB.
            // StringBuffer sql = new StringBuffer( "SELECT revs.doc_id, sequence, docid, revid,
            // json, no_attachments, deleted FROM revs, docs WHERE sequence>? AND current!=0 ");

            boolean checkDocTypes = docTypes.size() > 1 || (allDocTypes && docTypes.size() > 0);
            StringBuilder sql = new StringBuilder(
                    "SELECT revs.doc_id, sequence, docid, revid, no_attachments, deleted ");
            if (checkDocTypes)
                sql.append(", doc_type ");
            sql.append("FROM revs, docs WHERE sequence>? AND current!=0 ");
            if (minLastSequence == 0) {
                sql.append("AND deleted=0 ");
            }
            if (!allDocTypes && docTypes.size() > 0) {
                String docTypesString = getJoinedSQLQuotedStrings(docTypes.toArray(new String[docTypes.size()]));
                sql.append("AND doc_type IN (").append(docTypesString).append(") ");
            }
            // order result by deleted ASC so if multiple revs returned the non deleted are the first
            // NOTE: Views broken with concurrent update and delete
            // https://github.com/couchbase/couchbase-lite-java-core/issues/952
            sql.append("AND revs.doc_id = docs.doc_id ORDER BY revs.doc_id, deleted ASC, revid DESC");
            String[] selectArgs = { Long.toString(minLastSequence) };
            cursor = store.getStorageEngine().rawQuery(sql.toString(), selectArgs);

            boolean keepGoing = cursor.moveToNext(); // Go to first result row
            while (keepGoing) {
                // NOTE: skip row if 1st column is null
                // https://github.com/couchbase/couchbase-lite-java-core/issues/497
                if (cursor.isNull(0)) {
                    keepGoing = cursor.moveToNext();
                    continue;
                }

                long docID = cursor.getLong(0);

                // Reconstitute the document as a dictionary:
                long sequence = cursor.getLong(1);
                String docId = cursor.getString(2);
                if (docId.startsWith("_design/")) { // design docs don't get indexed!
                    keepGoing = cursor.moveToNext();
                    continue;
                }
                String revID = cursor.getString(3);
                boolean deleted = cursor.getInt(5) > 0;
                String docType = checkDocTypes ? cursor.getString(6) : null;

                // Skip rows with the same doc_id -- these are losing conflicts.
                // NOTE: Or Skip rows if 1st column is null
                // https://github.com/couchbase/couchbase-lite-java-core/issues/497
                ArrayList<String> conflicts = null;
                boolean isNull;
                while ((keepGoing = cursor.moveToNext())
                        && ((isNull = cursor.isNull(0)) || cursor.getLong(0) == docID)) {
                    if (isNull)
                        continue;
                    if (!deleted) {
                        if (conflicts == null)
                            conflicts = new ArrayList<String>();
                        conflicts.add(cursor.getString(3));
                    }
                }

                long realSequence = sequence; // because sequence may be changed, below
                if (minLastSequence > 0) {
                    // Find conflicts with documents from previous indexings.
                    Cursor cursor2 = null;
                    try {
                        String[] selectArgs2 = { Long.toString(docID), Long.toString(minLastSequence) };
                        cursor2 = store.getStorageEngine()
                                .rawQuery("SELECT revid, sequence FROM revs "
                                        + "WHERE doc_id=? AND sequence<=? AND current!=0 AND deleted=0 "
                                        + "ORDER BY revID DESC ", selectArgs2);

                        if (cursor2.moveToNext()) {
                            String oldRevID = cursor2.getString(0);
                            // This is the revision that used to be the 'winner'.
                            // Remove its emitted rows:
                            long oldSequence = cursor2.getLong(1);
                            String[] args = { Long.toString(oldSequence) };
                            for (SQLiteViewStore view : views) {
                                int changes = view.store.getStorageEngine().delete(view.queryString("maps_#"),
                                        "sequence=?", args);
                                deletedCount += changes;
                                int thisViewID = view.getViewID();
                                int newTotalRows = viewTotalRows.get(thisViewID) - changes;
                                viewTotalRows.put(thisViewID, newTotalRows);
                            }

                            String conflictRevID = oldRevID;
                            if (deleted || RevisionInternal.CBLCompareRevIDs(oldRevID, revID) > 0) {
                                // It still 'wins' the conflict, so it's the one that
                                // should be mapped [again], not the current revision!
                                conflictRevID = revID;
                                revID = oldRevID;
                                deleted = false;
                                sequence = oldSequence;
                            }

                            if (!deleted) {
                                // Conflict revisions:
                                if (conflicts == null)
                                    conflicts = new ArrayList<String>();
                                conflicts.add(conflictRevID);
                                while (cursor2.moveToNext()) {
                                    conflicts.add(cursor2.getString(0));
                                }
                            }
                        }
                    } finally {
                        if (cursor2 != null) {
                            cursor2.close();
                        }
                    }
                }

                if (deleted)
                    continue;

                // Get json blob:
                String[] selectArgs3 = { Long.toString(sequence) };
                byte[] json = SQLiteUtils.byteArrayResultForQuery(store.getStorageEngine(),
                        "SELECT json FROM revs WHERE sequence=?", selectArgs3);

                // Get the document properties, to pass to the map function:
                Map<String, Object> curDoc = store.documentPropertiesFromJSON(json, docId, revID, false, sequence);

                if (curDoc == null) {
                    Log.w(Log.TAG_VIEW, "Failed to parse JSON of doc %s rev %s", docID, revID);
                    continue;
                }
                curDoc.put("_local_seq", sequence);

                if (conflicts != null)
                    curDoc.put("_conflicts", conflicts);

                // Call the user-defined map() to emit new key/value pairs from this revision:
                i = -1;
                for (SQLiteViewStore view : views) {
                    curView = view;
                    ++i;
                    if (viewLastSequence[i] < realSequence) {
                        if (checkDocTypes) {
                            String viewDocType = viewDocTypes.get(view.getName());
                            if (viewDocType != null && !viewDocType.equals(docType))
                                continue; // skip; view's documentType doesn't match this doc
                        }
                        Log.v(Log.TAG_VIEW, "#%d: map '%s' for view %s...", sequence, docID, view.getName());
                        try {
                            emitBlock.setSequence(sequence);
                            mapBlocks.get(i).map(curDoc, emitBlock);
                        } catch (Throwable e) {
                            String msg = String.format(Locale.ENGLISH, "Error when calling map block of view '%s'",
                                    view.getName());
                            Log.e(Log.TAG_VIEW, msg, e);
                            throw new CouchbaseLiteException(msg, e, new Status(Status.CALLBACK_ERROR));
                        }
                    }
                }
            }

            // Finally, record the last revision sequence number that was indexed and update #rows:
            for (SQLiteViewStore view : views) {
                view.finishCreatingIndex();
                int newTotalRows = viewTotalRows.get(view.getViewID());
                ContentValues updateValues = new ContentValues();
                updateValues.put("lastSequence", dbMaxSequence);
                updateValues.put("total_docs", newTotalRows);
                String[] whereArgs = { Integer.toString(view.getViewID()) };
                store.getStorageEngine().update("views", updateValues, "view_id=?", whereArgs);
            }
            Log.v(Log.TAG_VIEW, "...Finished re-indexing (%s) to #%d (deleted %d, added %d)", viewNames(views),
                    dbMaxSequence, deletedCount, insertedCount.intValue());

            success = true;
            return new Status(Status.OK);
        } catch (SQLException ex) {
            throw new CouchbaseLiteException(ex, new Status(Status.DB_ERROR));
        } finally {
            curView = null;
            if (cursor != null)
                cursor.close();
            if (store != null)
                store.endTransaction(success);
        }
    }

    protected void emit(Object key, Object value, long sequence) throws JsonProcessingException {
        String valueJson;
        String keyJson = Manager.getObjectMapper().writeValueAsString(key);
        if (value == null) {
            valueJson = null;
        } else {
            valueJson = Manager.getObjectMapper().writeValueAsString(value);
        }

        // NOTE: execSQL() is little faster than insert()
        String[] args = { Long.toString(sequence), keyJson, valueJson };
        store.getStorageEngine().execSQL(queryString("INSERT INTO 'maps_#' (sequence, key, value) VALUES(?,?,?)"),
                args);
    }

    /**
     * Queries the view without performing any reducing or grouping.
     */
    @Override
    public List<QueryRow> regularQuery(final QueryOptions options) throws CouchbaseLiteException {

        final Predicate<QueryRow> postFilter = options.getPostFilter();

        int tmpLimit = QueryOptions.QUERY_OPTIONS_DEFAULT_LIMIT;
        int tmpSkip = 0;
        if (postFilter != null) {
            // #574: Custom post-filter means skip/limit apply to the filtered rows, not to the
            // underlying query, so handle them specially:
            tmpLimit = options.getLimit();
            tmpSkip = options.getSkip();
            if (tmpLimit == 0)
                return new ArrayList<QueryRow>(); // empty result set
            options.setLimit(QueryOptions.QUERY_OPTIONS_DEFAULT_LIMIT);
            options.setSkip(0);
        }

        final CountDown skip = new CountDown(tmpSkip);
        final CountDown limit = new CountDown(tmpLimit);

        final List<QueryRow> rows = new ArrayList<QueryRow>();

        Status status = runQuery(options, new QueryRowBlock() {
            @Override
            public Status onRow(byte[] keyData, byte[] valueData, String docID, Cursor cursor) {
                JsonDocument keyDoc = new JsonDocument(keyData);
                JsonDocument valueDoc = new JsonDocument(valueData);
                long sequence = Long.parseLong(cursor.getString(3));
                RevisionInternal docRevision = null;
                if (options.isIncludeDocs()) {
                    Object valueObject = valueDoc.jsonObject();
                    String linkedID = null;
                    if (valueObject instanceof Map)
                        linkedID = (String) ((Map) valueObject).get("_id");
                    if (linkedID != null) {
                        // Linked document: http://wiki.apache.org/couchdb/Introduction_to_CouchDB_views#Linked_documents
                        String linkedRev = (String) ((Map) valueObject).get("_rev");
                        docRevision = store.getDocument(linkedID, linkedRev, true);
                        sequence = docRevision.getSequence();
                    } else {
                        String revID = cursor.getString(4);
                        byte[] json = cursor.getBlob(5);
                        Map<String, Object> properties = store.documentPropertiesFromJSON(json, docID, revID, false,
                                sequence);
                        docRevision = SQLiteStore.revision(docID, // docID
                                revID, // revID
                                false, // deleted
                                sequence, // sequence
                                properties// properties
                        );
                    }
                }
                QueryRow row = new QueryRow(docID, sequence, keyDoc.jsonObject(), valueDoc.jsonObject(),
                        docRevision);
                if (postFilter != null) {
                    if (!postFilter.apply(row)) {
                        return new Status(Status.OK);
                    }
                    if (skip.getCount() > 0) {
                        skip.countDown();
                        return new Status(Status.OK);
                    }

                }
                rows.add(row);

                if (limit.countDown() == 0)
                    return new Status(0); /// stops the iteration
                return new Status(Status.OK);
            }
        });

        // If given keys, sort the output into that order, and add entries for missing keys:
        if (options.getKeys() != null && options.getKeys().size() > 0) {
            // Group rows by key:
            Map<Object, List<QueryRow>> rowsByKey = new HashMap<Object, List<QueryRow>>();
            for (QueryRow row : rows) {
                List<QueryRow> rs = rowsByKey.get(row.getKey());
                if (rs == null) {
                    rs = new ArrayList<QueryRow>();
                    rowsByKey.put(row.getKey(), rs);
                }
                rs.add(row);
            }

            // Now concatenate them in the order the keys are given in options:
            final List<QueryRow> sortedRows = new ArrayList<QueryRow>();
            for (Object key : options.getKeys()) {
                JsonDocument jsonDoc = null;
                try {
                    byte[] keyBytes = Manager.getObjectMapper().writeValueAsBytes(key);
                    jsonDoc = new JsonDocument(keyBytes);
                } catch (JsonProcessingException e) {
                    throw new CouchbaseLiteException(Status.INTERNAL_SERVER_ERROR);
                }
                List<QueryRow> rs = rowsByKey.get(jsonDoc.jsonObject());
                if (rs != null)
                    sortedRows.addAll(rs);
            }

            return sortedRows;
        }

        return rows;
    }

    /**
     * Queries the view, with reducing or grouping as per the options.
     * in CBL_SQLiteViewStorage.m
     * - (CBLQueryIteratorBlock) reducedQueryWithOptions: (CBLQueryOptions*)options status: (CBLStatus*)outStatus
     */
    @Override
    public List<QueryRow> reducedQuery(QueryOptions options) throws CouchbaseLiteException {
        final Predicate<QueryRow> postFilter = options.getPostFilter();

        final int groupLevel = options.getGroupLevel();
        final boolean group = options.isGroup() || (groupLevel > 0);
        final Reducer reduce = delegate.getReduce();
        if (options.isReduceSpecified()) {
            if (options.isReduce() && reduce == null) {
                Log.w(TAG, String.format(Locale.ENGLISH,
                        "Cannot use reduce option in view %s which has no reduce block defined", name));
                throw new CouchbaseLiteException(new Status(Status.BAD_PARAM));
            }
        }

        final List<Object> keysToReduce = new ArrayList<Object>(REDUCE_BATCH_SIZE);
        final List<Object> valuesToReduce = new ArrayList<Object>(REDUCE_BATCH_SIZE);
        final Object[] lastKeys = new Object[1];
        lastKeys[0] = null;
        final SQLiteViewStore that = this;

        final List<QueryRow> rows = new ArrayList<QueryRow>();

        Status status = runQuery(options, new QueryRowBlock() {
            @Override
            public Status onRow(byte[] keyData, byte[] valueData, String docID, Cursor cursor) {
                JsonDocument keyDoc = new JsonDocument(keyData);
                JsonDocument valueDoc = new JsonDocument(valueData);
                assert (keyDoc != null);
                Object keyObject = keyDoc.jsonObject();
                if (group && !groupTogether(keyObject, lastKeys[0], groupLevel)) {
                    if (lastKeys[0] != null) {
                        // This pair starts a new group, so reduce & record the last one:
                        Object key = groupKey(lastKeys[0], groupLevel);
                        Object reduced = (reduce != null) ? reduce.reduce(keysToReduce, valuesToReduce, false)
                                : null;
                        QueryRow row = new QueryRow(null, 0, key, reduced, null);
                        if (postFilter == null || postFilter.apply(row))
                            rows.add(row);
                        keysToReduce.clear();
                        valuesToReduce.clear();
                    }
                    lastKeys[0] = keyObject;
                }
                //Log.v(TAG, String.format("Query %s: Will reduce row with key=%s, value=%s",
                //        name, new String(keyData), new String(valueData)));

                /*
                Object valueOrData = valueData;
                if(reduce != null && rowValueIsEntireDoc(valueData)){
                // map fn emitted 'doc' as value, which was stored as a "*" placeholder; expand now:
                long sequence = Long.valueOf(cursor.getString(3));
                RevisionInternal rev = store.getDocument(docID, sequence);
                if(rev == null)
                    Log.w(TAG, String.format("%s: Couldn't load doc for row value", this));
                else
                    valueOrData = rev.getProperties();
                }
                */
                keysToReduce.add(keyObject);
                valuesToReduce.add(valueDoc.jsonObject());
                return new Status(Status.OK);
            }
        });

        if (keysToReduce != null && keysToReduce.size() > 0) {
            // Finish the last group (or the entire list, if no grouping):
            Object key = group ? groupKey(lastKeys[0], groupLevel) : null;
            Object reduced = (reduce != null) ? reduce.reduce(keysToReduce, valuesToReduce, false) : null;
            Log.v(TAG, String.format(Locale.ENGLISH, "Query %s: Reduced to key=%s, value=%s", name, key, reduced));
            QueryRow row = new QueryRow(null, 0, key, reduced, null);
            if (postFilter == null || postFilter.apply(row)) {
                rows.add(row);
            }
        }
        return rows;
    }

    @Override
    public List<Map<String, Object>> dump() {
        if (getViewID() < 0)
            return null;
        List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
        String sql = "SELECT sequence, key, value FROM 'maps_#' ORDER BY key";
        Cursor cursor = null;
        try {
            cursor = store.getStorageEngine().rawQuery(queryString(sql), null);
            cursor.moveToNext();
            while (!cursor.isAfterLast()) {
                Map<String, Object> row = new HashMap<String, Object>();
                row.put("seq", cursor.getInt(0));
                row.put("key", cursor.getString(1));
                row.put("value", cursor.getString(2));
                result.add(row);
                cursor.moveToNext();
            }
        } finally {
            if (cursor != null)
                cursor.close();
        }
        return result;
    }

    ///////////////////////////////////////////////////////////////////////////
    // Implementation of QueryRowStorage
    ///////////////////////////////////////////////////////////////////////////

    @Override
    public boolean rowValueIsEntireDoc(byte[] valueData) {
        return valueData.length == 1 && (new String(valueData)).equals("*");
    }

    @Override
    public Object parseRowValue(byte[] valueData) {
        // TODO: Implement
        return null;
    }

    @Override
    public Map<String, Object> getDocumentProperties(String docID, long sequence) {
        // TODO: Implement
        return null;
    }

    ///////////////////////////////////////////////////////////////////////////
    // Internal (Private) Instance Methods
    ///////////////////////////////////////////////////////////////////////////

    private int getViewID() {
        if (viewID < 0) {
            String sql = "SELECT view_id FROM views WHERE name=?";
            String[] args = { name };
            Cursor cursor = null;
            try {
                cursor = store.getStorageEngine().rawQuery(sql, args);
                if (cursor.moveToNext()) {
                    viewID = cursor.getInt(0);
                }
            } catch (SQLException e) {
                Log.e(Log.TAG_VIEW, "Error getting view id", e);
            } finally {
                if (cursor != null) {
                    cursor.close();
                }
            }
        }
        return viewID;
    }

    private static String viewNames(List<SQLiteViewStore> views) {
        StringBuilder sb = new StringBuilder();
        boolean first = true;
        for (ViewStore view : views) {
            if (first)
                first = false;
            else
                sb.append(", ");
            sb.append(view.getName());
        }
        return sb.toString();
    }

    private static String getJoinedSQLQuotedStrings(String[] strings) {
        if (strings == null)
            return null;

        if (strings.length == 0)
            return "";

        StringBuilder sb = new StringBuilder("'");
        boolean first = true;
        for (String s : strings) {
            if (first)
                first = false;
            else
                sb.append("','");
            sb.append(s.replace("'", "''"));
        }
        sb.append('\'');
        return sb.toString();
    }

    // pragma mark - QUERYING:

    /**
     * Generates and runs the SQL SELECT statement for a view query, calling the onRow callback.
     * in CBL_SQLiteViewStorage.m
     * - (CBLStatus) _runQueryWithOptions: (const CBLQueryOptions*)options onRow: (QueryRowBlock)onRow
     */
    private Status runQuery(QueryOptions options, QueryRowBlock block) {
        if (options == null)
            options = new QueryOptions();

        // OPT: It would be faster to use separate tables for raw-or ascii-collated views so that
        // they could be indexed with the right collation, instead of having to specify it here.
        String collationStr = "";
        if (collation == View.TDViewCollation.TDViewCollationASCII)
            collationStr += " COLLATE JSON_ASCII";
        else if (collation == View.TDViewCollation.TDViewCollationRaw)
            collationStr += " COLLATE JSON_RAW";

        StringBuilder sql = new StringBuilder("SELECT key, value, docid, revs.sequence");
        if (options.isIncludeDocs()) {
            sql.append(", revid, json");
        }
        sql.append(String.format(Locale.ENGLISH, " FROM 'maps_%s', revs, docs", mapTableName()));
        sql.append(" WHERE 1");

        List<String> argsList = new ArrayList<String>();

        if (options.getKeys() != null && options.getKeys().size() > 0) {
            sql.append(" AND key in (");
            String item = "?";
            for (Object key : options.getKeys()) {
                // null key should be ignored
                if (key != null) {
                    sql.append(item);
                    item = ", ?";
                    argsList.add(toJSONString(key));
                }
            }
            sql.append(')');
        }

        Object minKey = options.getStartKey();
        Object maxKey = options.getEndKey();
        String minKeyDocId = options.getStartKeyDocId();
        String maxKeyDocId = options.getEndKeyDocId();

        boolean inclusiveMin = options.isInclusiveStart();
        boolean inclusiveMax = options.isInclusiveEnd();
        if (options.isDescending()) {
            Object min = minKey;
            minKey = maxKey;
            maxKey = min;
            inclusiveMin = inclusiveMax;
            inclusiveMax = true;
            minKeyDocId = options.getEndKeyDocId();
            maxKeyDocId = options.getStartKeyDocId();
        }

        if (minKey != null) {
            String minKeyJSON = toJSONString(minKey);
            sql.append(inclusiveMin ? " AND key >= ?" : " AND key > ?");
            sql.append(collationStr);
            argsList.add(minKeyJSON);
            if (minKeyDocId != null && inclusiveMin) {
                //OPT: This calls the JSON collator a 2nd time unnecessarily.
                sql.append(String.format(Locale.ENGLISH, " AND (key > ? %s OR docid >= ?)", collationStr));
                argsList.add(minKeyJSON);
                argsList.add(minKeyDocId);
            }
        }

        if (maxKey != null) {
            maxKey = View.keyForPrefixMatch(maxKey, options.getPrefixMatchLevel());
            String maxKeyJSON = toJSONString(maxKey);
            sql.append(inclusiveMax ? " AND key <= ?" : " AND key < ?");
            sql.append(collationStr);
            argsList.add(maxKeyJSON);
            if (maxKeyDocId != null && inclusiveMax) {
                sql.append(String.format(Locale.ENGLISH, " AND (key < ? %s OR docid <= ?)", collationStr));
                argsList.add(maxKeyJSON);
                argsList.add(maxKeyDocId);
            }
        }

        sql.append(String.format(Locale.ENGLISH,
                " AND revs.sequence = 'maps_%s'.sequence AND docs.doc_id = revs.doc_id ORDER BY key",
                mapTableName()));
        sql.append(collationStr);
        if (options.isDescending()) {
            sql.append(" DESC");
        }
        sql.append(options.isDescending() ? ", docid DESC" : ", docid");

        sql.append(" LIMIT ? OFFSET ?");
        argsList.add(Integer.toString(options.getLimit()));
        argsList.add(Integer.toString(options.getSkip()));

        Log.v(Log.TAG_VIEW, "Query %s: %s | args: %s", name, sql.toString(), argsList);

        Status status = new Status(Status.OK);
        Cursor cursor = null;
        try {
            cursor = store.getStorageEngine().rawQuery(sql.toString(),
                    argsList.toArray(new String[argsList.size()]));
            // regular query
            cursor.moveToNext();
            while (!cursor.isAfterLast()) {
                // Call the block!
                byte[] keyData = cursor.getBlob(0);
                byte[] valueData = cursor.getBlob(1);
                String docID = cursor.getString(2);
                status = block.onRow(keyData, valueData, docID, cursor);
                if (status.isError())
                    break;
                else if (status.getCode() <= 0) {
                    status = new Status(Status.OK);
                    break;
                }
                cursor.moveToNext();
            }
        } finally {
            if (cursor != null)
                cursor.close();
        }
        return status;
    }

    private static String toJSONString(Object object) {
        if (object == null) {
            return null;
        }
        String result = null;
        try {
            result = Manager.getObjectMapper().writeValueAsString(object);
        } catch (Exception e) {
            Log.w(Log.TAG_VIEW, "Exception serializing object to json: %s", e, object);
        }
        return result;
    }

    private void updateTotalRows(int totalRows) {
        ContentValues values = new ContentValues();
        values.put("total_docs=", totalRows);
        store.getStorageEngine().update("views", values, "view_id=?", new String[] { String.valueOf(getViewID()) });
    }

    private String mapTableName() {
        if (_mapTableName == null) {
            _mapTableName = String.valueOf(getViewID());
        }
        return _mapTableName;
    }

    /**
     * The name of the map table is dynamic, based on the ID of the view. This method replaces a '#'
     * with the view ID in a query string.
     */
    private String queryString(String sql) {
        return sql.replaceAll("#", mapTableName());
    }

    private boolean runStatements(final String sql) {
        final SQLiteStore db = store;
        return db.runInTransaction(new TransactionalTask() {
            @Override
            public boolean run() {
                try {
                    db.runStatements(queryString(sql));
                } catch (SQLException e) {
                    return false;
                }
                return true;
            }
        });
    }

    private void createIndex() {
        String sql = "CREATE TABLE IF NOT EXISTS 'maps_#' ("
                + "sequence INTEGER NOT NULL REFERENCES revs(sequence) ON DELETE CASCADE,"
                + "key TEXT NOT NULL COLLATE JSON," + "value TEXT)";
        if (!runStatements(sql))
            Log.w(TAG, "Couldn't create view _index `%s`", name);
    }

    ///////////////////////////////////////////////////////////////////////////
    // Internal (Private) Static Methods
    ///////////////////////////////////////////////////////////////////////////

    /**
     * Are key1 and key2 grouped together at this groupLevel?
     */
    private static boolean groupTogether(Object key1, Object key2, int groupLevel) {
        if (groupLevel == 0 || !(key1 instanceof List) || !(key2 instanceof List)) {
            return key1.equals(key2);
        }
        @SuppressWarnings("unchecked")
        List<Object> key1List = (List<Object>) key1;
        @SuppressWarnings("unchecked")
        List<Object> key2List = (List<Object>) key2;

        // if either key list is smaller than groupLevel and the key lists are different
        // sizes, they cannot be equal.
        if ((key1List.size() < groupLevel || key2List.size() < groupLevel) && key1List.size() != key2List.size()) {
            return false;
        }

        int end = Math.min(groupLevel, Math.min(key1List.size(), key2List.size()));
        for (int i = 0; i < end; ++i) {
            if (!key1List.get(i).equals(key2List.get(i))) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns the prefix of the key to use in the result row, at this groupLevel
     */
    public static Object groupKey(Object key, int groupLevel) {
        if (groupLevel > 0 && (key instanceof List) && (((List<Object>) key).size() > groupLevel)) {
            return ((List<Object>) key).subList(0, groupLevel);
        } else {
            return key;
        }
    }

    /**
     * in CBL_SQLiteViewStorage.m
     * - (void) finishCreatingIndex
     */
    private void finishCreatingIndex() {
        String sql = "CREATE INDEX IF NOT EXISTS 'maps_#_keys' on 'maps_#'(key COLLATE JSON);"
                + "CREATE INDEX IF NOT EXISTS 'maps_#_sequence' ON 'maps_#'(sequence)";
        if (!runStatements(sql))
            Log.w(TAG, "Couldn't create view SQL index `%s`", name);
    }
}