Diseñada para integrarse perfectamente con la base de datos de Android y Room a través de @RawQuery, permitiendo la creación de consultas dinámicas de manera segura y legible.
Características • Instalación • Uso • Integración con Room • RepeatedQueryParameters • Contribuir
- ✨ Características
- 📦 Instalación
- 🚀 Uso
- 🔗 Integración con Room
- 📡 Integración con retrofit -> RepeatedQueryParameters
- 📝 Ejemplos Avanzados
- 🤝 Contribuir
- 🔗 Constructor de Consultas Fluido: API encadenable para una construcción de consultas clara y concisa
- 📊 Soporte Completo de Operaciones:
SELECT,INSERT,UPDATE, yDELETE - 🔄 Joins Complejos: Soporte para
INNER JOIN - ⚡ Operadores SQL: Amplia gama de operadores (
=,!=,>,<,LIKE,IN,BETWEEN,IS NULL, etc.) - 🧩 Condiciones Lógicas: Combina cláusulas fácilmente con
ANDyOR - 🔒 Agrupación de Condiciones: Encapsula condiciones en grupos
AND (...)/OR (...)para evitar fugas de precedencia - 🏛️ Integración con Room: Conversión directa a
SupportSQLiteQuerycon la función de extensiónasSQLiteQuery() - 💯 100% Kotlin: Código moderno, idiomático y nulo-seguro
Agrega la dependencia a tu archivo build.gradle:
- Agrega el repositorio de JitPack en tu archivo
build.gradlea nivel de proyecto:
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}- Luego, en el archivo
build.gradlede tu módulo, agrega la dependencia:
dependencies {
implementation 'com.github.blipblipcode:android-sql-query:0.0.4'
}A continuación se muestran ejemplos de cómo construir los diferentes tipos de consultas.
Para construir una consulta SELECT, utiliza QuerySelect.builder().
// Construye: SELECT name, email FROM users WHERE age > 30 AND status = 'active'
val selectQuery = QuerySelect.builder("users")
.where(SQLOperator.GreaterThan("age", 30))
.and("status", SQLOperator.Equals("status", "active"))
.setFields("name", "email")
.build()
// Agrega un límite de 10 resultados
selectQuery.limit(10)
// Agrega un offset
selectQuery.limit(10, 5)
val sqlString = selectQuery.asSql()
### ⬆️ ORDER BY
Para ordenar los resultados puedes utilizar el método `orderBy` con `OrderBy.Asc`, `OrderBy.Desc` o una lista mediante `OrderBy.Multiple`.
`OrderBy.Asc` y `OrderBy.Desc` aceptan tres parámetros:
| Parámetro | Tipo | Descripción |
|-------------|------------------------|-------------|
| `column` | `String` | Nombre de la columna. |
| `collation` | `Collation` | Cláusula COLLATE que se añade **al final** de la expresión (por defecto `Collation.NONE`). |
| `transform` | `(String) -> String` | Función que envuelve la columna en SQL arbitrario (REPLACE, LOWER, etc.). Se aplica **antes** del COLLATE. Por defecto es la identidad `{ it }`. |
```kotlin
// ORDER BY simple asc
val q1 = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
q1.orderBy(OrderBy.Asc("name")) // -> ORDER BY name ASC
// ORDER BY con múltiples columnas y direcciones mezcladas
q1.orderBy(OrderBy.Multiple(listOf(
OrderBy.Asc("department"),
OrderBy.Desc("created_at")
))) // -> ORDER BY department ASC, created_at DESC
// ORDER BY usando Collation (ej: NOCASE o RTRIM)
q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE))
// -> ORDER BY name COLLATE NOCASE ASC
q1.orderBy(OrderBy.Desc("name", collation = Collation.RTRIM))
// -> ORDER BY RTRIM(name) DESCUsa transform para aplicar funciones SQL sobre la columna (como REPLACE) y collation para
el COLLATE. El transform envuelve la columna antes de que se añada el COLLATE,
garantizando SQL correcto como:
SELECT * FROM vehicle
ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASCNota: En SQLite,
LOWER()no garantiza un ordenamiento case-insensitive correcto. Usa siempreCOLLATE NOCASEpara ignorar mayúsculas/minúsculas al ordenar.
// Eliminar guiones e ignorar mayúsculas/minúsculas
val strip = "-".removeCharsTransform() // extensión de CollationUtils
q1.orderBy(OrderBy.Asc("identification", collation = Collation.NOCASE, transform = strip))
// -> ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC
// Solo COLLATE NOCASE (sin transform)
q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE))
// -> ORDER BY name COLLATE NOCASE ASC
// Quitar espacios, guiones y puntos + COLLATE NOCASE
val stripChars = " -.".removeCharsTransform()
q1.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = stripChars))
// -> ORDER BY REPLACE(REPLACE(REPLACE(name, ' ', ''), '-', ''), '.', '') COLLATE NOCASE ASC
// Combinar RTRIM + NOCASE con transform
val stripDash = "-".removeCharsTransform()
q1.orderBy(OrderBy.Asc("code", collation = Collation.RTRIM and Collation.NOCASE, transform = stripDash))
// -> ORDER BY RTRIM(REPLACE(code, '-', '')) COLLATE NOCASE ASCPara construir una consulta INSERT, utiliza QueryInsert.builder().
// Construye: INSERT INTO users (name, age) VALUES ('Jane Doe', 28)
val insertQuery = QueryInsert.builder("users")
.add("name", "Jane Doe")
.add("age", 28)
.build()
val sqlString = insertQuery.asSql()Para construir una consulta UPDATE, utiliza QueryUpdate.builder().
// Construye: UPDATE users SET status = 'inactive' WHERE name = 'Jane Doe'
val updateQuery = QueryUpdate.builder("users")
.set("status", "inactive")
.where(SQLOperator.Equals("name", "Jane Doe"))
.build()
val sqlString = updateQuery.asSql()Para construir una consulta DELETE, utiliza QueryDelete.builder().
// Construye: DELETE FROM users WHERE status = 'inactive'
val deleteQuery = QueryDelete.builder("users")
.where(SQLOperator.Equals("status", "inactive"))
.build()
val sqlString = deleteQuery.asSql()La librería permite encadenar múltiples INNER JOIN de forma sencilla.
// Construye: (SELECT * FROM orders) INNER JOIN (SELECT id, name FROM customers) ON orders.customer_id = customers.id
val queryOrders = QuerySelect.builder("orders").where(SQLOperator.Equals("id", 1)).build()
val queryCustomers = QuerySelect.builder("customers").where(SQLOperator.Equals("id", 1)).setFields("id", "name").build()
val joinQuery = queryOrders.innerJoin(queryCustomers, "orders.customer_id = customers.id")
val sqlString = joinQuery.asSql()La principal ventaja de esta librería es su capacidad para generar consultas dinámicas para Room de forma segura. Se integra perfectamente con métodos DAO anotados con @RawQuery.
@Entity
data class User(
@PrimaryKey val id: Int,
val name: String,
val age: Int,
val status: String
)El método debe aceptar un objeto SupportSQLiteQuery.
@Dao
interface UserDao {
@RawQuery
fun getUsers(query: SupportSQLiteQuery): List<User>
}Desde tu repositorio o ViewModel, construye la consulta, conviértela con asSQLiteQuery() y pásala al método del DAO.
class UserRepository(private val userDao: UserDao) {
fun findActiveUsers(minAge: Int, nameFilter: String): List<User> {
// Construye la consulta dinámica
val query = QuerySelect.builder("User") // Room usa el nombre de la clase o @Entity(tableName)
.where(SQLOperator.GreaterThan("age", minAge))
.and("status", SQLOperator.Equals("status", "active"))
.and("name", SQLOperator.Like("name", nameFilter))
.setFields("id", "name", "age", "status")
.build()
// Convierte a SupportSQLiteQuery y ejecuta
return userDao.getUsers(query.asSQLiteQuery())
}
}RepeatedQueryParameters es una clase utilitaria destinada a facilitar el uso de parámetros de consulta repetidos cuando se integra con Retrofit y otras librerías que consumen Map/QueryMap de parámetros.
Descripción breve:
- Permite pasar listas como valores en un
@QueryMapy que Retrofit las expanda como múltiples pares clave=valor en la URL (ej:?tag=a&tag=b). - Mantiene el orden de inserción (hereda de
LinkedHashMap) para reproducibilidad en pruebas y cachés. - Omite elementos
nulldentro de listas y lanza excepción si se intenta usar una clave o valornull.
API y métodos principales:
RepeatedQueryParameters.create(vararg pairs: Pair<String, Any>): RepeatedQueryParameters— Crea la instancia a partir de pares clave/valor.RepeatedQueryParameters.fromMap(map: MutableMap<String, Any>): RepeatedQueryParameters— Convierte unMapexistente.RepeatedQueryParameters.empty(): RepeatedQueryParameters— Instancia vacía.addParameter(key: String, value: Any)— Agrega o reemplaza un parámetro simple.addRepeatedParameter(key: String, values: List<*>)— Agrega una lista que será expandida.
Compatibilidad con la extensión Queryable.asQueryRepeatedQueryParameters:
La librería expone una extensión Queryable.asQueryRepeatedQueryParameters() que convierte los operadores SQL (devueltos por getSqlOperators()) en una instancia de RepeatedQueryParameters. Esta extensión:
- Filtra los operadores usando un
predicate: (Pair<String, Any?>) -> Booleanopcional. - Expande automáticamente listas en
RepeatedQueryParametersmedianteaddRepeatedParameter.
Ejemplo usando la extensión asQueryRepeatedQueryParameters:
// Supongamos que `query` es un QuerySelect u otro Queryable con operadores que incluyen listas
val params = query.asQueryRepeatedQueryParameters()
// ahora `params` puede ser pasado directamente a Retrofit como @QueryMapEjemplo de uso con Retrofit:
interface ProductApi {
@GET("api/v2/product/list")
suspend fun getProductList(
@QueryMap options: RepeatedQueryParameters
): ResponsePaginListDto<ProductItemDto>
}
// Construcción de parámetros
val options = RepeatedQueryParameters.create(
"limit" to 50,
"offset" to 0,
"status" to listOf("active", "pending"),
"brand" to "michelin",
"sort" to "date"
)
// Llamada al API
val response = api.getProductList(options = options)
// Resultado en URL: ?limit=50&offset=0&status=active&status=pending&brand=michelin&sort=dateNotas de uso y buenas prácticas:
- Evita pasar valores
nullcomo clave o valor (la clase lanza excepción). - Para agregar dinámicamente parámetros desde un
Queryable, usaasQueryRepeatedQueryParameters()y opcionalmente provee unpredicatepara incluir/excluir pares. - Mantén simples los valores no list (String, Int, Boolean); las listas son las que generan repetición en la URL.
La librería publica sus reglas de consumo en consumer-rules.pro, que el AAR empaqueta como proguard.txt. Si tu app tiene minifyEnabled = true, esas reglas se aplican automáticamente: no necesitás agregar reglas propias para usar RepeatedQueryParameters con Retrofit.
Qué garantiza y por qué:
-keepattributes Signature: Retrofit valida el@QueryMappor reflexión (getGenericSuperclass()), leyendo la firma genéricaLinkedHashMap<String, Any>. Si R8 la elimina, la llamada falla en runtime con@QueryMap keys must be of type String: K.-keep,allowobfuscation,allowshrinking: la clase puede renombrarse y eliminarse si no se usa; lo que se conserva es su firma genérica y sus miembros públicos. Si tu código accede a la clase por reflexión (nombre de clase o de método), agregá tus propias reglas.
Importante para el consumidor: si tu proguard-rules.pro deshabilita globalmente los atributos (por ejemplo con reglas del tipo que filtren -keepattributes), la validación de Retrofit vuelve a fallar. Verificá que -keepattributes Signature siga aplicándose en tu app.
La cobertura de esta regla no es teórica: R8ObfuscationTest (en query/src/test) ejecuta el R8 real sobre la clase con las reglas publicadas y valida, tanto la firma genérica resultante como una llamada real a un servicio Retrofit de ejemplo.
val complexQuery = QuerySelect.builder("products")
.where(SQLOperator.GreaterThan("price", 100))
.and("category", SQLOperator.In("category", listOf("Electronics", "Computers")))
.and("stock", SQLOperator.Between("stock", 10, 100))
.or("featured", SQLOperator.Equals("featured", true))
.setFields("id", "name", "price")
.build()val bulkUpdate = QueryUpdate.builder("inventory")
.set("discount", 0.15)
.set("updated_at", System.currentTimeMillis())
.where(SQLOperator.LessThan("stock", 5))
.build()Las condiciones se encadenan en un solo nivel. Por eso un OR agregado al final no queda
ligado al AND anterior: en SQL AND tiene mayor precedencia que OR, así que
// ⛔ NO hagas esto: el AND se interpreta como (creator = 1 AND status = 1) OR (...)
val broken = QuerySelect.builder("tire_transfer_document")
.where(SQLOperator.Equals("creator", 1))
.and("status", SQLOperator.Equals("status", 1))
.or("doc_number", SQLOperator.Like("doc_number", "cal"))
.or("terminal_dest_display", SQLOperator.Like("terminal_dest_display", "cal"))
.build()Para expresses esa intención usá andGroup / orGroup. Cada condición del grupo conserva su propio
conector, y el símbolo del grupo define cómo se conecta el grupo a la cadena:
val query = QuerySelect.builder("tire_transfer_document")
.where(SQLOperator.Equals("creator", 1))
.and("status", SQLOperator.Equals("status", 1))
.andGroup(
"search",
LogicalOperation.Or(SQLOperator.Like("doc_number", "cal", CaseConversion.LOWER)),
LogicalOperation.Or(SQLOperator.Like("terminal_source_display", "cal", CaseConversion.LOWER)),
LogicalOperation.Or(SQLOperator.Like("terminal_dest_display", "cal", CaseConversion.LOWER))
)
.build()
// SELECT * FROM tire_transfer_document WHERE creator = 1 AND status = 1
// AND (LOWER(doc_number) LIKE LOWER('%cal%')
// OR LOWER(terminal_source_display) LIKE LOWER('%cal%')
// OR LOWER(terminal_dest_display) LIKE LOWER('%cal%'))orGroup hace lo inverso: conecta el grupo con OR a la cadena.
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.orGroup(
"role",
LogicalOperation.And(SQLOperator.Equals("role", "admin")),
LogicalOperation.And(SQLOperator.Equals("role", "root"))
)
.build()
// SELECT * FROM users WHERE status = 'active' OR (role = 'admin' AND role = 'root')Cuando todas las condiciones internas comparten el mismo conector, usá la sobrecarga con
LogicalType y evitás envolver cada una en LogicalOperation:
// andGroup("search", LogicalType.OR, like1, like2) -> AND (like1 OR like2)
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.andGroup(
"search",
LogicalType.OR,
SQLOperator.Like("doc_number", "cal"),
SQLOperator.Like("terminal_dest_display", "cal")
)
.build()
// SELECT * FROM users WHERE status = 'active'
// AND (doc_number LIKE '%cal%' OR terminal_dest_display LIKE '%cal%')Un grupo también puede ocupar la posición de WHERE, en cuyo caso se emite sin su conector:
QuerySelect.builder("users")
.where(
"search",
LogicalOperation.Multiple(
LogicalOperation.Or(SQLOperator.Equals("role", "admin")),
LogicalOperation.Or(SQLOperator.Equals("role", "root"))
)
)
.and("status", SQLOperator.Equals("status", "active"))
.build()
// SELECT * FROM users WHERE (role = 'admin' OR role = 'root') AND status = 'active'Los grupos funcionan en QueryDelete, se pueden anidar entre sí y se pueden agregar sobre una query
ya construida con los mismos métodos. Para inspeccionar o transformar un grupo usá
Multiple.operators(), que devuelve todos los operadores del grupo —incluidos los de grupos
anidados— en orden.
val query = QueryDelete.builder("sessions")
.where(SQLOperator.LessThan("expires_at", now))
.andGroup(
"stale",
LogicalType.OR,
SQLOperator.IsNull("refreshed_at"),
SQLOperator.LessThan("refreshed_at", threshold)
)
.build()
// DELETE FROM sessions WHERE expires_at < 1700000000
// AND (refreshed_at IS NULL OR refreshed_at < 1600000000)asSql(predicate) entra dentro de los grupos: conserva las condiciones que el predicado acepta
y descarta solo las que rechaza. Si ninguna sobrevive, el grupo desaparece; si el WHERE no
sobrevive, la primera condición restante asciende a la posición de WHERE para que la sentencia
nunca empiece con un conector colgante. getSqlOperators() (y por lo tanto
asQueryRepeatedQueryParameters()) devuelve todos los operadores del grupo, no solo el primero.
¡Las contribuciones son bienvenidas! Si deseas contribuir:
- 🍴 Haz un Fork del proyecto
- 🌿 Crea una rama para tu función (
git checkout -b feature/AmazingFeature) - 💾 Commit tus cambios (
git commit -m 'Add some AmazingFeature') - 📤 Push a la rama (
git push origin feature/AmazingFeature) - 🔃 Abre un Pull Request
Hecho con ❤️ para la comunidad Android
⭐ Si te gusta este proyecto, ¡dale una estrella en GitHub!