Skip to content

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

🗄️ Android SQL Query Builder

Una librería de Kotlin ligera y fluida para construir consultas SQL de forma programática

Kotlin Android Room Retrofit GitHub release (latest by date) Run Query Unit Tests

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


📋 Tabla de Contenidos


✨ Características

  • 🔗 Constructor de Consultas Fluido: API encadenable para una construcción de consultas clara y concisa
  • 📊 Soporte Completo de Operaciones: SELECT, INSERT, UPDATE, y DELETE
  • 🔄 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 AND y OR
  • 🔒 Agrupación de Condiciones: Encapsula condiciones en grupos AND (...) / OR (...) para evitar fugas de precedencia
  • 🏛️ Integración con Room: Conversión directa a SupportSQLiteQuery con la función de extensión asSQLiteQuery()
  • 💯 100% Kotlin: Código moderno, idiomático y nulo-seguro

📦 Instalación

Agrega la dependencia a tu archivo build.gradle:

  1. Agrega el repositorio de JitPack en tu archivo build.gradle a nivel de proyecto:
allprojects {
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}
  1. Luego, en el archivo build.gradle de tu módulo, agrega la dependencia:
dependencies {
    implementation 'com.github.blipblipcode:android-sql-query:0.0.4'
}

🚀 Uso

A continuación se muestran ejemplos de cómo construir los diferentes tipos de consultas.

🔍 SELECT

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) DESC

🔄 Transform + Collation

Usa 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 ASC

Nota: En SQLite, LOWER() no garantiza un ordenamiento case-insensitive correcto. Usa siempre COLLATE NOCASE para 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 ASC

➕ INSERT

Para 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()

🔄 UPDATE

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()

🗑️ DELETE

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()

🔗 INNER JOIN

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()

🏛️ Integración con Room

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.

1️⃣ Define tu Entidad

@Entity
data class User(
    @PrimaryKey val id: Int,
    val name: String,
    val age: Int,
    val status: String
)

2️⃣ Crea un Método DAO con @RawQuery

El método debe aceptar un objeto SupportSQLiteQuery.

@Dao
interface UserDao {
    @RawQuery
    fun getUsers(query: SupportSQLiteQuery): List<User>
}

3️⃣ Construye y Ejecuta tu Consulta Dinámica

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

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 @QueryMap y 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 null dentro de listas y lanza excepción si se intenta usar una clave o valor null.

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 un Map existente.
  • 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?>) -> Boolean opcional.
  • Expande automáticamente listas en RepeatedQueryParameters mediante addRepeatedParameter.

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 @QueryMap

Ejemplo 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=date

Notas de uso y buenas prácticas:

  • Evita pasar valores null como clave o valor (la clase lanza excepción).
  • Para agregar dinámicamente parámetros desde un Queryable, usa asQueryRepeatedQueryParameters() y opcionalmente provee un predicate para incluir/excluir pares.
  • Mantén simples los valores no list (String, Int, Boolean); las listas son las que generan repetición en la URL.

Ofuscación (R8 / ProGuard)

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 @QueryMap por reflexión (getGenericSuperclass()), leyendo la firma genérica LinkedHashMap<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.


📝 Ejemplos Avanzados

Consultas Complejas con Múltiples Condiciones

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()

Actualización Masiva

val bulkUpdate = QueryUpdate.builder("inventory")
    .set("discount", 0.15)
    .set("updated_at", System.currentTimeMillis())
    .where(SQLOperator.LessThan("stock", 5))
    .build()

Agrupación de condiciones AND / OR

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.


🤝 Contribuir

¡Las contribuciones son bienvenidas! Si deseas contribuir:

  1. 🍴 Haz un Fork del proyecto
  2. 🌿 Crea una rama para tu función (git checkout -b feature/AmazingFeature)
  3. 💾 Commit tus cambios (git commit -m 'Add some AmazingFeature')
  4. 📤 Push a la rama (git push origin feature/AmazingFeature)
  5. 🔃 Abre un Pull Request

Hecho con ❤️ para la comunidad Android

⭐ Si te gusta este proyecto, ¡dale una estrella en GitHub!

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages