diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index 466e983..7d7e590 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -9,7 +9,7 @@ on: pull_request: branches: - master - types: [opened, synchronize, reopened, closed] + types: [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). @@ -36,24 +36,47 @@ jobs: contents: read checks: write pull-requests: write + actions: write outputs: result: ${{ steps.run-tests.outcome }} steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 - name: β˜• Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + cache: gradle - - name: 🐘 Setup Gradle (cache) - uses: gradle/actions/setup-gradle@v3 + - name: πŸ“¦ Restore Gradle cache + id: gradle-cache + uses: actions/cache/restore@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: πŸ—‘οΈ Delete stale cache + if: steps.gradle-cache.outputs.cache-hit != 'true' && steps.gradle-cache.outputs.cache-matched-key != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "⚠️ Cache obsoleto detectado: ${{ steps.gradle-cache.outputs.cache-matched-key }}" + echo "πŸ—‘οΈ Eliminando cache antiguo..." + gh cache delete "${{ steps.gradle-cache.outputs.cache-matched-key }}" \ + --repo ${{ github.repository }} || true + echo "βœ… Cache antiguo eliminado" + + - name: πŸ”§ Grant execute permission to gradlew run: chmod +x ./gradlew @@ -67,6 +90,25 @@ jobs: --console=plain \ --stacktrace + - name: πŸ’Ύ Save Gradle cache + if: steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - 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: πŸ“Š Publish unit test results uses: EnricoMi/publish-unit-test-result-action@v2 if: always() @@ -77,7 +119,7 @@ jobs: comment_mode: always - name: πŸ“„ Upload test report on failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: unit-test-report-${{ github.run_number }} @@ -106,7 +148,7 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # historial completo para leer todos los tags @@ -207,18 +249,38 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 - name: β˜• Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '17' + cache: gradle - - name: 🐘 Setup Gradle (cache) - uses: gradle/actions/setup-gradle@v3 + - name: πŸ“¦ Restore Gradle cache + id: gradle-cache + uses: actions/cache/restore@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: πŸ—‘οΈ Delete stale cache + if: steps.gradle-cache.outputs.cache-hit != 'true' && steps.gradle-cache.outputs.cache-matched-key != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "⚠️ Cache obsolete detected: ${{ steps.gradle-cache.outputs.cache-matched-key }}" + echo "πŸ—‘οΈ Delete old cache..." + gh cache delete "${{ steps.gradle-cache.outputs.cache-matched-key }}" --repo ${{ github.repository }} || true + echo "βœ… Old cache deleted" + - name: πŸ”§ Grant execute permission to gradlew run: chmod +x ./gradlew @@ -231,19 +293,28 @@ jobs: --console=plain \ --stacktrace + - name: πŸ’Ύ Save Gradle cache + if: steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + - 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/" + echo "❌ No AAR release was found in query/build/outputs/aar/" exit 1 fi echo "aar_path=${AAR_PATH}" >> $GITHUB_OUTPUT - echo "βœ… AAR encontrado: ${AAR_PATH}" + echo "βœ… AAR found: ${AAR_PATH}" - name: πŸ“¦ Upload AAR as workflow artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: query-release-aar path: ${{ steps.find-aar.outputs.aar_path }} @@ -269,19 +340,19 @@ jobs: steps: - name: πŸ“₯ Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: πŸ“¦ Download AAR artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: query-release-aar path: ./release-artifacts - name: 🏷️ Create GitHub Release & Tag id: gh-release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.check-tag.outputs.tag_name }} name: Release ${{ needs.check-tag.outputs.tag_name }} @@ -413,7 +484,7 @@ jobs: steps: - name: πŸ“ Post pipeline summary comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/run-query-tests.yml b/.github/workflows/run-query-tests.yml index 7b61bac..69d0c8d 100644 --- a/.github/workflows/run-query-tests.yml +++ b/.github/workflows/run-query-tests.yml @@ -1,4 +1,4 @@ -name: Run Query Unit Tests +name: πŸ§ͺ Run Query Unit Tests on: push: @@ -10,51 +10,120 @@ on: branches: [ main, develop ] paths: - 'query/**' + types: [opened, reopened, synchronize] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +# ───────────────────────────────────────────────────────────────────────────── +# JOBS +# ───────────────────────────────────────────────────────────────────────────── jobs: - test: - name: Run Query Module Tests + + # ─────────────────────────────────────────────────────────────────────────── + # 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 + uses: actions/checkout@v6 + with: + fetch-depth: 1 - name: β˜• Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: '17' distribution: 'temurin' + java-version: '17' cache: gradle - - name: πŸ”‘ Grant execute permission for gradlew - run: chmod +x gradlew + - name: πŸ“¦ Restore Gradle cache + id: gradle-cache + uses: actions/cache/restore@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: πŸ—‘οΈ Delete stale cache + if: steps.gradle-cache.outputs.cache-hit != 'true' && steps.gradle-cache.outputs.cache-matched-key != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "⚠️ Cache obsoleto detectado: ${{ steps.gradle-cache.outputs.cache-matched-key }}" + echo "πŸ—‘οΈ Eliminando cache antiguo para liberar espacio..." + gh cache delete "${{ steps.gradle-cache.outputs.cache-matched-key }}" --repo ${{ github.repository }} || true + echo "βœ… Cache obsoleto eliminado" - - name: πŸ§ͺ Run Query Unit Tests - run: ./gradlew :query:runQueryUnitTests + - name: πŸ”§ Grant execute permission to gradlew + run: chmod +x ./gradlew - - name: πŸ“Š Upload test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results - path: query/build/test-results/testDebugUnitTest/ - retention-days: 30 + - name: πŸ§ͺ Run :query unit tests + id: run-tests + run: | + ./gradlew :query:test \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace - - name: πŸ“ˆ Upload test reports - uses: actions/upload-artifact@v4 + - name: πŸ“Š Publish unit test results + uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: - name: test-reports - path: query/build/reports/tests/testDebugUnitTest/ - retention-days: 30 - - - name: βœ… Test Summary - if: success() - run: echo "βœ… Todos los tests del mΓ³dulo query pasaron exitosamente!" + files: query/build/test-results/**/*.xml + check_name: πŸ“‹ Unit Test Results β€” :query + comment_title: πŸ§ͺ Unit Test Report β€” :query module + comment_mode: always - - name: ❌ Test Failed + - name: πŸ“„ Upload test report on failure + uses: actions/upload-artifact@v7 if: failure() - run: echo "❌ Algunos tests fallaron. Revisa los reportes para mΓ‘s detalles." + with: + name: unit-test-report-${{ github.run_number }} + path: query/build/reports/tests/ + retention-days: 14 + + - name: 🧹 Clean Temporal files. + if: always() + run: | + echo "🧹 Clean Temporal files.." + rm -rf ~/.gradle/caches/modules-2/modules-2.lock + rm -rf ~/.gradle/caches/*/plugin-resolution/ + rm -rf ~/.gradle/daemon/ + echo "✨ Clean Temporal files Complete" + - name: 🏁 workflow finished + if: always() + run: | + echo "==================================" + echo "🏁 Workflow Unit Tests finished" + echo "==================================" + echo "" + if [ "${{ job.status }}" == "success" ]; then + echo "βœ… state: SUCCESS" + else + echo "❌ state: FAILED" + fi + echo "" + echo "πŸ“… Date: $(date '+%Y-%m-%d %H:%M:%S')" + echo "==================================" \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b31e056..b24cb8e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,4 @@ plugins { alias(libs.plugins.android.library) apply false } -version = "0.13.3" +version = "0.13.4" diff --git a/query/src/main/java/com/blipblipcode/query/Deletable.kt b/query/src/main/java/com/blipblipcode/query/Deletable.kt new file mode 100644 index 0000000..9565070 --- /dev/null +++ b/query/src/main/java/com/blipblipcode/query/Deletable.kt @@ -0,0 +1,27 @@ +package com.blipblipcode.query + +/** + * Represents a queryable object that can be converted into a [QueryDelete]. + * + * Implementing classes expose a `toQueryDelete()` operation that builds a + * [QueryDelete] targeting the same data source described by the object and + * reusing its filtering conditions. + * + * Classes that aggregate multiple tables (such as [UnionQuery]) may need + * additional parameters to resolve which table to delete from and which + * column should be matched against the sub-query result. Those classes + * should provide extra overloads of `toQueryDelete` while keeping the + * parameter-less one defined here (typically throwing an informative + * [IllegalArgumentException]) so the contract remains uniform. + */ +interface Deletable { + + /** + * Converts this object into a [QueryDelete] instance. + * + * @return A new [QueryDelete] built from the filters of this object. + * @throws IllegalArgumentException if the conversion cannot be performed + * without additional context (e.g. a target table or a join column). + */ + fun toQueryDelete(): QueryDelete +} \ No newline at end of file diff --git a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt index fa896af..6d14844 100644 --- a/query/src/main/java/com/blipblipcode/query/QuerySelect.kt +++ b/query/src/main/java/com/blipblipcode/query/QuerySelect.kt @@ -20,7 +20,7 @@ class QuerySelect private constructor( private val table: String, private val operations: LinkedHashMap, private val fields: List -) : Queryable { +) : Queryable, Deletable { private var orderBy: OrderBy? = null private var limit: Limit? = null @@ -273,7 +273,7 @@ class QuerySelect private constructor( * @return A [QueryDelete] instance with the same filters as this [QuerySelect]. * @throws IllegalArgumentException if this [QuerySelect] has no WHERE clause defined. */ - fun toQueryDelete(): QueryDelete { + override fun toQueryDelete(): QueryDelete { require(where != null) { "QuerySelect must have a WHERE clause to be converted to QueryDelete" } val deleteQuery = QueryDelete.builder(getTableName()) diff --git a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt index 8d4fe3f..74d216a 100644 --- a/query/src/main/java/com/blipblipcode/query/UnionQuery.kt +++ b/query/src/main/java/com/blipblipcode/query/UnionQuery.kt @@ -16,7 +16,7 @@ import com.blipblipcode.query.operator.SQLOperator class UnionQuery private constructor( val queries: List, val useUnionAll: Boolean = false -) : Queryable { +) : Queryable, Deletable { private var orderBy: OrderBy? = null override fun getSqlOperators(): List> { return queries.flatMap { it.getSqlOperators() } @@ -195,6 +195,55 @@ class UnionQuery private constructor( return newBuilder { }.build() } + /** + * Converts this [UnionQuery] into a [QueryDelete] is **not supported** without extra + * context, because a UNION spans multiple tables and the resulting DELETE needs an + * explicit target table and a join column. + * + * Use [toQueryDelete] with a `targetTable` and a `keyColumn` instead. + * + * @throws IllegalArgumentException always. + */ + override fun toQueryDelete(): QueryDelete { + throw IllegalArgumentException( + "UnionQuery cannot be converted to QueryDelete without a target table and a " + + "key column. Call toQueryDelete(targetTable, keyColumn) instead." + ) + } + + /** + * Converts this [UnionQuery] into a [QueryDelete] that uses the union as a sub-query. + * + * The generated SQL follows the pattern: + * ``` + * DELETE FROM WHERE IN ( + * SELECT FROM ( + * + * ) + * ) + * ``` + * + * `ORDER BY` / `LIMIT` defined on the inner queries are kept untouched: they only shape + * the sub-query, not the outer DELETE. + * + * @param targetTable The name of the table to delete rows from. + * @param keyColumn The column used to match rows between `targetTable` and the union + * result. The same column must exist (and be selectable) on every + * inner query. + * @return A [QueryDelete] that deletes from `targetTable` matching the union result. + * @throws IllegalArgumentException if this [UnionQuery] contains fewer than two queries. + */ + fun toQueryDelete(targetTable: String, keyColumn: String): QueryDelete { + require(queries.size >= 2) { "At least two queries are required for a UNION" } + val subQuery = asSql() + return QueryDelete.builder(targetTable) + .where(SQLOperator.InSubquery(column = keyColumn, value = subQuery)) + .build() + } + + fun toQueriesDelete(): List { + return queries.map { it.toQueryDelete() } + } /** * A builder for creating `UnionQuery` instances. * This class provides a fluent API to construct a UNION query. 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 e10de1b..80bc662 100644 --- a/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt +++ b/query/src/main/java/com/blipblipcode/query/operator/SQLOperator.kt @@ -201,4 +201,16 @@ sealed interface SQLOperator: Copyable> { return Between(column, start, end, caseConversion) } } + + /** Represents an "IN" operation whose value is a sub-query string. */ + data class InSubquery( + override val column: String, + override val value: String, + override val caseConversion: CaseConversion = CaseConversion.NONE + ) : SQLOperator { + override val symbol: String = "IN" + override fun toSQLString(): String = + "${caseConversion.asSqlFunction(column)} $symbol (${caseConversion.asSqlFunction(value)})" + override fun clone(): SQLOperator = InSubquery(column, value, 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 61475c3..9524438 100644 --- a/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt +++ b/query/src/main/java/com/blipblipcode/query/utils/Extensions.kt @@ -2,7 +2,9 @@ package com.blipblipcode.query.utils import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery +import com.blipblipcode.query.Deletable import com.blipblipcode.query.InnerJoint +import com.blipblipcode.query.QueryDelete import com.blipblipcode.query.QuerySelect import com.blipblipcode.query.Queryable import com.blipblipcode.query.UnionQuery @@ -165,3 +167,32 @@ fun OrderBy.copyOrderBy( is OrderBy.Multiple -> OrderBy.Multiple(orders.map { it.copyOrderBy(column, collation, transform) }) } } + +/** + * Converts this [Deletable] into a [QueryDelete] using its default conversion. + * + * This is a thin alias over [Deletable.toQueryDelete] provided so call sites can + * use a Kotlin-friendly naming convention without losing the polymorphism of + * the [Deletable] contract. + * + * Works for any [QuerySelect] (no parameters needed) and any [UnionQuery] (which + * always throws and asks for a target table/key column via its `toDelete` + * overload below). + * + * @return A [QueryDelete] instance built from this [Deletable]. + * @throws IllegalArgumentException if the underlying conversion cannot be + * performed without extra context. + */ +fun Deletable.toDelete(): QueryDelete = toQueryDelete() + +/** + * Converts this [UnionQuery] into a [QueryDelete] that uses the union as a sub-query. + * + * @param targetTable The name of the table to delete rows from. + * @param keyColumn The column used to match rows between `targetTable` and the union + * result. The same column must exist (and be selectable) on every + * inner query. + * @return A [QueryDelete] instance that deletes from `targetTable` matching the union result. + */ +fun UnionQuery.toDelete(targetTable: String, keyColumn: String): QueryDelete = + toQueryDelete(targetTable, keyColumn) diff --git a/query/src/test/java/com/blipblipcode/query/DeletableTest.kt b/query/src/test/java/com/blipblipcode/query/DeletableTest.kt new file mode 100644 index 0000000..1173b34 --- /dev/null +++ b/query/src/test/java/com/blipblipcode/query/DeletableTest.kt @@ -0,0 +1,200 @@ +package com.blipblipcode.query + +import com.blipblipcode.query.operator.SQLOperator +import com.blipblipcode.query.utils.toDelete +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeletableTest { + + @Test + fun should_build_query_delete_with_same_table_and_where_clause_when_converting_query_select_in_to_query_delete() { + //GIVEN + val select = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .build() + + //WHEN + val delete = select.toQueryDelete() + + //THEN + assertEquals("users", delete.getTableName()) + assertEquals("DELETE FROM users WHERE id = 1", delete.asSql()) + } + + @Test + fun should_build_query_delete_with_and_clauses_when_converting_query_select_with_and_in_to_query_delete() { + //GIVEN + val select = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .and("status", SQLOperator.Equals("status", "active")) + .build() + + //WHEN + val delete = select.toQueryDelete() + + //THEN + assertEquals( + "DELETE FROM users WHERE id = 1 AND status = 'active'", + delete.asSql() + ) + } + + @Test + fun should_build_query_delete_with_or_clauses_when_converting_query_select_with_or_in_to_query_delete() { + //GIVEN + val select = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .or("role", SQLOperator.Equals("role", "guest")) + .build() + + //WHEN + val delete = select.toQueryDelete() + + //THEN + assertEquals( + "DELETE FROM users WHERE id = 1 OR role = 'guest'", + delete.asSql() + ) + } + + @Test + fun should_throw_illegal_argument_exception_when_query_select_has_no_where_clause_in_to_query_delete() { + //GIVEN + val select = QuerySelect.builder("users").build() + + //WHEN + val result = runCatching { select.toQueryDelete() } + + //THEN + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun should_ignore_order_by_and_limit_when_converting_query_select_in_to_query_delete() { + //GIVEN + val select = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .orderBy(com.blipblipcode.query.operator.OrderBy.Asc("name")) + .limit(10) + .build() + + //WHEN + val delete = select.toQueryDelete() + + //THEN + assertEquals("DELETE FROM users WHERE id = 1", delete.asSql()) + } + + @Test + fun should_resolve_to_query_delete_when_calling_to_delete_extension_on_query_select_in_to_delete() { + //GIVEN + val deletable: Deletable = QuerySelect.builder("users") + .where(SQLOperator.Equals("id", 1)) + .build() + + //WHEN + val delete = deletable.toDelete() + + //THEN + assertEquals("users", delete.getTableName()) + assertEquals("DELETE FROM users WHERE id = 1", delete.asSql()) + } + + @Test + fun should_throw_illegal_argument_exception_when_calling_to_delete_extension_on_union_query_without_target_in_to_delete() { + //GIVEN + val q1 = QuerySelect.builder("table1").where(SQLOperator.Equals("id", 1)).build() + val q2 = QuerySelect.builder("table2").where(SQLOperator.Equals("id", 2)).build() + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + val deletable: Deletable = unionQuery + + //WHEN + val result = runCatching { deletable.toDelete() } + + //THEN + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun should_build_query_delete_with_in_subquery_when_converting_union_query_in_to_query_delete() { + //GIVEN + val q1 = QuerySelect.builder("table1").where(SQLOperator.Equals("id", 1)).build() + val q2 = QuerySelect.builder("table2").where(SQLOperator.Equals("id", 2)).build() + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + val delete = unionQuery.toQueryDelete(targetTable = "users", keyColumn = "id") + + //THEN + assertEquals("users", delete.getTableName()) + val sql = delete.asSql() + assertTrue(sql.startsWith("DELETE FROM users WHERE id IN (")) + assertTrue(sql.contains("SELECT * FROM (")) + assertTrue(sql.contains("\nUNION\n")) + assertTrue(sql.endsWith(")")) + } + + @Test + fun should_build_query_delete_with_in_subquery_when_calling_to_delete_extension_on_union_query_with_target_in_to_delete() { + //GIVEN + val q1 = QuerySelect.builder("table1").where(SQLOperator.Equals("id", 1)).build() + val q2 = QuerySelect.builder("table2").where(SQLOperator.Equals("id", 2)).build() + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + val delete = unionQuery.toDelete(targetTable = "users", keyColumn = "id") + + //THEN + assertEquals("users", delete.getTableName()) + assertTrue(delete.asSql().startsWith("DELETE FROM users WHERE id IN (")) + } + + @Test + fun should_throw_illegal_argument_exception_when_union_query_builder_has_less_than_two_queries_in_build() { + //GIVEN + val q1 = QuerySelect.builder("table1").where(SQLOperator.Equals("id", 1)).build() + val builder = UnionQuery.builder(q1) + + //WHEN + val result = runCatching { builder.build() } + + //THEN + assertTrue(result.exceptionOrNull() is IllegalArgumentException) + } + + @Test + fun should_keep_inner_order_by_outside_the_subquery_when_converting_union_query_with_order_by_in_to_query_delete() { + //GIVEN + val q1 = QuerySelect.builder("table1") + .where(SQLOperator.Equals("id", 1)) + .orderBy(com.blipblipcode.query.operator.OrderBy.Asc("name")) + .build() + val q2 = QuerySelect.builder("table2") + .where(SQLOperator.Equals("id", 2)) + .build() + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + val sql = unionQuery.toQueryDelete(targetTable = "users", keyColumn = "id").asSql() + + //THEN + assertTrue(sql.contains("ORDER BY name ASC")) + } + + @Test + fun should_not_modify_source_queries_when_converting_union_query_in_to_query_delete() { + //GIVEN + val q1 = QuerySelect.builder("table1").where(SQLOperator.Equals("id", 1)).build() + val q2 = QuerySelect.builder("table2").where(SQLOperator.Equals("id", 2)).build() + val unionQuery = UnionQuery.builder(q1).addQuery(q2).build() + + //WHEN + unionQuery.toQueryDelete(targetTable = "users", keyColumn = "id") + + //THEN + assertEquals("SELECT * FROM table1 WHERE id = 1", q1.asSql()) + assertEquals("SELECT * FROM table2 WHERE id = 2", q2.asSql()) + } +} \ No newline at end of file