Contract klasa
Contract klasa ima samo ulogu da bude kontejner za konstante koje definišu imena za URI-je, tabele i kolone. Ova klasa nam omogućava upotrebu istih konstanti u svim ostalim klasama, što nam dosta olakšava posao u radu sa bazom podataka jer omogućava da na jednom mestu menjamo ime kolone ili slično.
1) Kreirnje klase i konstruktora
Prva stvar je da kreiramo klasu i njen prazan konstruktor. Pošto je ovo samo kontejner za konstante koje su statične nije potrebno praviti instancu ove klase, a da bi sprečili da neko slučajno napravi instancu klase, definišemo konstruktor kao privatan:
1 2 3 4 5 |
public class DbContract { public DbContract() { } } |
2) Kreiranje podklase koja predstavlja tabelu
Preporuka je da za svaku tabelu unutar baze podataka kreiramo novu statičnu podklasu koja implementira BaseColumns interfejs. U okviru ove klase definišemo konstante vezane za tabelu kao što je ime tabele i nazive kolona:
1 2 3 4 5 6 7 8 9 10 11 12 |
public class DbContract { public DbContract() { } public static final class BuyListTable implements BaseColumns { public static final String TABLE_NAME = "buyList"; public static final String COLUMN_NAME = "name"; public static final String COLUMN_AMOUNT = "amount"; public static final String COLUMN_TIMESTAMP = "time"; } } |
NAPOMENA:
Nije potrebno da dodelimo konstante za kolone sa nazivom _ID i _COUNT jer njih dobijamo samim tim što smo implementirali “BaseColumns” interfejs. Pogledajte više o ovome interfejsu ovde.
SQLiteOpenHelper klasa
Za maksimalnu kontrolu nad lokalnim podacima i rad sa SQLite bazom (za izvršavanje SQL zahteva…) je dobro napraviti klasu koja extenduje SQLiteOpenHelper klasu. “SQLiteOpenHelper” klase je kao što joj i samo ime kaže “helper” klasa koja olakšava kreiranje i verzionisanje baze.
Konstruktoska metoda
Kao što smo pomenuli naša klasa extenduje SQLiteOpenHelper.java klasu, pa njena konstruktorska metoda koju generiše Android Studio izgleda ovako:
1 2 3 4 5 6 |
public class AppDBHelper extends SQLiteOpenHelper { public AppDBHelper(@Nullable Context context, @Nullable String name, @Nullable SQLiteDatabase.CursorFactory factory, int version) { super(context, name, factory, version); } } |
Kao što se vidi ovako generisana konstruktorska metoda ima priličan broj atributa, medjutim ukoliko definišemo konstante za naziv i verziju baze onda bi konstruktorska metoda mogla da izgleda kao u sledećem primeru:
1 2 3 4 5 6 7 8 |
public class AppDBHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "buyList.db"; private static final int DATABASE_VERSION = 1; public AppDBHelper(@Nullable Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } } |
Odmah nakn ekstendovanja klase primećujemo da naša klasa mora da implementira dve metode: onCreate() i onUpgrade(). O njihovoj ulozi ćemo kasnije.
Instanciranje klase koristeći “singleton pattern”
Pošto se baza podataka koristi u celoj aplikaciji (services, fragmenata…) iz tog razloga, preporučena praksa je da primenite tzv. “Singleton Pattern” na instance SQLiteOpenHelper klase kako biste izbegli curenje memorije. Najbolje rešenje je da instancu baze podataka napravite pojedinačnom instancom tokom čitavog životnog ciklusa aplikacije.
1 2 3 4 5 6 7 8 |
private static AppDBHelper sInstance; public static synchronized AppDBHelper getInstance(Context context) { if (sInstance == null) { sInstance = new AppDBHelper(context.getApplicationContext()); } return sInstance; } |
Statička metoda getInstance() osigurava da će u bilo kom trenutku postojati samo jedna AppDBHelper instanca i samo jedna buyLista.db baza podataka. Ako instanca helper klase nije inicijalizovana (“sInstance” objekat), stvoriće se jedan objekat, a ako je već stvorena, onda će se jednostavno koristi taj postojeći objekat.
Referenca na bazu podataka
Dobijanje baze koja je kreirana čim je instancirana ova helper klasa se vrši pozivajući metodu getWriteableDatabase(). Pošto je konekcija sa bazom keširana pa nije “skupo” pozivati ovu metodu gde god nam treba u klasi.
1 |
SQLiteDatabase db = getWritableDatabase(); |
Sada kada imamo instancu naše baze koja je nasledila osobine klase SQLiteDatabase.java, možemo koristiti sve njene metode. Neke od često korišćenih metoda možete pogledati ovde.
Definisanje tabele u okviru onCreate()
U okviru onCreate() metode se definiše naša baza. Prvo napišemo “query” koji kreira tabelu, a zatim ga i izvršimo sa metodom execSQL(). SQL query koji kreira tabelu izgleda ovako:
1 2 3 4 5 6 |
CREATE TABLE table_name ( column1 datatype, column2 datatype, column3 datatype, .... ); |
A kada ga primenimo na naš primer ovako:
1 2 3 4 5 6 7 8 9 10 11 12 |
@Override public void onCreate(SQLiteDatabase sqLiteDatabase) { final String SQL_CREATE_BUY_LIST_TABLE = "CREATE TABLE " + DBContract.BuyListTable.TABLE_NAME + " (" + DBContract.BuyListTable._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + DBContract.BuyListTable.COLUMN_NAME + " TEXT NOT NULL, " + DBContract.BuyListTable.COLUMN_AMOUNT + " INTEGER NOT NULL, " + DBContract.BuyListTable.COLUMN_TIMESTAMP + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + ");"; sqLiteDatabase.execSQL(SQL_CREATE_BUY_LIST_TABLE); } |
NAPOMENA:
Mogli bi da izbegnemo ponavljanje sekvence DBContract.TabelaZadataka u okviru query-ija tako što bi importovali sve u contract klasi:
1 |
import packageName.database.DBContract.*; |
Nakon čega bi mogli da izbacimo DBContract iz query-ija.
Verzionisanje baze podataka
Kao što smo već pomenuli, klasa koja ekstenduje “SQLiteOpenHelper” se koristi i za verzionisanje baze podataka. Verzionisanje baze je bitno jer ukoliko u jednom trenutku u novoj verziji naše aplikacije ipak shvatimo da nam je potrebna još jedna kolona, bez ovoga nećemo moći to da uradimo.
Potrebno je da definišemo u okviru onUpgrade() metode šta želimo da se uradi ukoliko dodje do promene tabele. Ova metoda će se pozvati samo ako već postoji baza podataka sa istim DATABASE_NAME, ali je DATABASE_VERSION različito od verzije baze podataka koja postoji na disku.
Primer
U ovome primeru je prikazana najjednostavnija impementacija kada se u tom slučaju briše stara tabela i ponovo kreira:
1 2 3 4 5 6 7 8 |
@Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { /* Uništava tabelu */ db.execSQL("DROP TABLE IF EXISTS " + DBContract.BuyListTable.TABLE_NAME); /* Ponovno kreiranje tabele */ onCreate(db); } |
NAPOMENA:
Da bi se uopšte pozvala metoda onUpgrade() potrebno je u kodu promeniti verziju baze:
1 |
private static final int DATABASE_VERSION = 2; |
Query bazi podataka – db.query()
Jedna od najvažnijih funkcija za koju je namenjena ova helper metoda je dobijanje podataka iz baze. Podatke iz baze dobijamo standardnim SQL query-ijm koristeći metodu query() koja vraća Cursor objekat. Više o Cursor objektu i šta on predstavlja možete pogledati ovde.
Primer
1 2 3 4 5 6 7 8 9 10 11 12 |
public Cursor getAllItemsToCursor () { Cursor cursor = db.query( DBContract.BuyListTable.TABLE_NAME, null, null, null, null, null, DBContract.BuyListTable.COLUMN_TIMESTAMP + " DESC" ); return cursor; } |
Kada imamo podatke u okviru Cursor objekta potrebno je da prodjemo kroz njega i da popunimo listu to se najčešće u vidu jedne metode kao u sledećem primeru:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
* Get all items from database */ public List<BuyItem> getAllItemsFromDB() { List<BuyItem> buyItemsList = new ArrayList<>(); Cursor cursor = db.query( DBContract.BuyListTable.TABLE_NAME, null, null, null, null, null, DBContract.BuyListTable.COLUMN_TIMESTAMP + " DESC" ); /*Popunjavnje liste iz Cursora*/ while (cursor.moveToNext()){ BuyItem item = new BuyItem(); item.setName(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_NAME))); item.setId(cursor.getLong(cursor.getColumnIndex(DBContract.BuyListTable._ID))); item.setAmount(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_AMOUNT))); item.setmTimestamp(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_TIMESTAMP))); buyItemsList.add(item); } return buyItemsList; } |
Ubacivanje podataka – db.insert()
Za ubacivanje podataka u bazu se koristi metoda insert(). Ova metoda za parametare zahteva: “naziv tabele”, “nullColumnHack” i “ContentValues”. ContentValues je klasa zadužena za čuvanje key-value seta vrednosti u okviru jednog objekta. Za skladištenje jednog set-a podataka se koristi njena metoda (String key, TipPodataka value):
1 2 |
ContentValues cv = new ContentValues(); cv.put("nazivKolone, vrednostPolja); |
Tek kada imamo definisana polja u jednom redu i sačuvana u ContentValues objektu možemo ih ubaciti u bazu sa pomenutom metodom insert():
1 2 3 4 5 6 7 |
/* Insert new item to database*/ public void insertItemToDB(BuyItem buyItem) { ContentValues cv = new ContentValues(); cv.put(DBContract.BuyListTable.COLUMN_NAME, buyItem.getName()); cv.put(DBContract.BuyListTable.COLUMN_AMOUNT, buyItem.getAmount()); db.insert(DBContract.BuyListTable.TABLE_NAME, null, cv); } |
Brisanje reda iz tabele – db.delete()
Za brisanje reda tabele je potrebno proslediti metodi delete() naziv tabele i WHERE klauzulu na osnovu koje će pronaći željeni red:
1 2 3 4 5 6 7 |
/*Remove item from database*/ public void removeItemFromDB(long id) { db.delete(DBContract.BuyListTable.TABLE_NAME, DBContract.BuyListTable._ID + "=" + id, null); } |
Ceo kod ove klase možete videti ovde, dok ceo projekat možete naći na GitHub stranici “SQLdatabaseWithoutLibrary”.
Kada imamo napravljenu ovu helper klasu i definisane njene metode za rad sa bazom podataka, u MVVM arhitekturi njima pristupamo iz Repository klase dok ona dalje emituje promene na više nivoe. Kako izgleda ceo proces pogledajte u članku “Repository – ViewModel – View ciklus”.
Cursor
Cursor je interfejs koji predstavlja dvodimenzionalnu tabelu neke baze. Kada želimo da dobijemo podatke iz baze koristeći query() metodu (i u okviru nje SELECT naredbu), baza podataka vraća Cursor objekat, koji je zadužen da prihvati i skladišti dobijene podatke.
Pokazivač na početku uvek pokazuje na 0-tu lokaciju, a prvi podaci se ustvari skladiše na prvu lokaciju (nulta lokacija postoji i kada je Cursor prazan). Iz tog razloga kada želite da preuzemo podatke iz kursora, prvo moramo da pređemo na prvi zapis. Iz tog razlog moramo da koristimo
moveToFirst() metodu koj vodi pokazivač kursora na prvo mesto. Nakon toga tek možemo pristupiti podacima prisutnim u prvom zapisu. Pored ove metode postoje i sledeće metode za iteraciju kroz Cursor objekat:
- moveToLast()
- moveToNext()
- moveToPrevious()
- moveToPosition(position)
Da biste dobili ukupan broj elemenata rezultujućeg upita, koristićemo metodu getCount(), dok metodu isAfterLast() koristimo za proveru da li je postignut kraj rezultata upita.
Kursor pruža i tzv. geter metode (npr. getLong(columnIndex), getInt(columnIndex) …) za pristup podacima odredjene kolone. Za ovu metodu neophodan parametar je “columnIndex” a njega dobijamo sa metodom getColumnIndex().
Primer
Dok “prolazimo” kroz cursor redove vrednost polja u nekom redu iz željene kolone dobijamo na sledeći način:
1 |
int index = cursor.getColumnIndex("nazivKolone"); |
Indeks kolone koristimo za dobijanje podatka iz te kolone (npr. string) sa metodom getString():
1 |
String nekiStringPodatak = cursor.getString(index); |
Primer
Celu iteraciju kroz Cursor objekat može da izgleda ovako:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public List<BuyItem> getAllItemsFromDB() { Cursor cursor = getAllItemsToCursor(); List<BuyItem> buyItemsList = new ArrayList<>(); /*Popunjavnje liste iz Cursora*/ while (cursor.moveToNext()){ BuyItem item = new BuyItem(); item.setName(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_NAME))); item.setId(cursor.getLong(cursor.getColumnIndex(DBContract.BuyListTable._ID))); item.setAmount(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_AMOUNT))); item.setmTimestamp(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_TIMESTAMP))); buyItemsList.add(item); } return buyItemsList; } |
nullColumnHack
Ovaj parametar se koristi da androidu kaže šta da radi u slučaju da je ContentValues prazan. Ovo je bitno jer u SQL-u se koristi naredba:
1 |
INSERT INTO nazivBaze (nazivKolone) VALUES (vrednost); |
Kada se bazi prosledi prazan ContentValues onda ona ne zna šta da stavi na mesto zaduženo za “vrednost” (a ne može da ostane prazno). Upravo rešava ovaj parametar “nullColumnHack”, snjim se definiše šta će da se stavi u u polje baze kome nije prosledjeno ništa (najčešće se stavlja null).
Metode SQLiteDatabase klase
- beginTransaction() – startovanje transakcije u EXCLUSIVE modu kada želimo zapisivati u bazu.
- beginTransactionNonExclusive()() – startovanje transakcije u IMMEDIATE modu.
- endTransaction() – zaustavljanje transakcije
- execSQL(String sql) – izvršavanje jednog SQL izraza koji NIJE SELECT ili bilo koji drugi SQL izraz koji vraća podatke.
- getVersion() – vraća verziju baze.
- insert(String table, String nullColumnHack, ContentValues values) – Ubacuje novi član/red u tabelu
- insertOrThrow(String table, String nullColumnHack, ContentValues values) – Ubacuje novi član/red u tabelu ali izbacuje exception ako je transakcija neuspešna. Ova metod će vratiti -1 ako kod pravilno izvršen a izbaciće grešku ako nije.
- query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) – Izvršava prosledjeni SQL query i vraća Cursor, ima zaštitu od SQL injections.
- rawQuery(String sql, String[] selectionArgs) – Izvšava prosledjeni SQL query i vraća Cursor. Nema zaštitu i ogranjičenja, fleksibilniji pa se koristi kompleksnije upite.
- replace(String table, String nullColumnHack, ContentValues initialValues) – Zamenjuje neki red u tabeli sa drugim
- replaceOrThrow(String table, String nullColumnHack, ContentValues initialValues) – Zamenjuje neki red u tabeli sa drugim, ali izbacuje exception ako je transakcija neuspešna.
- setForeignKeyConstraintsEnabled(boolean enable) – Postavlja ograničenja u zavisnosti da li se koristi ForeignKey.
- setVersion(int version) – Setuje verziju baze programirano.
- update(String table, ContentValues values, String whereClause, String[] whereArgs) – Ažurira član/red tabele sa novim podacima.
SQL nivoi zaključavanja baze
SQL ima sledeće nivoe zaključavanja baze:
- UNLOCKED: Što znači: defaultno neaktivno zaključavanje.
- SHARED: Što znači: “Sada čitam podatke sada, nemojte pisati (ako se mora pisati u bazu mora će pričekati)”
- RESERVED: Što znači: “Uskoro planiram pisati.” Samo jedna veza može dobiti RESERVED zaključavanje, svi ostali pokušaji zapisa moraju pričekati svoj red.”
- PENDING: Što znači: “Pisaću čim svi ostali prestanu raditi stvari.”
- EXCLUSIVE: Što znači: “Pišem SADA, odlazi!” Sve se zaustavlja dok se DB ažurira.
Tipovi transakcija u zavisnosti od zaključavanja
U zavisnosti od ovih nivoa zaključavanja postoje tri vrste transakcija:
- DEFERRED : Automatski se obavalja zaključavnje i odključavanje svake SQL operacija. Filozofija ovdje je Just-In-Time. (Ovo je zadano kada nije naveden stvarni način rada.)
- IMMEDIATE : Odmah pokušajte pribaviti i zadržati RESERVED zaključavanje na svim bazama podataka koje je otvorila ova veza. Ovo trenutno blokira sve ostale pisce za vreme trajanja ove transakcije . BEGIN IMMEDIATE TRANSACTIONblokirat će ili neće uspjeti ako druga veza ima REZERVIRANO ili EKSKLUZIVNO zaključavanje na bilo kojem od otvorenih DB-ova ove veze.
- EXCLUSIVE : Odmah pokreće EXCLUSIVE zaključavanje na svim bazama podataka koje je otvorila ova veza. Ovo trenutno blokira sve ostale veze za vrijeme trajanja ove transakcije . BEGIN EXCLUSIVE TRANSACTIONće blokirati ili uspjeti ako neka druga veza ima bilo kakvu blokadu na bilo kojem od otvorenih DB-ova ove veze.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
public class AppDBHelper extends SQLiteOpenHelper { private static final String DATABASE_NAME = "buyList.db"; private static final int DATABASE_VERSION = 3; private static AppDBHelper sInstance; private final SQLiteDatabase db = getWritableDatabase(); public static synchronized AppDBHelper getInstance(Context context) { if (sInstance == null) { sInstance = new AppDBHelper(context.getApplicationContext()); } return sInstance; } public AppDBHelper(@Nullable Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase sqLiteDatabase) { /*Create Table*/ final String SQL_CREATE_BUY_LIST_TABLE = "CREATE TABLE " + DBContract.BuyListTable.TABLE_NAME + " (" + DBContract.BuyListTable._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + DBContract.BuyListTable.COLUMN_NAME + " TEXT NOT NULL, " + DBContract.BuyListTable.COLUMN_AMOUNT + " INTEGER NOT NULL, " + DBContract.BuyListTable.COLUMN_TIMESTAMP + " TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + ");"; sqLiteDatabase.execSQL(SQL_CREATE_BUY_LIST_TABLE); } /* Do something if database architecture change*/ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { /* Uništava tabelu */ db.execSQL("DROP TABLE IF EXISTS " + DBContract.BuyListTable.TABLE_NAME); /* Ponovno kreiranje tabele */ onCreate(db); } /* Get all items from database */ public List<BuyItem> getAllItemsFromDB() { List<BuyItem> buyItemsList = new ArrayList<>(); Cursor cursor = db.query( DBContract.BuyListTable.TABLE_NAME, null, null, null, null, null, DBContract.BuyListTable.COLUMN_TIMESTAMP + " DESC" ); while (cursor.moveToNext()){ BuyItem item = new BuyItem(); setItemFromCurrentCursor(item, cursor); buyItemsList.add(item); } return buyItemsList; } private void setItemFromCurrentCursor(BuyItem item, Cursor cursor) { item.setName(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_NAME))); item.setId(cursor.getLong(cursor.getColumnIndex(DBContract.BuyListTable._ID))); item.setAmount(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_AMOUNT))); item.setmTimestamp(cursor.getString(cursor.getColumnIndex(DBContract.BuyListTable.COLUMN_TIMESTAMP))); } /* Insert new item to database*/ public void insertItemToDB(BuyItem buyItem) { ContentValues cv = new ContentValues(); cv.put(DBContract.BuyListTable.COLUMN_NAME, buyItem.getName()); cv.put(DBContract.BuyListTable.COLUMN_AMOUNT, buyItem.getAmount()); db.insert(DBContract.BuyListTable.TABLE_NAME, null, cv); } /*Remove item from database*/ public void removeItemFromDB(long id) { db.delete(DBContract.BuyListTable.TABLE_NAME, DBContract.BuyListTable._ID + "=" + id, null); } } |
BaseColumns interfejs
Ovaj interfejs obezbedjuje dve kolone koje se najšeće koriste u okviru tabla: _ID i _COUNT. Njegov kod bi u osnovi ovako izgledao:
1 2 3 4 5 6 7 |
public interface BaseColumns { /** The unique ID for a row. Type: INTEGER (long) */ public static final String _ID = "_id"; /** The count of rows in a directory. Type: INTEGER */ public static final String _COUNT = "_count"; } |
Mi možemo definisati i sopstvene konstante za id bez upotrebe ovog određenog interfejsa, ali metode poput CursorAdapter.java zahteva baš konstantu poput “_ID” pa je dobro koristiti priloženi interfejs. Još jedan primer je i adapter ListView-a koristi kolonu _ID da bi vam dao jedinstveni ID stavke liste na koju se klikće u okviru OnItemClickListener.onItemClick (), bez potrebe da svaki put izričito navedete koji je vaš ID kolone.
Korišćenje uobičajenih imena omogućava Android platformi (kao i programerima takođe) da se obrate jedinici bilo koje stavke podataka, bez obzira na njenu ukupnu strukturu (tj. Druge, kolone koje nisu ID). Definisanje konstanti za najčešće korišćene nizove u interfejsu / klasi izbegava ponavljanje i greške u kucanju po celom kodu.