diff --git a/android-base/build.gradle b/android-base/build.gradle index ad4a4ded3..9501f86de 100644 --- a/android-base/build.gradle +++ b/android-base/build.gradle @@ -1,6 +1,5 @@ apply plugin: 'com.android.library' - android { compileSdkVersion 25 buildToolsVersion "25.0.2" @@ -32,6 +31,7 @@ dependencies { implementation 'com.google.dagger:dagger:2.9' implementation 'com.android.support:design:25.3.1' implementation 'com.android.support:appcompat-v7:25.3.1' + implementation 'org.apache.commons:commons-lang3:3.5' annotationProcessor 'com.google.dagger:dagger-compiler:2.9' androidTestAnnotationProcessor 'com.google.dagger:dagger-compiler:2.9' diff --git a/android-base/src/main/java/org/isoron/androidbase/storage/Column.java b/android-base/src/main/java/org/isoron/androidbase/storage/Column.java new file mode 100644 index 000000000..e4c228a58 --- /dev/null +++ b/android-base/src/main/java/org/isoron/androidbase/storage/Column.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + * + * + */ + +package org.isoron.androidbase.storage; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Column +{ + String name() default ""; +} diff --git a/android-base/src/main/java/org/isoron/androidbase/storage/SQLiteRepository.java b/android-base/src/main/java/org/isoron/androidbase/storage/SQLiteRepository.java new file mode 100644 index 000000000..3e665a582 --- /dev/null +++ b/android-base/src/main/java/org/isoron/androidbase/storage/SQLiteRepository.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2017 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + * + * + */ + +package org.isoron.androidbase.storage; + +import android.content.*; +import android.database.*; +import android.database.sqlite.*; +import android.support.annotation.*; +import android.util.*; + +import org.apache.commons.lang3.*; + +import java.lang.annotation.*; +import java.lang.reflect.*; +import java.util.*; + +public class SQLiteRepository +{ + @NonNull + private final Class klass; + + @NonNull + private final SQLiteDatabase db; + + public SQLiteRepository(@NonNull Class klass, @NonNull SQLiteDatabase db) + { + this.klass = klass; + this.db = db; + } + + @Nullable + public T find(@NonNull Long id) + { + return findFirst(String.format("where %s=?", getIdName()), + id.toString()); + } + + @NonNull + public List findAll(String query, String... params) + { + try (Cursor c = db.rawQuery(buildSelectQuery() + query, params)) + { + return cursorToMultipleRecords(c); + } + } + + @Nullable + public T findFirst(String query, String... params) + { + try (Cursor c = db.rawQuery(buildSelectQuery() + query, params)) + { + if (!c.moveToNext()) return null; + return cursorToSingleRecord(c); + } + } + + public void execSQL(String query, Object... params) + { + db.execSQL(query, params); + } + + public void save(T record) + { + try + { + Field fields[] = getFields(); + String columns[] = getColumnNames(); + + ContentValues values = new ContentValues(); + for (int i = 0; i < fields.length; i++) + fieldToContentValue(values, columns[i], fields[i], record); + + Long id = (Long) getIdField().get(record); + int affectedRows = 0; + + if (id != null) affectedRows = + db.update(getTableName(), values, getIdName() + "=?", + new String[]{ id.toString() }); + + if (id == null || affectedRows == 0) + { + id = db.insertOrThrow(getTableName(), null, values); + getIdField().set(record, id); + } + + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + public void remove(T record) + { + try + { + Long id = (Long) getIdField().get(record); + if (id == null) return; + + db.delete(getTableName(), getIdName() + "=?", + new String[]{ id.toString() }); + getIdField().set(record, null); + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + @NonNull + private List cursorToMultipleRecords(Cursor c) + { + List records = new LinkedList<>(); + while (c.moveToNext()) records.add(cursorToSingleRecord(c)); + return records; + } + + @NonNull + private T cursorToSingleRecord(Cursor cursor) + { + try + { + Constructor constructor = klass.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + T record = (T) constructor.newInstance(); + + int index = 0; + for (Field field : getFields()) + copyFieldFromCursor(record, field, cursor, index++); + return record; + } + catch (Exception e) + { + throw new RuntimeException(e); + } + } + + private void copyFieldFromCursor(T record, Field field, Cursor c, int index) + throws IllegalAccessException + { + if (field.getType().isAssignableFrom(Integer.class)) + field.set(record, c.getInt(index)); + else if (field.getType().isAssignableFrom(Long.class)) + field.set(record, c.getLong(index)); + else if (field.getType().isAssignableFrom(Double.class)) + field.set(record, c.getDouble(index)); + else if (field.getType().isAssignableFrom(String.class)) + field.set(record, c.getString(index)); + else throw new RuntimeException( + "Type not supported: " + field.getType().getName() + " " + + field.getName()); + } + + private void fieldToContentValue(ContentValues values, + String columnName, + Field field, + T record) + { + try + { + if (field.getType().isAssignableFrom(Integer.class)) + values.put(columnName, (Integer) field.get(record)); + else if (field.getType().isAssignableFrom(Long.class)) + values.put(columnName, (Long) field.get(record)); + else if (field.getType().isAssignableFrom(Double.class)) + values.put(columnName, (Double) field.get(record)); + else if (field.getType().isAssignableFrom(String.class)) + values.put(columnName, (String) field.get(record)); + else throw new RuntimeException( + "Type not supported: " + field.getName()); + } + catch (IllegalAccessException e) + { + throw new RuntimeException(e); + } + } + + private String buildSelectQuery() + { + return String.format("select %s from %s ", + StringUtils.join(getColumnNames(), ", "), getTableName()); + } + + private List> getFieldColumnPairs() + { + List> fields = new ArrayList<>(); + for (Field field : klass.getDeclaredFields()) + for (Annotation annotation : field.getAnnotations()) + { + if (!(annotation instanceof Column)) continue; + Column column = (Column) annotation; + fields.add(new Pair<>(field, column)); + } + return fields; + } + + @NonNull + private Field[] getFields() + { + List fields = new ArrayList<>(); + List> columns = getFieldColumnPairs(); + for (Pair pair : columns) fields.add(pair.first); + return fields.toArray(new Field[]{}); + } + + @NonNull + private String[] getColumnNames() + { + List names = new ArrayList<>(); + List> columns = getFieldColumnPairs(); + for (Pair pair : columns) + { + String cname = pair.second.name(); + if (cname.isEmpty()) cname = pair.first.getName(); + if (names.contains(cname)) + throw new RuntimeException("duplicated column : " + cname); + names.add(cname); + } + + return names.toArray(new String[]{}); + } + + @NonNull + private String getTableName() + { + String name = getTableAnnotation().name(); + if (name.isEmpty()) throw new RuntimeException("Table name is empty"); + return name; + } + + @NonNull + private String getIdName() + { + String id = getTableAnnotation().id(); + if (id.isEmpty()) throw new RuntimeException("Table id is empty"); + return id; + } + + @NonNull + private Field getIdField() + { + Field fields[] = getFields(); + String idName = getIdName(); + for (Field f : fields) + if (f.getName().equals(idName)) return f; + throw new RuntimeException("Field not found: " + idName); + } + + @NonNull + private Table getTableAnnotation() + { + Table t = null; + for (Annotation annotation : klass.getAnnotations()) + { + if (!(annotation instanceof Table)) continue; + t = (Table) annotation; + break; + } + + if (t == null) throw new RuntimeException("Table annotation not found"); + return t; + } +} diff --git a/android-base/src/main/java/org/isoron/androidbase/storage/Table.java b/android-base/src/main/java/org/isoron/androidbase/storage/Table.java new file mode 100644 index 000000000..c56711191 --- /dev/null +++ b/android-base/src/main/java/org/isoron/androidbase/storage/Table.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2017 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + * + * + */ + +package org.isoron.androidbase.storage; + +import java.lang.annotation.*; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Table +{ + String name(); + String id() default "id"; +} diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteRepositoryTest.java b/uhabits-android/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteRepositoryTest.java new file mode 100644 index 000000000..fb66b5da3 --- /dev/null +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteRepositoryTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.models.sqlite; + +import android.database.sqlite.*; +import android.support.test.runner.*; +import android.test.suitebuilder.annotation.*; + +import com.activeandroid.*; + +import org.apache.commons.lang3.builder.*; +import org.isoron.androidbase.storage.*; +import org.isoron.uhabits.*; +import org.junit.*; +import org.junit.runner.*; + +import static org.hamcrest.core.IsEqual.*; +import static org.junit.Assert.assertThat; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class SQLiteRepositoryTest extends BaseAndroidTest +{ + private SQLiteRepository repository; + + private SQLiteDatabase db; + + @Before + @Override + public void setUp() + { + super.setUp(); + repository = + new SQLiteRepository<>(ThingRecord.class, Cache.openDatabase()); + + db = Cache.openDatabase(); + db.execSQL("drop table if exists tests"); + db.execSQL("create table tests(" + + "id integer not null primary key autoincrement, " + + "color_number integer not null, score float not null, " + + "name string not null)"); + } + + @Test + public void testFind() throws Exception + { + db.execSQL("insert into tests(id, color_number, name, score) " + + "values (10, 20, 'hello', 8.0)"); + + ThingRecord record = repository.find(10L); + + assertNotNull(record); + assertThat(record.id, equalTo(10L)); + assertThat(record.color, equalTo(20)); + assertThat(record.name, equalTo("hello")); + assertThat(record.score, equalTo(8.0)); + } + + @Test + public void testSave_withId() throws Exception + { + ThingRecord record = new ThingRecord(); + record.id = 50L; + record.color = 10; + record.name = "hello"; + record.score = 5.0; + repository.save(record); + assertThat(record, equalTo(repository.find(50L))); + + record.name = "world"; + record.score = 128.0; + repository.save(record); + assertThat(record, equalTo(repository.find(50L))); + } + + @Test + public void testSave_withoutId() throws Exception + { + ThingRecord r1 = new ThingRecord(); + r1.color = 10; + r1.name = "hello"; + r1.score = 16.0; + repository.save(r1); + + ThingRecord r2 = new ThingRecord(); + r2.color = 20; + r2.name = "world"; + r2.score = 2.0; + repository.save(r2); + + assertThat(r1.id, equalTo(1L)); + assertThat(r2.id, equalTo(2L)); + } + + @Test + public void testRemove() throws Exception + { + ThingRecord rec1 = new ThingRecord(); + rec1.color = 10; + rec1.name = "hello"; + rec1.score = 16.0; + repository.save(rec1); + + ThingRecord rec2 = new ThingRecord(); + rec2.color = 20; + rec2.name = "world"; + rec2.score = 32.0; + repository.save(rec2); + + long id = rec1.id; + assertThat(rec1, equalTo(repository.find(id))); + assertThat(rec2, equalTo(repository.find(rec2.id))); + + repository.remove(rec1); + assertThat(rec1.id, equalTo(null)); + assertNull(repository.find(id)); + assertThat(rec2, equalTo(repository.find(rec2.id))); + + repository.remove(rec1); // should have no effect + assertNull(repository.find(id)); + } +} + +@Table(name = "tests") +class ThingRecord +{ + @Column + public Long id; + + @Column + public String name; + + @Column(name = "color_number") + public Integer color; + + @Column + public Double score; + + @Override + public boolean equals(Object o) + { + if (this == o) return true; + + if (o == null || getClass() != o.getClass()) return false; + + ThingRecord record = (ThingRecord) o; + + return new EqualsBuilder() + .append(id, record.id) + .append(name, record.name) + .append(color, record.color) + .isEquals(); + } + + @Override + public int hashCode() + { + return new HashCodeBuilder(17, 37) + .append(id) + .append(name) + .append(color) + .toHashCode(); + } + + @Override + public String toString() + { + return new ToStringBuilder(this) + .append("id", id) + .append("name", name) + .append("color", color) + .toString(); + } +}