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