RefreshSyncDownTarget.java

/*
 * Copyright (c) 2016-present, salesforce.com, inc.
 * All rights reserved.
 * Redistribution and use of this software in source and binary forms, with or
 * without modification, are permitted provided that the following conditions
 * are met:
 * - Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 * - Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 * - Neither the name of salesforce.com, inc. nor the names of its contributors
 * may be used to endorse or promote products derived from this software without
 * specific prior written permission of salesforce.com, inc.
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */
package com.salesforce.androidsdk.smartsync.util;

import android.text.TextUtils;
import android.util.Log;

import com.salesforce.androidsdk.rest.RestRequest;
import com.salesforce.androidsdk.rest.RestResponse;
import com.salesforce.androidsdk.smartstore.store.QuerySpec;
import com.salesforce.androidsdk.smartsync.manager.SyncManager;
import com.salesforce.androidsdk.util.JSONObjectHelper;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Target for sync which syncs down the records currently in a soup
 */
public class RefreshSyncDownTarget extends SyncDownTarget {

    private static final String TAG = "RefreshSyncDownTarget";
    public static final String FIELDLIST = "fieldlist";
    public static final String SOBJECT_TYPE = "sobjectType";
    public static final String SOUP_NAME = "soupName";
    public static final String COUNT_IDS_PER_SOQL = "coundIdsPerSoql";
    private List<String> fieldlist;
    private String objectType;
    private String soupName;
    private int countIdsPerSoql;
    private static final int defaultCountIdsPerSoql = 500;

    // NB: For each sync run - a fresh sync down target is created (by deserializing it from smartstore)
    // The following members are specific to a run
    // page will change during a run as we call start/continueFetch
    private boolean isResync = false;
    private int page = 0;


    /**
     * Return number of ids to pack in a single SOQL call
     */
    public int getCountIdsPerSoql() {
        return countIdsPerSoql;
    }

    /**
     * Set the number of ids to pack in a single SOQL call
     * SOQL query size limit is 10,000 characters (so ~500 ids)
     * This setter is to be used by tests primarily
     * @param count
     */
    public void setCountIdsPerSoql(int count) {
        countIdsPerSoql = count;
    }

    /**
     * Construct RefreshSyncDownTarget from json
     * @param target
     * @throws JSONException
     */
    public RefreshSyncDownTarget(JSONObject target) throws JSONException {
        super(target);
        this.fieldlist = JSONObjectHelper.toList(target.getJSONArray(FIELDLIST));
        this.objectType = target.getString(SOBJECT_TYPE);
        this.soupName = target.getString(SOUP_NAME);
        this.countIdsPerSoql = target.optInt(COUNT_IDS_PER_SOQL, defaultCountIdsPerSoql);
    }

    /**
     * Constructor
     * @param fieldlist
     * @param objectType
     */
    public RefreshSyncDownTarget(List<String> fieldlist, String objectType, String soupName) throws JSONException {
        super();
        this.queryType = QueryType.refresh;
        this.fieldlist = fieldlist;
        this.objectType = objectType;
        this.soupName = soupName;
        this.countIdsPerSoql = defaultCountIdsPerSoql;
    }

    /**
     * @return json representation of target
     * @throws JSONException
     */
    public JSONObject asJSON() throws JSONException {
        JSONObject target = super.asJSON();
        target.put(FIELDLIST, new JSONArray(fieldlist));
        target.put(SOBJECT_TYPE, objectType);
        target.put(SOUP_NAME, soupName);
        target.put(COUNT_IDS_PER_SOQL, countIdsPerSoql);
        return target;
    }

    @Override
    public JSONArray startFetch(SyncManager syncManager, long maxTimeStamp) throws IOException, JSONException {
        // During reSync, we can't make use of the maxTimeStamp that was captured during last refresh
        // since we expect records to have been fetched from the server and written to the soup directly outside a sync down operation
        // Instead during a reSymc, we compute maxTimeStamp from the records in the soup
        isResync = maxTimeStamp > 0;
        return  getIdsFromSmartStoreAndFetchFromServer(syncManager);
    }

    @Override
    public JSONArray continueFetch(SyncManager syncManager) throws IOException, JSONException {
        return page > 0 ? getIdsFromSmartStoreAndFetchFromServer(syncManager) : null;
    }

