diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml
new file mode 100644
index 0000000..466e983
--- /dev/null
+++ b/.github/workflows/release-pipeline.yml
@@ -0,0 +1,512 @@
+name: π Release Pipeline β PR to Master
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# TRIGGER
+# Corre en todos los eventos de PR hacia master.
+# Los pasos 3-4-5 solo se ejecutan cuando el PR es mergeado.
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+on:
+ pull_request:
+ branches:
+ - master
+ types: [opened, synchronize, reopened, closed]
+
+# Cancela runs previos en el mismo PR si llega un nuevo push.
+# Si el PR fue mergeado, no se cancela (es el run definitivo).
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event.action != 'closed' }}
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# JOBS
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+jobs:
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # STEP 1 β Unit Tests
+ # Corre en cada push al PR (opened / synchronize / reopened) y al mergear.
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ unit-tests:
+ name: π§ͺ Step 1 β Unit Tests
+ runs-on: ubuntu-latest
+ if: github.event.action != 'closed' || github.event.pull_request.merged == true
+ timeout-minutes: 30
+
+ permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+ outputs:
+ result: ${{ steps.run-tests.outcome }}
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: β Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: π Setup Gradle (cache)
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: π§ Grant execute permission to gradlew
+ run: chmod +x ./gradlew
+
+ - name: π§ͺ Run :query unit tests
+ id: run-tests
+ run: |
+ ./gradlew :query:test \
+ --no-daemon \
+ --warning-mode none \
+ --console=plain \
+ --stacktrace
+
+ - name: π Publish unit test results
+ uses: EnricoMi/publish-unit-test-result-action@v2
+ if: always()
+ with:
+ files: query/build/test-results/**/*.xml
+ check_name: π Unit Test Results β :query
+ comment_title: π§ͺ Unit Test Report β :query module
+ comment_mode: always
+
+ - name: π Upload test report on failure
+ uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: unit-test-report-${{ github.run_number }}
+ path: query/build/reports/tests/
+ retention-days: 14
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # STEP 2 β Check Tag Availability & Version Bump
+ # Verifica dos condiciones:
+ # a) El tag v[newVersion] NO existe aΓΊn (no duplicado).
+ # b) newVersion > latestTag (la versiΓ³n fue efectivamente incrementada).
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ check-tag:
+ name: π·οΈ Step 2 β Check Tag & Version Bump
+ runs-on: ubuntu-latest
+ if: github.event.action != 'closed' || github.event.pull_request.merged == true
+ timeout-minutes: 10
+
+ permissions:
+ contents: read
+
+ outputs:
+ version: ${{ steps.extract-version.outputs.version }}
+ tag_name: ${{ steps.extract-version.outputs.tag_name }}
+ latest_tag: ${{ steps.validate-version.outputs.latest_tag }}
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # historial completo para leer todos los tags
+
+ - name: π Extract version from build.gradle.kts
+ id: extract-version
+ run: |
+ # Busca 'version = "x.x.x"' en build.gradle.kts raΓz,
+ # con fallback al mΓ³dulo query.
+ VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true)
+
+ if [ -z "$VERSION" ]; then
+ VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' query/build.gradle.kts 2>/dev/null | head -1 || true)
+ fi
+
+ if [ -z "$VERSION" ]; then
+ echo "β No se encontrΓ³ 'version = \"...\"' en build.gradle.kts ni en query/build.gradle.kts"
+ exit 1
+ fi
+
+ TAG_NAME="v${VERSION}"
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT
+ echo "π VersiΓ³n en Gradle: ${VERSION} β Tag a crear: ${TAG_NAME}"
+
+ - name: "π Validate: new tag > latest tag & no duplicate"
+ id: validate-version
+ run: |
+ NEW_VERSION="${{ steps.extract-version.outputs.version }}"
+ NEW_TAG="${{ steps.extract-version.outputs.tag_name }}"
+
+ # ββ FunciΓ³n de comparaciΓ³n semver (soporta MAJOR.MINOR.PATCH) ββββββ
+ # Devuelve 0 si $1 > $2, 1 en caso contrario.
+ semver_gt() {
+ # strip leading 'v'
+ local A="${1#v}" B="${2#v}"
+ # split en partes usando IFS
+ local IFS=.
+ read -ra VA <<< "$A"
+ read -ra VB <<< "$B"
+ # rellenar con ceros si faltan segmentos (e.g. 1.0 β 1.0.0)
+ for i in 0 1 2; do
+ local a="${VA[$i]:-0}" b="${VB[$i]:-0}"
+ if (( 10#$a > 10#$b )); then return 0 # A > B
+ elif (( 10#$a < 10#$b )); then return 1 # A < B
+ fi
+ done
+ return 1 # A == B β no es estrictamente mayor
+ }
+
+ # ββ Obtener el ΓΊltimo tag semver del repo ββββββββββββββββββββββββ
+ # git tag -l lista todos; sort -V ordena semΓ‘nticamente; tail toma el mayor.
+ LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1)
+
+ if [ -z "$LATEST_TAG" ]; then
+ echo "βΉοΈ No hay tags previos en el repo. Primer release: ${NEW_TAG}"
+ echo "latest_tag=ninguno" >> $GITHUB_OUTPUT
+ echo "β
ValidaciΓ³n superada β primer release."
+ exit 0
+ fi
+
+ echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT
+ echo "π·οΈ Γltimo tag existente : ${LATEST_TAG}"
+ echo "π Nuevo tag a crear : ${NEW_TAG}"
+
+ # ββ a) Verificar que el tag exacto no exista βββββββββββββββββββββ
+ if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then
+ echo ""
+ echo "β ERROR: El tag ${NEW_TAG} ya existe en el repositorio."
+ echo " Incrementa la versiΓ³n en build.gradle.kts antes de mergear."
+ exit 1
+ fi
+
+ # ββ b) Verificar que newVersion > latestTag βββββββββββββββββββββββ
+ if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then
+ echo ""
+ echo "β
ValidaciΓ³n superada: ${NEW_TAG} > ${LATEST_TAG}"
+ else
+ echo ""
+ echo "β ERROR: La versiΓ³n ${NEW_VERSION} NO es mayor que el ΓΊltimo tag ${LATEST_TAG}."
+ echo " Regla: el nuevo tag debe ser estrictamente mayor al ΓΊltimo publicado."
+ echo " Ejemplo vΓ‘lido : ${LATEST_TAG} β v$(echo ${LATEST_TAG#v} | awk -F. '{print $1"."$2"."$3+1}')"
+ exit 1
+ fi
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # STEP 3 β Build :query Release AAR
+ # Depende de Step 1 y Step 2. Solo corre cuando el PR es mergeado.
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ build-release:
+ name: ποΈ Step 3 β Build :query Release AAR
+ runs-on: ubuntu-latest
+ needs: [unit-tests, check-tag]
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 30
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: β Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: π Setup Gradle (cache)
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: π§ Grant execute permission to gradlew
+ run: chmod +x ./gradlew
+
+ - name: ποΈ Assemble :query Release
+ run: |
+ ./gradlew :query:assembleRelease \
+ --no-daemon \
+ --warning-mode none \
+ --console=plain \
+ --stacktrace
+
+ - name: π Locate generated AAR
+ id: find-aar
+ run: |
+ AAR_PATH=$(find query/build/outputs/aar -name "*release*.aar" | head -1)
+ if [ -z "$AAR_PATH" ]; then
+ echo "β No se encontrΓ³ ningΓΊn AAR release en query/build/outputs/aar/"
+ exit 1
+ fi
+ echo "aar_path=${AAR_PATH}" >> $GITHUB_OUTPUT
+ echo "β
AAR encontrado: ${AAR_PATH}"
+
+ - name: π¦ Upload AAR as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: query-release-aar
+ path: ${{ steps.find-aar.outputs.aar_path }}
+ retention-days: 7
+ if-no-files-found: error
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # STEP 4 β Create Tag & GitHub Release
+ # Depende de Step 3. Adjunta el AAR al release.
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ create-release:
+ name: π― Step 4 β Create Tag & GitHub Release
+ runs-on: ubuntu-latest
+ needs: [build-release, check-tag]
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 10
+
+ permissions:
+ contents: write
+
+ outputs:
+ release_url: ${{ steps.gh-release.outputs.url }}
+
+ steps:
+ - name: π₯ Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: π¦ Download AAR artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: query-release-aar
+ path: ./release-artifacts
+
+ - name: π·οΈ Create GitHub Release & Tag
+ id: gh-release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ needs.check-tag.outputs.tag_name }}
+ name: Release ${{ needs.check-tag.outputs.tag_name }}
+ body: |
+ ## π¦ android-sql-query ${{ needs.check-tag.outputs.tag_name }}
+
+ Publicado automΓ‘ticamente desde PR #${{ github.event.pull_request.number }}
+ **${{ github.event.pull_request.title }}**
+
+ ---
+
+ ### π₯ Agregar como dependencia via JitPack
+
+ ```kotlin
+ // settings.gradle.kts
+ dependencyResolutionManagement {
+ repositories {
+ maven { url = uri("https://jitpack.io") }
+ }
+ }
+
+ // build.gradle.kts (module)
+ dependencies {
+ implementation("com.github.LeandroLCD:android-sql-query:${{ needs.check-tag.outputs.version }}")
+ }
+ ```
+
+ ---
+ π
Generado el: ${{ github.event.pull_request.merged_at }}
+ files: ./release-artifacts/*.aar
+ make_latest: true
+ fail_on_unmatched_files: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # STEP 5 β JitPack Build Log
+ # Espera a que JitPack indexe el tag y muestra el build log.
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ jitpack-build:
+ name: π‘ Step 5 β JitPack Build Log
+ runs-on: ubuntu-latest
+ needs: [create-release, check-tag]
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 20
+
+ outputs:
+ jitpack_status: ${{ steps.poll-jitpack.outputs.jitpack_status }}
+ jitpack_log_url: ${{ steps.poll-jitpack.outputs.jitpack_log_url }}
+
+ steps:
+ - name: β³ Initial wait β let JitPack index the new tag
+ run: |
+ echo "β³ Esperando 40s para que JitPack indexe el tag ${{ needs.check-tag.outputs.tag_name }}..."
+ sleep 40
+
+ - name: π Trigger JitPack build & poll status
+ id: poll-jitpack
+ run: |
+ VERSION="${{ needs.check-tag.outputs.version }}"
+ GROUP="com.github.LeandroLCD"
+ ARTIFACT="android-sql-query"
+ LOG_URL="https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/build.log"
+ API_URL="https://jitpack.io/api/builds/${GROUP}/${ARTIFACT}/${VERSION}"
+
+ echo "π Log URL : ${LOG_URL}"
+ echo "π API URL : ${API_URL}"
+
+ # Dispara la build solicitando el artefacto
+ echo "π Disparando build en JitPack..."
+ curl -s -o /dev/null -w "HTTP %{http_code}\n" \
+ "https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/${ARTIFACT}-${VERSION}.aar" || true
+
+ # Polling: mΓ‘ximo 15 intentos Γ 30s = 7.5 min
+ MAX=15
+ ATTEMPT=0
+ STATUS="unknown"
+
+ while [ $ATTEMPT -lt $MAX ]; do
+ ATTEMPT=$((ATTEMPT + 1))
+ echo "β³ Intento ${ATTEMPT}/${MAX} β consultando estado en JitPack..."
+
+ RESPONSE=$(curl -s --max-time 15 "${API_URL}" 2>/dev/null || echo '{}')
+ STATUS=$(echo "$RESPONSE" | python3 -c \
+ "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "unknown")
+
+ echo " π Status: ${STATUS}"
+
+ if [ "$STATUS" = "ok" ]; then
+ echo "β
JitPack build exitoso!"
+ break
+ elif [ "$STATUS" = "error" ]; then
+ echo "β JitPack build fallΓ³. Revisa el log:"
+ echo " ${LOG_URL}"
+ break
+ fi
+
+ [ $ATTEMPT -lt $MAX ] && sleep 30
+ done
+
+ echo "jitpack_status=${STATUS}" >> $GITHUB_OUTPUT
+ echo "jitpack_log_url=${LOG_URL}" >> $GITHUB_OUTPUT
+
+ - name: π Print JitPack build log
+ if: always()
+ run: |
+ VERSION="${{ needs.check-tag.outputs.version }}"
+ LOG_URL="https://jitpack.io/com/github/LeandroLCD/android-sql-query/${VERSION}/build.log"
+ echo "ββββββββββββββββββββββββββββββββββββββββ"
+ echo " JitPack Build Log β ${VERSION}"
+ echo "ββββββββββββββββββββββββββββββββββββββββ"
+ curl -s --max-time 30 "${LOG_URL}" || echo "β οΈ No se pudo obtener el log aΓΊn. URL: ${LOG_URL}"
+ echo "ββββββββββββββββββββββββββββββββββββββββ"
+
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ # PR ANNOTATION β Resumen del pipeline como comentario en el PR
+ # Corre siempre al final (after all jobs), sin importar el resultado.
+ # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ pr-summary:
+ name: π PR Summary Annotation
+ runs-on: ubuntu-latest
+ needs: [unit-tests, check-tag, build-release, create-release, jitpack-build]
+ # always() para que corra aunque algΓΊn job haya fallado o skipped
+ if: always() && (github.event.action != 'closed' || github.event.pull_request.merged == true)
+ timeout-minutes: 5
+
+ permissions:
+ pull-requests: write
+
+ steps:
+ - name: π Post pipeline summary comment on PR
+ uses: actions/github-script@v7
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const icon = (r) => ({
+ success: 'β
', failure: 'β', skipped: 'βοΈ', cancelled: 'π«'
+ }[r] ?? 'β οΈ');
+
+ const isMerged = ${{ github.event.pull_request.merged == true }};
+ const version = `${{ needs.check-tag.outputs.version }}`;
+ const tagName = `${{ needs.check-tag.outputs.tag_name }}`;
+ const latestTag = `${{ needs.check-tag.outputs.latest_tag }}`;
+ const releaseUrl = `${{ needs.create-release.outputs.release_url }}`;
+ const jitpackStatus = `${{ needs.jitpack-build.outputs.jitpack_status }}`;
+ const jitpackLog = `${{ needs.jitpack-build.outputs.jitpack_log_url }}`;
+
+ const r1 = `${{ needs.unit-tests.result }}`;
+ const r2 = `${{ needs.check-tag.result }}`;
+ const r3 = `${{ needs.build-release.result }}`;
+ const r4 = `${{ needs.create-release.result }}`;
+ const r5 = `${{ needs.jitpack-build.result }}`;
+
+ const mergeRow = isMerged
+ ? 'β
**Mergeado** β pipeline completo ejecutado'
+ : 'β³ **Pendiente de merge** β solo validaciones previas';
+
+ const releaseLink = releaseUrl
+ ? `[Ver GitHub Release](${releaseUrl})`
+ : 'β';
+
+ const jitpackRow = jitpackLog
+ ? `[π Build Log](${jitpackLog}) Β· Status: \`${jitpackStatus}\``
+ : 'β';
+
+ // Detalle de la validaciΓ³n semver para la tabla
+ const versionArrow = (latestTag && latestTag !== 'ninguno' && tagName)
+ ? `\`${latestTag}\` β \`${tagName}\``
+ : tagName ? `primer release: \`${tagName}\`` : 'N/A';
+
+ const depBlock = isMerged && version ? `
+ ### π₯ Dependency (JitPack)
+ \`\`\`kotlin
+ // settings.gradle.kts
+ maven { url = uri("https://jitpack.io") }
+
+ // build.gradle.kts
+ implementation("com.github.LeandroLCD:android-sql-query:${version}")
+ \`\`\`` : '';
+
+ const warningBlock = (!isMerged && (r1 === 'failure' || r2 === 'failure'))
+ ? `\n> β οΈ **Hay errores de validaciΓ³n.** CorrΓgelos antes de mergear.\n`
+ : '';
+
+ const body = `## π Release Pipeline β Resumen
+
+ ${warningBlock}
+ | # | Paso | Estado | Detalle |
+ |---|------|--------|---------|
+ | 1οΈβ£ | Unit Tests | ${icon(r1)} \`${r1}\` | Tests unitarios del mΓ³dulo \`:query\` |
+ | 2οΈβ£ | Check Tag & Bump | ${icon(r2)} \`${r2}\` | ${versionArrow} |
+ | 3οΈβ£ | Build Release AAR | ${icon(r3)} \`${r3}\` | \`./gradlew :query:assembleRelease\` |
+ | 4οΈβ£ | GitHub Release | ${icon(r4)} \`${r4}\` | Tag \`${tagName || 'N/A'}\` + AAR Β· ${releaseLink} |
+ | 5οΈβ£ | JitPack Build | ${icon(r5)} \`${r5}\` | ${jitpackRow} |
+
+ **π VersiΓ³n nueva:** \`${version || 'no detectada'}\` Β· **π·οΈ Γltimo tag:** \`${latestTag || 'β'}\`
+ **π Estado:** ${mergeRow}
+ ${depBlock}
+
+ ---
+ π€ Generado automΓ‘ticamente por el Release Pipeline Β· Run #${{ github.run_number }}`;
+
+ // Busca si ya existe un comentario previo del bot para actualizarlo
+ const comments = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const botComment = comments.data.find(c =>
+ c.user.type === 'Bot' && c.body.includes('Release Pipeline β Resumen')
+ );
+
+ if (botComment) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+ }
\ No newline at end of file
diff --git a/README.md b/README.md
index e775dd5..2daf1de 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,73 @@ val selectQuery = QuerySelect.builder("users")
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:
+
+```sql
+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.
+
+```kotlin
+// 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
diff --git a/TESTING_GUIDELINES.md b/TESTING_GUIDELINES.md
new file mode 100644
index 0000000..0dec3f3
--- /dev/null
+++ b/TESTING_GUIDELINES.md
@@ -0,0 +1,259 @@
+# Testing Guidelines for Agents
+
+## 1. Test Naming Convention
+
+Test names must follow **snake_case** format and clearly describe:
+- **What** is expected to happen (should)
+- **What behavior** is being tested
+- **When** that behavior occurs
+ - **Which method** is being tested (in_invoke, in_execute, etc.)
+
+### Format:
+```
+should_{action}_whent_{condition}_in_{method}
+```
+
+### Examples:
+```kotlin
+@Test
+fun should_remove_item_from_cart_when_quantity_is_zero_in_invoke()
+
+@Test
+fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke()
+
+@Test
+fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke()
+```
+
+## 2. Test Structure: Given-When-Then
+
+All tests must be divided into **3 clearly identified sections** with comments:
+
+```kotlin
+@Test
+fun should_do_something_when_condition_in_invoke() = runTest {
+ //GIVEN
+ // Preparation: variables, mocks, initial setup
+
+ //WHEN
+ // Execution: call to the method being tested
+
+ //THEN
+ // Verification: asserts and behavior verifications
+}
+```
+
+### Complete example:
+```kotlin
+@Test
+fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = 3
+ val product = productBuilder {
+ withId(productId)
+ withStock(5)
+ }
+ every { productRepository.getProductById(productId) } returns flowOf(product)
+
+ //WHEN
+ useCase.invoke(productId, quantity)
+
+ //THEN
+ coVerify { cartItemRepository.updateQuantity(productId, quantity) }
+}
+```
+
+## 3. Exception Testing
+
+To test exceptions, **use `runCatching`** to capture the result and verify the exception type:
+
+```kotlin
+@Test
+fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = -1
+
+ //WHEN
+ val result = runCatching { useCase.invoke(productId, quantity) }
+
+ //THEN
+ assert(result.exceptionOrNull() is AppError.Validation.QuantityMustBePositive)
+}
+```
+
+### Advantages of runCatching vs assertThrows:
+- More idiomatic in Kotlin
+- Allows easy inspection of exception properties
+- Cleaner and clearer syntax
+
+### Verifying exception properties:
+```kotlin
+@Test
+fun should_throw_InsufficientStock_when_quantity_exceeds_product_stock_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = 10
+ val product = productBuilder {
+ withId(productId)
+ withStock(5)
+ }
+ every { productRepository.getProductById(productId) } returns flowOf(product)
+
+ //WHEN
+ val result = runCatching { useCase.invoke(productId, quantity) }
+
+ //THEN
+ val exception = result.exceptionOrNull() as? AppError.Validation.InsufficientStock
+ assert(exception != null)
+ assertEquals(5, exception?.available)
+}
+```
+
+## 4. Builder Pattern for Object Creation
+
+To create test objects, **use the Builder pattern** with default values that allow easy creation of valid objects.
+
+### Location:
+Builders must be in a package named `builder` at the root of the corresponding test directory.
+
+Example path:
+```
+app/src/test/java/com/package/feature/domain/usecase/builder/
+```
+
+### Builder Structure:
+
+```kotlin
+class CartItemBuilder {
+ private var productId: String = "product-1"
+ private var quantity: Int = 2
+
+ fun withProductId(productId: String) = apply { this.productId = productId }
+ fun withQuantity(quantity: Int) = apply { this.quantity = quantity }
+
+ fun build(): CartItem {
+ return CartItem(productId, quantity)
+ }
+}
+
+fun cartItem(block: CartItemBuilder.() -> Unit): CartItem =
+ CartItemBuilder().apply(block).build()
+```
+
+### Using the Builder in Tests:
+
+#### Case 1: Using default values
+```kotlin
+//GIVEN
+val cartItem = cartItem { }
+```
+
+#### Case 2: Customizing some values
+```kotlin
+//GIVEN
+val cartItem = cartItem {
+ withProductId("custom-id")
+ withQuantity(5)
+}
+```
+
+#### Case 3: Customizing only one value
+```kotlin
+//GIVEN
+val cartItem = cartItem {
+ withQuantity(10)
+}
+// productId will have the default value "product-1"
+```
+
+### Builder Pattern Advantages:
+- **Default values**: Tests are more concise, you only specify what matters
+- **Readability**: Code is self-documenting
+- **Maintainability**: If the model changes, you only update the builder
+- **Flexibility**: Easy to create different test scenarios
+
+## 5. Complete Test Example
+
+```kotlin
+class UpdateCartItemUseCaseTest {
+
+ private lateinit var cartItemRepository: CartItemRepository
+ private lateinit var productRepository: ProductRepository
+ private lateinit var useCase: UpdateCartItemUseCase
+
+ @Before
+ fun setUp() {
+ cartItemRepository = mockk(relaxed = true)
+ productRepository = mockk()
+ useCase = UpdateCartItemUseCase(cartItemRepository, productRepository)
+ }
+
+ @Test
+ fun should_throw_QuantityMustBePositive_when_quantity_is_negative_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = -1
+
+ //WHEN
+ val result = runCatching { useCase.invoke(productId, quantity) }
+
+ //THEN
+ assert(result.exceptionOrNull() is AppError.Validation.QuantityMustBePositive)
+ }
+
+ @Test
+ fun should_remove_item_from_cart_when_quantity_is_zero_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = 0
+
+ //WHEN
+ useCase.invoke(productId, quantity)
+
+ //THEN
+ coVerify { cartItemRepository.removeFromCart(productId) }
+ coVerify(exactly = 0) { cartItemRepository.updateQuantity(any(), any()) }
+ }
+
+ @Test
+ fun should_update_quantity_when_quantity_is_valid_and_stock_is_sufficient_in_invoke() = runTest {
+ //GIVEN
+ val productId = "1"
+ val quantity = 3
+ val product = productBuilder {
+ withId(productId)
+ withStock(5)
+ }
+ every { productRepository.getProductById(productId) } returns flowOf(product)
+
+ //WHEN
+ useCase.invoke(productId, quantity)
+
+ //THEN
+ coVerify { cartItemRepository.updateQuantity(productId, quantity) }
+ }
+}
+```
+
+## 6. Best Practices Summary
+
+β
**DO:**
+- Use descriptive names in snake_case with `_in_{method}` suffix
+- Divide tests into GIVEN-WHEN-THEN
+- Use `runCatching` to capture exceptions
+- Use builders to create test objects
+- Define sensible default values in builders
+- Use `runTest` for tests with coroutines
+
+β **DON'T:**
+- Never include comments in the code
+- Use backticks in test names
+- Use `assertThrows` (prefer `runCatching`)
+- Create objects manually in each test
+- Use `mockk(relaxed = true)` for repositories
+- Mix preparation, execution, and verification logic
+- Omit the method name in the test name
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f5d5594..32fe77c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
@@ -35,15 +34,15 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlin{
- compilerOptions {
- jvmTarget = JvmTarget.JVM_17
- }
- }
buildFeatures {
compose = true
}
}
+kotlin{
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ }
+}
dependencies {
implementation(libs.androidx.core.ktx)
diff --git a/build.gradle.kts b/build.gradle.kts
index 5ea216f..b31e056 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false
-}
\ No newline at end of file
+}
+
+version = "0.13.3"
diff --git a/gradle.properties b/gradle.properties
index 20e2a01..132244e 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cb082f5..d5af3df 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,13 +1,13 @@
[versions]
-agp = "8.13.2"
-kotlin = "2.2.21"
-coreKtx = "1.17.0"
+agp = "9.0.1"
+kotlin = "2.3.10"
+coreKtx = "1.18.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
-activityCompose = "1.12.1"
-composeBom = "2025.12.00"
+activityCompose = "1.13.0"
+composeBom = "2026.03.00"
appcompat = "1.7.1"
material = "1.13.0"
sqliteKtx = "2.6.2"
@@ -33,7 +33,6 @@ androidx-sqlite-ktx = { group = "androidx.sqlite", name = "sqlite-ktx", version.
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 65b35ea..797c4e4 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
#Mon Oct 13 19:27:45 CLST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/query/build.gradle.kts b/query/build.gradle.kts
index bf22ef2..a07c73c 100644
--- a/query/build.gradle.kts
+++ b/query/build.gradle.kts
@@ -1,10 +1,7 @@
-import org.gradle.api.publish.maven.MavenPublication
-import org.gradle.kotlin.dsl.publishing
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.kotlin.android)
`maven-publish`
}
@@ -34,11 +31,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
- kotlin{
- compilerOptions {
- jvmTarget = JvmTarget.JVM_17
- }
- }
publishing {
singleVariant("release") {
@@ -46,6 +38,11 @@ android {
}
}
}
+kotlin{
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_17
+ }
+}
publishing {
diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt
index c315d49..fa896af 100644
--- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt
+++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt
@@ -44,8 +44,8 @@ class QuerySelect private constructor(
*/
fun newBuilder(consumer: (QueryBuilder) -> Unit): QueryBuilder {
val mOperations = LinkedHashMap()
- operations.map { (key, value) ->
- operations[key] = value.clone()
+ operations.forEach { (key, value) ->
+ mOperations[key] = value.clone()
}
val builder = QueryBuilder(table, mOperations)
where?.let { builder.where(it.first, it.second.clone()) }
@@ -142,9 +142,15 @@ class QuerySelect private constructor(
}
}
+ fun getOperators(): List> {
+ return operations.values.map { it.operator }
+ }
+
fun getOperations(): Map> {
return buildMap {
where?.let { put(it.first, it.second.operator) }
+ limit?.let { put("limit", it) }
+ orderBy?.let { put("orderBy", it) }
operations.forEach { put(it.key, it.value.operator) }
}
}
@@ -189,22 +195,23 @@ class QuerySelect private constructor(
*/
override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String {
val fieldStr = if (fields.isEmpty()) "*" else fields.joinToString(", ")
+ val filteredWhere = where?.takeIf { predicate(it.second.operator) }
val operationsStr =
if (operations.isNotEmpty()) operations.values.filter { predicate(it.operator) }
.joinToString(" ") { it.asString() } else ""
return buildString {
- if (where == null) {
+ if (filteredWhere == null) {
append("SELECT $fieldStr FROM $table")
} else {
- append("SELECT $fieldStr FROM $table ${where?.second?.asString()} $operationsStr".trim())
+ append("SELECT $fieldStr FROM $table ${filteredWhere.second.asString()} $operationsStr".trim())
}
- if (orderBy != null) {
+ orderBy?.takeIf { predicate(it) }?.let {
append(" ")
- append(orderBy!!.asString())
+ append(it.asString())
}
- if (limit != null) {
+ limit?.takeIf { predicate(it) }?.let {
append(" ")
- append(limit!!.asString())
+ append(it.asString())
}
}
}
@@ -221,6 +228,12 @@ class QuerySelect private constructor(
return this
}
+ fun clearOrderBy(): Queryable {
+ orderBy = null
+ return this
+ }
+
+
/**
* Adds a LIMIT clause to the query to limit the number of rows returned.
*
@@ -248,6 +261,41 @@ class QuerySelect private constructor(
return this.orderBy
}
+ fun getLimit(): Limit? {
+ return this.limit
+ }
+
+ /**
+ * Converts this [QuerySelect] into a [QueryDelete] targeting the same table and using the same
+ * WHERE and AND/OR conditions. Fields, ORDER BY and LIMIT are ignored since they are not
+ * applicable to DELETE statements.
+ *
+ * @return A [QueryDelete] instance with the same filters as this [QuerySelect].
+ * @throws IllegalArgumentException if this [QuerySelect] has no WHERE clause defined.
+ */
+ fun toQueryDelete(): QueryDelete {
+ require(where != null) { "QuerySelect must have a WHERE clause to be converted to QueryDelete" }
+
+ val deleteQuery = QueryDelete.builder(getTableName())
+ .where(where!!.second.operator)
+ .build()
+
+ operations.forEach { (key, operation) ->
+ deleteQuery.addLogicalOperation(key, operation)
+ }
+
+ return deleteQuery
+ }
+
+ /**
+ * Clears the LIMIT clause from the query.
+ * @return The current `QuerySelect` instance for chaining.
+ */
+ fun clearLimit(): QuerySelect {
+ limit = null
+ return this
+ }
+
/**
* A builder for creating `QuerySelect` instances.
@@ -311,6 +359,15 @@ class QuerySelect private constructor(
operations[operator.column] = LogicalOperation.AndNot(operator)
return this
}
+ /**
+ * Adds an AND NOT logical operation to the query.
+ * @param operator The SQL operator for this condition.
+ * @return The `QueryBuilder` instance for chaining.
+ */
+ fun andNot(key: String, operator: SQLOperator<*>): QueryBuilder {
+ operations[key] = LogicalOperation.AndNot(operator)
+ return this
+ }
/**
* Adds an EXISTS logical operation to the query.
diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt
index d0bb09f..8d4fe3f 100644
--- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt
+++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt
@@ -1,5 +1,6 @@
package com.blipblipcode.query
+import com.blipblipcode.query.operator.Limit
import com.blipblipcode.query.operator.LogicalOperation
import com.blipblipcode.query.operator.OrderBy
import com.blipblipcode.query.operator.SQLOperator
@@ -31,21 +32,31 @@ class UnionQuery private constructor(
/**
* Generates the SQL string for the UNION statement.
+ *
+ * If an ORDER BY has been explicitly set on this `UnionQuery` (via [orderBy]),
+ * it will be used and any ORDER BY clauses from the individual queries will be ignored.
+ * If no ORDER BY has been set on this `UnionQuery`, the ORDER BY clauses from the
+ * individual queries will be collected and combined at the end of the UNION.
+ *
* @return The complete UNION SQL query as a string.
* @throws IllegalArgumentException if less than two queries are provided.
*/
override fun asSql(): String {
require(queries.size >= 2) { "At least two queries are required for a UNION" }
- val orders = queries.fold(mutableListOf()) { acc, query ->
- query.getOrderBy()?.let { acc.add(it) }
- query.orderBy(null)
- acc
+ val orders = if (orderBy != null) {
+ listOf(orderBy!!)
+ } else {
+ queries.mapNotNull { it.getOrderBy() }
}
- orders.addAll(orderBy?.let { listOf(it) } ?: emptyList())
+
val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION"
-
+
return buildString {
- append(queries.joinToString("\n$unionKeyword\n") { it.asSql() })
+ append("SELECT * FROM (")
+ appendLine()
+ append(queries.joinToString("\n$unionKeyword\n") { it.asSql { op -> op !is OrderBy } })
+ appendLine()
+ append(")")
if (orders.isNotEmpty()) {
appendLine()
append(OrderBy.Multiple(orders).asString())
@@ -54,22 +65,35 @@ class UnionQuery private constructor(
}
/**
* Generates the SQL string for the UNION statement.
+ *
+ * If an ORDER BY has been explicitly set on this `UnionQuery` (via [orderBy]),
+ * it will be used (if it passes the predicate) and any ORDER BY clauses from the
+ * individual queries will be ignored.
+ * If no ORDER BY has been set on this `UnionQuery`, the ORDER BY clauses from the
+ * individual queries that pass the predicate will be collected and combined at the end.
+ *
* @param predicate The predicate to filter the operators.
* @return The complete UNION SQL query as a string.
* @throws IllegalArgumentException if less than two queries are provided.
*/
override fun asSql(predicate: (SQLOperator<*>) -> Boolean): String {
require(queries.size >= 2) { "At least two queries are required for a UNION" }
- val orders = queries.fold(mutableListOf()) { acc, query ->
- query.getOrderBy()?.let { acc.add(it) }
- query.orderBy(null)
- acc
+ val orders = if (orderBy != null) {
+ listOfNotNull(orderBy?.takeIf(predicate))
+ } else {
+ queries.mapNotNull { it.getOrderBy()?.takeIf(predicate) }
}
- orders.addAll(orderBy?.let { listOf(it) } ?: emptyList())
+
val unionKeyword = if (useUnionAll) "UNION ALL" else "UNION"
return buildString {
- append(queries.joinToString("\n$unionKeyword\n") { it.asSql(predicate) })
+ append("SELECT * FROM (")
+ this.appendLine()
+ append(queries.joinToString("\n$unionKeyword\n") { query ->
+ query.asSql { op -> predicate(op) && op !is OrderBy }
+ })
+ this.appendLine()
+ append(")")
if (orders.isNotEmpty()) {
appendLine()
append(OrderBy.Multiple(orders).asString())
@@ -78,11 +102,14 @@ class UnionQuery private constructor(
}
/**
- * Appends an ORDER BY clause to the entire UNION query.
- * Note that in most SQL dialects, an ORDER BY clause can only be applied to the final result of a UNION, not to individual `SELECT` statements within it.
+ * Sets an ORDER BY clause for the entire UNION query.
+ * When set, this ORDER BY takes precedence and any ORDER BY clauses
+ * defined in the individual queries will be ignored.
+ * If not set (null), the ORDER BY clauses from the individual queries
+ * will be collected and combined at the end of the UNION statement.
*
- * @param operator A vararg of `OrderExpression` objects specifying the columns and direction for sorting.
- * @return A new `QuerySelect` instance representing the UNION query with the added ORDER BY clause.
+ * @param operator The `OrderBy` object specifying the column and direction for sorting.
+ * @return This `UnionQuery` instance for chaining.
*/
fun orderBy(operator: OrderBy): Queryable {
orderBy = operator
@@ -95,6 +122,56 @@ class UnionQuery private constructor(
fun getOrderBy(): OrderBy? {
return orderBy
}
+
+ fun clearOrderBy(): Queryable {
+ orderBy = null
+ return this
+ }
+
+ /**
+ * Applies a LIMIT clause to all internal queries.
+ *
+ * @param count The maximum number of rows to return per query.
+ * @param offset The number of rows to skip before returning results (optional).
+ * @param override If true, replaces any existing LIMIT in each query.
+ * If false (default), only applies the LIMIT to queries that do not already have one.
+ * @return This `UnionQuery` instance for chaining.
+ */
+ fun limit(count: Int, offset: Int? = null, override: Boolean = false): UnionQuery {
+ queries.forEach { query ->
+ if (override || query.getLimit() == null) {
+ query.limit(count, offset)
+ }
+ }
+ return this
+ }
+
+ /**
+ * Applies a LIMIT clause to all internal queries using a [Limit] object.
+ *
+ * @param limitOperator The [Limit] object specifying the limit parameters.
+ * @param override If true, replaces any existing LIMIT in each query.
+ * If false (default), only applies the LIMIT to queries that do not already have one.
+ * @return This `UnionQuery` instance for chaining.
+ */
+ fun limit(limitOperator: Limit, override: Boolean = false): UnionQuery {
+ queries.forEach { query ->
+ if (override || query.getLimit() == null) {
+ query.limit(limitOperator)
+ }
+ }
+ return this
+ }
+
+ /**
+ * Removes the LIMIT clause from all internal queries.
+ *
+ * @return This `UnionQuery` instance for chaining.
+ */
+ fun clearLimit(): UnionQuery {
+ queries.forEach { it.clearLimit() }
+ return this
+ }
/**
* Creates a new `QueryBuilder` initialized with the current state of this `UnionQuery`.
* This allows for further modifications or additions to the existing union query.
@@ -238,22 +315,3 @@ class UnionQuery private constructor(
}
}
}
-fun main() {
- // Example usage:
- val query1 = QuerySelect.builder("users")
- .where(SQLOperator.Equals("age", 30))
- .and(SQLOperator.Equals("id", 30)).orderBy(
- OrderBy.Asc("name")).build()
- val query2 = QuerySelect.builder("admins")
- .where(SQLOperator.Equals("edad", 30))
- .and(SQLOperator.Equals("ege", 30))
- .limit(10)
- .orderBy(OrderBy.Desc("id")).build()
-
- val unionQuery = UnionQuery.builder(query1)
- .addQuery(query2)
- .unionAll()
- .build()
-
- println(unionQuery.asSql())
-}
\ No newline at end of file
diff --git a/query/src/main/java/com/blipblipcode/query/operator/Collation.kt b/query/src/main/java/com/blipblipcode/query/operator/Collation.kt
new file mode 100644
index 0000000..c526778
--- /dev/null
+++ b/query/src/main/java/com/blipblipcode/query/operator/Collation.kt
@@ -0,0 +1,83 @@
+package com.blipblipcode.query.operator
+
+/**
+ * Represents how a column expression should be collated when used in SQL ORDER BY.
+ * Implementations append a COLLATE clause (e.g. COLLATE NOCASE) or wrap the
+ * expression in a recognised SQL collation function (e.g. RTRIM(column)).
+ *
+ * Arbitrary transformations such as REPLACE or LOWER should be passed via the
+ * `transform` parameter of [OrderBy.Asc] / [OrderBy.Desc] instead.
+ */
+sealed interface Collation {
+ /** Apply this collation to the given SQL expression. */
+ fun apply(expression: String): String
+
+ /**
+ * Indicates whether this Collation produces a COLLATE suffix (e.g. " COLLATE NOCASE").
+ * Wrapper-style collations (like RTRIM) return false so they are
+ * applied before any COLLATE suffixes. Default is false.
+ */
+ fun isCollateSuffix(): Boolean = false
+
+ object NONE : Collation {
+ override fun apply(expression: String): String = expression
+ override fun toString(): String = "NONE"
+ }
+
+ object NOCASE : Collation {
+ override fun apply(expression: String): String = "$expression COLLATE NOCASE"
+ override fun isCollateSuffix(): Boolean = true
+ override fun toString(): String = "NOCASE"
+ }
+
+ object BINARY : Collation {
+ override fun apply(expression: String): String = "$expression COLLATE BINARY"
+ override fun isCollateSuffix(): Boolean = true
+ override fun toString(): String = "BINARY"
+ }
+
+ object RTRIM : Collation {
+ override fun apply(expression: String): String = "RTRIM($expression)"
+ override fun toString(): String = "RTRIM"
+ }
+
+ data class CustomCollate(val collateName: String) : Collation {
+ override fun apply(expression: String): String = "$expression COLLATE $collateName"
+ override fun isCollateSuffix(): Boolean = true
+ override fun toString(): String = collateName
+ }
+
+ /**
+ * Compose multiple Collation instances and apply them sequentially.
+ * Wrapper-style collations (e.g. RTRIM) are always applied before
+ * COLLATE-suffix collations (e.g. NOCASE, BINARY) regardless of
+ * the order they appear in [parts].
+ *
+ * Example: Composite(listOf(Collation.RTRIM, Collation.NOCASE))
+ * produces RTRIM(expr) COLLATE NOCASE
+ */
+ data class Composite(val parts: List) : Collation {
+ constructor(vararg parts: Collation) : this(parts.toList())
+ override fun apply(expression: String): String {
+ val (wrappers, suffixes) = parts.partition { !it.isCollateSuffix() }
+ val afterWrappers = wrappers.fold(expression) { acc, c -> c.apply(acc) }
+ return suffixes.fold(afterWrappers) { acc, c -> c.apply(acc) }
+ }
+ override fun toString(): String = parts.joinToString(" -> ")
+ }
+}
+
+/** Infix helper to compose two Collations into a Composite (left-to-right). */
+infix fun Collation.and(other: Collation): Collation = when (this) {
+ is Collation.Composite -> when (other) {
+ is Collation.Composite -> Collation.Composite(this.parts + other.parts)
+ else -> Collation.Composite(this.parts + other)
+ }
+ else -> when (other) {
+ is Collation.Composite -> Collation.Composite(listOf(this) + other.parts)
+ else -> Collation.Composite(listOf(this, other))
+ }
+}
+
+/** Convenience factory for building composites. */
+fun collationsOf(vararg parts: Collation): Collation = Collation.Composite(parts.toList())
diff --git a/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt b/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt
new file mode 100644
index 0000000..a5a70eb
--- /dev/null
+++ b/query/src/main/java/com/blipblipcode/query/operator/CollationUtils.kt
@@ -0,0 +1,27 @@
+package com.blipblipcode.query.operator
+
+/**
+ * Crea una funciΓ³n de transformaciΓ³n SQL que elimina (con REPLACE encadenado)
+ * los caracteres presentes en [this].
+ * Cada carΓ‘cter se eliminarΓ‘ mediante REPLACE(..., 'c', '').
+ * Si [this] estΓ‘ vacΓo, devuelve la identidad (no transforma nada).
+ *
+ * Ejemplo de uso:
+ * ```kotlin
+ * val strip = "-. ".removeCharsTransform()
+ * query.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = strip))
+ * // -> ORDER BY REPLACE(REPLACE(REPLACE(name, '-', ''), '.', ''), ' ', '') COLLATE NOCASE ASC
+ * ```
+ */
+fun String.removeCharsTransform(): (String) -> String {
+ if (isEmpty()) return { it }
+ val chars = this
+ return { expr ->
+ var result = expr
+ for (ch in chars) {
+ val escaped = if (ch == '\'') "''" else ch.toString()
+ result = "REPLACE($result, '$escaped', '')"
+ }
+ result
+ }
+}
diff --git a/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt b/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt
new file mode 100644
index 0000000..a2bf0a6
--- /dev/null
+++ b/query/src/main/java/com/blipblipcode/query/operator/Copyable.kt
@@ -0,0 +1,5 @@
+package com.blipblipcode.query.operator
+
+interface Copyable> {
+ fun clone(): T
+}
\ No newline at end of file
diff --git a/query/src/main/java/com/blipblipcode/query/operator/Field.kt b/query/src/main/java/com/blipblipcode/query/operator/Field.kt
index abda93e..9783f08 100644
--- a/query/src/main/java/com/blipblipcode/query/operator/Field.kt
+++ b/query/src/main/java/com/blipblipcode/query/operator/Field.kt
@@ -41,4 +41,8 @@ data class Field(
else -> value.toString()
}
}
+
+ override fun clone(): SQLOperator {
+ return Field(name, value)
+ }
}
\ No newline at end of file
diff --git a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt
index 51359eb..b4d8dd7 100644
--- a/query/src/main/java/com/blipblipcode/query/operator/Limit.kt
+++ b/query/src/main/java/com/blipblipcode/query/operator/Limit.kt
@@ -29,4 +29,8 @@ data class Limit(
override fun toString(): String {
return asString()
}
+
+ override fun clone(): SQLOperator {
+ return Limit(count, offset)
+ }
}
diff --git a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt
index 5acc8cb..8f0e828 100644
--- a/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt
+++ b/query/src/main/java/com/blipblipcode/query/operator/LogicalOperation.kt
@@ -10,7 +10,7 @@ import kotlin.collections.forEachIndexed
* @property symbol The type of the logical operation (e.g., AND, OR).
* @property operator The SQL operator that is part of the logical operation.
*/
-sealed interface LogicalOperation{
+sealed interface LogicalOperation: Copyable {
val symbol: String
val operator: SQLOperator<*>
@@ -20,8 +20,8 @@ sealed interface LogicalOperation{
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return Where(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return Where(operator.clone())
}
}
data class And(override val operator: SQLOperator<*>) : LogicalOperation {
@@ -30,8 +30,8 @@ sealed interface LogicalOperation{
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return And(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return And( operator.clone())
}
}
data class Or(override val operator: SQLOperator<*>) : LogicalOperation {
@@ -40,8 +40,8 @@ sealed interface LogicalOperation{
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return Or(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return Or(operator.clone())
}
}
data class AndNot(override val operator: SQLOperator<*>) : LogicalOperation {
@@ -49,8 +49,8 @@ sealed interface LogicalOperation{
override fun asString(): String {
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return And(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return AndNot(operator.clone())
}
}
data class Exists(override val operator: SQLOperator<*>) : LogicalOperation{
@@ -58,8 +58,8 @@ sealed interface LogicalOperation{
override fun asString(): String {
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return Exists(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return Exists(operator.clone())
}
}
data class Not(override val operator: SQLOperator<*>) : LogicalOperation {
@@ -67,8 +67,8 @@ sealed interface LogicalOperation{
override fun asString(): String {
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return Not(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return Not(operator.clone())
}
}
data class All(override val operator: SQLOperator<*>) : LogicalOperation {
@@ -76,8 +76,8 @@ sealed interface LogicalOperation{
override fun asString(): String {
return "$symbol ${operator.toSQLString()}"
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return All(arg[0] as SQLOperator<*>)
+ override fun clone(): LogicalOperation {
+ return All(operator.clone())
}
}
@Suppress("UNCHECKED_CAST")
@@ -99,8 +99,8 @@ sealed interface LogicalOperation{
}
}
- override fun clone(vararg arg: Any?): LogicalOperation {
- return Multiple(arg[0] as List, symbol = arg[1] as? String ?: symbol)
+ override fun clone(): LogicalOperation {
+ return Multiple(operations.map { it.clone() }, symbol = symbol)
}
}
@@ -110,5 +110,6 @@ sealed interface LogicalOperation{
*/
fun asString(): String
- fun clone(vararg arg: Any?): LogicalOperation
+
+
}
diff --git a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt
index 2a6eecd..9e20ffd 100644
--- a/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt
+++ b/query/src/main/java/com/blipblipcode/query/operator/OrdenBy.kt
@@ -1,80 +1,82 @@
package com.blipblipcode.query.operator
-sealed interface OrderBy:SQLOperator {
+sealed interface OrderBy : SQLOperator {
override val column: String
override val symbol: String
override val value: String
override val caseConversion: CaseConversion
+ get() = CaseConversion.NONE
+
+ val transform: (String) -> String
+ val collation: Collation
override fun asString(): String
+ override fun clone(): OrderBy
+
fun asSqlClause(): String {
return when (this) {
- is Asc , is Desc -> "${caseConversion.asSqlFunction(column)} $value"
+ is Asc, is Desc -> collation.apply(transform(column)) + " " + value
is Multiple -> orders.joinToString(", ") { it.asSqlClause() }
}
}
- fun clone(vararg params: Any?): OrderBy
- data class Asc(override val column: String) : OrderBy{
+ data class Asc(
+ override val column: String,
+ override val collation: Collation = Collation.NONE,
+ override val transform: (String) -> String = { it }
+ ) : OrderBy {
override val symbol: String = "ORDER BY"
override val value: String = "ASC"
- override val caseConversion: CaseConversion = CaseConversion.NONE
+
override fun asString(): String {
- return "$symbol ${caseConversion.asSqlFunction(column)} $value"
+ val expr = collation.apply(transform(column))
+ return "$symbol $expr $value"
}
- override fun clone(vararg params: Any?): OrderBy {
- return this.copy(column = params[0] as String)
+ override fun clone(): OrderBy {
+ return this.copy()
}
- override fun toString(): String {
- return asString()
- }
+ override fun toString(): String = asString()
}
- data class Desc(override val column: String) : OrderBy{
+
+ data class Desc(
+ override val column: String,
+ override val collation: Collation = Collation.NONE,
+ override val transform: (String) -> String = { it }
+ ) : OrderBy {
override val symbol: String = "ORDER BY"
override val value: String = "DESC"
- override val caseConversion: CaseConversion = CaseConversion.NONE
override fun asString(): String {
- return "$symbol ${caseConversion.asSqlFunction(column)} $value"
+ val expr = collation.apply(transform(column))
+ return "$symbol $expr $value"
}
- override fun clone(vararg params: Any?): OrderBy {
- return this.copy(column = params[0] as String)
+ override fun clone(): OrderBy {
+ return this.copy()
}
- override fun toString(): String {
- return asString()
- }
+ override fun toString(): String = asString()
}
- data class Multiple(val orders: List) : OrderBy{
+ data class Multiple(val orders: List) : OrderBy {
override val column: String
get() = orders.joinToString(", ") { it.column }
override val symbol: String = "ORDER BY"
override val value: String = orders.joinToString(", ") { it.value }
- override val caseConversion: CaseConversion = CaseConversion.NONE
-
+ override val collation: Collation = Collation.NONE
+ override val transform: (String) -> String = { it }
override fun asString(): String {
return "ORDER BY ${asSqlClause()}"
}
- override fun clone(vararg params: Any?): OrderBy {
- val newOrders = params.getOrNull(0) as? List<*>
- ?: return this.copy()
-
- if (newOrders.all { it is OrderBy }) {
- @Suppress("UNCHECKED_CAST")
- return this.copy(orders = newOrders as List)
- }
- throw IllegalArgumentException("The parameters provided for cloning are not of the List type.")
+ override fun clone(): OrderBy {
+ return this.copy()
}
- override fun toString(): String {
- return "ORDER BY ${asSqlClause()}"
- }
+ override fun toString(): String = "ORDER BY ${asSqlClause()}"
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -82,9 +84,7 @@ sealed interface OrderBy:SQLOperator {
return orders == other.orders
}
- override fun hashCode(): Int {
- return orders.hashCode()
- }
+ override fun hashCode(): Int = orders.hashCode() + 31 * symbol.hashCode()
}
}
\ No newline at end of file
diff --git a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt
index 6896c22..e10de1b 100644
--- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt
+++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt
@@ -6,7 +6,7 @@ package com.blipblipcode.query.operator
*
* @param T The type of the value being compared.
*/
-sealed interface SQLOperator {
+sealed interface SQLOperator: Copyable> {
val symbol: String
val column: String
val value: T
@@ -36,6 +36,7 @@ sealed interface SQLOperator {
*/
fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction(value.toString())}"
+
/** Represents an "=" operation. */
data class Equals(
override val column: String,
@@ -43,6 +44,9 @@ sealed interface SQLOperator {
override val caseConversion: CaseConversion = CaseConversion.NONE
) : SQLOperator {
override val symbol: String = "="
+ override fun clone(): SQLOperator {
+ return Equals(column, value, caseConversion)
+ }
}
/** Represents a "!=" operation. */
@@ -51,6 +55,9 @@ sealed interface SQLOperator {
override val value: T,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
override val symbol: String = "!="
+ override fun clone(): SQLOperator {
+ return NotEquals(column, value, caseConversion)
+ }
}
/** Represents a ">" operation. */
@@ -59,6 +66,9 @@ sealed interface SQLOperator {
override val value: T,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
override val symbol: String = ">"
+ override fun clone(): SQLOperator {
+ return GreaterThan(column, value, caseConversion)
+ }
}
/** Represents a "<" operation. */
@@ -67,6 +77,9 @@ sealed interface SQLOperator {
override val value: T,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
override val symbol: String = "<"
+ override fun clone(): SQLOperator {
+ return LessThan(column, value, caseConversion)
+ }
}
/** Represents a ">=" operation. */
@@ -74,7 +87,11 @@ sealed interface SQLOperator {
override val column: String,
override val value: T,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
+
override val symbol: String = ">="
+ override fun clone(): SQLOperator {
+ return GreaterThanOrEqual(column, value, caseConversion)
+ }
}
/** Represents a "<=" operation. */
@@ -83,6 +100,9 @@ sealed interface SQLOperator {
override val value: T,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
override val symbol: String = "<="
+ override fun clone(): SQLOperator {
+ return LessThanOrEqual(column, value, caseConversion)
+ }
}
/** Represents a "LIKE" operation. */
@@ -91,9 +111,14 @@ sealed interface SQLOperator {
override val value: String,
override val caseConversion: CaseConversion = CaseConversion.NONE) : SQLOperator {
override val symbol: String = "LIKE"
+
override fun toSQLString(): String {
return "${caseConversion.asSqlFunction(column)} $symbol ${caseConversion.asSqlFunction("'%$value%'")}"
}
+
+ override fun clone(): SQLOperator {
+ return Like(column, value, caseConversion)
+ }
}
/** Represents an "IN" operation. */
@@ -108,6 +133,9 @@ sealed interface SQLOperator {
}
return "${caseConversion.asSqlFunction(column)} $symbol ($list)"
}
+ override fun clone(): SQLOperator> {
+ return In(column, value, caseConversion)
+ }
}
/** Represents a "NOT IN" operation. */
@@ -122,6 +150,9 @@ sealed interface SQLOperator {
}
return "${caseConversion.asSqlFunction(column)} $symbol ($list)"
}
+ override fun clone(): SQLOperator> {
+ return NotIn(column, value, caseConversion)
+ }
}
/** Represents an "IS NULL" operation. */
@@ -131,6 +162,9 @@ sealed interface SQLOperator {
override val caseConversion: CaseConversion = CaseConversion.NONE
override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol"
override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol"
+ override fun clone(): SQLOperator {
+ return IsNull(column)
+ }
}
/** Represents an "IS NOT NULL" operation. */
@@ -140,6 +174,9 @@ sealed interface SQLOperator {
override val caseConversion: CaseConversion = CaseConversion.NONE
override fun toSQLString(): String = "${caseConversion.asSqlFunction(column)} $symbol"
override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol"
+ override fun clone(): SQLOperator {
+ return IsNotNull(column)
+ }
}
/** Represents a "BETWEEN" operation. */
@@ -154,6 +191,14 @@ sealed interface SQLOperator {
val endStr = caseConversion.asSqlFunction(end.toString())
return "${caseConversion.asSqlFunction(column)} $symbol '$startStr' AND '$endStr'"
}
- override fun asString(): String = "${caseConversion.asSqlFunction(column)} $symbol '${caseConversion.asSqlFunction(start.toString())}' AND '${caseConversion.asSqlFunction(start.toString())}'"
+
+ override fun asString(): String =
+ "${caseConversion.asSqlFunction(column)} $symbol '${caseConversion.asSqlFunction(start.toString())}' AND '${
+ caseConversion.asSqlFunction(start.toString())
+ }'"
+
+ override fun clone(): SQLOperator> {
+ return Between(column, start, end, caseConversion)
+ }
}
}
diff --git a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt
index b8a1aac..61475c3 100644
--- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt
+++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt
@@ -6,6 +6,20 @@ import com.blipblipcode.query.InnerJoint
import com.blipblipcode.query.QuerySelect
import com.blipblipcode.query.Queryable
import com.blipblipcode.query.UnionQuery
+import com.blipblipcode.query.operator.Collation
+import com.blipblipcode.query.operator.LogicalOperation
+import com.blipblipcode.query.operator.LogicalOperation.All
+import com.blipblipcode.query.operator.LogicalOperation.And
+import com.blipblipcode.query.operator.LogicalOperation.AndNot
+import com.blipblipcode.query.operator.LogicalOperation.Exists
+import com.blipblipcode.query.operator.LogicalOperation.Multiple
+import com.blipblipcode.query.operator.LogicalOperation.Not
+import com.blipblipcode.query.operator.LogicalOperation.Or
+import com.blipblipcode.query.operator.LogicalOperation.Where
+import com.blipblipcode.query.operator.OrderBy
+import com.blipblipcode.query.operator.OrderBy.Asc
+import com.blipblipcode.query.operator.OrderBy.Desc
+import com.blipblipcode.query.operator.SQLOperator
import com.blipblipcode.query.retrofit.RepeatedQueryParameters
/**
@@ -106,3 +120,48 @@ fun Queryable.asQueryRepeatedQueryParameters(predicate:(Pair) -> B
}
}
}
+
+/**
+ * Creates a copy of this `LogicalOperation` with a new `SQLOperator`.
+ *
+ * @param operator The new `SQLOperator` to use.
+ * @return A new `LogicalOperation` instance with the updated operator.
+ */
+fun LogicalOperation.copyOperation(operator: SQLOperator<*> = this.operator): LogicalOperation {
+ return when (this) {
+ is All -> All(operator = operator)
+ is And -> And(operator = operator)
+ is AndNot -> AndNot(operator = operator)
+ is Exists -> Exists(operator = operator)
+ is Multiple -> {
+ val updatedOperations = operations.toMutableList()
+ if (updatedOperations.isNotEmpty()) {
+ updatedOperations[0] = updatedOperations[0].copyOperation(operator)
+ }
+ Multiple(updatedOperations, symbol)
+ }
+ is Not -> Not(operator = operator)
+ is Or -> Or(operator = operator)
+ is Where -> Where(operator = operator)
+ }
+}
+
+/**
+ * Creates a copy of this `OrderBy` clause with updated parameters.
+ *
+ * @param column The new column name.
+ * @param collation The new collation to use.
+ * @param transform An optional transformation function for the column name.
+ * @return A new `OrderBy` instance with the updated parameters.
+ */
+fun OrderBy.copyOrderBy(
+ column: String = this.column,
+ collation: Collation = this.collation,
+ transform: (String) -> String = this.transform
+): OrderBy {
+ return when (this) {
+ is Asc -> Asc(column, collation, transform)
+ is Desc -> Desc(column, collation, transform)
+ is OrderBy.Multiple -> OrderBy.Multiple(orders.map { it.copyOrderBy(column, collation, transform) })
+ }
+}
diff --git a/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt b/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt
new file mode 100644
index 0000000..0ccb6e0
--- /dev/null
+++ b/query/src/test/java/com/blipblipcode/query/CollationUtilsTest.kt
@@ -0,0 +1,107 @@
+package com.blipblipcode.query
+
+import com.blipblipcode.query.operator.Collation
+import com.blipblipcode.query.operator.and
+import com.blipblipcode.query.operator.removeCharsTransform
+import com.blipblipcode.query.operator.OrderBy
+import com.blipblipcode.query.operator.SQLOperator
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CollationUtilsTest {
+
+ @Test
+ fun `removeCharsTransform builds REPLACE chain with COLLATE NOCASE`() {
+ val q = QuerySelect.builder("users")
+ .where(SQLOperator.Equals("status", "active"))
+ .build()
+
+ // eliminar espacios, guiones y puntos; ignorar mayΓΊsculas con COLLATE NOCASE
+ val strip = " -.".removeCharsTransform()
+
+ q.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE, transform = strip))
+
+ val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY REPLACE(REPLACE(REPLACE(name, ' ', ''), '-', ''), '.', '') COLLATE NOCASE ASC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+
+ @Test
+ fun `REPLACE with COLLATE NOCASE produces correct SQL for vehicle identification`() {
+ val q = QuerySelect.builder("vehicle")
+ .where(SQLOperator.Equals("active", true))
+ .build()
+
+ // Eliminar guiones y aplicar COLLATE NOCASE
+ val strip = "-".removeCharsTransform()
+ q.orderBy(OrderBy.Asc("identification", collation = Collation.NOCASE, transform = strip))
+
+ val expectedSql = "SELECT * FROM vehicle WHERE active = true ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+
+ @Test
+ fun `REPLACE with COLLATE BINARY produces correct SQL`() {
+ val q = QuerySelect.builder("products")
+ .where(SQLOperator.Equals("status", "available"))
+ .build()
+
+ val strip = " ".removeCharsTransform()
+ q.orderBy(OrderBy.Desc("code", collation = Collation.BINARY, transform = strip))
+
+ val expectedSql = "SELECT * FROM products WHERE status = 'available' ORDER BY REPLACE(code, ' ', '') COLLATE BINARY DESC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+
+ @Test
+ fun `COLLATE NOCASE alone is sufficient for case-insensitive ordering`() {
+ val q = QuerySelect.builder("users")
+ .where(SQLOperator.Equals("status", "active"))
+ .build()
+
+ q.orderBy(OrderBy.Asc("name", collation = Collation.NOCASE))
+
+ val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY name COLLATE NOCASE ASC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+
+ @Test
+ fun `empty string removeCharsTransform returns identity`() {
+ val transform = "".removeCharsTransform()
+ assertEquals("col", transform("col"))
+ }
+
+ @Test
+ fun `removeCharsTransform escapes single quotes`() {
+ val transform = "'".removeCharsTransform()
+ assertEquals("REPLACE(name, '''', '')", transform("name"))
+ }
+
+ @Test
+ fun `RTRIM and NOCASE composite collation with transform`() {
+ val q = QuerySelect.builder("items")
+ .where(SQLOperator.Equals("type", "A"))
+ .build()
+
+ val strip = "-".removeCharsTransform()
+ q.orderBy(OrderBy.Asc("code", collation = Collation.RTRIM and Collation.NOCASE, transform = strip))
+
+ val expectedSql = "SELECT * FROM items WHERE type = 'A' ORDER BY RTRIM(REPLACE(code, '-', '')) COLLATE NOCASE ASC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+
+ @Test
+ fun `Multiple OrderBy with different transforms and collations`() {
+ val q = QuerySelect.builder("vehicle")
+ .where(SQLOperator.Equals("active", true))
+ .build()
+
+ val stripDash = "-".removeCharsTransform()
+ q.orderBy(OrderBy.Multiple(listOf(
+ OrderBy.Asc("identification", collation = Collation.NOCASE, transform = stripDash),
+ OrderBy.Desc("name")
+ )))
+
+ val expectedSql = "SELECT * FROM vehicle WHERE active = true ORDER BY REPLACE(identification, '-', '') COLLATE NOCASE ASC, name DESC"
+ assertEquals(expectedSql, q.asSql().trim())
+ }
+}
diff --git a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt
index aa695d3..b04ab9d 100644
--- a/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt
+++ b/query/src/test/java/com/blipblipcode/query/QuerySelectTest.kt
@@ -5,13 +5,63 @@ import com.blipblipcode.query.operator.LogicalOperation
import com.blipblipcode.query.operator.OrderBy
import com.blipblipcode.query.operator.SQLOperator
import com.blipblipcode.query.utils.asSQLiteQuery
+import com.blipblipcode.query.utils.copyOperation
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
import org.junit.Test
class QuerySelectTest {
@Test
- fun `remove key with an existing key`() {
+ fun should_new_instance_when_copying_in_transformOperation() {
+
+ val key = "status"
+
+ val query = QuerySelect.builder("users")
+ .where(SQLOperator.Equals("id", 1))
+ .and(key, SQLOperator.Equals(key, "active"))
+ .build()
+ val cloneQuery = query.newBuilder {
+ it.transformOperation(key = key) { op ->
+ op.copyOperation(SQLOperator.NotEquals(key, "active"))
+ }
+ }.build()
+
+ assertNotEquals(query.hashCode(), cloneQuery.hashCode())
+ assertNotEquals(query.asSql(), cloneQuery.asSql())
+ assertTrue(cloneQuery.getSqlOperation(key) is SQLOperator.NotEquals)
+ }
+
+ @Test
+ fun should_create_new_instance_when_copying_in_copy(){
+ val query = QuerySelect.builder("users")
+ .where(SQLOperator.Equals("id", 1))
+ .build()
+ val cloneQuery = query.copy()
+
+ assertNotEquals(query.hashCode(), cloneQuery.hashCode())
+ }
+
+ @Test
+ fun should_maintain_state_and_create_new_instance_when_consumer_is_empty_in_newBuilder() {
+ val query = QuerySelect.builder("users")
+ .where(SQLOperator.Equals("id", 1))
+ .and("status", SQLOperator.Equals("status", "active"))
+ .build()
+
+ val newQuery = query.newBuilder { }.build()
+
+ assertEquals(query.asSql(), newQuery.asSql())
+ assertTrue("La nueva consulta deberΓa ser una instancia diferente", query !== newQuery)
+
+ val originalOp = query.getSqlOperation("status")
+ val newOp = newQuery.getSqlOperation("status")
+ assertTrue("Las operaciones internas tambiΓ©n deberΓan ser instancias diferentes (clones)", originalOp !== newOp)
+ }
+
+ @Test
+ fun should_remove_operation_when_key_exists_in_remove() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -22,7 +72,7 @@ class QuerySelectTest {
}
@Test
- fun `remove key with a non existent key`() {
+ fun should_do_nothing_when_key_does_not_exist_in_remove() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -32,7 +82,7 @@ class QuerySelectTest {
}
@Test
- fun `remove key with an empty string key`() {
+ fun should_remove_operation_when_key_is_empty_in_remove() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("", SQLOperator.Equals("status", "active"))
@@ -43,7 +93,7 @@ class QuerySelectTest {
}
@Test
- fun `remove key when operations map is empty`() {
+ fun should_do_nothing_when_operations_map_is_empty_in_remove() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -53,7 +103,7 @@ class QuerySelectTest {
}
@Test
- fun `remove key chaining call`() {
+ fun should_return_same_instance_for_chaining_in_remove() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -63,7 +113,7 @@ class QuerySelectTest {
}
@Test
- fun `setWhere operator to replace an existing clause`() {
+ fun should_replace_existing_clause_with_new_operator_in_setWhere() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -73,7 +123,7 @@ class QuerySelectTest {
}
@Test
- fun `setWhere operator with a different operator type`() {
+ fun should_replace_existing_clause_with_different_operator_type_in_setWhere() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -83,7 +133,7 @@ class QuerySelectTest {
}
@Test
- fun `setWhere operator chaining call`() {
+ fun should_return_same_instance_for_chaining_in_setWhere() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -92,7 +142,7 @@ class QuerySelectTest {
}
@Test
- fun `addLogicalOperation key operation with a new key`() {
+ fun should_add_new_logical_operation_when_key_is_new_in_addLogicalOperation() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -102,7 +152,7 @@ class QuerySelectTest {
}
@Test
- fun `addLogicalOperation key operation with a duplicate key`() {
+ fun should_overwrite_logical_operation_when_key_is_duplicate_in_addLogicalOperation() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "inactive"))
@@ -113,7 +163,7 @@ class QuerySelectTest {
}
@Test
- fun `addLogicalOperation key operation with an empty string key`() {
+ fun should_add_logical_operation_when_key_is_empty_in_addLogicalOperation() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -123,7 +173,7 @@ class QuerySelectTest {
}
@Test
- fun `addLogicalOperation key operation with various LogicalOperation types`() {
+ fun should_add_various_logical_operation_types_in_addLogicalOperation() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -133,7 +183,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields newFields with multiple field names`() {
+ fun should_set_multiple_fields_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -143,7 +193,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields newFields with a single field name`() {
+ fun should_set_single_field_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -153,7 +203,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields newFields with no arguments`() {
+ fun should_reset_to_all_fields_when_no_arguments_provided_in_setFields() {
val queryWithFields = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -165,7 +215,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields newFields immutability check`() {
+ fun should_maintain_immutability_of_original_instance_in_setFields() {
val originalQuery = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -177,7 +227,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields newFields with empty or blank strings`() {
+ fun should_handle_empty_or_blank_field_names_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -187,7 +237,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with a basic WHERE clause and all fields`() {
+ fun should_generate_sql_with_basic_where_clause_and_all_fields_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -196,7 +246,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with a basic WHERE `() {
+ fun should_generate_sql_without_where_clause_in_asSql() {
val query = QuerySelect.builder("users")
.build()
val expectedSql = "SELECT * FROM users"
@@ -204,7 +254,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with a WHERE clause and uppercase`() {
+ fun should_generate_sql_with_uppercase_conversion_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active", caseConversion = CaseConversion.UPPER))
@@ -214,7 +264,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with specific fields and no logical operations`() {
+ fun should_generate_sql_with_specific_fields_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.setFields("name", "email")
@@ -224,7 +274,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with a WHERE clause and a single logical operation`() {
+ fun should_generate_sql_with_single_logical_operation_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -234,7 +284,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with multiple logical operations`() {
+ fun should_generate_sql_with_multiple_logical_operations_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -245,17 +295,17 @@ class QuerySelectTest {
}
@Test
- fun `asSql with special characters in table or field names`() {
- val query = QuerySelect.builder("`user table`")
- .where(SQLOperator.Equals("`user id`", 1))
- .setFields("`first name`", "`last name`")
+ fun should_handle_special_characters_in_table_or_field_names_in_asSql() {
+ val query = QuerySelect.builder("user table")
+ .where(SQLOperator.Equals("user id", 1))
+ .setFields("first name", "last name")
.build()
- val expectedSql = "SELECT `first name`, `last name` FROM `user table` WHERE `user id` = 1"
+ val expectedSql = "SELECT first name, last name FROM user table WHERE user id = 1"
assertEquals(expectedSql, query.asSql().trim())
}
@Test
- fun `asSql after removing a logical operation`() {
+ fun should_reflect_removed_operation_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -267,7 +317,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql after changing the WHERE clause`() {
+ fun should_reflect_changed_where_clause_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -277,7 +327,7 @@ class QuerySelectTest {
}
@Test
- fun `asSql with empty fields list explicitly set`() {
+ fun should_handle_explicitly_set_empty_fields_in_asSql() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.setFields()
@@ -287,7 +337,7 @@ class QuerySelectTest {
}
@Test
- fun `asSQLiteQuery converts QuerySelect to SupportSQLiteQuery`() {
+ fun should_convert_to_support_sqlite_query_in_asSQLiteQuery() {
val querySelect = QuerySelect.builder("users")
.where(SQLOperator.Equals("name", "John"))
.build()
@@ -299,7 +349,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with a single limit value`() {
+ fun should_limit_results_with_single_value_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10)
@@ -309,7 +359,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with offset`() {
+ fun should_limit_results_with_offset_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(5, 10)
@@ -319,7 +369,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with chaining call`() {
+ fun should_return_same_instance_for_chaining_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10)
@@ -329,7 +379,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with various conditions`() {
+ fun should_handle_various_limit_and_offset_conditions_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10, 5)
@@ -339,7 +389,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with no offset`() {
+ fun should_handle_zero_offset_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10, 0)
@@ -349,7 +399,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with zero limit`() {
+ fun should_handle_zero_limit_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(0)
@@ -359,7 +409,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with negative limit`() {
+ fun should_handle_negative_limit_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(-5)
@@ -369,7 +419,7 @@ class QuerySelectTest {
}
@Test
- fun `limit results with negative offset`() {
+ fun should_handle_negative_offset_in_limit() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10, -5)
@@ -379,7 +429,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with ascending order`() {
+ fun should_order_by_ascending_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -389,7 +439,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with descending order`() {
+ fun should_order_by_descending_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -399,7 +449,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with multiple columns ascending`() {
+ fun should_order_by_multiple_columns_ascending_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -412,7 +462,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with multiple columns mixed directions`() {
+ fun should_order_by_multiple_columns_mixed_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -425,7 +475,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with descending then ascending`() {
+ fun should_order_by_descending_then_ascending_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -438,7 +488,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with ascending and descending columns`() {
+ fun should_order_by_ascending_and_descending_columns_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -451,7 +501,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with limit and ascending order`() {
+ fun should_order_by_ascending_with_limit_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(10)
@@ -462,7 +512,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with limit and descending order`() {
+ fun should_order_by_descending_with_limit_and_offset_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.limit(5, 10)
@@ -473,7 +523,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy chaining call`() {
+ fun should_return_same_instance_for_chaining_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -482,7 +532,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy replacing previous order`() {
+ fun should_replace_previous_order_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -493,7 +543,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with null value`() {
+ fun should_remove_ordering_when_null_provided_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -503,7 +553,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with three columns mixed directions`() {
+ fun should_order_by_three_columns_mixed_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
@@ -517,30 +567,30 @@ class QuerySelectTest {
}
@Test
- fun `orderBy with special characters in column names`() {
+ fun should_handle_special_characters_in_column_names_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.build()
- query.orderBy(OrderBy.Asc("`first name`"))
- val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY `first name` ASC"
+ query.orderBy(OrderBy.Asc("first name"))
+ val expectedSql = "SELECT * FROM users WHERE status = 'active' ORDER BY first name ASC"
assertEquals(expectedSql, query.asSql().trim())
}
@Test
- fun `orderBy with multiple columns and special characters`() {
- val query = QuerySelect.builder("`user table`")
- .where(SQLOperator.Equals("`user id`", 1))
+ fun should_handle_multiple_special_character_column_names_in_orderBy() {
+ val query = QuerySelect.builder("user table")
+ .where(SQLOperator.Equals("user id", 1))
.build()
query.orderBy(OrderBy.Multiple(listOf(
- OrderBy.Asc("`first name`"),
- OrderBy.Desc("`last name`")
+ OrderBy.Asc("first name"),
+ OrderBy.Desc("last name")
)))
- val expectedSql = "SELECT * FROM `user table` WHERE `user id` = 1 ORDER BY `first name` ASC, `last name` DESC"
+ val expectedSql = "SELECT * FROM user table WHERE user id = 1 ORDER BY first name ASC, last name DESC"
assertEquals(expectedSql, query.asSql().trim())
}
@Test
- fun `orderBy ascending with multiple logical operations`() {
+ fun should_order_ascending_with_multiple_logical_operations_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -552,7 +602,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy descending with multiple logical operations`() {
+ fun should_order_descending_with_multiple_logical_operations_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.and("status", SQLOperator.Equals("status", "active"))
@@ -564,7 +614,7 @@ class QuerySelectTest {
}
@Test
- fun `orderBy multiple with specific fields`() {
+ fun should_order_multiple_with_specific_fields_in_orderBy() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("name", "email", "created_at")
@@ -578,7 +628,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with single field alias`() {
+ fun should_handle_single_field_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.setFields("name AS full_name")
@@ -588,7 +638,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with multiple fields with aliases`() {
+ fun should_handle_multiple_field_aliases_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("name AS full_name", "email AS user_email", "created_at AS registration_date")
@@ -598,7 +648,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with mixed fields and aliases`() {
+ fun should_handle_mixed_fields_and_aliases_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("id", "name AS full_name", "email")
@@ -608,7 +658,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with function and alias`() {
+ fun should_handle_function_with_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("COUNT(*) AS total_users", "name AS user_name")
@@ -618,7 +668,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with uppercase alias`() {
+ fun should_handle_uppercase_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.setFields("name AS NAME", "email AS EMAIL")
@@ -628,17 +678,17 @@ class QuerySelectTest {
}
@Test
- fun `setFields with backtick quoted alias`() {
+ fun should_handle_backtick_quoted_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
- .setFields("`name` AS `full name`", "`email` AS `user email`")
+ .setFields("name AS full name", "email AS user email")
.build()
- val expectedSql = "SELECT `name` AS `full name`, `email` AS `user email` FROM users WHERE id = 1"
+ val expectedSql = "SELECT name AS full name, email AS user email FROM users WHERE id = 1"
assertEquals(expectedSql, query.asSql().trim())
}
@Test
- fun `setFields with table prefix and alias`() {
+ fun should_handle_table_prefix_and_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("users.id", 1))
.setFields("users.name AS full_name", "users.email AS user_email")
@@ -648,7 +698,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with aliases and orderBy`() {
+ fun should_work_with_aliases_and_orderBy_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("name AS full_name", "created_at AS registration_date")
@@ -659,7 +709,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with aliases and limit`() {
+ fun should_work_with_aliases_and_limit_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("name AS full_name", "email AS user_email")
@@ -670,7 +720,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with aliases orderBy and limit combined`() {
+ fun should_work_with_aliases_orderBy_and_limit_combined_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("name AS full_name", "created_at AS registration_date", "email AS user_email")
@@ -685,7 +735,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with CASE statement and alias`() {
+ fun should_handle_CASE_statement_with_alias_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.setFields("CASE WHEN status = 'active' THEN 'Active User' ELSE 'Inactive' END AS user_status")
@@ -695,7 +745,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with aggregate functions and aliases`() {
+ fun should_handle_aggregate_functions_with_aliases_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("COUNT(*) AS total", "SUM(salary) AS total_salary", "AVG(salary) AS average_salary")
@@ -705,7 +755,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with mathematical expression and alias`() {
+ fun should_handle_mathematical_expression_with_alias_in_setFields() {
val query = QuerySelect.builder("products")
.where(SQLOperator.Equals("category", "electronics"))
.setFields("name", "price", "price * 0.1 AS discount_amount")
@@ -715,7 +765,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with alias immutability`() {
+ fun should_maintain_immutability_when_using_aliases_in_setFields() {
val originalQuery = QuerySelect.builder("users")
.where(SQLOperator.Equals("id", 1))
.build()
@@ -727,7 +777,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields replacing previous fields with aliases`() {
+ fun should_replace_previous_fields_with_aliases_in_setFields() {
val query = QuerySelect.builder("users")
.where(SQLOperator.Equals("status", "active"))
.setFields("id", "name")
@@ -738,7 +788,7 @@ class QuerySelectTest {
}
@Test
- fun `setFields with multiple aliases using builder`() {
+ fun should_handle_multiple_aliases_using_builder_in_setFields() {
val query = QuerySelect.builder("employees")
.where(SQLOperator.Equals("department", "sales"))
.setFields("employee_id AS id", "first_name AS fname", "last_name AS lname", "salary AS monthly_salary")
diff --git a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt
index 4ac2c18..eb909a5 100644
--- a/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt
+++ b/query/src/test/java/com/blipblipcode/query/UnionQueryTest.kt
@@ -1,8 +1,12 @@
package com.blipblipcode.query
+import com.blipblipcode.query.builder.querySelect
+import com.blipblipcode.query.operator.Limit
import com.blipblipcode.query.operator.OrderBy
import com.blipblipcode.query.operator.SQLOperator
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -15,28 +19,28 @@ class UnionQueryTest {
@Test
fun `orderBy with single column`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Asc("name"))
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name ASC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name ASC"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `orderBy with multiple columns`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Asc("name"))
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name ASC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name ASC"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `orderBy with different sort directions`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("name"))
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name DESC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name DESC"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `orderBy overwriting previous clause`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("age"))
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY age DESC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY age DESC"
assertEquals(expectedSql, unionQuery.asSql())
}
@@ -50,42 +54,42 @@ class UnionQueryTest {
@Test
fun `asSql with two queries for UNION`() {
val unionQuery = UnionQuery.builder(query1).addQuery( query2).build()
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `asSql with two queries for UNION ALL`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).unionAll().build()
- val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\n)"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `asSql with multiple queries for UNION`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).addQuery(query3).build()
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nUNION\n${query3.asSql()}"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\nUNION\n${query3.asSql()}\n)"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `asSql with multiple queries for UNION ALL`() {
val unionQuery = UnionQuery.builder(query1).addQuery( query2).addQuery(query3).unionAll().build()
- val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nUNION ALL\n${query3.asSql()}"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nUNION ALL\n${query3.asSql()}\n)"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `asSql for UNION with an orderBy clause`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build().orderBy(OrderBy.Desc("name"))
- val expectedSql = "${query1.asSql()}\nUNION\n${query2.asSql()}\nORDER BY name DESC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION\n${query2.asSql()}\n)\nORDER BY name DESC"
assertEquals(expectedSql, unionQuery.asSql())
}
@Test
fun `asSql for UNION ALL with an orderBy clause`() {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).unionAll().build().orderBy(OrderBy.Asc("name"))
- val expectedSql = "${query1.asSql()}\nUNION ALL\n${query2.asSql()}\nORDER BY name ASC"
+ val expectedSql = "SELECT * FROM (\n${query1.asSql()}\nUNION ALL\n${query2.asSql()}\n)\nORDER BY name ASC"
assertEquals(expectedSql, unionQuery.asSql())
}
@@ -111,4 +115,227 @@ class UnionQueryTest {
val unionQuery = UnionQuery.builder(query1).addQuery(query2).build()
assertEquals(queries, unionQuery.queries)
}
+
+ @Test
+ fun `asSql preserve internal queries orderBy and combines it in UnionQuery`() {
+ val q1 = QuerySelect.builder("table1")
+ .where(SQLOperator.Equals("id", 1))
+ .orderBy(OrderBy.Asc("name"))
+ .build()
+ val q2 = QuerySelect.builder("table2")
+ .where(SQLOperator.Equals("id", 2))
+ .orderBy(OrderBy.Desc("date"))
+ .build()
+
+ val unionQuery = UnionQuery.builder(q1)
+ .addQuery(q2)
+ .build()
+
+ val sql = unionQuery.asSql()
+
+ // El SQL resultante de UnionQuery debe tener los ORDER BY combinados al final
+ // y las queries internas NO deben tener el ORDER BY (regla SQL para UNION)
+ val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY name ASC, date DESC"
+ assertEquals(expectedSql, sql)
+
+ // Verificamos que las queries originales NO fueron modificadas (no se les puso el orderBy en null)
+ assertNotNull(q1.getOrderBy())
+ assertNotNull(q2.getOrderBy())
+ assertEquals("name", q1.getOrderBy()?.column)
+ assertEquals("date", q2.getOrderBy()?.column)
+ }
+
+ @Test
+ fun `asSql with UnionQuery level orderBy ignores internal queries orderBy`() {
+ val q1 = QuerySelect.builder("table1")
+ .where(SQLOperator.Equals("id", 1))
+ .orderBy(OrderBy.Asc("name"))
+ .build()
+ val q2 = QuerySelect.builder("table2")
+ .where(SQLOperator.Equals("id", 2))
+ .build()
+
+ val unionQuery = UnionQuery.builder(q1)
+ .addQuery(q2)
+ .build()
+ .orderBy(OrderBy.Desc("id"))
+
+ val sql = unionQuery.asSql()
+
+ // Cuando se define un orderBy a nivel de UnionQuery, se ignoran los orderBy de las queries internas
+ val expectedSql = "SELECT * FROM (\nSELECT * FROM table1 WHERE id = 1\nUNION\nSELECT * FROM table2 WHERE id = 2\n)\nORDER BY id DESC"
+ assertEquals(expectedSql, sql)
+ }
+
+ // ---- limit() ----
+
+ @Test
+ fun should_apply_limit_to_all_queries_when_no_query_has_limit_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1") }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(10)
+
+ //THEN
+ assertEquals(10, q1.getLimit()?.count)
+ assertEquals(10, q2.getLimit()?.count)
+ }
+
+ @Test
+ fun should_apply_limit_with_offset_to_all_queries_when_no_query_has_limit_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1") }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(10, 5)
+
+ //THEN
+ assertEquals(10, q1.getLimit()?.count)
+ assertEquals(5, q1.getLimit()?.offset)
+ assertEquals(10, q2.getLimit()?.count)
+ assertEquals(5, q2.getLimit()?.offset)
+ }
+
+ @Test
+ fun should_not_replace_existing_limit_when_override_is_false_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1"); withLimit(3) }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(10, override = false)
+
+ //THEN
+ assertEquals(3, q1.getLimit()?.count)
+ assertEquals(10, q2.getLimit()?.count)
+ }
+
+ @Test
+ fun should_replace_existing_limit_when_override_is_true_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1"); withLimit(3) }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(10, override = true)
+
+ //THEN
+ assertEquals(10, q1.getLimit()?.count)
+ assertEquals(10, q2.getLimit()?.count)
+ }
+
+ @Test
+ fun should_apply_limit_operator_to_all_queries_when_no_query_has_limit_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1") }
+ val q2 = querySelect { withTable("table2") }
+ val limitOperator = Limit(20, 5)
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(limitOperator)
+
+ //THEN
+ assertEquals(limitOperator, q1.getLimit())
+ assertEquals(limitOperator, q2.getLimit())
+ }
+
+ @Test
+ fun should_not_replace_existing_limit_when_using_limit_operator_and_override_is_false_in_limit() {
+ //GIVEN
+ val existingLimit = Limit(3)
+ val q1 = querySelect { withTable("table1"); withLimit(existingLimit) }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(Limit(20), override = false)
+
+ //THEN
+ assertEquals(existingLimit, q1.getLimit())
+ assertEquals(20, q2.getLimit()?.count)
+ }
+
+ @Test
+ fun should_replace_existing_limit_when_using_limit_operator_and_override_is_true_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1"); withLimit(3) }
+ val q2 = querySelect { withTable("table2"); withLimit(5) }
+ val newLimit = Limit(50)
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.limit(newLimit, override = true)
+
+ //THEN
+ assertEquals(newLimit, q1.getLimit())
+ assertEquals(newLimit, q2.getLimit())
+ }
+
+ @Test
+ fun should_return_same_instance_when_calling_limit_in_limit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1") }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ val result = unionQuery.limit(10)
+
+ //THEN
+ assertEquals(unionQuery, result)
+ }
+
+ // ---- clearLimit() ----
+
+ @Test
+ fun should_remove_limit_from_all_queries_in_clearLimit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1"); withLimit(10) }
+ val q2 = querySelect { withTable("table2"); withLimit(5) }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.clearLimit()
+
+ //THEN
+ assertNull(q1.getLimit())
+ assertNull(q2.getLimit())
+ }
+
+ @Test
+ fun should_not_fail_when_queries_have_no_limit_in_clearLimit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1") }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ unionQuery.clearLimit()
+
+ //THEN
+ assertNull(q1.getLimit())
+ assertNull(q2.getLimit())
+ }
+
+ @Test
+ fun should_return_same_instance_in_clearLimit() {
+ //GIVEN
+ val q1 = querySelect { withTable("table1"); withLimit(10) }
+ val q2 = querySelect { withTable("table2") }
+ val unionQuery = UnionQuery.builder(q1).addQuery(q2).build()
+
+ //WHEN
+ val result = unionQuery.clearLimit()
+
+ //THEN
+ assertEquals(unionQuery, result)
+ }
}
\ No newline at end of file
diff --git a/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt b/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt
new file mode 100644
index 0000000..99e6daa
--- /dev/null
+++ b/query/src/test/java/com/blipblipcode/query/builder/QuerySelectBuilder.kt
@@ -0,0 +1,38 @@
+package com.blipblipcode.query.builder
+
+import com.blipblipcode.query.QuerySelect
+import com.blipblipcode.query.operator.Limit
+import com.blipblipcode.query.operator.OrderBy
+import com.blipblipcode.query.operator.SQLOperator
+
+class QuerySelectBuilder {
+ private var table: String = "default_table"
+ private var whereKey: String = "id"
+ private var whereOperator: SQLOperator<*> = SQLOperator.Equals("id", 1)
+ private var limit: Limit? = null
+ private var orderBy: OrderBy? = null
+
+ fun withTable(table: String) = apply { this.table = table }
+ fun withWhere(key: String, operator: SQLOperator<*>) = apply {
+ this.whereKey = key
+ this.whereOperator = operator
+ }
+ fun withLimit(count: Int, offset: Int? = null) = apply { this.limit = Limit(count, offset) }
+ fun withLimit(limit: Limit) = apply { this.limit = limit }
+ fun withOrderBy(orderBy: OrderBy) = apply { this.orderBy = orderBy }
+
+ fun build(): QuerySelect {
+ val query = QuerySelect.builder(table)
+ .where(whereOperator)
+ .also { builder ->
+ orderBy?.let { builder.orderBy(it) }
+ limit?.let { builder.limit(it) }
+ }
+ .build()
+ return query
+ }
+}
+
+fun querySelect(block: QuerySelectBuilder.() -> Unit): QuerySelect =
+ QuerySelectBuilder().apply(block).build()
+
diff --git a/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt
new file mode 100644
index 0000000..2241782
--- /dev/null
+++ b/query/src/test/java/com/blipblipcode/query/utils/CopyTest.kt
@@ -0,0 +1,31 @@
+package com.blipblipcode.query.utils
+
+import com.blipblipcode.query.operator.LogicalOperation
+import com.blipblipcode.query.operator.OrderBy
+import com.blipblipcode.query.operator.SQLOperator
+import org.junit.Assert.*
+import org.junit.Test
+
+class CopyTest {
+
+ @Test
+ fun should_create_new_instance_of_LogicalOperation_when_copying_in_copy() {
+ val operator = LogicalOperation.Where(SQLOperator.Equals("id", 1))
+
+ val newOperator = operator.copyOperation(operator = SQLOperator.NotEquals("id", 2))
+
+ assertNotEquals(operator, newOperator)
+ assertEquals(2, newOperator.operator.value)
+ }
+
+ @Test
+ fun should_create_new_instance_of_OrderBy_when_copying_in_copy() {
+ val orderBy = OrderBy.Asc("id")
+
+ val newOrderBy = orderBy.copyOrderBy(column = "name")
+
+ assertNotEquals(orderBy, newOrderBy)
+ assertEquals("name", newOrderBy.column)
+ }
+
+}
\ No newline at end of file