/* * Copyright (C) 2016 Mobile Jazz * * 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.mobilejazz.cacheio.caches; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import com.mobilejazz.cacheio.RxCache; import com.mobilejazz.cacheio.mappers.KeyMapper; import com.mobilejazz.cacheio.mappers.ValueMapper; import com.mobilejazz.cacheio.mappers.VersionMapper; import com.mobilejazz.cacheio.mappers.version.NoOpVersionMapper; import rx.Single; import rx.SingleSubscriber; import rx.functions.Func1; import java.io.*; import java.util.*; import java.util.concurrent.*; import static com.mobilejazz.cacheio.helper.Preconditions.checkArgument; public class SQLiteRxCache<K, V> implements RxCache<K, V> { private static final String COLUMN_KEY = "key"; private static final String COLUMN_VALUE = "value"; private static final String COLUMN_VERSION = "version"; private static final String COLUMN_CREATED = "created"; private static final String COLUMN_EXPIRES = "expires"; private final Builder<K, V> config; private SQLiteRxCache(Builder<K, V> proto) { this.config = new Builder<>(proto); } @Override public Single<V> get(final K key) { final Collection<K> keys = new ArrayList<>(1); keys.add(key); return getAll(keys).map(new Func1<Map<K, V>, V>() { @Override public V call(Map<K, V> map) { return map.get(key); } }); } @Override public Single<Map<K, V>> getAll(final Collection<K> keys) { return Single.create(new Single.OnSubscribe<Map<K, V>>() { @Override public void call(SingleSubscriber<? super Map<K, V>> singleSubscriber) { final GetAll task = new GetAll(keys, singleSubscriber); config.executor.execute(task); } }); } @Override public Single<V> put(final K key, V value, long expiry, TimeUnit unit) { final Map<K, V> map = new HashMap<>(1); map.put(key, value); return putAll(map, expiry, unit).map(new Func1<Map<K, V>, V>() { @Override public V call(Map<K, V> map) { return map.get(key); } }); } @Override public Single<Map<K, V>> putAll(final Map<K, V> map, final long expiry, final TimeUnit unit) { return Single.create(new Single.OnSubscribe<Map<K, V>>() { @Override public void call(SingleSubscriber<? super Map<K, V>> singleSubscriber) { final PutAll task = new PutAll(map, expiry, unit, singleSubscriber); config.executor.execute(task); } }); } @Override public Single<K> remove(K key) { final Collection<K> keys = new ArrayList<>(1); keys.add(key); return removeAll(keys).map(new Func1<Collection<K>, K>() { @Override public K call(Collection<K> keys) { return keys.iterator().next(); } }); } @Override public Single<Collection<K>> removeAll(final Collection<K> keys) { return Single.create(new Single.OnSubscribe<Collection<K>>() { @Override public void call(SingleSubscriber<? super Collection<K>> singleSubscriber) { final RemoveAll task = new RemoveAll(keys, singleSubscriber); config.executor.execute(task); } }); } private static String generatePlaceholders(int count) { final StringBuilder builder = new StringBuilder(); for (int i = 0; i < count; i++) { builder.append(",").append("?"); } return builder.length() == 0 ? "" : builder.toString().substring(1); } private String[] keysAsString(Collection<K> keys) { String[] result = new String[keys.size()]; int idx = 0; for (K key : keys) { result[idx++] = config.keyMapper.toString(key); } return result; } private final class GetAll implements Runnable { private final Collection<K> keys; private final SingleSubscriber<? super Map<K, V>> subscriber; private final Date now = new Date(); public GetAll(Collection<K> keys, SingleSubscriber<? super Map<K, V>> subscriber) { this.keys = keys; this.subscriber = subscriber; } @Override public void run() { try { final String timeStr = Long.toString(now.getTime()); final String sql = "SELECT * FROM " + config.tableName + " WHERE " + COLUMN_EXPIRES + " >= ? AND " + COLUMN_KEY + " IN (" + generatePlaceholders(keys.size()) + ")"; final String[] keysAsStrings = keysAsString(keys); final String[] args = new String[keysAsStrings.length + 1]; args[0] = timeStr; System.arraycopy(keysAsStrings, 0, args, 1, keysAsStrings.length); final ValueMapper valueValueMapper = config.valueMapper; final Cursor cursor = config.db.rawQuery(sql, args); final Map<K, V> result = new HashMap<>(); while (cursor.moveToNext()) { final String keyString = cursor.getString(cursor.getColumnIndex(COLUMN_KEY)); final byte[] blob = cursor.getBlob(cursor.getColumnIndex(COLUMN_VALUE)); final ByteArrayInputStream bytesIn = new ByteArrayInputStream(blob); final K key = config.keyMapper.fromString(keyString); final V value = valueValueMapper.read(config.valueType, bytesIn); result.put(key, value); } cursor.close(); subscriber.onSuccess(result); } catch (Throwable t) { subscriber.onError(t); } } } private final class PutAll implements Runnable { private final Map<K, V> map; private final long expiry; private final TimeUnit expiryUnit; private final SingleSubscriber<? super Map<K, V>> subscriber; private final Date now = new Date(); public PutAll(Map<K, V> map, long expiry, TimeUnit expiryUnit, SingleSubscriber<? super Map<K, V>> subscriber) { this.map = map; this.expiry = expiry; this.expiryUnit = expiryUnit; this.subscriber = subscriber; } @Override public void run() { final SQLiteDatabase db = config.db; final KeyMapper<K> keyMapper = config.keyMapper; final VersionMapper<V> versionMapper = config.versionMapper; final ValueMapper valueMapper = config.valueMapper; try { final Map<K, V> result = new HashMap<>(map.size()); final long createdAt = now.getTime(); final long expiresAt = expiry == Long.MAX_VALUE ? expiry : createdAt + expiryUnit.toMillis(expiry); db.beginTransaction(); final Set<K> keys = map.keySet(); final Set<K> keysToUpdate = new HashSet<>(keys); final String versionSql = "SELECT " + COLUMN_KEY + ", " + COLUMN_VERSION + " FROM " + config.tableName + " WHERE key in (" + generatePlaceholders(keys.size()) + ")"; final Cursor versionCursor = config.db.rawQuery(versionSql, keysAsString(keys)); // determine based on version which keys we should update while (versionCursor.moveToNext()) { final K key = keyMapper.fromString( versionCursor.getString(versionCursor.getColumnIndex(COLUMN_KEY))); final long version = versionCursor.getLong(versionCursor.getColumnIndex(COLUMN_VERSION)); final V value = map.get(key); final long valueVersion = versionMapper.getVersion(value); if (valueVersion != NoOpVersionMapper.UNVERSIONED && versionMapper.getVersion(value) < version) { // attempting to overwrite a newer version so remove the key from the update list keysToUpdate.remove(key); } } versionCursor.close(); // update if (keysToUpdate.size() > 0) { // delete previous entries final String sql = "DELETE FROM " + config.tableName + " WHERE " + COLUMN_KEY + " IN (" + generatePlaceholders(keysToUpdate.size()) + ")"; db.execSQL(sql, keysAsString(keysToUpdate)); // Insert new entries for (K key : keysToUpdate) { final V value = map.get(key); final long version = versionMapper.getVersion(value); final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(); valueMapper.write(value, bytesOut); bytesOut.close(); final ContentValues values = new ContentValues(2); values.put(COLUMN_KEY, keyMapper.toString(key)); values.put(COLUMN_VALUE, bytesOut.toByteArray()); values.put(COLUMN_VERSION, version); values.put(COLUMN_CREATED, createdAt); values.put(COLUMN_EXPIRES, expiresAt); db.insert(config.tableName, null, values); result.put(key, value); } } db.setTransactionSuccessful(); db.endTransaction(); subscriber.onSuccess(result); } catch (Throwable t) { db.endTransaction(); subscriber.onError(t); } } } private final class RemoveAll implements Runnable { private final Collection<K> keys; private final SingleSubscriber<? super Collection<K>> subscriber; public RemoveAll(Collection<K> keys, SingleSubscriber<? super Collection<K>> subscriber) { this.keys = keys; this.subscriber = subscriber; } @Override public void run() { final SQLiteDatabase db = config.db; try { db.beginTransaction(); final String sql = "DELETE FROM " + config.tableName + " WHERE " + COLUMN_KEY + " IN (" + generatePlaceholders(keys.size()) + ")"; db.execSQL(sql, keysAsString(keys)); db.setTransactionSuccessful(); db.endTransaction(); subscriber.onSuccess(keys); } catch (Throwable t) { db.endTransaction(); subscriber.onError(t); } } } public static <K, V> Builder<K, V> newBuilder(Class<K> keyType, Class<V> valueType) { return new Builder<K, V>().setKeyType(keyType).setValueType(valueType); } public static final class Builder<K, V> { public static final String CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS %s ( " + " key TEXT PRIMARY KEY, value BLOB NOT NULL, version REAL NOT NULL, " + " created REAL NOT NULL, expires REAL NOT NULL" + ")"; private static final String DELETE_EXPIRED_SQL = "DELETE FROM %s WHERE " + COLUMN_EXPIRES + " " + "< ?"; private Class<K> keyType; private Class<V> valueType; private String tableName; private SQLiteDatabase db; private KeyMapper<K> keyMapper; private ValueMapper valueMapper; private VersionMapper<V> versionMapper; private Executor executor; private Builder() { } private Builder(Builder<K, V> proto) { this.db = proto.db; this.keyType = proto.keyType; this.valueType = proto.valueType; this.tableName = proto.tableName; this.keyMapper = proto.keyMapper; this.valueMapper = proto.valueMapper; this.versionMapper = proto.versionMapper; this.executor = proto.executor; } public Builder<K, V> setExecutor(Executor executor) { this.executor = executor; return this; } public Builder<K, V> setDatabase(SQLiteDatabase db) { this.db = db; return this; } private Builder<K, V> setKeyType(Class<K> keyType) { this.keyType = keyType; return this; } private Builder<K, V> setValueType(Class<V> valueType) { this.valueType = valueType; return this; } public Builder<K, V> setTableName(String tableName) { this.tableName = tableName; return this; } public Builder<K, V> setKeyMapper(KeyMapper<K> keyMapper) { this.keyMapper = keyMapper; return this; } public Builder<K, V> setValueMapper(ValueMapper valueMapper) { this.valueMapper = valueMapper; return this; } public Builder<K, V> setVersionMapper(VersionMapper<V> versionMapper) { this.versionMapper = versionMapper; return this; } private void removeExpired() { final long now = System.currentTimeMillis(); db.execSQL(String.format(DELETE_EXPIRED_SQL, tableName), new Object[] { now }); } @SuppressWarnings("unchecked") public RxCache<K, V> build() { // defaults if (this.tableName == null) { this.tableName = keyType.getName().replaceAll("\\.", "_") + "___" + valueType.getName() .replaceAll("\\.", "_"); } if (this.versionMapper == null) { this.versionMapper = new NoOpVersionMapper<>(); } // assertions checkArgument(db, "Database cannot be null"); checkArgument(keyType, "Key type cannot be null"); checkArgument(valueType, "Value type cannot be null"); checkArgument(tableName, "Table name cannot be null"); checkArgument(keyMapper, "Key mapper cannot be null"); checkArgument(valueMapper, "Mapping context cannot be null"); checkArgument(executor, "Executor cannot be null"); checkArgument(versionMapper, "Version mapper cannot be null"); checkArgument(tableName, "Table name cannot be null"); // create the table if it doesn't exist db.execSQL(String.format(CREATE_TABLE_SQL, tableName)); // clear out all expired entries removeExpired(); // return new SQLiteRxCache<>(this); } } }