    private JSONArray getIdsFromSmartStoreAndFetchFromServer(SyncManager syncManager) throws IOException, JSONException {
        // Read from smartstore
        final QuerySpec querySpec;
        final List<String> idsInSmartStore = new ArrayList<>();
        final long maxTimeStamp;

        if (isResync) {
            // Getting full records from SmartStore to compute maxTimeStamp
            // So doing more db work in the hope of doing less server work
            querySpec = QuerySpec.buildAllQuerySpec(soupName, getIdFieldName(), QuerySpec.Order.ascending, getCountIdsPerSoql());
            JSONArray recordsFromSmartStore = syncManager.getSmartStore().query(querySpec, page);

            // Compute max time stamp
            maxTimeStamp = getLatestModificationTimeStamp(recordsFromSmartStore);

            // Get ids
            for (int i = 0; i < recordsFromSmartStore.length(); i++) {
                idsInSmartStore.add(recordsFromSmartStore.getJSONObject(i).getString(getIdFieldName()));
            }
        }
        else {
            querySpec = QuerySpec.buildSmartQuerySpec("SELECT {" + soupName + ":" + getIdFieldName()
                    + "} FROM {" + soupName + "} ORDER BY {" + soupName + ":" + getIdFieldName() + "} ASC", getCountIdsPerSoql());
            JSONArray result = syncManager.getSmartStore().query(querySpec, page);

            // Not a resync
            maxTimeStamp = 0;

            // Get ids
            for (int i = 0; i < result.length(); i++) {
                idsInSmartStore.add(result.getJSONArray(i).getString(0));
            }
        }

        // If fetch is starting, figuring out totalSize
        // NB: it might not be the correct value during resync
        //     since not all records will have changed
        if (page == 0) {
            totalSize = syncManager.getSmartStore().countQuery(querySpec);
        }

        if (idsInSmartStore.size() > 0) {
            // Get records from server that have changed after maxTimeStamp
            final JSONArray records = fetchFromServer(syncManager, idsInSmartStore, fieldlist, maxTimeStamp);

            // Increment page if there is more to fetch
            boolean done = getCountIdsPerSoql() * (page + 1) >= totalSize;
            page = (done ? 0 : page + 1);

            return records;
        }
        else {
            page = 0; // done
            return null;
        }
    }

    private JSONArray fetchFromServer(SyncManager syncManager, List<String> ids, List<String> fieldlist, long maxTimeStamp) throws IOException, JSONException {
        final String whereClause = ""
                + getIdFieldName() + " IN ('" + TextUtils.join("', '", ids) + "')"
                + (maxTimeStamp > 0 ? " AND " + getModificationDateFieldName() + " > " + Constants.TIMESTAMP_FORMAT.format(new Date(maxTimeStamp))
                : "");
        final String soql = SOQLBuilder.getInstanceWithFields(fieldlist).from(objectType).where(whereClause).build();
        final RestRequest request = RestRequest.getRequestForQuery(syncManager.apiVersion, soql);
        final RestResponse response = syncManager.sendSyncWithSmartSyncUserAgent(request);
        JSONObject responseJson = response.asJSONObject();
        return responseJson.getJSONArray(Constants.RECORDS);
    }

    @Override
    public Set<String> getListOfRemoteIds(SyncManager syncManager, Set<String> localIds) {
        if (localIds == null) {
            return null;
        }

        Set<String> remoteIds = new HashSet<>();

        try {
            List<String> localIdsList = new ArrayList<>(localIds);
            int sliceSize = getCountIdsPerSoql();
            int countSlices = (int) Math.ceil((double) localIds.size() / sliceSize);
            for (int slice=0; slice<countSlices; slice++) {
                List<String> idsToFetch = localIdsList.subList(slice*sliceSize, Math.min(localIdsList.size(), (slice+1)*sliceSize));
                JSONArray records = fetchFromServer(syncManager, idsToFetch, Arrays.asList(getIdFieldName()), 0 /* get all */);
                remoteIds.addAll(parseIdsFromResponse(records));
            }
        } catch (IOException e) {
            Log.e(TAG, "IOException thrown while fetching records", e);
        } catch (JSONException e) {
            Log.e(TAG, "JSONException thrown while fetching records", e);
        }

        return remoteIds;
    }

    /**
     * @return field list for this target
     */
    public List<String> getFieldlist() {
        return fieldlist;
    }

    /**
     * @return object type for this target
     */
    public String getObjectType() {
        return objectType;
    }
}