Fuera del cuarto
OutOfRoom es una capa de abstracción de base de datos desarrollada y utilizada para reemplazar el código escrito con ROOM ORM de mis proyectos. Esta biblioteca no es un ORM y no pretende convertirse en uno. Es solo una herramienta simple para mantener el código de persistencia limpio y organizado sin ORM.
¿Cómo?
Dejé de usar ORM. Acelera el desarrollo inicial, pero en proyectos grandes, los ORM se convierten en un cuello de botella, por lo que es necesario realizar demasiados trucos si utiliza una funcionalidad SQL muy específica. Los ORM también evitan que el desarrollador tenga que escribir un código de adaptación entre el paradigma relacional y el paradigma orientado a objetos. No utilizar un ORM producirá (algunos) códigos estándar mínimos, sin embargo, los beneficios de flexibilidad son enormes. Puede encontrar numerosos artículos y opiniones sobre esto en línea.
Objetivos de la biblioteca:
- Ser lo más simple posible al tiempo que proporciona un enfoque limpio para la persistencia de datos.
- desacoplar el esquema de la base de datos de los modelos (alejarse de las anotaciones en los modelos)
- capacidad para utilizar el sistema SQLite o la última versión de SQLite proporcionada por requery
- proporcionando la máxima flexibilidad al desarrollador
¿Cómo importar?
allprojects {
repositories {
maven { url 'https://maven.andob.info/repository/open_source/' }
}
}
Para usar con el sistema SQLite:
dependencies {
implementation 'ro.andob.outofroom:common-ddl:1.1.4'
implementation 'ro.andob.outofroom:common-dml:1.1.4'
implementation 'ro.andob.outofroom:common-query-builder:1.1.4'
implementation 'ro.andob.outofroom:binding-system-sqlite:1.1.4'
}
Para usar con la última versión de SQLite proporcionada por requery:
dependencies {
implementation 'ro.andob.outofroom:common-ddl:1.1.4'
implementation 'ro.andob.outofroom:common-dml:1.1.4'
implementation 'ro.andob.outofroom:common-query-builder:1.1.4'
implementation 'ro.andob.outofroom:binding-latest-sqlite:1.1.4'
implementation 'com.github.requery:sqlite-android:3.35.5'
implementation 'androidx.sqlite:sqlite-ktx:2.1.0'
}
Definir los modelos
Defina sus plantillas como simples POJO, sin anotaciones:
data class Note
(
val id : String,
val title : String,
val contents : String,
val color : NoteColor,
)
enum class NoteColor(val id : Int)
{
White(1),
Yellow(2),
Green(3),
}
Definición de esquema de base de datos
class NotesDatabaseSchema : Schema()
{
val noteTable = NoteTable()
class NoteTable : Table(name = "Note")
{
val id = Column(name = "id", type = SQLType.Text, notNull = true)
val title = Column(name = "title", type = SQLType.Text)
val contents = Column(name = "contents", type = SQLType.Text)
val color = Column(name = "color", type = SQLType.Integer)
override val primaryKey get() = PrimaryKey(id)
override val foreignKeys get() = listOf<ForeignKey>()
}
//todo more tables...
override val indices get() = listOf<Index>(
Index(table = noteTable, column = noteTable.title),
Index(table = noteTable, column = noteTable.contents)
)
}
Referencia de API:
Schema
class representa el esquema de la base de datos, que contiene tablas e índices. Esta clase también tiene una propiedadtables
que devolverá una lista de todas las tablas definidas dentro.Table
class representa una tabla de base de datos, que contiene columnas, claves primarias y externas. Las clases de tabla tienen una propiedadcolumns
que devolverá una lista de todas las columnas dentro de la tabla. También hay un métodotoCreateTableSQL
que devuelve una cadena con el equivalentecreate table ...
Comando SQL.Column
clase representa una columna de una tabla. Las columnas se definen por su nombre y tipo. De forma predeterminada, las columnas son anulables, pero puede agregarnotNull = true
.SQLType
enum contiene definiciones de tipo SQLite: Integer, Real, Text, Blob.PrimaryKey
puede ser simple:PrimaryKey(id)
, compuesto:PrimaryKey(id, name)
o simple con autoincremento:PrimaryKey.AutoIncrement(id)
Index
clase representa un índice de tabla. Los índices también pueden ser únicos (solo agregueunique = true
a la definición del índice para hacerlo único). También hay un métodotoCreateIndexSQL
que devuelve una cadena con el equivalentecreate index ...
Comando SQL.
Definir el ayudante para abrir la base de datos.
class NotesDatabaseOpenHelper
(
private val schema : NotesDatabaseSchema
) : SQLiteOpenHelper
(
/*context*/ App.context,
/*name*/ "notes.db",
/*cursorFactory*/ null,
/*version*/ 1,
/*onCorruption*/ { throw Error("Detected a corrupt database!") }
)
{
override fun onCreate(db : SQLiteDatabase)
{
for (table in schema.tables)
db.execSQL(table.toCreateTableSQL())
for (index in schema.indices)
db.execSQL(index.toCreateIndexSQL())
}
override fun onConfigure(db : SQLiteDatabase)
{
super.onConfigure(db)
db.enableWriteAheadLogging()
}
override fun onUpgrade(db : SQLiteDatabase?, oldVersion : Int, newVersion : Int)
{
//todo to migrate, use a migration manager such as Flyway or write your own
}
}
Definición del objeto contenedor / base de datos DAO
object NotesDatabase
{
private val openHelper = NotesDatabaseOpenHelper(schema)
private val entityManager get() = openHelper.toEntityManager()
private val schema get() = NotesDatabaseSchema()
fun noteDao() = NoteDao(schema, entityManager)
//more DAOs
}
Defina clases DAO similares a las definidas con ROOM.
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
//DAO methods...
}
Definición de métodos de adaptador dentro de DAO
Debe definir los métodos de adaptador que convertirán:
populateInsertData(insertData : InsertData, note : Note)
desde el modelo hasta los datos a introducir.parseQueryResult(queryResult : QueryResult) : Note
a partir de los datos resultantes de la consulta en el modelo. Un objeto QueryResult será equivalente a una fila de una tabla de resultados de consulta.
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
private fun populateInsertData(insertData : InsertData, note : Note)
{
insertData.putString(schema.noteTable.id, note.id)
insertData.putString(schema.noteTable.title, note.title)
insertData.putString(schema.noteTable.contents, note.contents)
insertData.putNoteColor(schema.noteTable.color, note.color)
}
private fun parseQueryResult(queryResult : QueryResult) : Note
{
return Note(
id = queryResult.getString(schema.noteTable.id)!!,
title = queryResult.getString(schema.noteTable.title)?:"",
contents = queryResult.getString(schema.noteTable.contents)?:"",
color = queryResult.getNoteColor(schema.noteTable.color),
)
}
}
Las clases InsertData y QueryResult tienen métodos getter / setter como:
InsertData:
fun putBoolean(column : Column, value : Boolean?) { ... }
fun putDouble(column : Column, value : Double?) { ... }
fun putFloat(column : Column, value : Float?) { ... }
fun putInt(column : Column, value : Int?) { ... }
fun putLong(column : Column, value : Long?) { ... }
fun putString(column : Column, value : String?) { ... }
QueryResult:
fun getBoolean(column : Column) : Boolean { ... }
fun getDouble(column : Column) : Double? { ... }
fun getFloat(column : Column) : Float? { ... }
fun getInt(column : Column) : Int? { ... }
fun getLong(column : Column) : Long? { ... }
fun getString(column : Column) : String? { ... }
//converts first cell of the row (QueryResult) into:
fun toBoolean() : Boolean { ... }
fun toDouble() : Double { ... }
fun toFloat() : Float { ... }
//...
Por supuesto, no todos los tipos de campos de clase son primitivos o cadenas. Puede definir “FieldAdapters” personalizados simplemente creando métodos de extensión en InsertData
Y QueryResult
clases. Esto sería equivalente a los métodos de conversión de SALA:
//file NotesDatabaseFieldAdapters.kt
private fun findNoteColor(id : Int) : NoteColor? = NoteColor.values().find { it.id==id }
fun InsertData.putNoteColor(column : Column, noteColor : NoteColor) = putInt(column, noteColor.id)
fun QueryResult.getNoteColor(column : Column) = getInt(column)?.let(::findNoteColor)?:NoteColor.White
DAO – entrada / actualización de datos
Usar entityManager.insert()
para ingresar o actualizar datos:
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
fun insert(note : Note, or : InsertOr = InsertOr.Fail)
{
entityManager.insert(or = or,
table = schema.noteTable,
columns = schema.noteTable.columns,
adapter = { insertData -> populateInsertData(insertData, note) })
}
fun update(note : Note) =
insert(note, or = InsertOr.Replace)
private fun populateInsertData(insertData : InsertData, note : Note) ...
private fun parseQueryResult(queryResult : QueryResult) : Note ...
}
Uso:
val note = Note(
id = UUID.randomUUID().toString(),
title = "test", contents = "test",
color = NoteColor.White)
NotesDatabase.noteDao().insert(note)
NotesDatabase.noteDao().update(note)
DAO: ejecución de comandos SQL
Usar entityManager.exec()
para ejecutar sentencias SQL que no tienen resultado.
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
fun delete(note : Note)
{
entityManager.exec(
sql = """delete from ${schema.noteTable}
where ${schema.noteTable.id} = ?""",
arguments = arrayOf(note.id))
}
fun deleteAll()
{
entityManager.exec("delete from ${schema.noteTable}")
}
private fun populateInsertData(insertData : InsertData, note : Note) ...
private fun parseQueryResult(queryResult : QueryResult) : Note ...
}
Nota: Para pasar argumentos, use la sintaxis de declaración preparada (? Dentro de la consulta, luego arguments = arrayOf(...)
). Esto es útil por razones de seguridad, para evitar la inyección de SQL.
Uso:
NotesDatabase.noteDao().delete(note)
NotesDatabase.noteDao().deleteAll()
DAO – recuperación de datos
Usar entityManager.query
para ejecutar sentencias SQL que tengan un resultado:
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
fun getAll() : List<Note>
{
return entityManager.query(
sql = "select * from ${schema.noteTable}",
adapter = ::parseQueryResult)
}
fun getById(noteId : String) : Note?
{
return entityManager.query(
sql = """select * from ${schema.noteTable}
where ${schema.noteTable.id} = ?
limit 1""",
arguments = arrayOf(noteId),
adapter = ::parseQueryResult
).firstOrNull()
}
fun getByIds(noteIds : List<String>) : List<Note>
{
return entityManager.query(
sql = """select * from ${schema.noteTable}
where ${schema.noteTable.id} in (${questionMarks(noteIds)})""",
arguments = noteIds.toTypedArray(),
adapter = ::parseQueryResult)
}
fun count() : Int
{
return entityManager.query(
sql = "select count(*) from ${schema.noteTable}",
adapter = { queryResult -> queryResult.toInt() }
).firstOrNull()?:0
}
private fun populateInsertData(insertData : InsertData, note : Note) ...
private fun parseQueryResult(queryResult : QueryResult) : Note ...
}
Nota: Para pasar argumentos, use la sintaxis de declaración preparada (? Dentro de la consulta, luego arguments = arrayOf(...)
). Esto es útil por razones de seguridad, para evitar la inyección de SQL.
Nota: para pasar una lista de temas (como en where .. in ..
cláusula), use la sintaxis de declaración preparada (questionMarks () para generar una cadena de?,?,? …, luego arguments = ...
). Esto es útil por razones de seguridad, para evitar la inyección de SQL.
Uso:
val allNotes = NotesDatabase.noteDao().getAll()
val someNote = NotesDatabase.noteDao().getById(note.id)
val someNotes = NotesDatabase.noteDao().getByIds(listOf(note.id))
val noteCount = NotesDatabase().noteDao().count()
DAO: usando el generador de consultas
Esta biblioteca también contiene un “Generador de consultas”, un clon de mi biblioteca. HABITACIÓN-Dynamic-Dao. Con él, puede convertir objetos de filtro en comandos SQL seleccionados. Por favor lea el tutorial de HABITACIÓN-Dynamic-Dao documentación. La sintaxis es muy similar:
data class NoteFilter
(
override val limit : Int,
override val offset : Int,
override val search : String?
) : IQueryBuilderFilter
class NoteDao
(
private val schema : NotesDatabaseSchema,
private val entityManager : EntityManager,
)
{
fun getFiltered(noteFilter : NoteFilter) : List<Note>
{
return entityManager.query(
sql = NoteQueryBuilder(schema, noteFilter).build(),
adapter = ::parseQueryResult)
}
private fun populateInsertData(insertData : InsertData, note : Note) ...
private fun parseQueryResult(queryResult : QueryResult) : Note ...
}
class NoteQueryBuilder
(
private val schema : NotesDatabaseSchema,
filter : NoteFilter,
) : QueryBuilder<NoteFilter>(filter)
{
override fun table() = schema.noteTable
override fun where(conditions : QueryWhereConditions) : String
{
if (filter.search != null)
{
conditions.addSearchConditions(
search = filter.search, columns = arrayOf(
schema.noteTable.title,
schema.noteTable.contents,
))
}
return conditions.mergeWithAnd()
}
}
val notes = NotesDatabase.noteDao().getFiltered(NoteFilter(search = "test", limit = 100, offset = 0))
Usando el sistema SQLite versus el último SQLite de Requery
Para usar esta biblioteca con el sistema SQLite (la biblioteca SQLite incluida en el sistema operativo Android), simplemente importe los componentes relevantes:
implementation 'ro.andob.outofroom:binding-system-sqlite:1.1.4'
import ro.andob.outofroom.system_sqlite.toEntityManager
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
Para usar esta biblioteca con la última versión de SQLite proporcionada por requery, simplemente importe los componentes relevantes:
implementation 'ro.andob.outofroom:binding-latest-sqlite:1.1.4'
import ro.andob.outofroom.latest_sqlite.toEntityManager
import io.requery.android.database.sqlite.SQLiteDatabase
import io.requery.android.database.sqlite.SQLiteOpenHelper
Con la biblioteca de compatibilidad de consultas de SQLite, se incluirá una versión de la biblioteca SQLite con su aplicación. Esto producirá un tamaño de APK más grande. Ventajas de usar la última versión de SQLite: velocidad, correcciones de seguridad, todos los usuarios de su aplicación usarán exactamente la misma versión de SQLite en una amplia gama de dispositivos.
Migración de ROOM
Esta biblioteca no proporciona una herramienta automática para migrar desde ROOM. La forma recomendada de migrar es:
- Escriba pruebas unitarias en todo el nivel de persistencia (en TODOS los métodos de todos los DAO)
- Reemplace ROOM con OutOfRoom, reescriba el código manteniendo intacta la API de DAO (firmas de métodos)
- Ejecute pruebas unitarias de persistencia nuevamente, corrija errores
Licencia
Copyright 2021 Andrei Dobrescu
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.
.