From c0bba32b5e1706cbc46f9b08beda59b1ca084dca Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 26 Apr 2024 07:43:59 +0900 Subject: [PATCH 001/155] Fix documentation link --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c30a267..ddb3411 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,9 @@ Supported targets: Please visit [docs.soil-kt.com](https://docs.soil-kt.com/) for Quick Start, guides of features and more. -* Getting started with [Query](https://docs.soil-kt.com/query/hello-query) -* Getting started with [Form](https://docs.soil-kt.com/form/hello-form) -* Getting started with [Space](https://docs.soil-kt.com/space/hello-space) +* Getting started with [Query](https://docs.soil-kt.com/guide/query/hello-query) +* Getting started with [Form](https://docs.soil-kt.com/guide/form/hello-form) +* Getting started with [Space](https://docs.soil-kt.com/guide/space/hello-space) ## License From eae605819cf4fe0e8acc9f9af7a1ced35e60aab6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Apr 2024 15:47:50 +0900 Subject: [PATCH 002/155] Add release.yml for generated release notes --- .github/release.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/release.yml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..c2ca2d6 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +# https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuration-options +changelog: + exclude: + labels: + - ignore-for-release + authors: + - octocat + categories: + - title: "Breaking Changes :mega:" + labels: + - breaking-change + - title: "New Features :tada:" + labels: + - enhancement + - title: "Bugfixes :hammer_and_wrench:" + labels: + - bug + - title: "Dependency Updates :arrow_up:" + labels: + - dependencies + - title: "Other Changes :ninja:" + labels: + - "*" From 8c566de637059c558ea9169a5103dab184453306 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Apr 2024 15:52:53 +0900 Subject: [PATCH 003/155] Create release pull request github action workflow --- .github/workflows/create-release-pr.yml | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/create-release-pr.yml diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml new file mode 100644 index 0000000..fd3db21 --- /dev/null +++ b/.github/workflows/create-release-pr.yml @@ -0,0 +1,65 @@ +name: Create Release PR +on: + workflow_dispatch: + inputs: + version: + description: Version to release + type: string + required: true + +jobs: + create-release-pr: + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + env: + RELEASE_VERSION: ${{ github.event.inputs.version }} + RELEASE_PR_BRANCH: release/${{ github.event.inputs.version }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Git Config #https://github.com/orgs/community/discussions/26560 + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Git Checkout + run: | + git checkout -b $RELEASE_PR_BRANCH origin/$GITHUB_REF_NAME + + - name: Update Version + run: | + echo "Bump up version to $RELEASE_VERSION" + sed -i "/version=/c version=$RELEASE_VERSION" gradle.properties + if [[ -n $(git status -s) ]]; then + git add gradle.properties + git commit -m "Bump up version to $RELEASE_VERSION" + else + exit 1 + fi + + - name: Git Push + run: | + git push origin $RELEASE_PR_BRANCH + + - name: Create PR + run: | + RELEASE_PREVIOUS_TAG=$(git describe --tags --abbrev=0 origin/$GITHUB_REF_NAME) + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + /repos/$GITHUB_REPOSITORY/releases/generate-notes \ + -f configuration_file_path=".github/release.yml" \ + -f tag_name="$RELEASE_VERSION" \ + -f target_commitish="$RELEASE_PR_BRANCH" \ + -f previous_tag_name="$RELEASE_PREVIOUS_TAG" | jq -r .body > release.txt + + gh pr create --title "Release v$RELEASE_VERSION" --body-file release.txt --base $GITHUB_REF_NAME --assignee $GITHUB_TRIGGERING_ACTOR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7cd5a88fcfcadc86150b3a51898e68fe7b262d09 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Apr 2024 16:07:36 +0900 Subject: [PATCH 004/155] Create publish package github action workflow --- .github/workflows/publish.yml | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..97bca2e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Check branch + if: ${{ !contains(github.ref, 'refs/heads/release/') }} + run: | + echo "This action runs only on branches that start with 'release/'" + exit 1 + + - name: Checkout sources + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create Release Tag + run: | + export VERSION=$(cat gradle.properties | grep "version" | awk -F '=' '{print $2}') && \ + gh config set prompt disabled && \ + gh release create \ + --target "$GITHUB_REF_NAME" \ + --title "v$VERSION" \ + --generate-notes \ + $VERSION + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Publish to Maven Central + run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} From 73ffb238763d461a51dc0c65224a5bc0c44219da Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Apr 2024 16:58:16 +0900 Subject: [PATCH 005/155] Remove 'v' prefix --- .github/workflows/create-release-pr.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index fd3db21..12d50f4 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -60,6 +60,6 @@ jobs: -f target_commitish="$RELEASE_PR_BRANCH" \ -f previous_tag_name="$RELEASE_PREVIOUS_TAG" | jq -r .body > release.txt - gh pr create --title "Release v$RELEASE_VERSION" --body-file release.txt --base $GITHUB_REF_NAME --assignee $GITHUB_TRIGGERING_ACTOR + gh pr create --title "Release $RELEASE_VERSION" --body-file release.txt --base $GITHUB_REF_NAME --assignee $GITHUB_TRIGGERING_ACTOR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 97bca2e..0320fd8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,7 +27,7 @@ jobs: gh config set prompt disabled && \ gh release create \ --target "$GITHUB_REF_NAME" \ - --title "v$VERSION" \ + --title "$VERSION" \ --generate-notes \ $VERSION env: From d06150ab854390587cd785dccdca8fec15425047 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Apr 2024 17:01:15 +0900 Subject: [PATCH 006/155] Rename pull_request.yml to check-pr.yml --- .github/workflows/{pull_request.yml => check-pr.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{pull_request.yml => check-pr.yml} (100%) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/check-pr.yml similarity index 100% rename from .github/workflows/pull_request.yml rename to .github/workflows/check-pr.yml From b808a26027f8d513507ff2f47f1b0d2dbc31b2d0 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 29 Apr 2024 10:13:03 +0900 Subject: [PATCH 007/155] Create codeql.yml --- .github/workflows/codeql.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6491b78 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +name: "CodeQL" + +on: + workflow_dispatch: + schedule: + - cron: '0 17 * * 5' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: 'ubuntu-latest' + timeout-minutes: 360 + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From a82bb05d24a2cac44022795f997e45811b1e2250 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 29 Apr 2024 10:43:03 +0900 Subject: [PATCH 008/155] Revert "Merge pull request #6 from soil-kt/try-codeql-analysis" This reverts commit a7809c4711da7dcdf5fd8d3cef1402dee8915f1e, reversing changes made to 04cff1abed3275e1ca521cba949f7769aa1e5dba. --- .github/workflows/codeql.yml | 50 ------------------------------------ 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 6491b78..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: "CodeQL" - -on: - workflow_dispatch: - schedule: - - cron: '0 17 * * 5' - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: 'ubuntu-latest' - timeout-minutes: 360 - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: java-kotlin - build-mode: autobuild - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - - - name: Setup JDK - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 17 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" From b7144a8f21a32548ea82937a5b62c44967770549 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 29 Apr 2024 15:49:15 +0900 Subject: [PATCH 009/155] Configure dokka plugin --- Makefile | 4 ++++ build.gradle.kts | 2 +- soil-form/build.gradle.kts | 1 + soil-query-compose-runtime/build.gradle.kts | 1 + soil-query-compose/build.gradle.kts | 1 + soil-query-core/build.gradle.kts | 1 + soil-space/build.gradle.kts | 1 + 7 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e2fbc35..fcfe979 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ dist.wasm: fmt: @$(GRADLE_CMD) spotlessApply +.PHONY: dokka +dokka: + @$(GRADLE_CMD) dokkaHtmlMultiModule + .PHONY: publish publish: @$(GRADLE_CMD) publish diff --git a/build.gradle.kts b/build.gradle.kts index 89716d1..28e5589 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.maven.publish) apply false - alias(libs.plugins.dokka) apply false + alias(libs.plugins.dokka) alias(libs.plugins.spotless) } diff --git a/soil-form/build.gradle.kts b/soil-form/build.gradle.kts index c4f638c..665beb3 100644 --- a/soil-form/build.gradle.kts +++ b/soil-form/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) } val buildTarget = the() diff --git a/soil-query-compose-runtime/build.gradle.kts b/soil-query-compose-runtime/build.gradle.kts index 0bca6f1..f118101 100644 --- a/soil-query-compose-runtime/build.gradle.kts +++ b/soil-query-compose-runtime/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) } val buildTarget = the() diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index f55077a..2bccd7c 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) } val buildTarget = the() diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index dc3a6e3..d0c753e 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) } val buildTarget = the() diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index f47e334..820d01a 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) } val buildTarget = the() From 617db34d33c0b906b5b761a09a2b828d214b8d72 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 29 Apr 2024 17:22:16 +0900 Subject: [PATCH 010/155] Integrate api-reference deployment step into the release workflow --- .github/workflows/publish.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0320fd8..93bb599 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,12 @@ name: Publish on: workflow_dispatch: + inputs: + deploy_api_reference: + description: 'Deploy API Reference?' + required: true + default: true + type: boolean jobs: publish: @@ -9,6 +15,9 @@ jobs: permissions: contents: write + env: + RELEASE_PAGES_BRANCH: main + steps: - name: Check branch if: ${{ !contains(github.ref, 'refs/heads/release/') }} @@ -49,3 +58,14 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + + - name: Build API Reference + if: ${{ github.event.inputs.deploy_api_reference == 'true' }} + run: ./gradlew dokkaHtmlMultiModule + + - name: Deploy API Reference to Cloudflare Pages + if: ${{ github.event.inputs.deploy_api_reference == 'true' }} + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_API_TOKEN }} + command: pages deploy build/dokka/htmlMultiModule --project-name=soil-api-reference --branch $RELEASE_PAGES_BRANCH From 3f175b521570d09091a26782151cd52d6534e65c Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 1 May 2024 08:41:51 +0900 Subject: [PATCH 011/155] Add special thanks headline to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index ddb3411..4deb6f1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,13 @@ Please visit [docs.soil-kt.com](https://docs.soil-kt.com/) for Quick Start, guid * Getting started with [Space](https://docs.soil-kt.com/guide/space/hello-space) +## Special Thanks + +Thank you for featuring our library in the following sources: + +- [jetc.dev Newsletter Issue #212](https://jetc.dev/issues/212.html) + + ## License ``` From d783d30f1f672ae402b104b5db2dbbd0cbc8a1b7 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 3 May 2024 14:48:54 +0900 Subject: [PATCH 012/155] Bump compose-multiplatform to 1.16.10-beta03 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3426f56..9f41e07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ androidx-activity = "1.8.2" androidx-annotation = "1.7.1" androidx-core = "1.12.0" androidx-lifecycle = "2.7.0" -compose = "1.6.5" -compose-multiplatform = "1.6.2" +compose = "1.6.6" +compose-multiplatform = "1.6.10-beta03" dokka = "1.9.20" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" From 2ea816b1facdf03199a81d926cd5a94abde81849 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 3 May 2024 15:08:50 +0900 Subject: [PATCH 013/155] Removed warnings in README due to fixes in the update to 1.6.10 refs: #11 --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 4deb6f1..599b835 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,6 @@ Source code: > [!NOTE] > Currently, the only browsers that support WasmGC are Chrome and Firefox. For the latest compatibility information, please visit https://webassembly.org/features/. -> [!WARNING] -> We are aware of an issue with Kotlin Wasm on mobile browsers where the app's screen size may not adjust correctly. [compose-multiplatform #4620](https://github.com/JetBrains/compose-multiplatform/issues/4620) - ## Download From 8ba1eaa494c4e802f1b65dafc0c479f1e195a7dc Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 4 May 2024 08:31:12 +0900 Subject: [PATCH 014/155] Bump compose-multiplatform to 1.16.10-rc01 --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f41e07..0fdb6e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ androidx-activity = "1.8.2" androidx-annotation = "1.7.1" androidx-core = "1.12.0" androidx-lifecycle = "2.7.0" -compose = "1.6.6" -compose-multiplatform = "1.6.10-beta03" +compose = "1.6.7" +compose-multiplatform = "1.6.10-rc01" dokka = "1.9.20" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" From 8d6a81ee6499f7020c4d508fc1cdb33aa06a2a32 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 4 May 2024 09:08:25 +0900 Subject: [PATCH 015/155] Tweak task names in Makefile --- Makefile | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index fcfe979..b815590 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ GRADLE_CMD ?= ./gradlew ADB_CMD ?= adb +.DEFAULT_GOAL := help +.PHONY: help +help: + @grep -E '^[a-zA-Z_-][a-zA-Z_\-\.]+:.*$$' $(MAKEFILE_LIST) | awk -F ':' '{ print $$1 }' + .PHONY: clean clean: @$(GRADLE_CMD) clean @@ -9,10 +14,6 @@ clean: build: @$(GRADLE_CMD) assemble -.PHONY: dist.wasm -dist.wasm: - @$(GRADLE_CMD) wasmJsBrowserDistribution - .PHONY: fmt fmt: @$(GRADLE_CMD) spotlessApply @@ -32,15 +33,19 @@ publish.local: ## ----- Sample App ----- ## -.PHONY: play.wasm -play.wasm: - @$(GRADLE_CMD) wasmJsBrowserDevelopmentRun +.PHONY: sample.android.run +sample.android.run: + @$(GRADLE_CMD) installDebug + @$(ADB_CMD) shell am start -n soil.kmp/soil.kmp.MainActivity -.PHONY: play.desktop -play.desktop: +.PHONY: sample.desktop.run +sample.desktop.run: @$(GRADLE_CMD) runDistributable -.PHONY: play.android -play.android: - @$(GRADLE_CMD) installDebug - @$(ADB_CMD) shell am start -n soil.kmp/soil.kmp.MainActivity +.PHONY: sample.wasm.dist +sample.wasm.dist: + @$(GRADLE_CMD) wasmJsBrowserDistribution + +.PHONY: sample.wasm.run +sample.wasm.run: + @$(GRADLE_CMD) wasmJsBrowserDevelopmentRun From 869480a719ff8f8b002272775ac0824c1e5071df Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 4 May 2024 09:37:44 +0900 Subject: [PATCH 016/155] Create deploy sample github action workflow --- .github/workflows/deploy-sample-wasm.yml | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/deploy-sample-wasm.yml diff --git a/.github/workflows/deploy-sample-wasm.yml b/.github/workflows/deploy-sample-wasm.yml new file mode 100644 index 0000000..36a5def --- /dev/null +++ b/.github/workflows/deploy-sample-wasm.yml @@ -0,0 +1,34 @@ +name: Deploy Sample App +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + env: + RELEASE_PAGES_BRANCH: main + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build WasmJs + run: ./gradlew wasmJsBrowserDistribution + + - name: Deploy Sample to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_API_TOKEN }} + command: pages deploy sample/composeApp/build/dist/wasmJs/productionExecutable --project-name=soil-sample --branch $RELEASE_PAGES_BRANCH From f36a1b66ac352ea0604b25e338765a822e0fbfaf Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 4 May 2024 20:00:37 +0900 Subject: [PATCH 017/155] Fix workflow syntax --- .github/workflows/deploy-sample-wasm.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-sample-wasm.yml b/.github/workflows/deploy-sample-wasm.yml index 36a5def..fdae973 100644 --- a/.github/workflows/deploy-sample-wasm.yml +++ b/.github/workflows/deploy-sample-wasm.yml @@ -31,4 +31,4 @@ jobs: uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_API_TOKEN }} - command: pages deploy sample/composeApp/build/dist/wasmJs/productionExecutable --project-name=soil-sample --branch $RELEASE_PAGES_BRANCH + command: pages deploy sample/composeApp/build/dist/wasmJs/productionExecutable --project-name=soil-sample --branch ${{ env.RELEASE_PAGES_BRANCH }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93bb599..a690748 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,4 +68,4 @@ jobs: uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_API_TOKEN }} - command: pages deploy build/dokka/htmlMultiModule --project-name=soil-api-reference --branch $RELEASE_PAGES_BRANCH + command: pages deploy build/dokka/htmlMultiModule --project-name=soil-api-reference --branch ${{ env.RELEASE_PAGES_BRANCH }} From 37e64c3c92217e555daf6505aa80c9cfb21ab7b8 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 4 May 2024 13:58:22 +0900 Subject: [PATCH 018/155] Add a KDoc comments for the soil.query package --- .../soil/query/AndroidMemoryPressure.kt | 20 ++++ .../soil/query/AndroidNetworkConnectivity.kt | 14 +++ .../soil/query/AndroidWindowVisibility.kt | 9 ++ .../kotlin/soil/query/InfiniteQueryCommand.kt | 37 +++++++ .../soil/query/InfiniteQueryCommands.kt | 28 +++++ .../kotlin/soil/query/InfiniteQueryKey.kt | 67 ++++++++++++ .../kotlin/soil/query/InfiniteQueryRef.kt | 28 +++++ .../commonMain/kotlin/soil/query/Mutation.kt | 24 +++++ .../kotlin/soil/query/MutationAction.kt | 26 +++++ .../kotlin/soil/query/MutationClient.kt | 9 ++ .../kotlin/soil/query/MutationCommand.kt | 33 +++++- .../kotlin/soil/query/MutationCommands.kt | 18 ++++ .../kotlin/soil/query/MutationKey.kt | 58 +++++++++- .../kotlin/soil/query/MutationModel.kt | 61 +++++++++++ .../kotlin/soil/query/MutationNotifier.kt | 13 +++ .../kotlin/soil/query/MutationOptions.kt | 14 ++- .../kotlin/soil/query/MutationReceiver.kt | 16 ++- .../kotlin/soil/query/MutationRef.kt | 30 ++++++ .../kotlin/soil/query/MutationState.kt | 3 + .../src/commonMain/kotlin/soil/query/Query.kt | 24 +++++ .../kotlin/soil/query/QueryAction.kt | 36 +++++++ .../kotlin/soil/query/QueryChunk.kt | 15 +++ .../kotlin/soil/query/QueryClient.kt | 73 +++++++++++++ .../kotlin/soil/query/QueryCommand.kt | 49 +++++++++ .../kotlin/soil/query/QueryCommands.kt | 22 ++++ .../kotlin/soil/query/QueryFilter.kt | 32 ++++++ .../commonMain/kotlin/soil/query/QueryKey.kt | 52 +++++++++ .../kotlin/soil/query/QueryModel.kt | 71 +++++++++++++ .../kotlin/soil/query/QueryOptions.kt | 38 +++++++ .../kotlin/soil/query/QueryReceiver.kt | 16 ++- .../commonMain/kotlin/soil/query/QueryRef.kt | 25 +++++ .../kotlin/soil/query/QueryState.kt | 3 + .../commonMain/kotlin/soil/query/SwrCache.kt | 100 ++++++++++++++++++ .../commonMain/kotlin/soil/query/SwrClient.kt | 24 +++++ .../soil/query/internal/ActorOptions.kt | 22 ++++ .../query/internal/ActorSharingStarted.kt | 8 ++ .../kotlin/soil/query/internal/Logging.kt | 11 ++ .../soil/query/internal/LoggingOptions.kt | 9 ++ .../soil/query/internal/MemoryPressure.kt | 39 +++++++ .../query/internal/NetworkConnectivity.kt | 35 ++++++ .../kotlin/soil/query/internal/Platform.kt | 10 ++ .../soil/query/internal/PriorityQueue.kt | 38 +++++++ .../kotlin/soil/query/internal/Retry.kt | 22 ++++ .../soil/query/internal/RetryOptions.kt | 34 ++++++ .../soil/query/internal/TimeBasedCache.kt | 49 ++++++++- .../kotlin/soil/query/internal/UniqueId.kt | 33 ++++++ .../soil/query/internal/WindowVisibility.kt | 35 ++++++ .../kotlin/soil/query/IosMemoryPressure.kt | 11 ++ .../kotlin/soil/query/IosWindowVisiblity.kt | 7 ++ .../soil/query/WasmJsNetworkConnectivity.kt | 3 + .../soil/query/WasmJsWindowVisibility.kt | 3 + 51 files changed, 1448 insertions(+), 9 deletions(-) diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt index 92a2528..7afdca0 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt @@ -9,6 +9,23 @@ import android.content.res.Configuration import soil.query.internal.MemoryPressure import soil.query.internal.MemoryPressureLevel +/** + * Implementation of [MemoryPressure] for Android. + * + * In the Android system, [ComponentCallbacks2] is used to monitor memory pressure states. + * It notifies the memory pressure state based on the level parameter of [ComponentCallbacks2.onTrimMemory]. + * + * | Android Trim Level | MemoryPressureLevel | + * |:-------------------------------|:----------------------| + * | TRIM_MEMORY_UI_HIDDEN | Low | + * | TRIM_MEMORY_MODERATE | Low | + * | TRIM_MEMORY_RUNNING_MODERATE | Low | + * | TRIM_MEMORY_BACKGROUND | High | + * | TRIM_MEMORY_RUNNING_LOW | High | + * | TRIM_MEMORY_COMPLETE | Critical | + * | TRIM_MEMORY_RUNNING_CRITICAL | Critical | + * + */ class AndroidMemoryPressure( private val context: Context ) : MemoryPressure { @@ -23,6 +40,9 @@ class AndroidMemoryPressure( obw = null } + /** + * Implementation of [ComponentCallbacks2] for observing memory pressure. + */ class ObserverWrapper( private val observer: MemoryPressure.Observer ) : ComponentCallbacks2 { diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt index 3ed864a..906bc66 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt @@ -12,6 +12,17 @@ import androidx.annotation.RequiresPermission import soil.query.internal.NetworkConnectivity import soil.query.internal.NetworkConnectivityEvent +/** + * Implementation of [NetworkConnectivity] for Android. + * + * In the Android system, [ConnectivityManager] is used to monitor network connectivity states. + * + * **Note**: This implementation requires the `ACCESS_NETWORK_STATE` permission. + * + * ``` + * + * ``` + */ class AndroidNetworkConnectivity( private val context: Context ) : NetworkConnectivity { @@ -37,6 +48,9 @@ class AndroidNetworkConnectivity( obw = null } + /** + * Implementation of [ConnectivityManager.NetworkCallback] for observing network connectivity. + */ class ObserverWrapper( private val observer: NetworkConnectivity.Observer ) : ConnectivityManager.NetworkCallback() { diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt index 0ef1b47..ee98311 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt @@ -11,6 +11,12 @@ import androidx.lifecycle.ProcessLifecycleOwner import soil.query.internal.WindowVisibility import soil.query.internal.WindowVisibilityEvent +/** + * Implementation of [WindowVisibility] for Android. + * + * In the Android system, [ProcessLifecycleOwner] is used to monitor window visibility states. + * It notifies the window visibility state based on the lifecycle state of [ProcessLifecycleOwner]. + */ class AndroidWindowVisibility( private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get() ) : WindowVisibility { @@ -36,6 +42,9 @@ class AndroidWindowVisibility( } } + /** + * Implementation of [DefaultLifecycleObserver] for observing window visibility. + */ class CallbackWrapper( private val callback: WindowVisibility.Observer ) : DefaultLifecycleObserver { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index 6b1cf90..c90fee8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -7,6 +7,16 @@ import soil.query.internal.RetryFn import soil.query.internal.exponentialBackOff import kotlin.coroutines.cancellation.CancellationException +/** + * Fetches data for the [InfiniteQueryKey] using the value of [variable]. + * + * @receiver [QueryCommand.Context] for [InfiniteQueryKey]. + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param variable Value of the parameter required for fetching data for [InfiniteQueryKey]. + * @param retryFn Retry strategy. + */ suspend fun QueryCommand.Context>.fetch( key: InfiniteQueryKey, variable: S, @@ -22,6 +32,15 @@ suspend fun QueryCommand.Context>.fetch( } } +/** + * Revalidates the data for the [InfiniteQueryKey] using the value of [chunks]. + * + * @receiver [QueryCommand.Context] for [InfiniteQueryKey]. + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param chunks Data to revalidate. + */ suspend fun QueryCommand.Context>.revalidate( key: InfiniteQueryKey, chunks: QueryChunks @@ -47,6 +66,15 @@ suspend fun QueryCommand.Context>.revalidate( return Result.success(newData) } +/** + * Dispatches the result of fetching data for the [InfiniteQueryKey]. + * + * @receiver [QueryCommand.Context] for [InfiniteQueryKey]. + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param variable Value of the parameter required for fetching data for [InfiniteQueryKey]. + */ suspend inline fun QueryCommand.Context>.dispatchFetchChunksResult( key: InfiniteQueryKey, variable: S @@ -59,6 +87,15 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .onFailure(::dispatchFetchFailure) } +/** + * Dispatches the result of revalidating data for the [InfiniteQueryKey]. + * + * @receiver [QueryCommand.Context] for [InfiniteQueryKey]. + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param chunks Data to revalidate. + */ suspend inline fun QueryCommand.Context>.dispatchRevalidateChunksResult( key: InfiniteQueryKey, chunks: QueryChunks diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index cfb66c2..e5d926e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -5,8 +5,22 @@ package soil.query import soil.query.internal.vvv +/** + * Query command for [InfiniteQueryKey]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + */ sealed class InfiniteQueryCommands : QueryCommand> { + /** + * Performs data fetching and validation based on the current data state. + * + * This command is invoked via [InfiniteQueryRef] when a new mount point (subscriber) is added. + * + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param revision The revision of the data to be fetched. + */ data class Connect( val key: InfiniteQueryKey, val revision: String? = null @@ -26,6 +40,14 @@ sealed class InfiniteQueryCommands : QueryCommand> { } } + /** + * Invalidates the data and performs data fetching and validation based on the current data state. + * + * This command is invoked via [InfiniteQueryRef] when the data is invalidated. + * + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param revision The revision of the data to be invalidated. + */ data class Invalidate( val key: InfiniteQueryKey, val revision: String @@ -45,6 +67,12 @@ sealed class InfiniteQueryCommands : QueryCommand> { } } + /** + * Fetches additional data for [InfiniteQueryKey] using [param]. + * + * @param key Instance of a class implementing [InfiniteQueryKey]. + * @param param The parameter required for fetching data for [InfiniteQueryKey]. + */ data class LoadMore( val key: InfiniteQueryKey, val param: S diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index 7b0b17b..38cfaf0 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -6,18 +6,69 @@ package soil.query import soil.query.internal.SurrogateKey import soil.query.internal.UniqueId +/** + * [InfiniteQueryKey] for managing [Query] associated with [id]. + * + * The difference from [QueryKey] is that it's an interface for infinite fetching data using a retrieval method known as "infinite scroll." + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + */ interface InfiniteQueryKey { + + /** + * A unique identifier used for managing [InfiniteQueryKey]. + */ val id: InfiniteQueryId + + /** + * Suspending function to retrieve data. + * + * @receiver QueryReceiver You can use a custom QueryReceiver within the fetch function. + */ val fetch: suspend QueryReceiver.(param: S) -> T + + /** + * Function returning the initial parameter. + */ val initialParam: () -> S + + /** + * Function returning the parameter for additional fetching. + * + * `chunks` contains the retrieved data. + */ val loadMoreParam: (chunks: QueryChunks) -> S? + + /** + * Configure the Query [options]. + * + * If unspecified, the default value of [SwrCachePolicy] is used. + */ val options: QueryOptions? + /** + * Function to specify placeholder data. + * + * You can specify placeholder data instead of the initial loading state. + * + * @see QueryPlaceholderData + */ fun onPlaceholderData(): QueryPlaceholderData>? = null + /** + * Function to convert specific exceptions as data. + * + * Depending on the type of exception that occurred during data retrieval, it is possible to recover it as normal data. + * + * @see QueryRecoverData + */ fun onRecoverData(): QueryRecoverData>? = null } +/** + * Unique identifier for [InfiniteQueryKey]. + */ @Suppress("unused") open class InfiniteQueryId( override val namespace: String, @@ -46,6 +97,22 @@ internal fun InfiniteQueryKey.hasMore(chunks: QueryChunks): B return loadMoreParam(chunks) != null } +/** + * Function for building implementations of [InfiniteQueryKey] using [Kotlin Delegation](https://kotlinlang.org/docs/delegation.html). + * + * **Note:** By implementing through delegation, you can reduce the impact of future changes to [InfiniteQueryKey] interface extensions. + * + * Usage: + * + * ```kotlin + * class GetPostsKey(userId: Int? = null) : InfiniteQueryKey by buildInfiniteQueryKey( + * id = Id(userId), + * fetch = { param -> ... } + * initialParam = { PageParam(limit = 20) }, + * loadMoreParam = { chunks -> ... } + * ) + * ``` + */ fun buildInfiniteQueryKey( id: InfiniteQueryId, fetch: suspend QueryReceiver.(param: S) -> T, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 77c6c26..1d03f5f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -7,11 +7,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch +/** + * A reference to an [Query] for [InfiniteQueryKey]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @property key Instance of a class implementing [InfiniteQueryKey]. + * @param query Transparently referenced [Query]. + * @constructor Creates an [InfiniteQueryRef]. + */ class InfiniteQueryRef( val key: InfiniteQueryKey, query: Query> ) : Query> by query { + /** + * Starts the [Query]. + * + * This function must be invoked when a new mount point (subscriber) is added. + * + * @param scope The [CoroutineScope] to launch the [Query] actor. + */ fun start(scope: CoroutineScope) { actor.launchIn(scope = scope) scope.launch { @@ -20,14 +36,26 @@ class InfiniteQueryRef( } } + /** + * Invalidates the [Query]. + * + * Calling this function will invalidate the retrieved data of the [Query], + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ suspend fun invalidate() { command.send(InfiniteQueryCommands.Invalidate(key, state.value.revision)) } + /** + * Resumes the [Query]. + */ private suspend fun resume() { command.send(InfiniteQueryCommands.Connect(key, state.value.revision)) } + /** + * Fetches data for the [InfiniteQueryKey] using the value of [param]. + */ suspend fun loadMore(param: S) { command.send(InfiniteQueryCommands.LoadMore(key, param)) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index 748c89d..83046e4 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -8,13 +8,37 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +/** + * Mutation as the base interface for an [MutationClient] implementations. + * + * @param T Type of the return value from the mutation. + */ interface Mutation { + + /** + * Coroutine [Flow] to launch the actor. + */ val actor: Flow<*> + + /** + * [Shared Flow][SharedFlow] to receive mutation [events][MutationEvent]. + */ val event: SharedFlow + + /** + * [State Flow][StateFlow] to receive the current state of the mutation. + */ val state: StateFlow> + + /** + * [Send Channel][SendChannel] to manipulate the state of the mutation. + */ val command: SendChannel> } +/** + * Events occurring in the mutation. + */ enum class MutationEvent { Ping } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt index c605de1..5e494f3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt @@ -5,17 +5,40 @@ package soil.query import soil.query.internal.epoch +/** + * Mutation actions are used to update the [mutation state][MutationState]. + * + * @param T Type of the return value from the mutation. + */ sealed interface MutationAction { + /** + * Resets the mutation state. + */ data object Reset : MutationAction + /** + * Indicates that the mutation is in progress. + */ data object Mutating : MutationAction + /** + * Indicates that the mutation is successful. + * + * @param data The data to be updated. + * @param dataUpdatedAt The timestamp when the data was updated. + */ data class MutateSuccess( val data: T, val dataUpdatedAt: Long? = null ) : MutationAction + /** + * Indicates that the mutation has failed. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. + */ data class MutateFailure( val error: Throwable, val errorUpdatedAt: Long? = null @@ -25,6 +48,9 @@ sealed interface MutationAction { typealias MutationReducer = (MutationState, MutationAction) -> MutationState typealias MutationDispatch = (MutationAction) -> Unit +/** + * Creates a [MutationReducer] function. + */ fun createMutationReducer(): MutationReducer = { state, action -> when (action) { is MutationAction.Reset -> { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt index 1c33fd2..0642b98 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt @@ -3,10 +3,19 @@ package soil.query +/** + * A Mutation client, which allows you to make mutations actor and handle [MutationKey]. + */ interface MutationClient { + /** + * The default mutation options. + */ val defaultMutationOptions: MutationOptions + /** + * Gets the [MutationRef] by the specified [MutationKey]. + */ fun getMutation( key: MutationKey ): MutationRef diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index d4bf662..951eb2f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -10,10 +10,23 @@ import soil.query.internal.exponentialBackOff import soil.query.internal.vvv import kotlin.coroutines.cancellation.CancellationException +/** + * Mutation command to handle mutation. + * + * @param T Type of the return value from the mutation. + */ interface MutationCommand { + /** + * Handles the mutation. + */ suspend fun handle(ctx: Context) + /** + * Context for mutation command. + * + * @param T Type of the return value from the mutation. + */ interface Context { val receiver: MutationReceiver val options: MutationOptions @@ -23,6 +36,11 @@ interface MutationCommand { } } +/** + * Determines whether a mutation operation is necessary based on the current state. + * + * @return `true` if mutation operation is allowed, `false` otherwise. + */ fun MutationCommand.Context.shouldMutate(revision: String): Boolean { if (options.isOneShot && state.isMutated) { return false @@ -33,7 +51,14 @@ fun MutationCommand.Context.shouldMutate(revision: String): Boolean { return !state.isPending } - +/** + * Mutates the data. + * + * @param key Instance of a class implementing [MutationKey]. + * @param variable The variable to be mutated. + * @param retryFn The retry function. + * @return The result of the mutation. + */ suspend fun MutationCommand.Context.mutate( key: MutationKey, variable: S, @@ -49,6 +74,12 @@ suspend fun MutationCommand.Context.mutate( } } +/** + * Dispatches the mutation result. + * + * @param key Instance of a class implementing [MutationKey]. + * @param variable The variable to be mutated. + */ suspend inline fun MutationCommand.Context.dispatchMutateResult( key: MutationKey, variable: S diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index 97590df..684e377 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -5,7 +5,22 @@ package soil.query import soil.query.internal.vvv +/** + * Mutation commands are used to update the [mutation state][MutationState]. + * + * @param T Type of the return value from the mutation. + */ sealed class MutationCommands : MutationCommand { + + /** + * Executes the [mutate][MutationKey.mutate] function of the specified [MutationKey]. + * + * **Note:** The mutation is not executed if the revision is different. + * + * @param key Instance of a class implementing [MutationKey]. + * @param variable The variable to be mutated. + * @param revision The revision of the mutation state. + */ data class Mutate( val key: MutationKey, val variable: S, @@ -22,6 +37,9 @@ sealed class MutationCommands : MutationCommand { } } + /** + * Resets the mutation state. + */ class Reset : MutationCommands() { override suspend fun handle(ctx: MutationCommand.Context) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt index 71bd110..7382e7f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt @@ -7,16 +7,49 @@ import soil.query.internal.SurrogateKey import soil.query.internal.UniqueId import soil.query.internal.uuid +/** + * Interface for mutations key. + * + * MutationKey is typically used to perform side effects on external resources, such as creating, updating, or deleting data. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ interface MutationKey { + + /** + * A unique identifier used for managing [MutationKey]. + */ val id: MutationId + + /** + * Suspending function to mutate the variable. + * + * @receiver MutationReceiver You can use a custom MutationReceiver within the mutate function. + */ val mutate: suspend MutationReceiver.(variable: S) -> T + + /** + * Configure the Mutation [options]. + * + * If unspecified, the default value of [MutationOptions] is used. + */ val options: MutationOptions? - // Pessimistic Updates - // https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates + /** + * Function to update the query cache after the mutation is executed. + * + * This is often referred to as ["Pessimistic Updates"](https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates). + * + * @param variable The variable to be mutated. + * @param data The data returned by the mutation. + */ fun onQueryUpdate(variable: S, data: T): QueryEffect? = null } +/** + * Unique identifier for [MutationKey]. + */ @Suppress("unused") open class MutationId( override val namespace: String, @@ -41,6 +74,14 @@ open class MutationId( } companion object { + + /** + * Automatically generates a [MutationId]. + * + * Generates an ID for one-time use, so it cannot be shared among multiple places of use. + * + * FIXME: Since this function is for automatic ID assignment, it might be better not to have arguments. + */ fun auto( namespace: String = "auto/${uuid()}", vararg tags: SurrogateKey @@ -50,6 +91,19 @@ open class MutationId( } } +/** + * Function for building implementations of [MutationKey] using [Kotlin Delegation](https://kotlinlang.org/docs/delegation.html). + * + * **Note:** By implementing through delegation, you can reduce the impact of future changes to [MutationKey] interface extensions. + * + * Usage: + * + * ```kotlin + * class CreatePostKey : MutationKey by buildMutationKey( + * mutate = { body -> ... } + * ) + * ``` + */ fun buildMutationKey( id: MutationId = MutationId.auto(), mutate: suspend MutationReceiver.(variable: S) -> T, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt index 6d269f7..2e32dcb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt @@ -5,23 +5,84 @@ package soil.query import kotlin.math.max +/** + * Data model for the state handled by [MutationKey]. + * + * All data models related to mutations, implement this interface. + * + * @param T Type of the return value from the mutation. + */ interface MutationModel { + + /** + * The return value from the mutation. + */ val data: T? + + /** + * The timestamp when the data was updated. + */ val dataUpdatedAt: Long + + /** + * The error that occurred. + */ val error: Throwable? + + /** + * The timestamp when the error occurred. + */ val errorUpdatedAt: Long + + /** + * The status of the mutation. + */ val status: MutationStatus + + /** + * The number of times the mutation has been mutated. + */ val mutatedCount: Int + /** + * The revision of the currently snapshot. + */ val revision: String get() = "d-$dataUpdatedAt/e-$errorUpdatedAt" + + /** + * The timestamp when the mutation was submitted. + */ val submittedAt: Long get() = max(dataUpdatedAt, errorUpdatedAt) + + /** + * Returns `true` if the mutation is idle, `false` otherwise. + */ val isIdle: Boolean get() = status == MutationStatus.Idle + + /** + * Returns `true` if the mutation is pending, `false` otherwise. + */ val isPending: Boolean get() = status == MutationStatus.Pending + + /** + * Returns `true` if the mutation is successful, `false` otherwise. + */ val isSuccess: Boolean get() = status == MutationStatus.Success + + /** + * Returns `true` if the mutation is a failure, `false` otherwise. + */ val isFailure: Boolean get() = status == MutationStatus.Failure + + /** + * Returns `true` if the mutation has been mutated, `false` otherwise. + */ val isMutated: Boolean get() = mutatedCount > 0 } +/** + * The status of the mutation. + */ enum class MutationStatus { Idle, Pending, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt index f1e54e6..2d0dead 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt @@ -3,6 +3,19 @@ package soil.query +/** + * MutationNotifier is used to notify the mutation result. + */ fun interface MutationNotifier { + + /** + * Notifies the mutation success + * + * Mutation success usually implies data update, causing side effects on related queries. + * This callback is used as a trigger for re-fetching or revalidating data managed by queries. + * It invokes with the [QueryEffect] set in [MutationKey.onQueryUpdate]. + * + * @param sideEffects The side effects of the mutation for related queries. + */ fun onMutateSuccess(sideEffects: QueryEffect) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 1b66221..89f9b1a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -12,11 +12,19 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds - +/** + * [MutationOptions] providing settings related to the internal behavior of an [Mutation]. + */ data class MutationOptions( - // NOTE: Only allows mutate to execute once while active (until reset). + + /** + * Only allows mutate to execute once while active (until reset). + */ val isOneShot: Boolean = false, - // NOTE: Requires revision match as a precondition for executing mutate. + + /** + * Requires revision match as a precondition for executing mutate. + */ val isStrictMode: Boolean = false, // ----- ActorOptions ----- // diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationReceiver.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationReceiver.kt index b40bab9..25d0bed 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationReceiver.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationReceiver.kt @@ -3,7 +3,21 @@ package soil.query -// NOTE: Extension receiver for referencing external instances needed when executing mutate +/** + * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. + * + * Usage: + * + * ```kotlin + * class KtorReceiver( + * val client: HttpClient + * ) : QueryReceiver, MutationReceiver + * ``` + */ interface MutationReceiver { + + /** + * Default implementation for [MutationReceiver]. + */ companion object : MutationReceiver } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index cb5cece..3dbdbdd 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -9,11 +9,27 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch +/** + * A reference to a [Mutation] for [MutationKey]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + * @property key Instance of a class implementing [MutationKey]. + * @param mutation The mutation to perform. + * @constructor Creates a [MutationRef]. + */ class MutationRef( val key: MutationKey, mutation: Mutation ) : Mutation by mutation { + /** + * Starts the [Mutation]. + * + * This function must be invoked when a new mount point (subscriber) is added. + * + * @param scope The [CoroutineScope] to launch the [Mutation] actor. + */ fun start(scope: CoroutineScope) { actor.launchIn(scope = scope) scope.launch { @@ -21,6 +37,12 @@ class MutationRef( } } + /** + * Mutates the variable. + * + * @param variable The variable to be mutated. + * @return The result of the mutation. + */ suspend fun mutate(variable: S): T { mutateAsync(variable) val submittedAt = state.value.submittedAt @@ -34,10 +56,18 @@ class MutationRef( } } + /** + * Mutates the variable asynchronously. + * + * @param variable The variable to be mutated. + */ suspend fun mutateAsync(variable: S) { command.send(MutationCommands.Mutate(key, variable, state.value.revision)) } + /** + * Resets the mutation state. + */ suspend fun reset() { command.send(MutationCommands.Reset()) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index 1495d1d..24f11e8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -3,6 +3,9 @@ package soil.query +/** + * State for managing the execution result of [Mutation]. + */ data class MutationState( override val data: T? = null, override val dataUpdatedAt: Long = 0, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 600425e..496701f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -8,13 +8,37 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +/** + * Query as the base interface for an [QueryClient] implementations. + * + * @param T Type of the return value from the query. + */ interface Query { + + /** + * Coroutine [Flow] to launch the actor. + */ val actor: Flow<*> + + /** + * [Shared Flow][SharedFlow] to receive query [events][QueryEvent]. + */ val event: SharedFlow + + /** + * [State Flow][StateFlow] to receive the current state of the query. + */ val state: StateFlow> + + /** + * [Send Channel][SendChannel] to manipulate the state of the query. + */ val command: SendChannel> } +/** + * Events occurring in the query. + */ enum class QueryEvent { Invalidate, Resume, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt index 860f372..49a0c49 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt @@ -3,26 +3,59 @@ package soil.query +/** + * Query actions are used to update the [query state][QueryState]. + * + * @param T Type of the return value from the query. + */ sealed interface QueryAction { + /** + * Indicates that the query is fetching data. + * + * @param isInvalidated Indicates whether the query is invalidated. + */ data class Fetching( val isInvalidated: Boolean? = null ) : QueryAction + /** + * Indicates that the query is successful. + * + * @param data The data to be updated. + * @param dataUpdatedAt The timestamp when the data was updated. + * @param dataStaleAt The timestamp when the data becomes stale. + */ data class FetchSuccess( val data: T, val dataUpdatedAt: Long, val dataStaleAt: Long ) : QueryAction + /** + * Indicates that the query has failed. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. + * @param paused The paused status of the query. + */ data class FetchFailure( val error: Throwable, val errorUpdatedAt: Long, val paused: QueryFetchStatus.Paused? = null ) : QueryAction + /** + * Invalidates the query. + */ data object Invalidate : QueryAction + /** + * Forces the query to update the data. + * + * @param data The data to be updated. + * @param dataUpdatedAt The timestamp when the data was updated. + */ data class ForceUpdate( val data: T, val dataUpdatedAt: Long @@ -32,6 +65,9 @@ sealed interface QueryAction { typealias QueryReducer = (QueryState, QueryAction) -> QueryState typealias QueryDispatch = (QueryAction) -> Unit +/** + * Creates a [QueryReducer] function. + */ fun createQueryReducer(): QueryReducer = { state, action -> when (action) { is QueryAction.Fetching -> { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt index 8e131b1..9fd4ed4 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt @@ -3,6 +3,12 @@ package soil.query +/** + * Data chunk type that holds values in combination with [param] related to [data]. + * + * In [InfiniteQueryKey], multiple data fetches are performed due to additional retrieval. + * [QueryChunk] is a data structure for holding the result of one fetch. [QueryChunks] holds multiple [QueryChunk] results. + */ data class QueryChunk( val data: T, val param: S @@ -10,9 +16,15 @@ data class QueryChunk( typealias QueryChunks = List> +/** + * Returns the data of all chunks. + */ val QueryChunks, S>.chunkedData: List get() = flatMap { it.data } +/** + * Transforms all chunk data with [transform] and returns the extracted data using [selector]. + */ inline fun QueryChunks.chunked( transform: (QueryChunk) -> Iterable, selector: (U) -> E @@ -20,6 +32,9 @@ inline fun QueryChunks.chunked( return flatMap(transform).map(selector) } +/** + * Modifies the data of all chunks that match the condition. + */ fun QueryChunks, S>.modifyData( match: (T) -> Boolean, edit: T.() -> T diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index 10e940f..b9a81f7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -5,47 +5,120 @@ package soil.query import soil.query.internal.UniqueId +/** + * A Query client, which allows you to make queries actor and handle [QueryKey] and [InfiniteQueryKey]. + */ interface QueryClient { + /** + * The default query options. + */ val defaultQueryOptions: QueryOptions + /** + * Gets the [QueryRef] by the specified [QueryKey]. + */ fun getQuery(key: QueryKey): QueryRef + /** + * Gets the [InfiniteQueryRef] by the specified [InfiniteQueryKey]. + */ fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef + /** + * Prefetches the query by the specified [QueryKey]. + * + * **Note:** + * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. + * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. + */ fun prefetchQuery(key: QueryKey) + /** + * Prefetches the infinite query by the specified [InfiniteQueryKey]. + * + * **Note:** + * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. + * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. + */ fun prefetchInfiniteQuery(key: InfiniteQueryKey) } +/** + * Interface for directly accessing retrieved [Query] data by [QueryClient]. + * + * [QueryPlaceholderData] is designed to calculate initial data from other [Query]. + * This is useful when the type included in the list on the overview screen matches the type of content on the detailed screen. + */ interface QueryReadonlyClient { + + /** + * Retrieves data of the [QueryKey] associated with the [id]. + */ fun getQueryData(id: QueryId): T? + /** + * Retrieves data of the [InfiniteQueryKey] associated with the [id]. + */ fun getInfiniteQueryData(id: InfiniteQueryId): QueryChunks? } +/** + * Interface for causing side effects on [Query] under the control of [QueryClient]. + * + * [QueryEffect] is designed to allow side effects such as updating, deleting, and revalidating queries. + * It is useful for handling [MutationKey.onQueryUpdate] after executing [Mutation] that affects [Query] data. + */ interface QueryMutableClient : QueryReadonlyClient { + /** + * Updates the data of the [QueryKey] associated with the [id]. + */ fun updateQueryData( id: QueryId, edit: T.() -> T ) + /** + * Updates the data of the [InfiniteQueryKey] associated with the [id]. + */ fun updateInfiniteQueryData( id: InfiniteQueryId, edit: QueryChunks.() -> QueryChunks ) + /** + * Invalidates the queries by the specified [InvalidateQueriesFilter]. + */ fun invalidateQueries(filter: InvalidateQueriesFilter) + /** + * Invalidates the queries by the specified [UniqueId]. + */ fun invalidateQueriesBy(vararg ids: U) + /** + * Removes the queries by the specified [RemoveQueriesFilter]. + * + * **Note:** + * Queries will be removed from [QueryClient], but [QueryRef] instances on the subscriber side will remain until they are dereferenced. + * Also, the [kotlinx.coroutines.CoroutineScope] associated with the [kotlinx.coroutines.Job] will be canceled at the time of removal. + */ fun removeQueries(filter: RemoveQueriesFilter) + /** + * Removes the queries by the specified [UniqueId]. + */ fun removeQueriesBy(vararg ids: U) + /** + * Resumes the queries by the specified [ResumeQueriesFilter]. + */ fun resumeQueries(filter: ResumeQueriesFilter) + /** + * Resumes the queries by the specified [UniqueId]. + */ fun resumeQueriesBy(vararg ids: U) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index 0e85339..f78984b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -12,10 +12,23 @@ import soil.query.internal.toEpoch import soil.query.internal.vvv import kotlin.coroutines.cancellation.CancellationException +/** + * Query command to handle query. + * + * @param T Type of the return value from the query. + */ interface QueryCommand { + /** + * Handles the query. + */ suspend fun handle(ctx: Context) + /** + * Context for query command. + * + * @param T Type of the return value from the query. + */ interface Context { val receiver: QueryReceiver val options: QueryOptions @@ -24,6 +37,11 @@ interface QueryCommand { } } +/** + * Determines whether a fetch operation is necessary based on the current state. + * + * @return `true` if fetch operation is allowed, `false` otherwise. + */ fun QueryCommand.Context.shouldFetch(revision: String? = null): Boolean { if (revision != null && revision != state.revision) { return false @@ -37,6 +55,15 @@ fun QueryCommand.Context.shouldFetch(revision: String? = null): Boolean { || state.isStaled() } +/** + * Determines whether query processing needs to be paused based on [error]. + * + * Use [QueryOptions.pauseDurationAfter] for cases like HTTP 503 errors, where requests from the client need to be stopped for a certain period. + * There's currently no mechanism for automatic resumption after the specified duration. + * Resuming will require implementation on the UI, such as a retry button. + * + * @return Returns [QueryFetchStatus.Paused] if pausing is necessary, otherwise returns `null`. + */ fun QueryCommand.Context.shouldPause(error: Throwable): QueryFetchStatus.Paused? { val pauseTime = options.pauseDurationAfter?.invoke(error) if (pauseTime != null && pauseTime.isPositive()) { @@ -45,6 +72,13 @@ fun QueryCommand.Context.shouldPause(error: Throwable): QueryFetchStatus. return null } +/** + * Fetches the data. + * + * @param key Instance of a class implementing [QueryKey]. + * @param retryFn The retry function. + * @return The result of the fetch. + */ suspend fun QueryCommand.Context.fetch( key: QueryKey, retryFn: RetryFn = options.exponentialBackOff(onRetry = onRetryCallback(key.id)) @@ -59,6 +93,11 @@ suspend fun QueryCommand.Context.fetch( } } +/** + * Dispatches the fetch result. + * + * @param key Instance of a class implementing [QueryKey]. + */ suspend inline fun QueryCommand.Context.dispatchFetchResult(key: QueryKey) { fetch(key) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } @@ -66,6 +105,11 @@ suspend inline fun QueryCommand.Context.dispatchFetchResult(key: QueryKey .onFailure(::dispatchFetchFailure) } +/** + * Dispatches the fetch success. + * + * @param data The fetched data. + */ fun QueryCommand.Context.dispatchFetchSuccess(data: T) { val currentAt = epoch() val action = QueryAction.FetchSuccess( @@ -76,6 +120,11 @@ fun QueryCommand.Context.dispatchFetchSuccess(data: T) { dispatch(action) } +/** + * Dispatches the fetch failure. + * + * @param error The fetch error. + */ fun QueryCommand.Context.dispatchFetchFailure(error: Throwable) { val currentAt = epoch() val action = QueryAction.FetchFailure( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 482d92f..2c33ad9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -5,7 +5,21 @@ package soil.query import soil.query.internal.vvv +/** + * Query command for [QueryKey]. + * + * @param T Type of data to retrieve. + */ sealed class QueryCommands : QueryCommand { + + /** + * Performs data fetching and validation based on the current data state. + * + * This command is invoked via [QueryRef] when a new mount point (subscriber) is added. + * + * @param key Instance of a class implementing [QueryKey]. + * @param revision The revision of the data to be fetched. + */ data class Connect( val key: QueryKey, val revision: String? = null @@ -21,6 +35,14 @@ sealed class QueryCommands : QueryCommand { } } + /** + * Invalidates the data and performs data fetching and validation based on the current data state. + * + * This command is invoked via [QueryRef] when the data is invalidated. + * + * @param key Instance of a class implementing [QueryKey]. + * @param revision The revision of the data to be invalidated. + */ data class Invalidate( val key: QueryKey, val revision: String diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt index f483966..40ecba8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt @@ -5,27 +5,56 @@ package soil.query import soil.query.internal.SurrogateKey +/** + * Interface for filtering side effect queries by [QueryMutableClient]. + */ interface QueryFilter { + /** + * Management category of queries to be filtered. + * + * If unspecified, all [QueryFilterType]s are targeted. + */ val type: QueryFilterType? + /** + * Tag keys of queries to be filtered. + */ val keys: Array? + /** + * Further conditions to narrow down the filtering targets based on fields of [QueryModel]. + */ val predicate: ((QueryModel<*>) -> Boolean)? } +/** + * Filter for invalidating queries. + * + * @see [QueryMutableClient.invalidateQueries], [QueryMutableClient.invalidateQueriesBy] + */ class InvalidateQueriesFilter( override val type: QueryFilterType? = null, override val keys: Array? = null, override val predicate: ((QueryModel<*>) -> Boolean)? = null, ) : QueryFilter +/** + * Filter for removing queries. + * + * @see [QueryMutableClient.removeQueries], [QueryMutableClient.removeQueriesBy] + */ class RemoveQueriesFilter( override val type: QueryFilterType? = null, override val keys: Array? = null, override val predicate: ((QueryModel<*>) -> Boolean)? = null, ) : QueryFilter +/** + * Filter for resuming queries. + * + * @see [QueryMutableClient.resumeQueries], [QueryMutableClient.resumeQueriesBy] + */ class ResumeQueriesFilter( override val keys: Array? = null, override val predicate: (QueryModel<*>) -> Boolean @@ -33,6 +62,9 @@ class ResumeQueriesFilter( override val type: QueryFilterType = QueryFilterType.Active } +/** + * Query management categories for filtering. + */ enum class QueryFilterType { Active, Inactive diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index 30e3641..cc44e8f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -6,16 +6,54 @@ package soil.query import soil.query.internal.SurrogateKey import soil.query.internal.UniqueId +/** + * [QueryKey] for managing [Query] associated with [id]. + * + * @param T Type of data to retrieve. + */ interface QueryKey { + + /** + * A unique identifier used for managing [QueryKey]. + */ val id: QueryId + + /** + * Suspending function to retrieve data. + * + * @receiver QueryReceiver You can use a custom QueryReceiver within the fetch function. + */ val fetch: suspend QueryReceiver.() -> T + + /** + * Configure the Query [options]. + * + * If unspecified, the default value of [SwrCachePolicy] is used. + */ val options: QueryOptions? + /** + * Function to specify placeholder data. + * + * You can specify placeholder data instead of the initial loading state. + * + * @see QueryPlaceholderData + */ fun onPlaceholderData(): QueryPlaceholderData? = null + /** + * Function to convert specific exceptions as data. + * + * Depending on the type of exception that occurred during data retrieval, it is possible to recover it as normal data. + * + * @see QueryRecoverData + */ fun onRecoverData(): QueryRecoverData? = null } +/** + * Unique identifier for [QueryKey]. + */ @Suppress("unused") open class QueryId( override val namespace: String, @@ -40,6 +78,20 @@ open class QueryId( } } +/** + * Function for building implementations of [QueryKey] using [Kotlin Delegation](https://kotlinlang.org/docs/delegation.html). + * + * **Note:** By implementing through delegation, you can reduce the impact of future changes to [QueryKey] interface extensions. + * + * Usage: + * + * ```kotlin + * class GetPostKey(private val postId: Int) : QueryKey by buildQueryKey( + * id = Id(postId), + * fetch = { ... } + * ) + * ``` + */ fun buildQueryKey( id: QueryId, fetch: suspend QueryReceiver.() -> T, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt index e38a5d4..679ad62 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt @@ -5,26 +5,90 @@ package soil.query import soil.query.internal.epoch +/** + * Data model for the state handled by [QueryKey] or [InfiniteQueryKey]. + * + * All data models related to queries, implement this interface. + * + * @param T Type of data to retrieve. + */ interface QueryModel { + + /** + * The return value from the query. + */ val data: T? + + /** + * The timestamp when the data was updated. + */ val dataUpdatedAt: Long + + /** + * The timestamp when the data is considered stale. + */ val dataStaleAt: Long + + /** + * The error that occurred. + */ val error: Throwable? + + /** + * The timestamp when the error occurred. + */ val errorUpdatedAt: Long + + /** + * The status of the query. + */ val status: QueryStatus + + /** + * The fetch status of the query. + */ val fetchStatus: QueryFetchStatus + + /** + * Returns `true` if the query is invalidated, `false` otherwise. + */ val isInvalidated: Boolean + + /** + * Returns `true` if the query is placeholder data, `false` otherwise. + */ val isPlaceholderData: Boolean + /** + * The revision of the currently snapshot. + */ val revision: String get() = "d-$dataUpdatedAt/e-$errorUpdatedAt" + + /** + * Returns `true` if the query is pending, `false` otherwise. + */ val isPending: Boolean get() = status == QueryStatus.Pending + + /** + * Returns `true` if the query is successful, `false` otherwise. + */ val isSuccess: Boolean get() = status == QueryStatus.Success + + /** + * Returns `true` if the query is a failure, `false` otherwise. + */ val isFailure: Boolean get() = status == QueryStatus.Failure + /** + * Returns `true` if the query is staled, `false` otherwise. + */ fun isStaled(currentAt: Long = epoch()): Boolean { return dataStaleAt < currentAt } + /** + * Returns `true` if the query is paused, `false` otherwise. + */ fun isPaused(currentAt: Long = epoch()): Boolean { return when (val value = fetchStatus) { is QueryFetchStatus.Paused -> value.unpauseAt > currentAt @@ -34,13 +98,20 @@ interface QueryModel { } } +/** + * The status of the query. + */ enum class QueryStatus { Pending, Success, Failure } +/** + * The fetch status of the query. + */ sealed class QueryFetchStatus { + data object Idle : QueryFetchStatus() data object Fetching : QueryFetchStatus() data class Paused( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index a6d770b..467b079 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -14,12 +14,50 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +/** + * [QueryOptions] providing settings related to the internal behavior of an [Query]. + */ data class QueryOptions( + + /** + * The duration after which the returned value of the fetch function block is considered stale. + */ val staleTime: Duration = Duration.ZERO, + + /** + * The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. + */ val gcTime: Duration = 5.minutes, + + /** + * Maximum window time on prefetch processing. + * + * If this time is exceeded, the [kotlinx.coroutines.CoroutineScope] within the prefetch processing will terminate, + * but the command processing within the Actor will continue as long as [keepAliveTime] is set. + */ val prefetchWindowTime: Duration = 1.seconds, + + /** + * Determines whether query processing needs to be paused based on error. + * + * @see [shouldPause] + */ val pauseDurationAfter: ((Throwable) -> Duration?)? = null, + + /** + * Automatically revalidate active [Query] when the network reconnects. + * + * **Note:** + * This setting is only effective when [soil.query.internal.NetworkConnectivity] is available. + */ val revalidateOnReconnect: Boolean = true, + + /** + * Automatically revalidate active [Query] when the window is refocused. + * + * **Note:** + * This setting is only effective when [soil.query.internal.WindowVisibility] is available. + */ val revalidateOnFocus: Boolean = true, // ----- ActorOptions ----- // diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryReceiver.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryReceiver.kt index ee87267..8e0502e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryReceiver.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryReceiver.kt @@ -3,7 +3,21 @@ package soil.query -// NOTE: Extension receiver for referencing external instances needed when executing query. +/** + * Extension receiver for referencing external instances needed when executing query. + * + * Usage: + * + * ```kotlin + * class KtorReceiver( + * val client: HttpClient + * ) : QueryReceiver, MutationReceiver + * ``` + */ interface QueryReceiver { + + /** + * Default implementation for [QueryReceiver]. + */ companion object : QueryReceiver } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index c923f87..78c1efe 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -7,10 +7,26 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch +/** + * A reference to an [Query] for [QueryKey]. + * + * @param T Type of data to retrieve. + * @property key Instance of a class implementing [QueryKey]. + * @param query Transparently referenced [Query]. + * @constructor Creates an [QueryRef] + */ class QueryRef( val key: QueryKey, query: Query ) : Query by query { + + /** + * Starts the [Query]. + * + * This function must be invoked when a new mount point (subscriber) is added. + * + * @param scope The [CoroutineScope] to launch the [Query] actor. + */ fun start(scope: CoroutineScope) { actor.launchIn(scope = scope) scope.launch { @@ -19,10 +35,19 @@ class QueryRef( } } + /** + * Invalidates the [Query]. + * + * Calling this function will invalidate the retrieved data of the [Query], + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ suspend fun invalidate() { command.send(QueryCommands.Invalidate(key, state.value.revision)) } + /** + * Resumes the [Query]. + */ private suspend fun resume() { command.send(QueryCommands.Connect(key, state.value.revision)) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index 09b7d40..cca191d 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -3,6 +3,9 @@ package soil.query +/** + * State for managing the execution result of [Query]. + */ data class QueryState( override val data: T? = null, override val dataUpdatedAt: Long = 0, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 3a25985..fd56204 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -43,6 +43,28 @@ import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Implementation of the [SwrClient] interface. + * + * [Query] internally manages two categories: + * - Active: When there is one or more references or within the [QueryOptions.keepAliveTime] period + * - Inactive: When there are no references and past the [QueryOptions.keepAliveTime] period + * + * [Query] in the Active state does not disappear from memory unless one of the following conditions is met: + * - [vacuum] is executed due to memory pressure + * - [removeQueries] is explicitly called + * + * On the other hand, [Query] in the Inactive state gradually disappears from memory when one of the following conditions is met: + * - Exceeds the maximum retention count of [TimeBasedCache] + * - Past the [QueryOptions.gcTime] period since saved in [TimeBasedCache] + * - [evictCache] or [clearCache] is executed for unnecessary memory release + * + * [Mutation] is managed similarly to Active state [Query], but it is not explicitly deleted like [removeQueries]. + * Typically, since the result of [Mutation] execution is not reused, it does not cache after going inactive. + * + * @param policy The policy for the [SwrCache]. + * @constructor Creates a new [SwrCache] instance. + */ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClient { constructor(coroutineScope: CoroutineScope) : this(SwrCachePolicy(coroutineScope)) @@ -61,6 +83,9 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private var mountedIds: Set = emptySet() private var mountedScope: CoroutineScope? = null + /** + * Releases data in memory based on the specified [level]. + */ @Suppress("MemberVisibilityCanBePrivate") fun gc(level: MemoryPressureLevel = MemoryPressureLevel.Low) { when (level) { @@ -662,22 +687,94 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } +/** + * Policy for the [SwrCache]. + */ data class SwrCachePolicy( + + /** + * [CoroutineScope] for coroutines executed on the [SwrCache]. + * + * **Note:** + * The [SwrCache] internals are not thread-safe. + * Always use a scoped implementation such as [SwrCacheScope] or [kotlinx.coroutines.MainScope] with limited concurrency. + */ val coroutineScope: CoroutineScope, + + /** + * Default [MutationOptions] applied to [Mutation]. + */ val mutationOptions: MutationOptions = MutationOptions(), + + /** + * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. + */ val mutationReceiver: MutationReceiver = MutationReceiver, + + /** + * Management of active [Mutation] instances. + */ val mutationStore: MutableMap> = mutableMapOf(), + + /** + * Default [QueryOptions] applied to [Query]. + */ val queryOptions: QueryOptions = QueryOptions(), + + /** + * Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. + */ val queryReceiver: QueryReceiver = QueryReceiver, + + /** + * Management of active [Query] instances. + */ val queryStore: MutableMap> = mutableMapOf(), + + /** + * Management of cached data for inactive [Query] instances. + */ val queryCache: TimeBasedCache> = TimeBasedCache(DEFAULT_CAPACITY), + + /** + * Receiving events of memory pressure. + */ val memoryPressure: MemoryPressure = MemoryPressure.Unsupported, + + /** + * Receiving events of network connectivity. + */ val networkConnectivity: NetworkConnectivity = NetworkConnectivity.Unsupported, + + /** + * The delay time to resume queries after network connectivity is reconnected. + * + * **Note:** + * This setting is only effective when [networkConnectivity] is available. + */ val networkResumeAfterDelay: Duration = 2.seconds, + + /** + * The specified filter to resume queries after network connectivity is reconnected. + * + * **Note:** + * This setting is only effective when [networkConnectivity] is available. + */ val networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( predicate = { it.isFailure } ), + + /** + * Receiving events of window visibility. + */ val windowVisibility: WindowVisibility = WindowVisibility.Unsupported, + + /** + * The specified filter to resume queries after window visibility is refocused. + * + * **Note:** + * This setting is only effective when [windowVisibility] is available. + */ val windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( predicate = { it.isStaled() } ) @@ -687,6 +784,9 @@ data class SwrCachePolicy( } } +/** + * [CoroutineScope] with limited concurrency for [SwrCache]. + */ @OptIn(ExperimentalCoroutinesApi::class) class SwrCacheScope(parent: Job? = null) : CoroutineScope { override val coroutineContext: CoroutineContext = diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt index 8744b11..9cfbf6b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt @@ -3,11 +3,35 @@ package soil.query +/** + * An all-in-one [SwrClient] integrating [MutationClient] and [QueryClient] for library users. + * + * Swr stands for "stall-while-revalidate". + */ interface SwrClient : MutationClient, QueryClient { + /** + * Executes side effects for queries. + */ fun perform(sideEffects: QueryEffect) + /** + * Executes initialization procedures based on events. + * + * Features dependent on the platform are lazily initialized. + * The following features work correctly by notifying the start of [SwrClient] usage for each mount: + * - [soil.query.internal.NetworkConnectivity] + * - [soil.query.internal.MemoryPressure] + * - [soil.query.internal.WindowVisibility] + * + * @param id Unique string for each mount point. + */ fun onMount(id: String) + /** + * Executes cleanup procedures based on events. + * + * @param id Unique string for each mount point. + */ fun onUnmount(id: String) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt index 002a225..849e502 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt @@ -5,10 +5,32 @@ package soil.query.internal import kotlin.time.Duration +/** + * Interface providing settings related to the internal behavior of an Actor. + * + * The actual Actor is managed as a Coroutine Flow for each [UniqueId]. + * Using [newSharingStarted], it remains active only when there are subscribers. + */ interface ActorOptions { + + /** + * Duration to remain state as an active. + * + * The temporary measure to keep the state active once it's no longer referenced anywhere. + * + * **Note:** On the Android platform, there is a possibility of temporarily unsubscribing due to configuration changes such as screen rotation. + * Set a duration of at least a few seconds to ensure that the Job processing within the Coroutine Flow is not canceled. + * This value has the same meaning as [SharingStarted.WhileSubscribed(5_000)](https://github.com/search?q=repo%3Aandroid%2Fnowinandroid+WhileSubscribed) defined in ViewModel in [Now in Android App](https://github.com/android/nowinandroid). + */ val keepAliveTime: Duration } +/** + * Creates a new [ActorSharingStarted] specific for Actor. + * + * @param onActive Callback handler to notify when becoming active. + * @param onInactive Callback handler to notify when becoming inactive. + */ fun ActorOptions.newSharingStarted( onActive: ActorCallback? = null, onInactive: ActorCallback? = null diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt index b56cea3..c435431 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt @@ -10,6 +10,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach import kotlin.time.Duration +/** + * Implementation of [SharingStarted] for Actor's custom [SharingStarted.WhileSubscribed] implementations. + * + * @see [ActorOptions.newSharingStarted] + */ class ActorSharingStarted( keepAliveTime: Duration, private val onActive: ActorCallback? = null, @@ -33,4 +38,7 @@ class ActorSharingStarted( } } +/** + * Callback handler to notify based on the active state of the Actor. + */ typealias ActorCallback = () -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt index c6ca7f2..8a00ecf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt @@ -3,6 +3,17 @@ package soil.query.internal +/** + * Logger functional interface to output log messages. + * + * This functional interface provides logging for debugging purposes for developers. + */ fun interface LoggerFn { + + /** + * Outputs the log message. + * + * @param message Log message. + */ fun log(message: String) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt index 8cbfcd0..ab4ddfa 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt @@ -3,7 +3,16 @@ package soil.query.internal +/** + * Interface providing settings for logging output for debugging purposes. + */ interface LoggingOptions { + + /** + * Specifies the logger function. + * + * **Note:** When LoggerFn is set, it writes internal logic state changes to the log. + */ val logger: LoggerFn? } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt index 5a890ea..3eea789 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt @@ -7,10 +7,24 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +/** + * Interface for receiving events of memory pressure. + */ interface MemoryPressure { + + /** + * Adds an [observer] to receive events. + */ fun addObserver(observer: Observer) + + /** + * Removes an [observer] that receives events. + */ fun removeObserver(observer: Observer) + /** + * Provides a Flow to receive events of memory pressure. + */ fun asFlow(): Flow = callbackFlow { val observer = object : Observer { override fun onReceive(level: MemoryPressureLevel) { @@ -21,10 +35,20 @@ interface MemoryPressure { awaitClose { removeObserver(observer) } } + /** + * Observer interface for receiving events of memory pressure. + */ interface Observer { + + /** + * Receives a [level] of memory pressure. + */ fun onReceive(level: MemoryPressureLevel) } + /** + * An object indicating unsupported for the capability of memory pressure. + */ companion object Unsupported : MemoryPressure { override fun addObserver(observer: Observer) = Unit @@ -32,8 +56,23 @@ interface MemoryPressure { } } +/** + * Levels of memory pressure. + */ enum class MemoryPressureLevel { + + /** + * Indicates low memory pressure. + */ Low, + + /** + * Indicates moderate memory pressure. + */ High, + + /** + * Indicates severe memory pressure. + */ Critical } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt index 091a19d..2de2554 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt @@ -7,10 +7,24 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +/** + * Interface for receiving events of network connectivity. + */ interface NetworkConnectivity { + + /** + * Adds an [observer] to receive events. + */ fun addObserver(observer: Observer) + + /** + * Removes an [observer] that receives events. + */ fun removeObserver(observer: Observer) + /** + * Provides a Flow to receive events of network connectivity. + */ fun asFlow(): Flow = callbackFlow { val observer = object : Observer { override fun onReceive(event: NetworkConnectivityEvent) { @@ -21,10 +35,20 @@ interface NetworkConnectivity { awaitClose { removeObserver(observer) } } + /** + * Observer interface for receiving events of network connectivity. + */ interface Observer { + + /** + * Receives a [event] of network connectivity. + */ fun onReceive(event: NetworkConnectivityEvent) } + /** + * An object indicating unsupported for the capability of network connectivity. + */ companion object Unsupported : NetworkConnectivity { override fun addObserver(observer: Observer) = Unit @@ -32,7 +56,18 @@ interface NetworkConnectivity { } } +/** + * Events of network connectivity. + */ enum class NetworkConnectivityEvent { + + /** + * The network is available. + */ Available, + + /** + * The network is lost. + */ Lost } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt index 587677a..57a225e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt @@ -5,8 +5,18 @@ package soil.query.internal import kotlin.time.Duration +/** + * Returns the current epoch time. + * + * @return Epoch seconds + */ expect fun epoch(): Long +/** + * Generate a Version 4 UUID. + * + * @return UUID string. + */ expect fun uuid(): String internal fun Duration.toEpoch(at: Long = epoch()): Long { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt index 313889b..310c484 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt @@ -5,12 +5,23 @@ package soil.query.internal // TODO: Significantly slower than java.util.PriorityQueue. // Consider rewriting the implementation later. + +/** + * Priority queue implementation. + * + * @param E Element that implemented [Comparable] interface. + * @param capacity Initial capacity. + * @constructor Creates a priority queue with the specified capacity. + */ class PriorityQueue( capacity: Int ) where E : Any, E : Comparable { private val data = ArrayList(capacity) + /** + * Adds the specified [element] to the queue. + */ fun push(element: E) { val index = data.binarySearch(element) if (index < 0) { @@ -20,14 +31,30 @@ class PriorityQueue( } } + /** + * Returns the element with the highest priority. + * + * @return The element with the highest priority, or `null` if the queue is empty. + */ fun peek(): E? { return data.firstOrNull() } + /** + * Removes the element with the highest priority. + * + * @return The element with the highest priority, or `null` if the queue is empty. + */ fun pop(): E? { return data.removeFirstOrNull() } + /** + * Removes the specified [element] from the queue. + * + * @param element The element to be removed. + * @return `true` if the element was removed, `false` otherwise. + */ fun remove(element: E): Boolean { val index = data.binarySearch(element) return if (index < 0) { @@ -38,12 +65,23 @@ class PriorityQueue( } } + /** + * Removes all elements from the queue. + */ fun clear() { data.clear() } + /** + * Returns `true` if the queue is empty. + * + * @return `true` if the queue is empty, `false` otherwise. + */ fun isEmpty(): Boolean = data.isEmpty() } +/** + * @see [PriorityQueue.isEmpty] + */ @Suppress("NOTHING_TO_INLINE") inline fun PriorityQueue<*>.isNotEmpty(): Boolean = !isEmpty() diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt index 330b5f1..3bf1586 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt @@ -5,13 +5,35 @@ package soil.query.internal import kotlin.time.Duration +/** + * Functional interface for retry logic applied to queries or mutations within a command. + * + * @param T The return type of the function to which retry logic is applied. + */ fun interface RetryFn { + + /** + * Executes the [block] function under retry control. + * + * @param block Function to which retry logic is applied. + * @return The result of executing the [block] function. + */ suspend fun withRetry(block: suspend () -> T): T } +/** + * Interface to indicate whether an [Throwable] is retryable, provided as a default options. + */ @Suppress("SpellCheckingInspection") interface Retryable { + + /** + * @return `true` only if retrying is possible. + */ val canRetry: Boolean } +/** + * Callback function to notify the execution of retry logic. + */ typealias RetryCallback = (err: Throwable, count: Int, nextBackOff: Duration) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt index e2e1af0..7150a5e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt @@ -8,16 +8,50 @@ import kotlin.coroutines.cancellation.CancellationException import kotlin.random.Random import kotlin.time.Duration +/** + * Interface providing settings for retry logic. + */ interface RetryOptions { + + /** + * Specifies whether to retry the operation. + */ val shouldRetry: (Throwable) -> Boolean + + /** + * The number of times to retry the operation. + */ val retryCount: Int + + /** + * The initial interval for retrying the operation. + */ val retryInitialInterval: Duration + + /** + * The maximum interval for retrying the operation. + */ val retryMaxInterval: Duration + + /** + * The multiplier for the next interval. + */ val retryMultiplier: Double + + /** + * The randomization factor for the next interval. + */ val retryRandomizationFactor: Double + + /** + * The random number generator for the next interval. + */ val retryRandomizer: Random } +/** + * Generates an [RetryFn] for Exponential Backoff Strategy. + */ fun RetryOptions.exponentialBackOff( onRetry: RetryCallback? = null ) = RetryFn { block -> diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt index 7c750c2..eb643bb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt @@ -5,6 +5,15 @@ package soil.query.internal import kotlin.time.Duration +/** + * A time-based cache that evicts entries based on their time-to-live (TTL). + * + * @param K The type of keys maintained by this cache. + * @param V The type of mapped values. + * @property capacity The maximum number of entries that this cache can hold. + * @property time A function that returns the current time in seconds since the epoch. + * @constructor Creates a new time-based cache with the specified capacity and time function. + */ class TimeBasedCache( private val capacity: Int, private val time: () -> Long = { epoch() } @@ -13,12 +22,23 @@ class TimeBasedCache( private val cache = LinkedHashMap>(capacity) private val queue = PriorityQueue>(capacity) + /** + * Returns the number of entries in this cache. + */ val size: Int get() = cache.size + /** + * Returns the keys contained in this cache. + */ val keys: Set get() = cache.keys + /** + * Returns the value of the specified key in this cache + * + * @return `null` if the key is not found or has expired. + */ operator fun get(key: K): V? { val item = cache[key] if (item != null && time() < item.expires) { @@ -27,6 +47,12 @@ class TimeBasedCache( return null } + /** + * Save the specified key with value in this cache. + * + * - If the cache is full, the oldest entry will be evicted. + * - If the key already exists, delete the old entry and save the new entry. + */ fun set(key: K, value: V, ttl: Duration) { val now = time() if (cache.containsKey(key)) { @@ -41,6 +67,9 @@ class TimeBasedCache( queue.push(item) } + /** + * Updates the value associated with the specified key in this cache. + */ fun swap(key: K, edit: V.() -> V) { val current = cache[key] ?: return val changed = current.copy(value = current.value.edit()) @@ -49,7 +78,9 @@ class TimeBasedCache( queue.push(changed) } - + /** + * Evicts entries that have expired. + */ fun evict(now: Long = time()) { if (cache.isEmpty()) { return @@ -70,11 +101,17 @@ class TimeBasedCache( } } + /** + * Removes the entry for the specified key from this cache if it is present. + */ fun delete(key: K) { val item = cache.remove(key) ?: return queue.remove(item) } + /** + * Removes all entries from this cache. + */ fun clear() { cache.clear() queue.clear() @@ -84,6 +121,16 @@ class TimeBasedCache( return "TimeBasedCache[capacity=$capacity, keys=${cache.keys}]" } + /** + * An item in the cache that holds a key, a value, and an expiration time. + * + * @param K The type of keys maintained by this cache + * @param V The type of mapped values + * @property key The key of this item + * @property value The value of this item + * @property expires The expiration time of this item + * @constructor Creates a new item with the specified key, value, and expiration time. + */ data class Item( val key: K, val value: V, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt index 4618eef..451f052 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt @@ -3,9 +3,42 @@ package soil.query.internal +/** + * Interface for unique identifiers. + * + * The unique identifier is a combination of a namespace and tags. + * This [UniqueId] is used to cache the results of queries and mutations. + */ interface UniqueId { + + /** + * The namespace of this unique identifier. + * + * There are no specific formatting rules. + * It's important for each resource to have a unique namespace, similar to URL paths. + */ val namespace: String + + /** + * The tags of this unique identifier. + * + * If the [namespace] matches but the tags are different, they are treated as different cache entries. + * Tags are useful for separating caches for variations based on query parameters or grouping for revalidation. + * + * `null` values are not allowed. Instead, represent them with compound types like [Pair]. + * + * ``` + * val tags = arrayOf( + * "param1" to null, + * "param2" to 123 + * ) + * ``` + */ val tags: Array } + +/** + * A surrogate key for unique identifiers. + */ typealias SurrogateKey = Any diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt index 4ce38b1..73b78cb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt @@ -7,11 +7,25 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +/** + * Interface for receiving events of window visibility. + + */ interface WindowVisibility { + /** + * Adds an [observer] to receive events. + */ fun addObserver(observer: Observer) + + /** + * Removes an [observer] that receives events. + */ fun removeObserver(observer: Observer) + /** + * Provides a Flow to receive events of window visibility. + */ fun asFlow(): Flow = callbackFlow { val observer = object : Observer { override fun onReceive(event: WindowVisibilityEvent) { @@ -22,10 +36,20 @@ interface WindowVisibility { awaitClose { removeObserver(observer) } } + /** + * Observer interface for receiving events of window visibility. + */ interface Observer { + + /** + * Receives a [event] of window visibility. + */ fun onReceive(event: WindowVisibilityEvent) } + /** + * An object indicating unsupported for the capability of window visibility. + */ companion object Unsupported : WindowVisibility { override fun addObserver(observer: Observer) = Unit @@ -33,7 +57,18 @@ interface WindowVisibility { } } +/** + * Events of window visibility. + */ enum class WindowVisibilityEvent { + + /** + * The window enters the foreground. + */ Foreground, + + /** + * The window enters the background. + */ Background } diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt index 842ad4c..c1f4b7e 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt @@ -15,6 +15,17 @@ import platform.darwin.NSObject import soil.query.internal.MemoryPressure import soil.query.internal.MemoryPressureLevel +/** + * Implementation of [MemoryPressure] for iOS. + * + * In the iOS system, [NSNotificationCenter] is used to monitor memory pressure states. + * + * | iOS Notification | MemoryPressureLevel | + * |:----------------------------------------|:----------------------| + * | UIApplicationDidEnterBackground | Low | + * | UIApplicationDidReceiveMemoryWarning | Critical | + * + */ @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) class IosMemoryPressure : MemoryPressure { diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt b/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt index 373e017..bf29c91 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt @@ -15,6 +15,13 @@ import platform.darwin.NSObject import soil.query.internal.WindowVisibility import soil.query.internal.WindowVisibilityEvent +/** + * Implementation of [WindowVisibility] for iOS. + * + * In the iOS system, [UIApplicationDidBecomeActiveNotification] and [UIApplicationWillResignActiveNotification] + * are used to monitor window visibility states. + * It notifies the window visibility state based on the notification received. + */ @OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) class IosWindowVisibility : WindowVisibility { diff --git a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt index 0fb0e79..08ce2b6 100644 --- a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt +++ b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt @@ -8,6 +8,9 @@ import org.w3c.dom.events.Event import soil.query.internal.NetworkConnectivity import soil.query.internal.NetworkConnectivityEvent +/** + * Implementation of [NetworkConnectivity] for WasmJs. + */ class WasmJsNetworkConnectivity : NetworkConnectivity { private var onlineListener: ((Event) -> Unit)? = null diff --git a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt index e431b6d..a7fd282 100644 --- a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt +++ b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt @@ -9,6 +9,9 @@ import soil.query.internal.WindowVisibility import soil.query.internal.WindowVisibilityEvent import soil.query.internal.document as documentAlt +/** + * Implementation of [WindowVisibility] for WasmJs. + */ class WasmJsWindowVisibility : WindowVisibility { private var visibilityListener: ((Event) -> Unit)? = null From 73f93f345f82f33f91be9f1e29b704c7dda7b2a6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 09:44:53 +0900 Subject: [PATCH 019/155] Add a KDoc comments for the soil.query.compose package --- .../query/compose/InfiniteQueryComposable.kt | 19 ++++++++ .../soil/query/compose/InfiniteQueryObject.kt | 48 +++++++++++++++++++ .../soil/query/compose/MutationComposable.kt | 10 +++- .../soil/query/compose/MutationObject.kt | 42 ++++++++++++++++ .../soil/query/compose/QueryComposable.kt | 18 +++++++ .../kotlin/soil/query/compose/QueryObject.kt | 32 +++++++++++++ .../soil/query/compose/SwrClientProvider.kt | 9 ++++ .../kotlin/soil/query/compose/Util.kt | 39 ++++++++++++++- 8 files changed, 215 insertions(+), 2 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index b982a2b..f48d292 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -15,6 +15,15 @@ import soil.query.QueryClient import soil.query.QueryState import soil.query.QueryStatus +/** + * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @return A [InfiniteQueryObject] each the query state changed. + */ @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, @@ -30,6 +39,16 @@ fun rememberInfiniteQuery( } } +/** + * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. + * @param select A function to select data from [QueryChunks]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @return A [InfiniteQueryObject] with selected data each the query state changed. + */ @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt index d6d3903..3b0f86a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt @@ -9,13 +9,38 @@ import soil.query.QueryFetchStatus import soil.query.QueryModel import soil.query.QueryStatus +/** + * A InfiniteQueryObject represents [QueryModel]s interface for infinite fetching data using a retrieval method known as "infinite scroll." + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + */ @Stable sealed interface InfiniteQueryObject : QueryModel { + + /** + * Refreshes the data. + */ val refresh: suspend () -> Unit + + /** + * Fetches data for the [InfiniteQueryKey][soil.query.InfiniteQueryKey] using the [parameter][loadMoreParam]. + */ val loadMore: suspend (param: S) -> Unit + + /** + * The parameter for next fetching. If null, it means there is no more data to fetch. + */ val loadMoreParam: S? } +/** + * A InfiniteQueryLoadingObject represents the initial loading state of the [InfiniteQueryObject]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @constructor Creates a [InfiniteQueryLoadingObject]. + */ @Immutable data class InfiniteQueryLoadingObject( override val data: T?, @@ -33,6 +58,13 @@ data class InfiniteQueryLoadingObject( override val status: QueryStatus = QueryStatus.Pending } +/** + * A InfiniteQueryLoadingErrorObject represents the initial loading error state of the [InfiniteQueryObject]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @constructor Creates a [InfiniteQueryLoadingErrorObject]. + */ @Immutable data class InfiniteQueryLoadingErrorObject( override val data: T?, @@ -50,6 +82,13 @@ data class InfiniteQueryLoadingErrorObject( override val status: QueryStatus = QueryStatus.Failure } +/** + * A InfiniteQuerySuccessObject represents the successful state of the [InfiniteQueryObject]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @constructor Creates a [InfiniteQuerySuccessObject]. + */ @Immutable data class InfiniteQuerySuccessObject( override val data: T, @@ -67,6 +106,15 @@ data class InfiniteQuerySuccessObject( override val status: QueryStatus = QueryStatus.Success } +/** + * A InfiniteQueryRefreshErrorObject represents the refresh error state of the [InfiniteQueryObject]. + * + * This state is used when the data is successfully retrieved once, but an error occurs during the refresh or additional fetching. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + * @constructor Creates a [InfiniteQueryRefreshErrorObject]. + */ @Immutable data class InfiniteQueryRefreshErrorObject( override val data: T, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index e5f397c..f80f3f0 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -14,6 +14,15 @@ import soil.query.MutationRef import soil.query.MutationState import soil.query.MutationStatus +/** + * Remember a [MutationObject] and subscribes to the mutation state of [key]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + * @param key The [MutationKey] for managing [mutation][soil.query.Mutation] associated with [id][soil.query.MutationId]. + * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @return A [MutationObject] each the mutation state changed. + */ @Composable fun rememberMutation( key: MutationKey, @@ -29,7 +38,6 @@ fun rememberMutation( } } - private fun MutationState.toObject( mutation: MutationRef, ): MutationObject { diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt index 26b2475..ceefa8b 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt @@ -8,13 +8,37 @@ import androidx.compose.runtime.Stable import soil.query.MutationModel import soil.query.MutationStatus +/** + * A MutationObject represents [MutationModel]s interface for mutating data. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ @Stable sealed interface MutationObject : MutationModel { + + /** + * Mutates the variable. + */ val mutate: suspend (variable: S) -> T + + /** + * Mutates the variable asynchronously. + */ val mutateAsync: suspend (variable: S) -> Unit + + /** + * Resets the mutation state. + */ val reset: suspend () -> Unit } +/** + * A MutationIdleObject represents the initial idle state of the [MutationObject]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ @Immutable data class MutationIdleObject( override val data: T?, @@ -29,6 +53,12 @@ data class MutationIdleObject( override val status: MutationStatus = MutationStatus.Idle } +/** + * A Mutation Loading Object represents the waiting execution result state of the [Mutation Object]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ @Immutable data class MutationLoadingObject( override val data: T?, @@ -43,6 +73,12 @@ data class MutationLoadingObject( override val status: MutationStatus = MutationStatus.Pending } +/** + * A MutationErrorObject represents the error state of the [MutationObject]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ @Immutable data class MutationErrorObject( override val data: T?, @@ -57,6 +93,12 @@ data class MutationErrorObject( override val status: MutationStatus = MutationStatus.Failure } +/** + * A MutationSuccessObject represents the successful state of the [MutationObject]. + * + * @param T Type of the return value from the mutation. + * @param S Type of the variable to be mutated. + */ @Immutable data class MutationSuccessObject( override val data: T, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index dfd503b..480cf53 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -14,6 +14,14 @@ import soil.query.QueryRef import soil.query.QueryState import soil.query.QueryStatus +/** + * Remember a [QueryObject] and subscribes to the query state of [key]. + * + * @param T Type of data to retrieve. + * @param key The [QueryKey] for managing [query][soil.query.Query]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @return A [QueryObject] each the query state changed. + */ @Composable fun rememberQuery( key: QueryKey, @@ -29,6 +37,16 @@ fun rememberQuery( } } +/** + * Remember a [QueryObject] and subscribes to the query state of [key]. + * + * @param T Type of data to retrieve. + * @param U Type of selected data. + * @param key The [QueryKey] for managing [query][soil.query.Query]. + * @param select A function to select data from [T]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @return A [QueryObject] with selected data each the query state changed. + */ @Composable fun rememberQuery( key: QueryKey, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt index 53d220c..6532c07 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt @@ -10,11 +10,25 @@ import soil.query.QueryModel import soil.query.QueryStatus +/** + * A QueryObject represents [QueryModel]s interface for fetching data. + * + * @param T Type of data to retrieve. + */ @Stable sealed interface QueryObject : QueryModel { + + /** + * Refreshes the data. + */ val refresh: suspend () -> Unit } +/** + * A QueryIdleObject represents the initial loading state of the [QueryObject]. + * + * @param T Type of data to retrieve. + */ @Immutable data class QueryLoadingObject( override val data: T?, @@ -30,6 +44,11 @@ data class QueryLoadingObject( override val status: QueryStatus = QueryStatus.Pending } +/** + * A QueryLoadingErrorObject represents the initial loading error state of the [QueryObject]. + * + * @param T Type of data to retrieve. + */ @Immutable data class QueryLoadingErrorObject( override val data: T?, @@ -45,6 +64,11 @@ data class QueryLoadingErrorObject( override val status: QueryStatus = QueryStatus.Failure } +/** + * A QuerySuccessObject represents the successful state of the [QueryObject]. + * + * @param T Type of data to retrieve. + */ @Immutable data class QuerySuccessObject( override val data: T, @@ -60,6 +84,14 @@ data class QuerySuccessObject( override val status: QueryStatus = QueryStatus.Success } +/** + * A QueryRefreshErrorObject represents the refresh error state of the [QueryObject]. + * + * This state is used when the data is successfully retrieved once, but an error occurs during the refresh. + * + * @param T Type of data to retrieve. + * @constructor Creates a [QueryRefreshErrorObject]. + */ @Immutable data class QueryRefreshErrorObject( override val data: T, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt index b0df7d8..886abb0 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt @@ -10,6 +10,12 @@ import androidx.compose.runtime.staticCompositionLocalOf import soil.query.SwrClient import soil.query.internal.uuid +/** + * Provides a [SwrClient] to the [content] over [LocalSwrClient] + * + * @param client Applying to [LocalSwrClient]. + * @param content The content under the [CompositionLocalProvider]. + */ @Composable fun SwrClientProvider( client: SwrClient, @@ -27,6 +33,9 @@ fun SwrClientProvider( } } +/** + * CompositionLocal for [SwrClient]. + */ val LocalSwrClient = staticCompositionLocalOf { error("CompositionLocal 'SwrClient' not present") } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 3395a51..295f25f 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -8,14 +8,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import kotlinx.coroutines.flow.launchIn import soil.query.InfiniteQueryKey +import soil.query.MutationClient import soil.query.MutationKey +import soil.query.QueryClient import soil.query.QueryKey import soil.query.ResumeQueriesFilter import soil.query.SwrClient - typealias QueriesErrorReset = () -> Unit +/** + * Remember a [QueriesErrorReset] to resume all queries with [filter] matched. + * + * @param filter The filter to match queries. + * @param client The [SwrClient] to resume queries. By default, it uses the [LocalSwrClient]. + * @return A [QueriesErrorReset] to resume queries. + */ @Composable fun rememberQueriesErrorReset( filter: ResumeQueriesFilter = remember { ResumeQueriesFilter(predicate = { it.isFailure }) }, @@ -27,9 +35,20 @@ fun rememberQueriesErrorReset( return reset } +/** + * Keep the query alive. + * + * Normally, a query stays active only when there are one or more references to it. + * This function is useful when you want to keep the query active for some reason even if it's not directly needed. + * For example, it can prevent data for a related query from becoming inactive, moving out of cache over time, such as when transitioning to a previous screen. + * + * @param key The [QueryKey] to keep alive. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + */ @Composable fun KeepAlive( key: QueryKey<*>, + // TODO Use QueryClient instead of SwrClient client: SwrClient = LocalSwrClient.current ) { val query = remember(key) { client.getQuery(key) } @@ -38,9 +57,18 @@ fun KeepAlive( } } +/** + * Keep the infinite query alive. + * + * @param key The [InfiniteQueryKey] to keep alive. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * + * @see KeepAlive + */ @Composable fun KeepAlive( key: InfiniteQueryKey<*, *>, + // TODO Use QueryClient instead of SwrClient client: SwrClient = LocalSwrClient.current ) { val query = remember(key) { client.getInfiniteQuery(key) } @@ -49,9 +77,18 @@ fun KeepAlive( } } +/** + * Keep the mutation alive. + * + * @param key The [MutationKey] to keep alive. + * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * + * @see KeepAlive + */ @Composable fun KeepAlive( key: MutationKey<*, *>, + // TODO Use MutationClient instead of SwrClient client: SwrClient = LocalSwrClient.current ) { val query = remember(key) { client.getMutation(key) } From 284cf223492cef2484f74d5616cbe4cf5fc330a2 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 13:58:32 +0900 Subject: [PATCH 020/155] Add a KDoc comments for the soil.query.compose.runtime package --- .../soil/query/compose/runtime/Await.kt | 94 ++++++++++++++++++- .../soil/query/compose/runtime/AwaitHost.kt | 23 +++++ .../soil/query/compose/runtime/Catch.kt | 78 ++++++++++++++- .../query/compose/runtime/CatchThrowHost.kt | 23 +++++ .../compose/runtime/ContentVisibility.kt | 13 ++- .../query/compose/runtime/ErrorBoundary.kt | 58 ++++++++++++ .../soil/query/compose/runtime/Loadable.kt | 19 +++- .../soil/query/compose/runtime/Suspense.kt | 34 ++++++- 8 files changed, 336 insertions(+), 6 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt index 9f155b0..eb478c5 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt @@ -19,7 +19,6 @@ import soil.query.compose.QueryRefreshErrorObject import soil.query.compose.QuerySuccessObject import soil.query.internal.uuid - @Composable inline fun Await( state: Loadable, @@ -43,6 +42,18 @@ inline fun Await( } } +/** + * Await for a [QueryModel] to be fulfilled. + * + * The content will be displayed when the query is fulfilled. + * The await will be managed by the [AwaitHost]. + * + * @param T Type of data to retrieve. + * @param state The [QueryModel] to await. + * @param key The key to identify the await. + * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. + * @param content The content to display when the query is fulfilled. + */ @Composable inline fun Await( state: QueryModel, @@ -64,6 +75,20 @@ inline fun Await( } } +/** + * Await for two [QueryModel] to be fulfilled. + * + * The content will be displayed when the queries are fulfilled. + * The await will be managed by the [AwaitHost]. + * + * @param T1 Type of data to retrieve. + * @param T2 Type of data to retrieve. + * @param state1 The first [QueryModel] to await. + * @param state2 The second [QueryModel] to await. + * @param key The key to identify the await. + * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. + * @param content The content to display when the queries are fulfilled. + */ @Composable inline fun Await( state1: QueryModel, @@ -88,6 +113,22 @@ inline fun Await( } } +/** + * Await for three [QueryModel] to be fulfilled. + * + * The content will be displayed when the queries are fulfilled. + * The await will be managed by the [AwaitHost]. + * + * @param T1 Type of data to retrieve. + * @param T2 Type of data to retrieve. + * @param T3 Type of data to retrieve. + * @param state1 The first [QueryModel] to await. + * @param state2 The second [QueryModel] to await. + * @param state3 The third [QueryModel] to await. + * @param key The key to identify the await. + * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. + * @param content The content to display when the queries are fulfilled. + */ @Composable inline fun Await( state1: QueryModel, @@ -115,6 +156,24 @@ inline fun Await( } } +/** + * Await for four [QueryModel] to be fulfilled. + * + * The content will be displayed when the queries are fulfilled. + * The await will be managed by the [AwaitHost]. + * + * @param T1 Type of data to retrieve. + * @param T2 Type of data to retrieve. + * @param T3 Type of data to retrieve. + * @param T4 Type of data to retrieve. + * @param state1 The first [QueryModel] to await. + * @param state2 The second [QueryModel] to await. + * @param state3 The third [QueryModel] to await. + * @param state4 The fourth [QueryModel] to await. + * @param key The key to identify the await. + * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. + * @param content The content to display when the queries are fulfilled. + */ @Composable inline fun Await( state1: QueryModel, @@ -145,6 +204,26 @@ inline fun Await( } } +/** + * Await for five [QueryModel] to be fulfilled. + * + * The content will be displayed when the queries are fulfilled. + * The await will be managed by the [AwaitHost]. + * + * @param T1 Type of data to retrieve. + * @param T2 Type of data to retrieve. + * @param T3 Type of data to retrieve. + * @param T4 Type of data to retrieve. + * @param T5 Type of data to retrieve. + * @param state1 The first [QueryModel] to await. + * @param state2 The second [QueryModel] to await. + * @param state3 The third [QueryModel] to await. + * @param state4 The fourth [QueryModel] to await. + * @param state5 The fifth [QueryModel] to await. + * @param key The key to identify the await. + * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. + * @param content The content to display when the queries are fulfilled. + */ @Composable inline fun Await( state1: QueryModel, @@ -178,6 +257,16 @@ inline fun Await( } } +/** + * Await for [QueryModel] to be fulfilled. + * + * This function is part of the [Await]. + * It is used to handle the [QueryModel] state and display the content when the query is fulfilled. + * + * @param T Type of data to retrieve. + * @param state The [QueryModel] to await. + * @param content The content to display when the query is fulfilled. + */ @Composable fun AwaitHandler( state: QueryModel, @@ -202,6 +291,9 @@ fun AwaitHandler( } } +/** + * Returns true if the [QueryModel] is awaited. + */ fun QueryModel<*>.isAwaited(): Boolean { return isPending || (isFailure && fetchStatus == QueryFetchStatus.Fetching) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/AwaitHost.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/AwaitHost.kt index 5af6a96..fc28d0d 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/AwaitHost.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/AwaitHost.kt @@ -6,17 +6,37 @@ package soil.query.compose.runtime import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf +/** + * A host to manage the await state of a key. + * + * @see Await + */ @Stable interface AwaitHost { + /** + * Returns the set of keys that are awaited. + */ val keys: Set + /** + * Returns `true` if the key is awaited. + */ operator fun get(key: Any): Boolean + /** + * Sets the key to be awaited or not. + */ operator fun set(key: Any, isAwaited: Boolean) + /** + * Removes the key from the awaited set. + */ fun remove(key: Any) + /** + * A noop implementation of [AwaitHost]. + */ companion object Noop : AwaitHost { override val keys: Set = emptySet() @@ -29,6 +49,9 @@ interface AwaitHost { } } +/** + * CompositionLocal for [AwaitHost]. + */ val LocalAwaitHost = compositionLocalOf { AwaitHost } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt index 07f1f3b..a524a6c 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt @@ -46,6 +46,13 @@ fun Catch( } } +/** + * Catch for a [QueryModel] to be rejected. + * + * @param state The [QueryModel] to catch. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state: QueryModel<*>, @@ -60,6 +67,14 @@ fun Catch( ) } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param state1 The first [QueryModel] to catch. + * @param state2 The second [QueryModel] to catch. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state1: QueryModel<*>, @@ -76,6 +91,15 @@ fun Catch( ) } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param state1 The first [QueryModel] to catch. + * @param state2 The second [QueryModel] to catch. + * @param state3 The third [QueryModel] to catch. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state1: QueryModel<*>, @@ -94,6 +118,13 @@ fun Catch( ) } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param states The [QueryModel]s to catch. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( vararg states: QueryModel<*>, @@ -108,6 +139,14 @@ fun Catch( ) } +/** + * Catch for a [QueryModel] to be rejected. + * + * @param state The [QueryModel] to catch. + * @param filterIsInstance A function to filter the error. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state: QueryModel<*>, @@ -121,6 +160,15 @@ fun Catch( } } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param state1 The first [QueryModel] to catch. + * @param state2 The second [QueryModel] to catch. + * @param filterIsInstance A function to filter the error. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state1: QueryModel<*>, @@ -138,6 +186,16 @@ fun Catch( } } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param state1 The first [QueryModel] to catch. + * @param state2 The second [QueryModel] to catch. + * @param state3 The third [QueryModel] to catch. + * @param filterIsInstance A function to filter the error. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( state1: QueryModel<*>, @@ -156,6 +214,14 @@ fun Catch( } } +/** + * Catch for any [QueryModel]s to be rejected. + * + * @param states The [QueryModel]s to catch. + * @param filterIsInstance A function to filter the error. + * @param isEnabled Whether to catch the error. + * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. + */ @Composable fun Catch( vararg states: QueryModel<*>, @@ -172,8 +238,18 @@ fun Catch( } } - +/** + * A scope for handling error content within the [Catch] function. + */ object CatchScope { + + /** + * Throw propagates the caught exception to a [CatchThrowHost]. + * + * @param error The caught exception. + * @param key The key to identify the caught exception. + * @param host The [CatchThrowHost] to manage the caught exception. By default, it uses the [LocalCatchThrowHost]. + */ @Composable fun Throw( error: Throwable, diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/CatchThrowHost.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/CatchThrowHost.kt index 2578495..53c7f92 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/CatchThrowHost.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/CatchThrowHost.kt @@ -6,17 +6,37 @@ package soil.query.compose.runtime import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf +/** + * A host to manage the caught errors of a key. + * + * @see Catch + */ @Stable interface CatchThrowHost { + /** + * Returns the set of keys that have caught errors. + */ val keys: Set + /** + * Returns the caught error of the key. + */ operator fun get(key: Any): Throwable? + /** + * Sets the caught error of the key. + */ operator fun set(key: Any, error: Throwable) + /** + * Removes the caught error of the key. + */ fun remove(key: Any) + /** + * A noop implementation of [CatchThrowHost]. + */ companion object Noop : CatchThrowHost { override val keys: Set = emptySet() @@ -29,6 +49,9 @@ interface CatchThrowHost { } } +/** + * CompositionLocal for [CatchThrowHost]. + */ val LocalCatchThrowHost = compositionLocalOf { CatchThrowHost } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt index 01d46d0..2b560a0 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt @@ -11,8 +11,17 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.invisibleToUser -// NOTE: Visibility must be treated similarly to Android View's invisible because it needs to receive the loading state from Await placed as a child. -// (If AnimatedVisibility's visible=false, the content isn't called while it's hidden, so Await won't function) +/** + * A composable that can hide its [content] without removing it from the layout. + * + * **Note:** + * Visibility must be treated similarly to Android View's invisible because it needs to receive the loading state from Await placed as a child. + * (If AnimatedVisibility's visible=false, the content isn't called while it's hidden, so Await won't function) + * + * @param hidden Whether the content should be hidden. + * @param modifier The modifier to be applied to the layout. + * @param content The content of the [ContentVisibility]. + */ @OptIn(ExperimentalComposeUiApi::class) @Composable fun ContentVisibility( diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt index 1d40e50..fdeac3a 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt @@ -16,6 +16,52 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +/** + * Wrap an ErrorBoundary around other [Catch] composable functions to catch errors and render a fallback UI. + * + * **Note:** + * Typically, this function is defined at the top level of a screen and used for default error handling. + * Do not propagate errors from ErrorBoundary to higher-level components since the error state is managed by [state]. + * Instead, it's recommended to catch and handle domain-specific exceptions within [Catch] content blocks. + * + * Usage: + * + * ```kotlin + * ErrorBoundary( + * modifier = Modifier.fillMaxSize(), + * fallback = { + * ContentUnavailable( + * error = it.err, + * reset = it.reset, + * modifier = Modifier.matchParentSize() + * ) + * }, + * onError = { e -> println(e.toString()) }, + * onReset = rememberQueriesErrorReset() + * ) { + * Suspense(..) { + * val query = rememberGetPostsQuery() + * .. + * Catch(query) { e -> + * // You can also write your own error handling logic. + * if (e is DomainSpecificException) { + * Alert(..) + * return@Catch + * } + * Throw(e) + * } + * } + * } + * ``` + * + * @param modifier The modifier to be applied to the layout. + * @param fallback The fallback UI to render when an error is caught. + * @param onError The callback to be called when an error is caught. + * @param onReset The callback to be called when the reset button is clicked. + * @param state The state of the [ErrorBoundary]. + * @param content The content of the [ErrorBoundary]. + * @see Catch + */ @Composable fun ErrorBoundary( modifier: Modifier = Modifier, @@ -52,16 +98,28 @@ fun ErrorBoundary( } } +/** + * Context information to pass to the fallback UI of [ErrorBoundary]. + * + * @property err The caught error. + * @property reset The callback to invoke when the reset button placed within the content is clicked. + */ @Stable class ErrorBoundaryContext( val err: Throwable, val reset: (() -> Unit)? ) +/** + * State of the [ErrorBoundary]. + */ @Stable class ErrorBoundaryState : CatchThrowHost { private val hostMap = mutableStateMapOf() + /** + * Returns the caught error. + */ val error: Throwable? get() = hostMap.values.firstOrNull() diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt index b26debc..38f6c52 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt @@ -6,18 +6,35 @@ package soil.query.compose.runtime import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -// For migration purposes only +// TODO QueryModel implementation +/** + * Promise-like data structure that represents the state of a value that is being loaded. + * + * Currently, this interface is intended for temporary use as a migration to queries. + * Useful when combining the `query.compose.runtime` package with other asynchronous processing. + * + * @param T The type of the value that has been loaded. + */ @Stable sealed interface Loadable { + /** + * Represents the state of a value that is being loaded. + */ @Immutable data object Pending : Loadable + /** + * Represents the state of a value that has been loaded. + */ @Immutable data class Fulfilled( val data: T ) : Loadable + /** + * Represents the state of a value that has been rejected. + */ @Immutable data class Rejected( val error: Throwable diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt index 0c5d5cd..4322d84 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt @@ -20,7 +20,33 @@ import kotlinx.coroutines.delay import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds - +/** + * Suspense render a fallback UI while some asynchronous work is being done. + * + * Typically, this function is defined at least once at the top level of a screen and used for initial loading. + * Suspense can be nested as needed, which is useful for performing partial loading. + * + * Usage: + * + * ```kotlin + * Suspense( + * fallback = { ContentLoading(modifier = Modifier.matchParentSize()) }, + * modifier = Modifier.fillMaxSize() + * ) { + * val query = rememberGetPostsQuery() + * Await(query) { posts -> + * PostList(posts) + * } + * } + * ``` + * + * @param fallback The fallback UI to render components like loading. + * @param modifier The modifier to be applied to the layout. + * @param state The [SuspenseState] to manage the suspense. + * @param contentThreshold The duration after which the initial content load is considered complete. + * @param content The content to display when the suspense is not awaited. + * @see Await + */ @Composable fun Suspense( fallback: @Composable BoxScope.() -> Unit, @@ -49,6 +75,9 @@ fun Suspense( } } +/** + * State of the [Suspense]. + */ @Stable class SuspenseState : AwaitHost { private val hostMap = mutableStateMapOf() @@ -67,6 +96,9 @@ class SuspenseState : AwaitHost { hostMap.remove(key) } + /** + * Returns `true` if any of the [Await] is awaited. + */ fun isAwaited(): Boolean { return hostMap.any { it.value } } From d2c4ebb3dd0bff654d817bca76a12b0d16717639 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 16:21:41 +0900 Subject: [PATCH 021/155] Use QueryClient instead of SwrClient --- .../src/commonMain/kotlin/soil/query/compose/Util.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 295f25f..74b9471 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -48,8 +48,7 @@ fun rememberQueriesErrorReset( @Composable fun KeepAlive( key: QueryKey<*>, - // TODO Use QueryClient instead of SwrClient - client: SwrClient = LocalSwrClient.current + client: QueryClient = LocalSwrClient.current ) { val query = remember(key) { client.getQuery(key) } LaunchedEffect(Unit) { @@ -68,8 +67,7 @@ fun KeepAlive( @Composable fun KeepAlive( key: InfiniteQueryKey<*, *>, - // TODO Use QueryClient instead of SwrClient - client: SwrClient = LocalSwrClient.current + client: QueryClient = LocalSwrClient.current ) { val query = remember(key) { client.getInfiniteQuery(key) } LaunchedEffect(Unit) { From 31eb0e9268d400faf2425a8d93fc02aee45f2cd1 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 16:22:06 +0900 Subject: [PATCH 022/155] Use MutationClient instead of SwrClient --- .../src/commonMain/kotlin/soil/query/compose/Util.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 74b9471..569bc35 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -86,8 +86,7 @@ fun KeepAlive( @Composable fun KeepAlive( key: MutationKey<*, *>, - // TODO Use MutationClient instead of SwrClient - client: SwrClient = LocalSwrClient.current + client: MutationClient = LocalSwrClient.current ) { val query = remember(key) { client.getMutation(key) } LaunchedEffect(Unit) { From 8bd3f0ed9a275ebe6c62c22fcda12c065df0a407 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 16:52:10 +0900 Subject: [PATCH 023/155] Loadable implements QueryModel --- .../soil/query/compose/runtime/Loadable.kt | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt index 38f6c52..2608272 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt @@ -5,8 +5,11 @@ package soil.query.compose.runtime import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import soil.query.QueryFetchStatus +import soil.query.QueryModel +import soil.query.QueryStatus +import soil.query.internal.epoch -// TODO QueryModel implementation /** * Promise-like data structure that represents the state of a value that is being loaded. * @@ -16,27 +19,55 @@ import androidx.compose.runtime.Stable * @param T The type of the value that has been loaded. */ @Stable -sealed interface Loadable { +sealed class Loadable : QueryModel { /** * Represents the state of a value that is being loaded. */ @Immutable - data object Pending : Loadable + data object Pending : Loadable() { + override val data: Nothing = throw IllegalStateException("Pending") + override val dataUpdatedAt: Long = 0 + override val dataStaleAt: Long = 0 + override val error: Throwable? = null + override val errorUpdatedAt: Long = 0 + override val status: QueryStatus = QueryStatus.Pending + override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Fetching + override val isInvalidated: Boolean = false + override val isPlaceholderData: Boolean = false + } /** * Represents the state of a value that has been loaded. */ @Immutable data class Fulfilled( - val data: T - ) : Loadable + override val data: T + ) : Loadable() { + override val dataUpdatedAt: Long = epoch() + override val dataStaleAt: Long = Long.MAX_VALUE + override val error: Throwable? = null + override val errorUpdatedAt: Long = 0 + override val status: QueryStatus = QueryStatus.Success + override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle + override val isInvalidated: Boolean = false + override val isPlaceholderData: Boolean = false + } /** * Represents the state of a value that has been rejected. */ @Immutable data class Rejected( - val error: Throwable - ) : Loadable + override val error: Throwable + ) : Loadable() { + override val data: Nothing = throw IllegalStateException("Rejected") + override val dataUpdatedAt: Long = 0 + override val dataStaleAt: Long = 0 + override val errorUpdatedAt: Long = epoch() + override val status: QueryStatus = QueryStatus.Failure + override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle + override val isInvalidated: Boolean = false + override val isPlaceholderData: Boolean = false + } } From 44428e902a6df6d0316f2ad6a04980922d726eae Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 16:54:25 +0900 Subject: [PATCH 024/155] Remove Catch composable function that passes Loadable as an argument --- .../soil/query/compose/runtime/Catch.kt | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt index a524a6c..64060d1 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt @@ -10,42 +10,6 @@ import androidx.compose.runtime.remember import soil.query.QueryModel import soil.query.internal.uuid -@Composable -fun Catch( - state: Loadable<*>, - isEnabled: Boolean = true, - content: @Composable CatchScope.(err: Throwable) -> Unit = { Throw(error = it) } -) { - Catch( - state = state, - filterIsInstance = { it }, - isEnabled = isEnabled, - content = content - ) -} - -@Composable -fun Catch( - state: Loadable<*>, - filterIsInstance: (err: Throwable) -> T?, - isEnabled: Boolean = true, - content: @Composable CatchScope.(err: T) -> Unit = { Throw(error = it) } -) { - when (state) { - is Loadable.Rejected -> { - val err = remember(state.error, isEnabled) { - state.error.takeIf { isEnabled }?.let(filterIsInstance) - } - if (err != null) { - with(CatchScope) { content(err) } - } - } - - is Loadable.Fulfilled, - is Loadable.Pending -> Unit - } -} - /** * Catch for a [QueryModel] to be rejected. * From c2af7d1fcfaec34b680bf78659ecba306b633ae3 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 12 May 2024 17:03:23 +0900 Subject: [PATCH 025/155] Remove Await composable function that passes Loadable as an argument --- .../soil/query/compose/runtime/Await.kt | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt index eb478c5..a5e4beb 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt @@ -19,29 +19,6 @@ import soil.query.compose.QueryRefreshErrorObject import soil.query.compose.QuerySuccessObject import soil.query.internal.uuid -@Composable -inline fun Await( - state: Loadable, - key: Any? = null, - host: AwaitHost = LocalAwaitHost.current, - content: @Composable (T) -> Unit -) { - val id = remember(key) { key ?: uuid() } - when (state) { - is Loadable.Fulfilled -> content(state.data) - is Loadable.Rejected, - is Loadable.Pending -> Unit - } - LaunchedEffect(id, state) { - host[id] = state is Loadable.Pending - } - DisposableEffect(id) { - onDispose { - host.remove(id) - } - } -} - /** * Await for a [QueryModel] to be fulfilled. * From 6725b92b69c5aa6fd82bb0b2feab614a96bde484 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 19 May 2024 10:06:38 +0900 Subject: [PATCH 026/155] Add a KDoc comments for the soil.space package --- .../src/commonMain/kotlin/soil/space/Atom.kt | 154 ++++++++++++++++++ .../kotlin/soil/space/AtomSelector.kt | 46 ++++++ .../commonMain/kotlin/soil/space/AtomStore.kt | 11 ++ .../commonMain/kotlin/soil/space/Platform.kt | 15 ++ 4 files changed, 226 insertions(+) diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 0842eae..72bf335 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -6,6 +6,30 @@ package soil.space import androidx.compose.runtime.Immutable import kotlin.jvm.JvmName +/** + * Atom is a unit of state in Space. + * + * The instance created serves as a key to store values of a specified type in [AtomStore]. + * It is designed so that the instance itself acts as the reference key, instead of the user having to assign a unique key. + * Therefore, even if the definitions are exactly the same, different instances will be managed as separate keys. + * + * **Note:** + * Specifying a [saver] is optional, but if you expect state restoration on the Android platform, it is crucial to specify it. + * + * Usage: + * + * ```kotlin + * val counterAtom = atom(0) + * val titleAtom = atom("hello, world", saverKey = "title") + * ``` + * + * @param T The type of the value to be stored. + * @property initialValue The initial value to be stored. + * @property saver The saver to be used to save and restore the value. + * @property scope The scope to be used to manage the value. + * + * TODO constructor should be internal + */ @Immutable class Atom( val initialValue: T, @@ -13,6 +37,15 @@ class Atom( val scope: AtomScope? = null ) +/** + * Creates an [Atom]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saver The saver to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ fun atom( initialValue: T, saver: AtomSaver? = null, @@ -21,6 +54,30 @@ fun atom( return Atom(initialValue, saver, scope) } +/** + * Creates an [Atom] using [AtomSaverKey]. + * + * Automatically selects an [AtomSaver] for the [Type][T] if it matches any of the following. + * The [saverKey] is used as a key for the [AtomSaver]. + * + * | Type | AtomSaver | + * | :---------- | :-------------| + * | String | stringSaver | + * | Boolean | booleanSaver | + * | Int | intSaver | + * | Long | longSaver | + * | Double | doubleSaver | + * | Float | floatSaver | + * | Char | charSaver | + * | Short | shortSaver | + * | Byte | byteSaver | + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ @Suppress("UNCHECKED_CAST") inline fun atom( initialValue: T, @@ -42,6 +99,15 @@ inline fun atom( return atom(initialValue, saver, scope) } +/** + * Creates an [Atom] using [AtomSaverKey] for [CommonParcelable]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ inline fun atom( initialValue: T, saverKey: AtomSaverKey, @@ -50,6 +116,25 @@ inline fun atom( return atom(initialValue, parcelableSaver(saverKey), scope) } +/** + * Creates an [Atom] using [AtomSaverKey] for [ArrayList]. + * + * Automatically selects an [AtomSaver] for the [Type][T] if it matches any of the following. + * The [saverKey] is used as a key for the [AtomSaver]. + * + * | Type | AtomSaver | + * | :-------------- | :---------------------------- | + * | String | stringArrayListSaver | + * | Int | integerArrayListSaver | + * | CharSequence | charSequenceArrayListSaver | + * + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ @Suppress("UNCHECKED_CAST") inline fun atom( initialValue: ArrayList, @@ -65,6 +150,15 @@ inline fun atom( return atom(initialValue, saver, scope) } +/** + * Creates an [Atom] using [AtomSaverKey] for [ArrayList] with [CommonParcelable][T]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ @JvmName("atomWithParcelable") inline fun atom( initialValue: ArrayList, @@ -74,6 +168,15 @@ inline fun atom( return atom(initialValue, parcelableArrayListSaver(saverKey), scope) } +/** + * Creates an [Atom] using [AtomSaverKey] for [Array] with [CommonParcelable][T]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ inline fun atom( initialValue: Array, saverKey: AtomSaverKey, @@ -82,6 +185,15 @@ inline fun atom( return atom(initialValue, parcelableArraySaver(saverKey), scope) } +/** + * Creates an [Atom] using [AtomSaverKey] for [CommonSerializable]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ inline fun atom( initialValue: T, saverKey: AtomSaverKey, @@ -90,13 +202,35 @@ inline fun atom( return atom(initialValue, serializableSaver(saverKey), scope) } +/** + * Interface for saving and restoring values to a [CommonBundle]. + * + * Currently, this restoration feature is designed specifically for the Android Platform. + * + * @param T The type of the value to be saved and restored. + */ interface AtomSaver { + + /** + * Saves a value to a [CommonBundle]. + * + * @param bundle The [CommonBundle] to save the value to. + * @param value The value to save. + */ fun save(bundle: CommonBundle, value: T) + + /** + * Restores a value from a [CommonBundle]. + * + * @param bundle The [CommonBundle] to restore the value from. + * @return The restored value. + */ fun restore(bundle: CommonBundle): T? } typealias AtomSaverKey = String +// TODO Switch to internal using @PublishedApi fun stringSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: String) { @@ -249,6 +383,26 @@ fun charSequenceArrayListSaver(key: AtomSaverKey): AtomSaver get(atom: Atom): T + + /** + * Retrieves the value associated with the specified [AtomRef] reference key. + * + * @param T The type of the value to be retrieved. + * @param atom The [AtomRef] reference key for the value to be retrieved. + * @return The retrieved value. + */ fun get(atom: AtomRef): T } +/** + * AtomRef holds a value derived from one or more [Atom] references as a [block] function. + * + * @param T The type of the value to be retrieved. + * @property block The function that retrieves the value derived from one or more [Atom] references key. + * + * TODO: The constructor should be internal. + */ @Immutable class AtomRef( val block: AtomSelector.() -> T ) +/** + * Creates an [AtomRef] with the specified [block] function. + * + * Usage: + * + * ```kotlin + * val counter1Atom = atom(0) + * val counter2Atom = atom(0) + * val sumAtom = atom { + * get(counter1Atom) + get(counter2Atom) + * } + * ``` + * + * @param T The type of the value to be retrieved. + * @param block The function that retrieves the value derived from one or more [Atom] references key. + * @return The created [AtomRef]. + */ fun atom(block: AtomSelector.() -> T): AtomRef { return AtomRef(block) } diff --git a/soil-space/src/commonMain/kotlin/soil/space/AtomStore.kt b/soil-space/src/commonMain/kotlin/soil/space/AtomStore.kt index 55c0a81..0049498 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/AtomStore.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/AtomStore.kt @@ -3,7 +3,18 @@ package soil.space +/** + * An interface for storing and retrieving atoms. + */ interface AtomStore : AtomSelector { + + /** + * Set the value of an atom. + * + * @param T The type of the value to be stored. + * @param atom The reference key. + * @param value The value to be stored. + */ fun set(atom: Atom, value: T) override fun get(atom: AtomRef): T { diff --git a/soil-space/src/commonMain/kotlin/soil/space/Platform.kt b/soil-space/src/commonMain/kotlin/soil/space/Platform.kt index 60a91db..85b84ed 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Platform.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Platform.kt @@ -3,9 +3,21 @@ package soil.space +/** + * Interface for handling Android platform-specific Parcelable within KMP. + */ expect interface CommonParcelable + +/** + * Class for handling JVM-specific Serializable within KMP. + */ expect interface CommonSerializable +/** + * Class for handling Android platform-specific Bundle within KMP. + * + * Currently, this class provides operations specific to the Android platform's Bundle. + */ expect class CommonBundle() { fun containsKey(key: String): Boolean fun putString(key: String?, value: String?) @@ -42,6 +54,9 @@ expect class CommonBundle() { fun getCharSequenceArrayList(key: String?): ArrayList? } +/** + * Interface for handling Android platform-specific SavedStateProvider within KMP. + */ expect fun interface CommonSavedStateProvider { fun saveState(): CommonBundle } From 30293668a2dcef90ac324e0649308f7f6f0468f3 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 19 May 2024 13:35:49 +0900 Subject: [PATCH 027/155] Add a KDoc comments for the soil.space.compose package --- .../soil/space/compose/AtomViewModel.kt | 20 ++++++++++++++-- .../kotlin/soil/space/compose/AtomRoot.kt | 23 +++++++++++++++++++ .../soil/space/compose/AtomSaveableStore.kt | 15 ++++++++++++ .../kotlin/soil/space/compose/AtomState.kt | 15 ++++++++++++ .../kotlin/soil/space/compose/AtomValue.kt | 23 +++++++++++++++++++ 5 files changed, 94 insertions(+), 2 deletions(-) diff --git a/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt b/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt index f551110..14fceb5 100644 --- a/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt +++ b/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt @@ -15,6 +15,11 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import soil.space.AtomStore +/** + * A [ViewModel] for managing [AtomStore]. + * + * @param handle The [SavedStateHandle] to save the state of the [AtomStore]. + */ class AtomViewModel( handle: SavedStateHandle ) : ViewModel() { @@ -31,8 +36,12 @@ class AtomViewModel( } } -// Note: -// ref. https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-apis#compose +/** + * Remember an [AtomStore] using [AtomViewModel]. + * + * @param key The key to identify the [AtomViewModel]. + * @return The [AtomStore] remembered by [AtomViewModel]. + */ @Composable fun rememberViewModelStore( key: String? @@ -48,6 +57,13 @@ fun rememberViewModelStore( return remember(vm) { vm.store } } +/** + * Remember an [AtomStore] using [AtomViewModel]. + * + * @param viewModelStoreOwner The [ViewModelStoreOwner] to create the [AtomViewModel]. + * @param key The key to identify the [AtomViewModel]. + * @return The [AtomStore] remembered by [AtomViewModel]. + */ @Composable fun rememberViewModelStore( viewModelStoreOwner: ViewModelStoreOwner, diff --git a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomRoot.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomRoot.kt index 5f06032..eb19c30 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomRoot.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomRoot.kt @@ -11,6 +11,17 @@ import soil.space.Atom import soil.space.AtomScope import soil.space.AtomStore +/** + * Provides multiple [AtomStore]. You can provide [AtomStore] corresponding to [AtomScope] with different lifecycles. + * + * To use the Remember API for handling [Atom] within [content], it is essential to declare [AtomRoot] somewhere in the parent tree. + * The scoped store is provided to child components using the Remember API via [LocalAtomOwner]. + * + * @param primary The specified [AtomScope] used preferentially as the default value for [fallbackScope]. + * @param others Additional [AtomScope] and [AtomStore] pairs with lifecycles different from [primary]. + * @param fallbackScope A function that returns an alternate [AtomScope] if a corresponding [AtomScope] for an [Atom] is not found. By default, it returns the [AtomScope] of [primary]. + * @param content The content of the [AtomRoot]. + */ @Composable fun AtomRoot( primary: Pair, @@ -26,6 +37,15 @@ fun AtomRoot( } } +/** + * Provides a single [AtomStore]. + * + * To use the Remember API for handling [Atom] within [content], it is essential to declare [AtomRoot] somewhere in the parent tree. + * The [store] is provided to child components using the Remember API via [LocalAtomOwner]. + * + * @param store An instance of [AtomStore]. + * @param content The content of the [AtomRoot]. + */ @Composable fun AtomRoot( store: AtomStore, @@ -54,6 +74,9 @@ private class ScopedAtomStore( } } +/** + * CompositionLocal for [AtomStore]. + */ val LocalAtomOwner = staticCompositionLocalOf { error("CompositionLocal 'LocalAtomOwner' not present") } diff --git a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt index a764029..90bd476 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt @@ -15,6 +15,11 @@ import soil.space.AtomStore import soil.space.CommonBundle import soil.space.CommonSavedStateProvider +/** + * A [AtomStore] implementation that saves and restores the state of [Atom]s. + * + * @param savedState The saved state to be restored. + */ @Suppress("SpellCheckingInspection") class AtomSaveableStore( private val savedState: CommonBundle? = null @@ -64,6 +69,16 @@ class AtomSaveableStore( } } +/** + * Remember a [AtomSaveableStore] that saves and restores the state of [Atom]s. + * + * **Note:** + * [LocalSaveableStateRegistry] is required to save and restore the state. + * If [LocalSaveableStateRegistry] is not found, the state will not be saved and restored. + * + * @param key The key to save and restore the state. By default, it resolves using [currentCompositeKeyHash]. + * @return The remembered [AtomSaveableStore]. + */ @Suppress("SpellCheckingInspection") @Composable fun rememberSaveableStore(key: String? = null): AtomStore { diff --git a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomState.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomState.kt index 20f141b..26aab06 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomState.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomState.kt @@ -12,6 +12,13 @@ import androidx.compose.runtime.remember import soil.space.Atom import soil.space.AtomStore +/** + * [MutableState] for handling the state values of [Atom] managed by [AtomStore]. + * + * @param T The type of the state value. + * @param state The [State] used to retrieve the state value. + * @param update A function to update the state value. + */ @Stable class AtomState( private val state: State, @@ -25,6 +32,14 @@ class AtomState( override operator fun component2(): (T) -> Unit = update } +/** + * Remember an [AtomState] for the specified [Atom] reference key. + * + * @param T The type of the state value. + * @param atom The reference key. + * @param store The [AtomStore] that manages the state using the [atom] reference key. By default, it resolves using [LocalAtomOwner]. + * @return The [AtomState] for the specified [Atom] reference key. + */ @Composable fun rememberAtomState( atom: Atom, diff --git a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomValue.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomValue.kt index b1abbcf..3ecb7e0 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomValue.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomValue.kt @@ -12,11 +12,26 @@ import soil.space.Atom import soil.space.AtomRef import soil.space.AtomStore +/** + * [State] for handling the state values of [Atom] managed by [AtomStore]. + * + * @param T The type of the state value. + * @param state The [State] used to retrieve the state value. + */ @Stable class AtomValue( private val state: State, ) : State by state + +/** + * Remember an [AtomValue] for the specified [Atom] reference key. + * + * @param T The type of the state value. + * @param atom The reference key. + * @param store The [AtomStore] that manages the state using the [atom] reference key. By default, it resolves using [LocalAtomOwner]. + * @return The [AtomValue] for the specified [Atom] reference key. + */ @Composable fun rememberAtomValue( atom: Atom, @@ -26,6 +41,14 @@ fun rememberAtomValue( return remember(state) { AtomValue(state) } } +/** + * Remember an [AtomValue] for the specified [AtomRef] reference key. + * + * @param T The type of the state value. + * @param atom The reference key. + * @param store The [AtomStore] that manages the state using the [atom] reference key. By default, it resolves using [LocalAtomOwner]. + * @return The [AtomValue] for the specified [AtomRef] reference key. + */ @Composable fun rememberAtomValue( atom: AtomRef, From 770a0d14378237cc067ad364d1179cf48aaff2c9 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 19 May 2024 14:53:10 +0900 Subject: [PATCH 028/155] Change constructor to internal to provide factory methods --- soil-space/src/commonMain/kotlin/soil/space/Atom.kt | 8 ++------ .../src/commonMain/kotlin/soil/space/AtomSelector.kt | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 72bf335..36c12cb 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -27,11 +27,9 @@ import kotlin.jvm.JvmName * @property initialValue The initial value to be stored. * @property saver The saver to be used to save and restore the value. * @property scope The scope to be used to manage the value. - * - * TODO constructor should be internal */ @Immutable -class Atom( +class Atom internal constructor( val initialValue: T, val saver: AtomSaver? = null, val scope: AtomScope? = null @@ -397,10 +395,8 @@ fun charSequenceArrayListSaver(key: AtomSaverKey): AtomSaver( +class AtomRef internal constructor( val block: AtomSelector.() -> T ) From 05a4fce153fc0ec2e92dbf232156d843ee066577 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 19 May 2024 15:06:00 +0900 Subject: [PATCH 029/155] Use @PublishedApi --- .../kotlin/soil/space/Atom.android.kt | 12 +++-- .../src/commonMain/kotlin/soil/space/Atom.kt | 49 ++++++++++++------- .../skikoMain/kotlin/soil/space/Atom.skiko.kt | 13 +++-- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt b/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt index 2379408..0c4d9cd 100644 --- a/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt +++ b/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt @@ -5,7 +5,8 @@ package soil.space import androidx.core.os.BundleCompat -actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: T) { bundle.putParcelable(key, value) @@ -17,7 +18,8 @@ actual inline fun parcelableSaver(key: AtomSaverK } } -actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: ArrayList) { bundle.putParcelableArrayList(key, value) @@ -30,7 +32,8 @@ actual inline fun parcelableArrayListSaver(key: A } @Suppress("UNCHECKED_CAST") -actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: Array) { bundle.putParcelableArray(key, value) @@ -43,7 +46,8 @@ actual inline fun parcelableArraySaver(key: AtomS } @Suppress("DEPRECATION") -actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: T) { bundle.putSerializable(key, value) diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 36c12cb..939de0f 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -228,8 +228,8 @@ interface AtomSaver { typealias AtomSaverKey = String -// TODO Switch to internal using @PublishedApi -fun stringSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun stringSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: String) { bundle.putString(key, value) @@ -241,7 +241,8 @@ fun stringSaver(key: AtomSaverKey): AtomSaver { } } -fun booleanSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun booleanSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Boolean) { bundle.putBoolean(key, value) @@ -253,7 +254,8 @@ fun booleanSaver(key: AtomSaverKey): AtomSaver { } } -fun intSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun intSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Int) { bundle.putInt(key, value) @@ -265,7 +267,8 @@ fun intSaver(key: AtomSaverKey): AtomSaver { } } -fun longSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun longSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Long) { bundle.putLong(key, value) @@ -277,7 +280,8 @@ fun longSaver(key: AtomSaverKey): AtomSaver { } } -fun doubleSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun doubleSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Double) { bundle.putDouble(key, value) @@ -289,7 +293,8 @@ fun doubleSaver(key: AtomSaverKey): AtomSaver { } } -fun floatSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun floatSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Float) { bundle.putFloat(key, value) @@ -301,7 +306,8 @@ fun floatSaver(key: AtomSaverKey): AtomSaver { } } -fun charSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun charSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Char) { bundle.putChar(key, value) @@ -313,7 +319,8 @@ fun charSaver(key: AtomSaverKey): AtomSaver { } } -fun shortSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun shortSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Short) { bundle.putShort(key, value) @@ -325,7 +332,8 @@ fun shortSaver(key: AtomSaverKey): AtomSaver { } } -fun byteSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal fun byteSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: Byte) { bundle.putByte(key, value) @@ -337,15 +345,20 @@ fun byteSaver(key: AtomSaverKey): AtomSaver { } } -expect inline fun parcelableSaver(key: AtomSaverKey): AtomSaver +@PublishedApi +internal expect inline fun parcelableSaver(key: AtomSaverKey): AtomSaver -expect inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> +@PublishedApi +internal expect inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> -expect inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> +@PublishedApi +internal expect inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> -expect inline fun serializableSaver(key: AtomSaverKey): AtomSaver +@PublishedApi +internal expect inline fun serializableSaver(key: AtomSaverKey): AtomSaver -fun integerArrayListSaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal fun integerArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: ArrayList) { bundle.putIntegerArrayList(key, value) @@ -357,7 +370,8 @@ fun integerArrayListSaver(key: AtomSaverKey): AtomSaver> { } } -fun stringArrayListSaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal fun stringArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: ArrayList) { bundle.putStringArrayList(key, value) @@ -369,7 +383,8 @@ fun stringArrayListSaver(key: AtomSaverKey): AtomSaver> { } } -fun charSequenceArrayListSaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal fun charSequenceArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: ArrayList) { bundle.putCharSequenceArrayList(key, value) diff --git a/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt b/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt index 98fc102..73d1796 100644 --- a/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt +++ b/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt @@ -3,7 +3,8 @@ package soil.space -actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: T) { bundle.putParcelable(key, value) @@ -15,7 +16,8 @@ actual inline fun parcelableSaver(key: AtomSaverK } } -actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: ArrayList) { bundle.putParcelableArrayList(key, value) @@ -27,8 +29,10 @@ actual inline fun parcelableArrayListSaver(key: A } } + @Suppress("UNCHECKED_CAST") -actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { +@PublishedApi +internal actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { override fun save(bundle: CommonBundle, value: Array) { bundle.putParcelableArray(key, value) @@ -40,7 +44,8 @@ actual inline fun parcelableArraySaver(key: AtomS } } -actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { +@PublishedApi +internal actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { override fun save(bundle: CommonBundle, value: T) { bundle.putSerializable(key, value) From d405a6533def7603464130968c4dc0383e59c64e Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 20 May 2024 21:46:07 +0900 Subject: [PATCH 030/155] Thanks to Android Dev Notes for mentioning our library :heart_eyes: --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 599b835..3cd66f6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Please visit [docs.soil-kt.com](https://docs.soil-kt.com/) for Quick Start, guid Thank you for featuring our library in the following sources: - [jetc.dev Newsletter Issue #212](https://jetc.dev/issues/212.html) +- [Android Dev Notes #Twitter](https://twitter.com/androiddevnotes/status/1792409220484350109) ## License From 4e95c208dc7d89f29d46840e03350c36ea91d355 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 25 May 2024 14:21:41 +0900 Subject: [PATCH 031/155] Bump compose-multiplatform to 1.16.10 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0fdb6e3..84404e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ androidx-annotation = "1.7.1" androidx-core = "1.12.0" androidx-lifecycle = "2.7.0" compose = "1.6.7" -compose-multiplatform = "1.6.10-rc01" +compose-multiplatform = "1.6.10" dokka = "1.9.20" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" From dd945ff33f4de5c5c333e88ffd7c996284a00a42 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 25 May 2024 15:48:41 +0900 Subject: [PATCH 032/155] Use androidx.core-bundle --- gradle/libs.versions.toml | 4 + soil-space/build.gradle.kts | 2 + .../kotlin/soil/space/Atom.android.kt | 99 ++++- .../kotlin/soil/space/Platform.android.kt | 14 - .../src/commonMain/kotlin/soil/space/Atom.kt | 381 +++++++++++++----- .../commonMain/kotlin/soil/space/Platform.kt | 62 --- .../soil/space/compose/AtomSaveableStore.kt | 18 +- .../skikoMain/kotlin/soil/space/Atom.skiko.kt | 58 --- .../kotlin/soil/space/Platform.skiko.kt | 50 --- 9 files changed, 373 insertions(+), 315 deletions(-) delete mode 100644 soil-space/src/androidMain/kotlin/soil/space/Platform.android.kt delete mode 100644 soil-space/src/commonMain/kotlin/soil/space/Platform.kt delete mode 100644 soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt delete mode 100644 soil-space/src/skikoMain/kotlin/soil/space/Platform.skiko.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84404e7..a3d0c5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,8 @@ androidx-lifecycle = "2.7.0" compose = "1.6.7" compose-multiplatform = "1.6.10" dokka = "1.9.20" +jbx-core-bundle = "1.0.0" +jbx-savedstate = "1.2.0" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" kotlinx-serialization = "1.6.3" @@ -28,6 +30,8 @@ compose-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4- compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +jbx-core-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version.ref = "jbx-core-bundle" } +jbx-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jbx-savedstate" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index 820d01a..328b159 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -47,6 +47,8 @@ kotlin { commonMain.dependencies { implementation(compose.runtime) implementation(compose.runtimeSaveable) + api(libs.jbx.savedstate) + api(libs.jbx.core.bundle) } androidMain.dependencies { diff --git a/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt b/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt index 0c4d9cd..2675924 100644 --- a/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt +++ b/soil-space/src/androidMain/kotlin/soil/space/Atom.android.kt @@ -3,29 +3,104 @@ package soil.space +import android.os.Parcelable +import androidx.core.bundle.Bundle import androidx.core.os.BundleCompat +import java.io.Serializable + +/** + * Creates an [Atom] using [AtomSaverKey] for [Parcelable]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ +@JvmName("atomWithParcelable") +inline fun atom( + initialValue: T, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, parcelableSaver(saverKey), scope) +} + +/** + * Creates an [Atom] using [AtomSaverKey] for [ArrayList] with [Parcelable][T]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ +@JvmName("atomWithParcelableArrayList") +inline fun atom( + initialValue: ArrayList, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom> { + return atom(initialValue, parcelableArrayListSaver(saverKey), scope) +} + +/** + * Creates an [Atom] using [AtomSaverKey] for [Array] with [Parcelable][T]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ +@JvmName("atomWithParcelableArray") +inline fun atom( + initialValue: Array, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom> { + return atom(initialValue, parcelableArraySaver(saverKey), scope) +} + +/** + * Creates an [Atom] using [AtomSaverKey] for [Serializable]. + * + * @param T The type of the value to be stored. + * @param initialValue The initial value to be stored. + * @param saverKey The key to be used to save and restore the value. + * @param scope The scope to be used to manage the value. + * @return The created Atom. + */ +@JvmName("atomWithSerializable") +inline fun atom( + initialValue: T, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, serializableSaver(saverKey), scope) +} @PublishedApi -internal actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { +internal inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: T) { + override fun save(bundle: Bundle, value: T) { bundle.putParcelable(key, value) } - override fun restore(bundle: CommonBundle): T? { + override fun restore(bundle: Bundle): T? { return if (bundle.containsKey(key)) BundleCompat.getParcelable(bundle, key, T::class.java) else null } } } @PublishedApi -internal actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { +internal inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: ArrayList) { + override fun save(bundle: Bundle, value: ArrayList) { bundle.putParcelableArrayList(key, value) } - override fun restore(bundle: CommonBundle): ArrayList? { + override fun restore(bundle: Bundle): ArrayList? { return BundleCompat.getParcelableArrayList(bundle, key, T::class.java) } } @@ -33,13 +108,13 @@ internal actual inline fun parcelableArrayListSav @Suppress("UNCHECKED_CAST") @PublishedApi -internal actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { +internal inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: Array) { + override fun save(bundle: Bundle, value: Array) { bundle.putParcelableArray(key, value) } - override fun restore(bundle: CommonBundle): Array? { + override fun restore(bundle: Bundle): Array? { return BundleCompat.getParcelableArray(bundle, key, T::class.java) as? Array } } @@ -47,13 +122,13 @@ internal actual inline fun parcelableArraySaver(k @Suppress("DEPRECATION") @PublishedApi -internal actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { +internal inline fun serializableSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: T) { + override fun save(bundle: Bundle, value: T) { bundle.putSerializable(key, value) } - override fun restore(bundle: CommonBundle): T? { + override fun restore(bundle: Bundle): T? { // TODO: Compat package starting with Core-ktx Version 1.13 // ref. https://issuetracker.google.com/issues/317403466 return if (bundle.containsKey(key)) bundle.getSerializable(key) as T else null diff --git a/soil-space/src/androidMain/kotlin/soil/space/Platform.android.kt b/soil-space/src/androidMain/kotlin/soil/space/Platform.android.kt deleted file mode 100644 index 586b647..0000000 --- a/soil-space/src/androidMain/kotlin/soil/space/Platform.android.kt +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.space - -import android.os.Bundle -import android.os.Parcelable -import androidx.savedstate.SavedStateRegistry -import java.io.Serializable - -actual typealias CommonParcelable = Parcelable -actual typealias CommonSerializable = Serializable -actual typealias CommonBundle = Bundle -actual typealias CommonSavedStateProvider = SavedStateRegistry.SavedStateProvider diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 939de0f..610dc0a 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -1,9 +1,10 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 - +@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") package soil.space import androidx.compose.runtime.Immutable +import androidx.core.bundle.Bundle import kotlin.jvm.JvmName /** @@ -83,37 +84,21 @@ inline fun atom( scope: AtomScope? = null ): Atom { val saver: AtomSaver? = when (T::class) { - String::class -> stringSaver(saverKey) as AtomSaver Boolean::class -> booleanSaver(saverKey) as AtomSaver + Byte::class -> byteSaver(saverKey) as AtomSaver + Char::class -> charSaver(saverKey) as AtomSaver + Short::class -> shortSaver(saverKey) as AtomSaver Int::class -> intSaver(saverKey) as AtomSaver Long::class -> longSaver(saverKey) as AtomSaver - Double::class -> doubleSaver(saverKey) as AtomSaver Float::class -> floatSaver(saverKey) as AtomSaver - Char::class -> charSaver(saverKey) as AtomSaver - Short::class -> shortSaver(saverKey) as AtomSaver - Byte::class -> byteSaver(saverKey) as AtomSaver + Double::class -> doubleSaver(saverKey) as AtomSaver + String::class -> stringSaver(saverKey) as AtomSaver + CharSequence::class -> charSequenceSaver(saverKey) as AtomSaver else -> null } return atom(initialValue, saver, scope) } -/** - * Creates an [Atom] using [AtomSaverKey] for [CommonParcelable]. - * - * @param T The type of the value to be stored. - * @param initialValue The initial value to be stored. - * @param saverKey The key to be used to save and restore the value. - * @param scope The scope to be used to manage the value. - * @return The created Atom. - */ -inline fun atom( - initialValue: T, - saverKey: AtomSaverKey, - scope: AtomScope? = null -): Atom { - return atom(initialValue, parcelableSaver(saverKey), scope) -} - /** * Creates an [Atom] using [AtomSaverKey] for [ArrayList]. * @@ -134,74 +119,119 @@ inline fun atom( * @return The created Atom. */ @Suppress("UNCHECKED_CAST") +@JvmName("atomWithArrayList") inline fun atom( initialValue: ArrayList, saverKey: AtomSaverKey, scope: AtomScope? = null ): Atom> { val saver = when (T::class) { - String::class -> stringArrayListSaver(saverKey) as AtomSaver> Int::class -> integerArrayListSaver(saverKey) as AtomSaver> - CharSequence::class -> charSequenceArrayListSaver(saverKey) as AtomSaver> + String::class -> stringArrayListSaver(saverKey) as AtomSaver> else -> null } return atom(initialValue, saver, scope) } -/** - * Creates an [Atom] using [AtomSaverKey] for [ArrayList] with [CommonParcelable][T]. - * - * @param T The type of the value to be stored. - * @param initialValue The initial value to be stored. - * @param saverKey The key to be used to save and restore the value. - * @param scope The scope to be used to manage the value. - * @return The created Atom. - */ -@JvmName("atomWithParcelable") -inline fun atom( - initialValue: ArrayList, +@JvmName("atomWithBooleanArray") +inline fun atom( + initialValue: BooleanArray, saverKey: AtomSaverKey, scope: AtomScope? = null -): Atom> { - return atom(initialValue, parcelableArrayListSaver(saverKey), scope) +): Atom { + return atom(initialValue, booleanArraySaver(saverKey), scope) } -/** - * Creates an [Atom] using [AtomSaverKey] for [Array] with [CommonParcelable][T]. - * - * @param T The type of the value to be stored. - * @param initialValue The initial value to be stored. - * @param saverKey The key to be used to save and restore the value. - * @param scope The scope to be used to manage the value. - * @return The created Atom. - */ -inline fun atom( +@JvmName("atomWithByteArray") +inline fun atom( + initialValue: ByteArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, byteArraySaver(saverKey), scope) +} + +@JvmName("atomWithShortArray") +inline fun atom( + initialValue: ShortArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, shortArraySaver(saverKey), scope) +} + +@JvmName("atomWithCharArray") +inline fun atom( + initialValue: CharArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, charArraySaver(saverKey), scope) +} + +@JvmName("atomWithIntArray") +inline fun atom( + initialValue: IntArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, intArraySaver(saverKey), scope) +} + +@JvmName("atomWithLongArray") +inline fun atom( + initialValue: LongArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, longArraySaver(saverKey), scope) +} + +@JvmName("atomWithFloatArray") +inline fun atom( + initialValue: FloatArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, floatArraySaver(saverKey), scope) +} + +@JvmName("atomWithDoubleArray") +inline fun atom( + initialValue: DoubleArray, + saverKey: AtomSaverKey, + scope: AtomScope? = null +): Atom { + return atom(initialValue, doubleArraySaver(saverKey), scope) +} + +@Suppress("UNCHECKED_CAST") +@JvmName("atomWithArray") +inline fun atom( initialValue: Array, saverKey: AtomSaverKey, scope: AtomScope? = null ): Atom> { - return atom(initialValue, parcelableArraySaver(saverKey), scope) + val saver = when (T::class) { + String::class -> stringArraySaver(saverKey) as AtomSaver> + CharSequence::class -> charSequenceArraySaver(saverKey) as AtomSaver> + else -> null + } + return atom(initialValue, saver, scope) } -/** - * Creates an [Atom] using [AtomSaverKey] for [CommonSerializable]. - * - * @param T The type of the value to be stored. - * @param initialValue The initial value to be stored. - * @param saverKey The key to be used to save and restore the value. - * @param scope The scope to be used to manage the value. - * @return The created Atom. - */ -inline fun atom( - initialValue: T, +@JvmName("atomWithBundle") +inline fun atom( + initialValue: Bundle, saverKey: AtomSaverKey, scope: AtomScope? = null -): Atom { - return atom(initialValue, serializableSaver(saverKey), scope) +): Atom { + return atom(initialValue, bundleSaver(saverKey), scope) } + /** - * Interface for saving and restoring values to a [CommonBundle]. + * Interface for saving and restoring values to a [Bundle]. * * Currently, this restoration feature is designed specifically for the Android Platform. * @@ -210,20 +240,20 @@ inline fun atom( interface AtomSaver { /** - * Saves a value to a [CommonBundle]. + * Saves a value to a [Bundle]. * - * @param bundle The [CommonBundle] to save the value to. + * @param bundle The [Bundle] to save the value to. * @param value The value to save. */ - fun save(bundle: CommonBundle, value: T) + fun save(bundle: Bundle, value: T) /** - * Restores a value from a [CommonBundle]. + * Restores a value from a [Bundle]. * - * @param bundle The [CommonBundle] to restore the value from. + * @param bundle The [Bundle] to restore the value from. * @return The restored value. */ - fun restore(bundle: CommonBundle): T? + fun restore(bundle: Bundle): T? } typealias AtomSaverKey = String @@ -231,24 +261,37 @@ typealias AtomSaverKey = String @PublishedApi internal fun stringSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: String) { + override fun save(bundle: Bundle, value: String) { bundle.putString(key, value) } - override fun restore(bundle: CommonBundle): String? { + override fun restore(bundle: Bundle): String? { return if (bundle.containsKey(key)) bundle.getString(key) else null } } } +@PublishedApi +internal fun charSequenceSaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: CharSequence) { + bundle.putCharSequence(key, value) + } + + override fun restore(bundle: Bundle): CharSequence? { + return if (bundle.containsKey(key)) bundle.getCharSequence(key) else null + } + } +} + @PublishedApi internal fun booleanSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Boolean) { + override fun save(bundle: Bundle, value: Boolean) { bundle.putBoolean(key, value) } - override fun restore(bundle: CommonBundle): Boolean? { + override fun restore(bundle: Bundle): Boolean? { return if (bundle.containsKey(key)) bundle.getBoolean(key) else null } } @@ -257,11 +300,11 @@ internal fun booleanSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun intSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Int) { + override fun save(bundle: Bundle, value: Int) { bundle.putInt(key, value) } - override fun restore(bundle: CommonBundle): Int? { + override fun restore(bundle: Bundle): Int? { return if (bundle.containsKey(key)) bundle.getInt(key) else null } } @@ -270,11 +313,11 @@ internal fun intSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun longSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Long) { + override fun save(bundle: Bundle, value: Long) { bundle.putLong(key, value) } - override fun restore(bundle: CommonBundle): Long? { + override fun restore(bundle: Bundle): Long? { return if (bundle.containsKey(key)) bundle.getLong(key) else null } } @@ -283,11 +326,11 @@ internal fun longSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun doubleSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Double) { + override fun save(bundle: Bundle, value: Double) { bundle.putDouble(key, value) } - override fun restore(bundle: CommonBundle): Double? { + override fun restore(bundle: Bundle): Double? { return if (bundle.containsKey(key)) bundle.getDouble(key) else null } } @@ -296,11 +339,11 @@ internal fun doubleSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun floatSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Float) { + override fun save(bundle: Bundle, value: Float) { bundle.putFloat(key, value) } - override fun restore(bundle: CommonBundle): Float? { + override fun restore(bundle: Bundle): Float? { return if (bundle.containsKey(key)) bundle.getFloat(key) else null } } @@ -309,11 +352,11 @@ internal fun floatSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun charSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Char) { + override fun save(bundle: Bundle, value: Char) { bundle.putChar(key, value) } - override fun restore(bundle: CommonBundle): Char? { + override fun restore(bundle: Bundle): Char? { return if (bundle.containsKey(key)) bundle.getChar(key) else null } } @@ -322,11 +365,11 @@ internal fun charSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun shortSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Short) { + override fun save(bundle: Bundle, value: Short) { bundle.putShort(key, value) } - override fun restore(bundle: CommonBundle): Short? { + override fun restore(bundle: Bundle): Short? { return if (bundle.containsKey(key)) bundle.getShort(key) else null } } @@ -335,36 +378,24 @@ internal fun shortSaver(key: AtomSaverKey): AtomSaver { @PublishedApi internal fun byteSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { - override fun save(bundle: CommonBundle, value: Byte) { + override fun save(bundle: Bundle, value: Byte) { bundle.putByte(key, value) } - override fun restore(bundle: CommonBundle): Byte? { + override fun restore(bundle: Bundle): Byte? { return if (bundle.containsKey(key)) bundle.getByte(key) else null } } } -@PublishedApi -internal expect inline fun parcelableSaver(key: AtomSaverKey): AtomSaver - -@PublishedApi -internal expect inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> - -@PublishedApi -internal expect inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> - -@PublishedApi -internal expect inline fun serializableSaver(key: AtomSaverKey): AtomSaver - @PublishedApi internal fun integerArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: ArrayList) { + override fun save(bundle: Bundle, value: ArrayList) { bundle.putIntegerArrayList(key, value) } - override fun restore(bundle: CommonBundle): ArrayList? { + override fun restore(bundle: Bundle): ArrayList? { return bundle.getIntegerArrayList(key) } } @@ -373,25 +404,155 @@ internal fun integerArrayListSaver(key: AtomSaverKey): AtomSaver> @PublishedApi internal fun stringArrayListSaver(key: AtomSaverKey): AtomSaver> { return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: ArrayList) { + override fun save(bundle: Bundle, value: ArrayList) { bundle.putStringArrayList(key, value) } - override fun restore(bundle: CommonBundle): ArrayList? { + override fun restore(bundle: Bundle): ArrayList? { return bundle.getStringArrayList(key) } } } @PublishedApi -internal fun charSequenceArrayListSaver(key: AtomSaverKey): AtomSaver> { - return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: ArrayList) { - bundle.putCharSequenceArrayList(key, value) +internal fun booleanArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: BooleanArray) { + bundle.putBooleanArray(key, value) + } + + override fun restore(bundle: Bundle): BooleanArray? { + return bundle.getBooleanArray(key) + } + } +} + +@PublishedApi +internal fun byteArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: ByteArray) { + bundle.putByteArray(key, value) + } + + override fun restore(bundle: Bundle): ByteArray? { + return bundle.getByteArray(key) + } + } +} + +@PublishedApi +internal fun shortArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: ShortArray) { + bundle.putShortArray(key, value) + } + + override fun restore(bundle: Bundle): ShortArray? { + return bundle.getShortArray(key) + } + } +} + +@PublishedApi +internal fun charArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: CharArray) { + bundle.putCharArray(key, value) + } + + override fun restore(bundle: Bundle): CharArray? { + return bundle.getCharArray(key) + } + } +} + +@PublishedApi +internal fun intArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: IntArray) { + bundle.putIntArray(key, value) + } + + override fun restore(bundle: Bundle): IntArray? { + return bundle.getIntArray(key) + } + } +} + +@PublishedApi +internal fun longArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: LongArray) { + bundle.putLongArray(key, value) + } + + override fun restore(bundle: Bundle): LongArray? { + return bundle.getLongArray(key) + } + } +} + +@PublishedApi +internal fun floatArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: FloatArray) { + bundle.putFloatArray(key, value) + } + + override fun restore(bundle: Bundle): FloatArray? { + return bundle.getFloatArray(key) + } + } +} + +@PublishedApi +internal fun doubleArraySaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: DoubleArray) { + bundle.putDoubleArray(key, value) + } + + override fun restore(bundle: Bundle): DoubleArray? { + return bundle.getDoubleArray(key) + } + } +} + +@PublishedApi +internal fun stringArraySaver(key: AtomSaverKey): AtomSaver> { + return object : AtomSaver> { + override fun save(bundle: Bundle, value: Array) { + bundle.putStringArray(key, value) + } + + override fun restore(bundle: Bundle): Array? { + return bundle.getStringArray(key) + } + } +} + +@PublishedApi +internal fun charSequenceArraySaver(key: AtomSaverKey): AtomSaver> { + return object : AtomSaver> { + override fun save(bundle: Bundle, value: Array) { + bundle.putCharSequenceArray(key, value) + } + + override fun restore(bundle: Bundle): Array? { + return bundle.getCharSequenceArray(key) + } + } +} + +@PublishedApi +internal fun bundleSaver(key: AtomSaverKey): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: Bundle) { + bundle.putBundle(key, value) } - override fun restore(bundle: CommonBundle): ArrayList? { - return bundle.getCharSequenceArrayList(key) + override fun restore(bundle: Bundle): Bundle? { + return bundle.getBundle(key) } } } diff --git a/soil-space/src/commonMain/kotlin/soil/space/Platform.kt b/soil-space/src/commonMain/kotlin/soil/space/Platform.kt deleted file mode 100644 index 85b84ed..0000000 --- a/soil-space/src/commonMain/kotlin/soil/space/Platform.kt +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.space - -/** - * Interface for handling Android platform-specific Parcelable within KMP. - */ -expect interface CommonParcelable - -/** - * Class for handling JVM-specific Serializable within KMP. - */ -expect interface CommonSerializable - -/** - * Class for handling Android platform-specific Bundle within KMP. - * - * Currently, this class provides operations specific to the Android platform's Bundle. - */ -expect class CommonBundle() { - fun containsKey(key: String): Boolean - fun putString(key: String?, value: String?) - fun getString(key: String?): String? - fun putBoolean(key: String?, value: Boolean) - fun getBoolean(key: String): Boolean - fun putInt(key: String?, value: Int) - fun getInt(key: String): Int - fun putLong(key: String?, value: Long) - fun getLong(key: String?): Long - fun putDouble(key: String?, value: Double) - fun getDouble(key: String?): Double - fun putFloat(key: String?, value: Float) - fun getFloat(key: String?): Float - fun putChar(key: String?, value: Char) - fun getChar(key: String?): Char - fun putShort(key: String?, value: Short) - fun getShort(key: String?): Short - fun putByte(key: String?, value: Byte) - fun getByte(key: String?): Byte - fun putParcelable(key: String?, value: CommonParcelable?) - fun getParcelable(key: String?): T? - fun putParcelableArray(key: String?, value: Array?) - fun getParcelableArray(key: String?): Array? - fun putParcelableArrayList(key: String?, value: ArrayList?) - fun getParcelableArrayList(key: String?): ArrayList? - fun putSerializable(key: String?, value: CommonSerializable?) - fun getSerializable(key: String?): CommonSerializable? - fun putIntegerArrayList(key: String?, value: ArrayList?) - fun getIntegerArrayList(key: String?): ArrayList? - fun putStringArrayList(key: String?, value: ArrayList?) - fun getStringArrayList(key: String?): ArrayList? - fun putCharSequenceArrayList(key: String?, value: ArrayList?) - fun getCharSequenceArrayList(key: String?): ArrayList? -} - -/** - * Interface for handling Android platform-specific SavedStateProvider within KMP. - */ -expect fun interface CommonSavedStateProvider { - fun saveState(): CommonBundle -} diff --git a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt index 90bd476..5946f34 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomSaveableStore.kt @@ -10,10 +10,10 @@ import androidx.compose.runtime.currentCompositeKeyHash import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.core.bundle.Bundle +import androidx.savedstate.SavedStateRegistry import soil.space.Atom import soil.space.AtomStore -import soil.space.CommonBundle -import soil.space.CommonSavedStateProvider /** * A [AtomStore] implementation that saves and restores the state of [Atom]s. @@ -22,8 +22,8 @@ import soil.space.CommonSavedStateProvider */ @Suppress("SpellCheckingInspection") class AtomSaveableStore( - private val savedState: CommonBundle? = null -) : AtomStore, CommonSavedStateProvider { + private val savedState: Bundle? = null +) : AtomStore, SavedStateRegistry.SavedStateProvider { private val stateMap: MutableMap, ManagedState<*>> = mutableMapOf() @@ -46,8 +46,8 @@ class AtomSaveableStore( state?.value = value } - override fun saveState(): CommonBundle { - val box = savedState ?: CommonBundle() + override fun saveState(): Bundle { + val box = savedState ?: Bundle() stateMap.keys.forEach { atom -> val value = stateMap[atom] as ManagedState<*> value.onSave(box) @@ -59,11 +59,11 @@ class AtomSaveableStore( private val atom: Atom, ) : MutableState by mutableStateOf(atom.initialValue) { - fun onSave(bundle: CommonBundle) { + fun onSave(bundle: Bundle) { atom.saver?.save(bundle, value) } - fun onRestore(bundle: CommonBundle) { + fun onRestore(bundle: Bundle) { atom.saver?.restore(bundle)?.let { value = it } } } @@ -89,7 +89,7 @@ fun rememberSaveableStore(key: String? = null): AtomStore { } val registry = LocalSaveableStateRegistry.current val store = remember(registry) { - AtomSaveableStore(registry?.consumeRestored(finalKey) as? CommonBundle) + AtomSaveableStore(registry?.consumeRestored(finalKey) as? Bundle) } if (registry != null) { DisposableEffect(registry, finalKey, store) { diff --git a/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt b/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt deleted file mode 100644 index 73d1796..0000000 --- a/soil-space/src/skikoMain/kotlin/soil/space/Atom.skiko.kt +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.space - -@PublishedApi -internal actual inline fun parcelableSaver(key: AtomSaverKey): AtomSaver { - return object : AtomSaver { - override fun save(bundle: CommonBundle, value: T) { - bundle.putParcelable(key, value) - } - - override fun restore(bundle: CommonBundle): T? { - return if (bundle.containsKey(key)) bundle.getParcelable(key) else null - } - } -} - -@PublishedApi -internal actual inline fun parcelableArrayListSaver(key: AtomSaverKey): AtomSaver> { - return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: ArrayList) { - bundle.putParcelableArrayList(key, value) - } - - override fun restore(bundle: CommonBundle): ArrayList? { - return bundle.getParcelableArrayList(key) - } - } -} - - -@Suppress("UNCHECKED_CAST") -@PublishedApi -internal actual inline fun parcelableArraySaver(key: AtomSaverKey): AtomSaver> { - return object : AtomSaver> { - override fun save(bundle: CommonBundle, value: Array) { - bundle.putParcelableArray(key, value) - } - - override fun restore(bundle: CommonBundle): Array? { - return bundle.getParcelableArray(key) as? Array - } - } -} - -@PublishedApi -internal actual inline fun serializableSaver(key: AtomSaverKey): AtomSaver { - return object : AtomSaver { - override fun save(bundle: CommonBundle, value: T) { - bundle.putSerializable(key, value) - } - - override fun restore(bundle: CommonBundle): T? { - return if (bundle.containsKey(key)) bundle.getSerializable(key) as T else null - } - } -} diff --git a/soil-space/src/skikoMain/kotlin/soil/space/Platform.skiko.kt b/soil-space/src/skikoMain/kotlin/soil/space/Platform.skiko.kt deleted file mode 100644 index 844e68a..0000000 --- a/soil-space/src/skikoMain/kotlin/soil/space/Platform.skiko.kt +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.space - - -actual interface CommonParcelable -actual interface CommonSerializable -actual typealias CommonBundle = FakeBundle - -@Suppress("UNUSED_PARAMETER") -class FakeBundle { - fun containsKey(key: String): Boolean = false - fun putString(key: String?, value: String?) = Unit - fun getString(key: String?): String? = null - fun putBoolean(key: String?, value: Boolean) = Unit - fun getBoolean(key: String): Boolean = false - fun putInt(key: String?, value: Int) = Unit - fun getInt(key: String): Int = 0 - fun putLong(key: String?, value: Long) = Unit - fun getLong(key: String?): Long = 0 - fun putDouble(key: String?, value: Double) = Unit - fun getDouble(key: String?): Double = 0.0 - fun putFloat(key: String?, value: Float) = Unit - fun getFloat(key: String?): Float = 0.0f - fun putChar(key: String?, value: Char) = Unit - fun getChar(key: String?): Char = Char.MIN_VALUE - fun putShort(key: String?, value: Short) = Unit - fun getShort(key: String?): Short = 0 - fun putByte(key: String?, value: Byte) = Unit - fun getByte(key: String?): Byte = Byte.MIN_VALUE - fun putParcelable(key: String?, value: CommonParcelable?) = Unit - fun getParcelable(key: String?): T? = null - fun putParcelableArray(key: String?, value: Array?) = Unit - fun getParcelableArray(key: String?): Array? = null - fun putParcelableArrayList(key: String?, value: ArrayList?) = Unit - fun getParcelableArrayList(key: String?): ArrayList? = null - fun putSerializable(key: String?, value: CommonSerializable?) = Unit - fun getSerializable(key: String?): CommonSerializable? = null - fun putIntegerArrayList(key: String?, value: ArrayList?) = Unit - fun getIntegerArrayList(key: String?): ArrayList? = null - fun putStringArrayList(key: String?, value: ArrayList?) = Unit - fun getStringArrayList(key: String?): ArrayList? = null - fun putCharSequenceArrayList(key: String?, value: ArrayList?) = Unit - fun getCharSequenceArrayList(key: String?): ArrayList? = null -} - -actual fun interface CommonSavedStateProvider { - actual fun saveState(): CommonBundle -} From ea24e672e676fe0e546f9667efdfc1c9e916597d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 25 May 2024 10:17:19 +0000 Subject: [PATCH 033/155] Apply automatic changes --- soil-space/src/commonMain/kotlin/soil/space/Atom.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 610dc0a..3fb9365 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -1,5 +1,6 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 + @file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") package soil.space From 940dc9c6d1f3623afd912f1ccbf60ad6a19d303b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 26 May 2024 12:20:13 +0900 Subject: [PATCH 034/155] Add a KDoc comments for the soil.form package --- .../src/commonMain/kotlin/soil/form/Field.kt | 82 ++++++++++++++++++- .../kotlin/soil/form/FieldPolicy.kt | 53 +++++++++++- .../commonMain/kotlin/soil/form/FormPolicy.kt | 15 ++++ .../commonMain/kotlin/soil/form/FormRule.kt | 11 +++ .../soil/form/FormValidationException.kt | 10 +++ .../commonMain/kotlin/soil/form/Submission.kt | 35 ++++++++ .../kotlin/soil/form/SubmissionPolicy.kt | 18 +++- .../kotlin/soil/form/ValidationRule.kt | 17 ++++ .../kotlin/soil/form/ValidationRuleBuilder.kt | 26 +++++- 9 files changed, 261 insertions(+), 6 deletions(-) diff --git a/soil-form/src/commonMain/kotlin/soil/form/Field.kt b/soil-form/src/commonMain/kotlin/soil/form/Field.kt index b06e1d9..1282a03 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/Field.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/Field.kt @@ -3,31 +3,109 @@ package soil.form +/** + * A field is a single input in a form. It has a name, a value, and a set of errors. + * + * @param V The type of the value of the field. + */ interface Field { + + /** + * The name of the field. + */ val name: FieldName + + /** + * The value of the field. + */ val value: V + + /** + * The errors of the field. If the field has no errors, this should be an empty list. + */ val errors: FieldErrors + + /** + * Returns `true` if the field is dirty, `false` otherwise. + * A field is dirty if its value has changed since the initial value. + */ val isDirty: Boolean + + /** + * Returns `true` if the field is enabled, `false` otherwise. + * A field is enabled if it is not disabled. + */ val isEnabled: Boolean + + /** + * Returns `true` if the field is touched, `false` otherwise. + * Sets to touched when the field loses focus after being focused. + */ val isTouched: Boolean + + /** + * Returns `true` if the field is focused, `false` otherwise. + * Sets to focused when the field gains focus. + */ val isFocused: Boolean + + /** + * Callback to notify when the value of the field is changed. + */ val onChange: (V) -> Unit + + /** + * Callback to notify when the field gains focus. + */ val onFocus: () -> Unit + + /** + * Callback to notify when the field loses focus. + */ val onBlur: () -> Unit + + /** + * Returns `true` if the field has errors, `false` otherwise. + */ val hasError: Boolean get() = errors.isNotEmpty() - // NOTE: This is an escape hatch intended for cases where you want to execute similar validation as onBlur while KeyboardActions are set. - // It is not intended for use in other contexts. + /** + * Triggers validation of the field. + * + * **NOTE:* + * This is an escape hatch intended for cases where you want to execute similar validation as onBlur while KeyboardActions are set. + * It is not intended for use in other contexts. + */ fun virtualTrigger(validateOn: FieldValidateOn) } +/** + * This field name is used to uniquely identify a field within a form. + */ typealias FieldName = String + +/** + * Represents a single error message in the field. + */ typealias FieldError = String + +/** + * Represents multiple error messages in the field. + */ typealias FieldErrors = List +/** + * Creates error messages for a field. + * + * @param messages Error messages. There must be at least one error message. + * @return The generated error messages for the field. + */ fun fieldError(vararg messages: String): FieldErrors { require(messages.isNotEmpty()) return listOf(*messages) } +/** + * Syntax sugar representing that there are no errors in the field. + */ val noErrors: FieldErrors = emptyList() diff --git a/soil-form/src/commonMain/kotlin/soil/form/FieldPolicy.kt b/soil-form/src/commonMain/kotlin/soil/form/FieldPolicy.kt index 3237ccd..ebfeac4 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/FieldPolicy.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/FieldPolicy.kt @@ -6,26 +6,73 @@ package soil.form import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** + * Represents the settings for field-related policies, such as validation behavior. + * + * @property validationTrigger The timing to trigger automatic validation. + * @property validationDelay The settings for delayed execution of field validation. + * @constructor Creates a new instance of [FieldPolicy]. + */ data class FieldPolicy( val validationTrigger: FieldValidationTrigger = FieldValidationTrigger, val validationDelay: FieldValidationDelay = FieldValidationDelay() ) +/** + * Represents the settings for delayed execution of field validation. + * + * @property onMount The delay before the validation when the field mount. + * @property onChange The delay before validation when the field value changes. + * @property onBlur The delay before validation when the field loses focus. + * @constructor Creates a new instance of [FieldValidationDelay]. + */ data class FieldValidationDelay( val onMount: Duration = Duration.ZERO, val onChange: Duration = 250.milliseconds, val onBlur: Duration = Duration.ZERO ) +/** + * Enumerates the timings when field validation is performed. + */ enum class FieldValidateOn { - Mount, Change, Blur, Submit + + /** When the field component is mounted */ + Mount, + + /** When the input value changes */ + Change, + + /** When the focus is lost */ + Blur, + + /** When the form is submit */ + Submit } +/** + * Represents the timings that trigger field validation. + */ interface FieldValidationTrigger { + + /** + * The timing to trigger automatic validation. + */ val startAt: FieldValidateOn + /** + * Returns the timing to trigger the next validation. + * + * @param state The current validation timing + * @param isPassed Whether the validation was successful + * @return The next validation timing + */ fun next(state: FieldValidateOn, isPassed: Boolean): FieldValidateOn + /** + * By default, this setting automatically performs validation after the field loses focus, + * and if validation fails, it will re-validate each time the form input changes. + */ companion object Default : FieldValidationTrigger { override val startAt: FieldValidateOn = FieldValidateOn.Blur @@ -45,6 +92,10 @@ interface FieldValidationTrigger { } } + /** + * Automatically performs validation at the time of form submission, + * and thereafter re-validates each time the form input changes. + */ object Submit : FieldValidationTrigger { override val startAt: FieldValidateOn = FieldValidateOn.Submit diff --git a/soil-form/src/commonMain/kotlin/soil/form/FormPolicy.kt b/soil-form/src/commonMain/kotlin/soil/form/FormPolicy.kt index e8250b6..4ee3e31 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/FormPolicy.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/FormPolicy.kt @@ -3,12 +3,27 @@ package soil.form +/** + * Represents the settings for the overall form policy. + * + * @property field Settings related to field policies. + * @property submission Settings related to submission control. + * @constructor Creates a new instance of [FormPolicy]. + */ data class FormPolicy( val field: FieldPolicy = FieldPolicy(), val submission: SubmissionPolicy = SubmissionPolicy() ) { companion object { + + /** + * By default, this setting automatically performs validation after the field loses focus, and before submission. + */ val Default = FormPolicy() + + /** + * This setting performs validation only before submission. + */ val Minimal = FormPolicy( field = FieldPolicy( validationTrigger = FieldValidationTrigger.Submit diff --git a/soil-form/src/commonMain/kotlin/soil/form/FormRule.kt b/soil-form/src/commonMain/kotlin/soil/form/FormRule.kt index 308880f..b1aac11 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/FormRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/FormRule.kt @@ -3,6 +3,17 @@ package soil.form +/** + * Represents a rule that can be applied to a form field. + */ fun interface FormRule { + + /** + * Tests the given value against the rule. + * + * @param value The value to test. + * @param dryRun Whether to perform the test without side effects. + * @return `true` if the value passes the rule; `false` otherwise. + */ fun test(value: T, dryRun: Boolean): Boolean } diff --git a/soil-form/src/commonMain/kotlin/soil/form/FormValidationException.kt b/soil-form/src/commonMain/kotlin/soil/form/FormValidationException.kt index c996e21..95bae84 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/FormValidationException.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/FormValidationException.kt @@ -3,6 +3,16 @@ package soil.form +/** + * Exception used for handling validation errors during the submission process. + * + * This exception is useful when the validation logic is on the API side and validation errors can be detected from the response's status code. + * By passing a mapping of field names to error messages in [errors], you can identify the fields where errors occurred. + * + * @property errors Mapping of validation error information. + * @property cause The exception source that caused this exception. + * @constructor Creates a new instance with specified validation error information. + */ class FormValidationException( val errors: FormErrors, cause: Throwable? = null diff --git a/soil-form/src/commonMain/kotlin/soil/form/Submission.kt b/soil-form/src/commonMain/kotlin/soil/form/Submission.kt index 8dc784f..03deaeb 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/Submission.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/Submission.kt @@ -3,12 +3,47 @@ package soil.form +/** + * A submission represents the submission control of a form. + */ interface Submission { + + /** + * Returns `true` if the field is dirty, `false` otherwise. + * A form value is dirty if its value has changed since the initial value. + */ val isDirty: Boolean + + /** + * Returns `true` if the form has validation error, `false` otherwise. + * A form is error if any of its fields has validation error. + */ val hasError: Boolean + + /** + * Returns `true` if the form is submitting, `false` otherwise. + * This is useful to prevent duplicate submissions until the submission process is completed. + */ val isSubmitting: Boolean + + /** + * Returns `true` if the form is submitted, `false` otherwise. + */ val isSubmitted: Boolean + + /** + * Returns the number of times submission process was called. + */ val submitCount: Int + + /** + * Returns `true` if the form can be submitted, `false` otherwise. + * This is useful when you want to disable the submit button itself until field validation errors are resolved. + */ val canSubmit: Boolean + + /** + * A callback to notify that the submit button has been clicked. + */ val onSubmit: () -> Unit } diff --git a/soil-form/src/commonMain/kotlin/soil/form/SubmissionPolicy.kt b/soil-form/src/commonMain/kotlin/soil/form/SubmissionPolicy.kt index d01e2d1..bc329ce 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/SubmissionPolicy.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/SubmissionPolicy.kt @@ -6,13 +6,29 @@ package soil.form import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** + * Represents the settings for submission-related policies, such as pre-validation behavior. + * + * @property preValidation Whether to perform pre-validation before submission. + * @property preValidationDelay The settings for delayed execution of pre-validation. + * @constructor Creates a new instance of [SubmissionPolicy]. + */ data class SubmissionPolicy( val preValidation: Boolean = true, val preValidationDelay: SubmissionPreValidationDelay = SubmissionPreValidationDelay() ) +/** + * Represents the settings for delayed execution of pre-validation. + * + * **Note:** + * [onMount] as a zero duration is not recommended as it triggers based on the registration of Field's Rules. + * + * @property onMount The delay before the pre-validation when the form is mounted. + * @property onChange The delay before pre-validation when the form value changes. + * @constructor Creates a new instance of [SubmissionPreValidationDelay]. + */ data class SubmissionPreValidationDelay( - // NOTE: As it triggers based on the registration of Field's Rules, a zero duration is not recommended val onMount: Duration = 200.milliseconds, val onChange: Duration = 200.milliseconds ) diff --git a/soil-form/src/commonMain/kotlin/soil/form/ValidationRule.kt b/soil-form/src/commonMain/kotlin/soil/form/ValidationRule.kt index 914713d..52f1664 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/ValidationRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/ValidationRule.kt @@ -3,8 +3,25 @@ package soil.form +/** + * Represents a validation rule for a form field. + * + * @param V The type of the value to be validated. + */ fun interface ValidationRule { + + /** + * Tests the given value against this rule. + * + * @param value The value to be tested. + * @return The errors found in the value. If the value is valid, this should be an empty list. + */ fun test(value: V): FieldErrors } +/** + * A set of validation rules. + * + * @param V The type of the value to be validated. + */ typealias ValidationRuleSet = Set> diff --git a/soil-form/src/commonMain/kotlin/soil/form/ValidationRuleBuilder.kt b/soil-form/src/commonMain/kotlin/soil/form/ValidationRuleBuilder.kt index cab8481..07683e5 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/ValidationRuleBuilder.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/ValidationRuleBuilder.kt @@ -3,19 +3,41 @@ package soil.form +/** + * Builder for creating a set of validation rules. + * + * @param V The type of the value to be validated. + */ class ValidationRuleBuilder { private val rules: MutableSet> = mutableSetOf() + /** + * Adds a rule to the set of rules. + * + * @param rule The rule to be added. + */ fun extend(rule: ValidationRule) = this.apply { rules.add(rule) } + /** + * Builds the set of rules. + * + * @return The set of rules. + */ fun build(): ValidationRuleSet { if (rules.isEmpty()) error("Rule must be at least one.") return rules.toSet() } } -fun rules(block: ValidationRuleBuilder.() -> Unit): ValidationRuleSet { - return ValidationRuleBuilder().apply(block).build() +/** + * Creates a set of validation rules. + * + * @param V The type of the value to be validated. + * @param block The block of code to build the rules. + * @return The set of rules. + */ +fun rules(block: ValidationRuleBuilder.() -> Unit): ValidationRuleSet { + return ValidationRuleBuilder().apply(block).build() } From 780ef82c152334fbe57af4c2d15553d54fcb3a68 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 26 May 2024 14:58:57 +0900 Subject: [PATCH 035/155] Add a KDoc comments for the soil.form.rule package --- .../kotlin/soil/form/rule/ArrayRule.kt | 47 +++++++++- .../kotlin/soil/form/rule/BooleanRule.kt | 33 ++++++- .../kotlin/soil/form/rule/CollectionRule.kt | 49 ++++++++++- .../kotlin/soil/form/rule/DoubleRule.kt | 59 ++++++++++++- .../kotlin/soil/form/rule/FloatRule.kt | 59 ++++++++++++- .../kotlin/soil/form/rule/IntRule.kt | 35 +++++++- .../kotlin/soil/form/rule/LongRule.kt | 32 ++++++- .../kotlin/soil/form/rule/ObjectRule.kt | 60 ++++++++++++- .../kotlin/soil/form/rule/OptionalRule.kt | 34 +++++++- .../kotlin/soil/form/rule/StringRule.kt | 85 ++++++++++++++++++- 10 files changed, 483 insertions(+), 10 deletions(-) diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/ArrayRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/ArrayRule.kt index 559bfa5..c8eb55b 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/ArrayRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/ArrayRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias ArrayRule = ValidationRule> typealias ArrayRuleBuilder = ValidationRuleBuilder> +/** + * A rule that tests the array value. + * + * @property predicate The predicate to test the array value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [ArrayRuleTester]. + */ class ArrayRuleTester( val predicate: Array.() -> Boolean, val message: () -> String @@ -21,14 +28,52 @@ class ArrayRuleTester( } } +/** + * Validates that the array is not empty. + * + * Usage: + * ```kotlin + * rules> { + * notEmpty { "must be not empty" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun ArrayRuleBuilder.notEmpty(message: () -> String) { extend(ArrayRuleTester(Array::isNotEmpty, message)) } +/** + * Validates that the array size is at least [limit]. + * + * Usage: + * ```kotlin + * rules> { + * minSize(3) { "must have at least 3 items" } + * } + * ``` + * + * @param limit The minimum number of elements the array must have. + * @param message The message to return when the test fails. + */ fun ArrayRuleBuilder.minSize(limit: Int, message: () -> String) { extend(ArrayRuleTester({ size >= limit }, message)) } +/** + * Validates that the array size is no more than [limit]. + * + * Usage: + * ```kotlin + * rules> { + * maxSize(20) { "must have at no more 20 items" } + * } + * ``` + * + * @param limit The maximum number of elements the array can have. + * @param message The message to return when the test fails. + */ fun ArrayRuleBuilder.maxSize(limit: Int, message: () -> String) { extend(ArrayRuleTester({ size <= limit }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/BooleanRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/BooleanRule.kt index 0fa1bb9..1172358 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/BooleanRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/BooleanRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias BooleanRule = ValidationRule typealias BooleanRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the boolean value. + * + * @property predicate The predicate to test the boolean value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [BooleanRuleTester]. + */ class BooleanRuleTester( val predicate: Boolean.() -> Boolean, val message: () -> String @@ -21,10 +28,34 @@ class BooleanRuleTester( } } +/** + * Validates that the boolean value is `true`. + * + * Usage: + * ```kotlin + * rules { + * isTrue { "must be true" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun BooleanRuleBuilder.isTrue(message: () -> String) { extend(BooleanRuleTester({ this }, message)) } +/** + * Validates that the boolean value is `false`. + * + * Usage: + * ```kotlin + * rules { + * isFalse { "must be false" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun BooleanRuleBuilder.isFalse(message: () -> String) { extend(BooleanRuleTester({ !this }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/CollectionRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/CollectionRule.kt index d27687d..80a074e 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/CollectionRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/CollectionRule.kt @@ -3,15 +3,24 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors +// TODO: ListRule, SetRule, MapRule + typealias CollectionRule = ValidationRule> typealias CollectionRuleBuilder = ValidationRuleBuilder> +/** + * A rule that tests the collection value. + * + * @property predicate The predicate to test the collection value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [CollectionRuleTester]. + */ class CollectionRuleTester( val predicate: Collection.() -> Boolean, val message: () -> String @@ -21,14 +30,52 @@ class CollectionRuleTester( } } +/** + * Validates that the collection is not empty. + * + * Usage: + * ```kotlin + * rules> { + * notEmpty { "must not be empty" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun CollectionRuleBuilder.notEmpty(message: () -> String) { extend(CollectionRuleTester(Collection::isNotEmpty, message)) } +/** + * Validates that the collection size is at least [limit]. + * + * Usage: + * ```kotlin + * rules> { + * minSize(3) { "must have at least 3 items" } + * } + * ``` + * + * @param limit The minimum number of elements the collection must have. + * @param message The message to return when the test fails. + */ fun CollectionRuleBuilder.minSize(limit: Int, message: () -> String) { extend(CollectionRuleTester({ size >= limit }, message)) } +/** + * Validates that the collection size is no more than [limit]. + * + * Usage: + * ```kotlin + * rules> { + * maxSize(20) { "must have at no more 20 items" } + * } + * ``` + * + * @param limit The maximum number of elements the collection can have. + * @param message The message to return when the test fails. + */ fun CollectionRuleBuilder.maxSize(limit: Int, message: () -> String) { extend(CollectionRuleTester({ size <= limit }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/DoubleRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/DoubleRule.kt index af5260d..4ea0d2e 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/DoubleRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/DoubleRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias DoubleRule = ValidationRule typealias DoubleRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the double value. + * + * @property predicate The predicate to test the double value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [DoubleRuleTester]. + */ class DoubleRuleTester( val predicate: Double.() -> Boolean, val message: () -> String @@ -21,18 +28,68 @@ class DoubleRuleTester( } } +/** + * Validates that the double value is greater than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * minimum(3.0) { "must be greater than or equal to 3.0" } + * } + * ``` + * + * @param limit The minimum value the double must have. + * @param message The message to return when the test fails. + */ fun DoubleRuleBuilder.minimum(limit: Double, message: () -> String) { extend(DoubleRuleTester({ this >= limit }, message)) } +/** + * Validates that the double value is less than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * maximum(20.0) { "must be less than or equal to 20.0" } + * } + * ``` + * + * @param limit The maximum value the double can have. + * @param message The message to return when the test fails. + */ fun DoubleRuleBuilder.maximum(limit: Double, message: () -> String) { extend(DoubleRuleTester({ this <= limit }, message)) } +/** + * Validates that the double value is `NaN`. + * + * Usage: + * ```kotlin + * rules { + * isNaN { "must be NaN" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun DoubleRuleBuilder.isNaN(message: () -> String) { extend(DoubleRuleTester({ isNaN() }, message)) } +/** + * Validates that the double value is not `NaN`. + * + * Usage: + * ```kotlin + * rules { + * notNaN { "must be not NaN" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun DoubleRuleBuilder.notNaN(message: () -> String) { extend(DoubleRuleTester({ !isNaN() }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/FloatRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/FloatRule.kt index 117138c..8fa6e06 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/FloatRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/FloatRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias FloatRule = ValidationRule typealias FloatRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the float value. + * + * @property predicate The predicate to test the float value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [FloatRuleTester]. + */ class FloatRuleTester( val predicate: Float.() -> Boolean, val message: () -> String @@ -21,18 +28,68 @@ class FloatRuleTester( } } +/** + * Validates that the float value is greater than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * minimum(3.0f) { "must be greater than or equal to 3.0" } + * } + * ``` + * + * @param limit The minimum value the float must have. + * @param message The message to return when the test fails. + */ fun FloatRuleBuilder.minimum(limit: Float, message: () -> String) { extend(FloatRuleTester({ this >= limit }, message)) } +/** + * Validates that the float value is less than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * maximum(20.0f) { "must be less than or equal to 20.0" } + * } + * ``` + * + * @param limit The maximum value the float can have. + * @param message The message to return when the test fails. + */ fun FloatRuleBuilder.maximum(limit: Float, message: () -> String) { extend(FloatRuleTester({ this <= limit }, message)) } +/** + * Validates that the float value is `NaN`. + * + * Usage: + * ```kotlin + * rules { + * isNaN { "must be NaN" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun FloatRuleBuilder.isNaN(message: () -> String) { extend(FloatRuleTester({ isNaN() }, message)) } +/** + * Validates that the float value is not `NaN`. + * + * Usage: + * ```kotlin + * rules { + * notNaN { "must be not NaN" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun FloatRuleBuilder.notNaN(message: () -> String) { extend(FloatRuleTester({ !isNaN() }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/IntRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/IntRule.kt index 241ea54..2e5010c 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/IntRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/IntRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias IntRule = ValidationRule typealias IntRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the integer value. + * + * @property predicate The predicate to test the integer value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [IntRuleTester]. + */ class IntRuleTester( val predicate: Int.() -> Boolean, val message: () -> String @@ -21,10 +28,36 @@ class IntRuleTester( } } +/** + * Validates that the integer value is greater than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * minimum(3) { "must be greater than or equal to 3" } + * } + * ``` + * + * @param limit The minimum value the integer must have. + * @param message The message to return when the test fails. + */ fun IntRuleBuilder.minimum(limit: Int, message: () -> String) { extend(IntRuleTester({ this >= limit }, message)) } +/** + * Validates that the integer value is less than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * maximum(20) { "must be less than or equal to 20" } + * } + * ``` + * + * @param limit The maximum value the integer can have. + * @param message The message to return when the test fails. + */ fun IntRuleBuilder.maximum(limit: Int, message: () -> String) { extend(IntRuleTester({ this <= limit }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/LongRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/LongRule.kt index ef210c9..36956e8 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/LongRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/LongRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias LongRule = ValidationRule typealias LongRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the long value. + * + * @property predicate The predicate to test the long value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [LongRuleTester]. + */ class LongRuleTester( val predicate: Long.() -> Boolean, val message: () -> String @@ -21,10 +28,33 @@ class LongRuleTester( } } +/** + * Validates that the long value is greater than or equal to [limit]. + * + * Usage: + * ```kotlin + * rules { + * minimum(3) { "must be greater than or equal to 3" } + * } + * ``` + * + * @param limit The minimum value the long must have. + * @param message The message to return when the test fails. + */ fun LongRuleBuilder.minimum(limit: Long, message: () -> String) { extend(LongRuleTester({ this >= limit }, message)) } +/** + * Validates that the long value is less than or equal to [limit]. + * + * rules { + * maximum(20) { "must be less than or equal to 20" } + * } + * + * @param limit The maximum value the long can have. + * @param message The message to return when the test fails. + */ fun LongRuleBuilder.maximum(limit: Long, message: () -> String) { extend(LongRuleTester({ this <= limit }, message)) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/ObjectRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/ObjectRule.kt index 2d7499b..12f758c 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/ObjectRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/ObjectRule.kt @@ -3,16 +3,23 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors import soil.form.rules typealias ObjectRule = ValidationRule typealias ObjectRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the object value. + * + * @property predicate The predicate to test the object value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [ObjectRuleTester]. + */ class ObjectRuleTester( val predicate: V.() -> Boolean, val message: () -> String @@ -22,6 +29,12 @@ class ObjectRuleTester( } } +/** + * A rule that chains a transformation function with a set of rules. + * + * @property transform The transformation function to apply to the object value. + * @constructor Creates a new instance of [ObjectRuleChainer]. + */ class ObjectRuleChainer( val transform: (V) -> S ) : ObjectRule { @@ -32,15 +45,60 @@ class ObjectRuleChainer( return transform(value).let { ruleSet.flatMap { rule -> rule.test(it) } } } + /** + * Chains a set of rules to the transformation function. + * + * Usage: + * ```kotlin + * rules { + * notBlank { "must be not blank" } + * cast { it.length } then { + * minimum(3) { "must be at least 3 characters" } + * maximum(20) { "must be at most 20 characters" } + * } + * } + * ``` + * + * @param block The block to build the set of rules. + */ infix fun then(block: ValidationRuleBuilder.() -> Unit) { ruleSet = rules(block) } } +/** + * Validates that the object value passes the given [predicate]. + * + * Usage: + * ```kotlin + * rules { + * test({ title.isNotBlank() }) { "Title must be not blank" } + * } + * ``` + * + * @param predicate The predicate to test the object value. Returns `true` if the test passes; `false` otherwise. + * @param message The message to return when the test fails. + */ fun ObjectRuleBuilder.test(predicate: V.() -> Boolean, message: () -> String) { extend(ObjectRuleTester(predicate, message)) } +/** + * Chains a transformation function with a set of rules. + * + * Usage: + * ```kotlin + * rules { + * notBlank { "must be not blank" } + * cast { it.length } then { + * minimum(3) { "must be at least 3 characters" } + * maximum(20) { "must be at most 20 characters" } + * } + * } + * ``` + * + * @param transform The transformation function to apply to the object value. + */ fun ObjectRuleBuilder.cast(transform: (V) -> S): ObjectRuleChainer { return ObjectRuleChainer(transform).also { extend(it) } } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/OptionalRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/OptionalRule.kt index dd76aed..71b5a54 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/OptionalRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/OptionalRule.kt @@ -3,15 +3,21 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.rules typealias OptionalRule = ValidationRule typealias OptionalRuleBuilder = ValidationRuleBuilder +/** + * A rule that chains non-optional rules. If the value is not `null`, the rule set is applied to the value. + * + * @property message The message to return when the value is `null`. + * @constructor Creates a new instance of [OptionalRuleChainer]. + */ class OptionalRuleChainer( val message: () -> String ) : OptionalRule { @@ -26,11 +32,37 @@ class OptionalRuleChainer( } } + /** + * Chains a set of rules to the non-optional value. + * + * Usage: + * ```kotlin + * rules { + * notNull { "must be not null" } then { + * minLength(3) { "must be at least 3 characters" } + * } + * } + * + * @param block The block to build the rule set. + */ infix fun then(block: ValidationRuleBuilder.() -> Unit) { ruleSet = rules(block) } } +/** + * Validates that the optional value is not `null`. + * + * ```kotlin + * rules { + * notNull { "must be not null" } then { + * minLength(3) { "must be at least 3 characters" } + * } + * } + * + * @param message The message to return when the value is `null`. + * @return The rule chainer to chain the non-optional rules. + */ fun OptionalRuleBuilder.notNull(message: () -> String): OptionalRuleChainer { return OptionalRuleChainer(message).also { extend(it) } } diff --git a/soil-form/src/commonMain/kotlin/soil/form/rule/StringRule.kt b/soil-form/src/commonMain/kotlin/soil/form/rule/StringRule.kt index f614e39..7a9d8cb 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/rule/StringRule.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/rule/StringRule.kt @@ -3,15 +3,22 @@ package soil.form.rule -import soil.form.fieldError import soil.form.FieldErrors import soil.form.ValidationRule import soil.form.ValidationRuleBuilder +import soil.form.fieldError import soil.form.noErrors typealias StringRule = ValidationRule typealias StringRuleBuilder = ValidationRuleBuilder +/** + * A rule that tests the string value. + * + * @property predicate The predicate to test the string value. Returns `true` if the test passes; `false` otherwise. + * @property message The message to return when the test fails. + * @constructor Creates a new instance of [StringRuleTester]. + */ class StringRuleTester( val predicate: String.() -> Boolean, val message: () -> String @@ -21,26 +28,102 @@ class StringRuleTester( } } +/** + * Validates that the string value is not empty. + * + * Usage: + * ```kotlin + * rules { + * notEmpty { "must be not empty" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.notEmpty(message: () -> String) { extend(StringRuleTester(String::isNotEmpty, message)) } +/** + * Validates that the string value is not blank. + * + * Usage: + * ```kotlin + * rules { + * notBlank { "must be not blank" } + * } + * ``` + * + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.notBlank(message: () -> String) { extend(StringRuleTester(String::isNotBlank, message)) } +/** + * Validates that the string length is at least [limit] characters. + * + * Usage: + * ```kotlin + * rules { + * minLength(3) { "must be at least 3 characters" } + * } + * ``` + * + * @param limit The minimum number of characters the string must have. + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.minLength(limit: Int, message: () -> String) { extend(StringRuleTester({ length >= limit }, message)) } +/** + * Validates that the string length is no more than [limit] characters. + * + * Usage: + * ```kotlin + * rules { + * maxLength(20) { "must be at no more 20 characters" } + * } + * ``` + * + * @param limit The maximum number of characters the string can have. + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.maxLength(limit: Int, message: () -> String) { extend(StringRuleTester({ length <= limit }, message)) } +/** + * Validates that the string matches the [pattern]. + * + * Usage: + * ```kotlin + * rules { + * pattern("^[A-Za-z]+$") { "must be alphabetic" } + * } + * ``` + * + * @param pattern The regular expression pattern the string must match. + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.pattern(pattern: String, message: () -> String) { pattern(Regex(pattern), message) } +/** + * Validates that the string matches the [pattern]. + * + * Usage: + * ```kotlin + * rules { + * pattern(Regex("^[A-Za-z]+$")) { "must be alphabetic" } + * } + * ``` + * + * @param pattern The regular expression pattern the string must match. + * @param message The message to return when the test fails. + */ fun StringRuleBuilder.pattern(pattern: Regex, message: () -> String) { extend(StringRuleTester({ pattern.matches(this) }, message)) } From 67ad899ce7c2d5a99ec3ad5c5dde93a96248bf40 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 26 May 2024 17:25:28 +0900 Subject: [PATCH 036/155] Add a KDoc comments for the soil.form.compose package --- .../kotlin/soil/form/compose/Controller.kt | 40 +++++++++++++ .../kotlin/soil/form/compose/FieldControl.kt | 23 ++++++++ .../kotlin/soil/form/compose/Form.kt | 31 ++++++++++ .../kotlin/soil/form/compose/FormScope.kt | 49 ++++++++++++++- .../kotlin/soil/form/compose/FormScopeExt.kt | 59 +++++++++++++++++++ .../kotlin/soil/form/compose/FormState.kt | 52 +++++++++++++++- .../kotlin/soil/form/compose/ModifierExt.kt | 6 ++ .../soil/form/compose/SubmissionControl.kt | 24 ++++++++ 8 files changed, 281 insertions(+), 3 deletions(-) diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/Controller.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/Controller.kt index be0f697..15d96d2 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/Controller.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/Controller.kt @@ -26,6 +26,26 @@ import soil.form.FieldName import soil.form.FieldValidateOn import soil.form.Submission +/** + * A controller for a form [field control][FieldControl]. + * + * To minimize the impact of re-composition due to updates in input values, + * the [FieldControl] is passed to a [Controller], which then connects the actual input component with the [Field] interface. + * + * Usage: + * ```kotlin + * Form(..) { + * .. + * Controller(control = rememberFirstNameFieldControl()) { field -> + * TextField(value = field.value, onValueChange = field.onChange, ...) + * } + * } + * ``` + * + * @param V The type of the field value. + * @param control The field control to be managed. + * @param content The content to be displayed. + */ @OptIn(FlowPreview::class) @Composable fun Controller( @@ -139,6 +159,26 @@ internal class FieldController( } } +/** + * A controller for a form [submission control][SubmissionControl]. + * + * To minimize the impact of re-composition due to updates in input values, + * the [SubmissionControl] is passed to a [Controller], which then connects the actual input component with the [Submission] interface. + * + * Usage: + * ```kotlin + * Form(..) { + * .. + * Controller(control = rememberSubmissionRuleAutoControl()) { submission -> + * Button(onClick = submission.onSubmit, enabled = submission.canSubmit, ...) + * } + * } + * ``` + * + * @param T The type of the submission value. + * @param control The submission control to be managed. + * @param content The content to be displayed. + */ @OptIn(FlowPreview::class) @Composable fun Controller( diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/FieldControl.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/FieldControl.kt index c12d551..c6f8d63 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/FieldControl.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/FieldControl.kt @@ -10,6 +10,21 @@ import soil.form.FieldPolicy import soil.form.FieldValidateOn import soil.form.FormRule +/** + * Represents a field control in a form. + * + * @param V The type of the field value. + * @property name The name of the field. + * @property policy The policy of the field. + * @property rule The rule to validate the field value. + * @property defaultValue The default value of the field. + * @property getValue The function to get the current field value. + * @property setValue The function to set the new field value. + * @property getErrors The function to get the current field errors. + * @property setErrors The function to set the new field errors. If the new errors are empty, the field is considered valid. + * @property shouldTrigger The function to determine if the field should trigger validation on the given event. + * @property isEnabled The function to determine if the field is enabled. + */ @Stable class FieldControl( val name: FieldName, @@ -23,6 +38,14 @@ class FieldControl( val shouldTrigger: (FieldValidateOn) -> Boolean, val isEnabled: () -> Boolean ) { + + /** + * Validates the field value. + * + * @param value The value to validate. Defaults to the current field value. + * @param dryRun Whether to perform a dry run. Defaults to `false`. If `true`, the field errors are not updated. + * @return `true` if the field value is valid; `false` otherwise. + */ fun validate(value: V = getValue(), dryRun: Boolean = false): Boolean { return rule.test(value, dryRun) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt index 062b9cf..aee1758 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt @@ -28,6 +28,37 @@ import soil.form.FormPolicy import soil.form.FormRule import soil.form.FormValidationException +/** + * A Form to manage the state and actions of input fields, and create a child block of [FormScope]. + * + * Usage: + * ```kotlin + * Form( + * onSubmit = { + * // Handle submit + * }, + * initialValue = "", + * policy = FormPolicy.Minimal + * ) { // this: FormScope + * .. + * } + * ``` + * + * **Note:** + * If you are expecting state restoration on the Android platform, please check if the type specified in [initialValue] is restorable. + * Inside the Form, `rememberSaveable` is used to manage input values, and runtime errors will be thrown from the API for unsupported types. + * + * @param T The type of the form value. + * @param onSubmit The submit handler to call when the form is submit. + * @param initialValue The initial value of the form. + * @param modifier The modifier to apply to this layout node. + * @param onError The error handler to call when an error occurs on submit. + * @param saver The saver to save and restore the form state. + * @param key The key to reset the form state. + * @param policy The policy to apply to the form. + * @param coroutineScope The coroutine scope to launch the submit handler. + * @param content The content block to create the child block of [FormScope]. + */ @Composable fun Form( onSubmit: suspend (T) -> Unit, diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/FormScope.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/FormScope.kt index 619b56a..e5f04ee 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/FormScope.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/FormScope.kt @@ -13,15 +13,49 @@ import soil.form.FieldValidateOn import soil.form.FormRule import soil.form.FormRules - +/** + * A scope to manage the state and actions of input fields in a form. + * + * @param T The type of the form value. + */ @Stable class FormScope internal constructor( private val state: FormStateImpl, private val submitHandler: SubmitHandler ) { + /** + * The current form state. + */ val formState: FormState get() = state + /** + * Remembers a field control for the given field name. + * + * Usage: + * ```kotlin + * rememberFieldControl( + * name = "First name", + * select = { firstName }, + * update = { copy(firstName = it) } + * ) { + * if (firstName.isNotBlank()) { + * noErrors + * } else { + * fieldError("must be not blank") + * } + * } + * ``` + * + * @param V The type of the field value. + * @param name The name of the field. + * @param select The function to select the field value. + * @param update The function to update the field value. + * @param enabled The function to determine if the field is enabled. + * @param dependsOn The set of field names that this field depends on. + * @param validate The function to validate the field value. + * @return The remembered [field control][FieldControl]. + */ @Composable fun rememberFieldControl( name: FieldName, @@ -100,6 +134,19 @@ class FormScope internal constructor( } } + /** + * Remembers a submission control for the form. + * + * Usage: + * ```kotlin + * rememberSubmissionControl { rules, dryRun -> + * rules.values.map { it.test(this, dryRun = dryRun) }.all { it } + * } + * ``` + * + * @param validate The function to validate the form value. + * @return The remembered [submission control][SubmissionControl]. + */ @Composable fun rememberSubmissionControl( validate: T.(rules: FormRules, dryRun: Boolean) -> Boolean diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/FormScopeExt.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/FormScopeExt.kt index cbb642e..5c40e17 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/FormScopeExt.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/FormScopeExt.kt @@ -13,6 +13,29 @@ import soil.form.ValidationRuleBuilder import soil.form.ValidationRuleSet import soil.form.rules +/** + * Remembers a field control for the given field name with the given rule set. + * + * Usage: + * ```kotlin + * rememberFieldRuleControl( + * name = "First name", + * select = { firstName }, + * update = { copy(firstName = it) } + * ) { + * notBlank { "must be not blank" } + * } + * ``` + * + * @param T The type of the form value. + * @param V The type of the field value. + * @param name The name of the field. + * @param select The function to select the field value. + * @param update The function to update the field value. + * @param enabled The function to determine if the field is enabled. + * @param dependsOn The set of field names that this field depends on. + * @param builder The block to build the rule set. + */ @Composable fun FormScope.rememberFieldRuleControl( name: FieldName, @@ -32,6 +55,32 @@ fun FormScope.rememberFieldRuleControl( ) } +/** + * Remembers a field control for the given field name with the given rule set. + * + * Usage: + * ```kotlin + * val ruleSet = rules { + * notBlank { "must be not blank" } + * } + * + * rememberFieldRuleControl( + * name = "First name", + * select = { firstName }, + * update = { copy(firstName = it) }, + * ruleSet = ruleSet + * ) + * ``` + * + * @param T The type of the form value. + * @param V The type of the field value. + * @param name The name of the field. + * @param select The function to select the field value. + * @param update The function to update the field value. + * @param enabled The function to determine if the field is enabled. + * @param dependsOn The set of field names that this field depends on. + * @param ruleSet The rule set to validate the field value. + */ @Composable fun FormScope.rememberFieldRuleControl( name: FieldName, @@ -57,6 +106,9 @@ fun FormScope.rememberFieldRuleControl( ) } +/** + * Remembers a submission rule control that automatically controls state of the form. + */ @Composable fun FormScope.rememberSubmissionRuleAutoControl(): SubmissionControl { return rememberSubmissionControl(validate = { rules, dryRun -> @@ -69,6 +121,13 @@ fun FormScope.rememberSubmissionRuleAutoControl(): SubmissionContro }) } +/** + * Remembers a watch value that automatically updates when the form state changes. + * + * @param T The type of the form value. + * @param V The type of the watch value. + * @param select The function to select the watch value. + */ @Composable fun FormScope.rememberWatch(select: T.() -> V): V { val value by remember { derivedStateOf { with(formState.value) { select() } } } diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/FormState.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/FormState.kt index 69a90cd..0c4e42c 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/FormState.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/FormState.kt @@ -16,23 +16,71 @@ import soil.form.FormErrors import soil.form.FormFieldDependencies import soil.form.FormFieldNames import soil.form.FormPolicy -import soil.form.FormTriggers -import soil.form.FormRules import soil.form.FormRule +import soil.form.FormRules +import soil.form.FormTriggers +/** + * The managed state of a form. + * + * @param T The type of the form value. + */ @Stable interface FormState { + + /** + * The initial value of the form. + */ val initialValue: T + + /** + * The current value of the form. + */ val value: T + + /** + * Whether the form is currently submitting. + */ val isSubmitting: Boolean + + /** + * Whether the form has been submitted. + */ val isSubmitted: Boolean + + /** + * The number of times submission process was called. + */ val submitCount: Int + + /** + * The validation errors of the form. + */ val errors: FormErrors + + /** + * The validation triggers of the form. + */ val triggers: FormTriggers + + /** + * The validation rules of the form. + */ val rules: FormRules + + /** + * The dependencies of the form fields. + */ val dependsOn: FormFieldDependencies + + /** + * The field dependencies of the form fields. + */ val watchers: FormFieldDependencies + /** + * Returns `true` if the form has validation error, `false` otherwise. + */ val hasError: Boolean get() = errors.values.any { it.isNotEmpty() } } diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/ModifierExt.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/ModifierExt.kt index 42eff59..ba0a4ca 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/ModifierExt.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/ModifierExt.kt @@ -8,6 +8,12 @@ import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.onFocusChanged import soil.form.Field +/** + * Adds a callback to be invoked when the focus state of the field changes. + * + * @param target The field to observe. + * @return The applied modifier. + */ fun Modifier.onFocusChanged(target: Field<*>): Modifier { return this then Modifier.onFocusChanged(target::onFocusChanged) } diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/SubmissionControl.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/SubmissionControl.kt index 392df99..d6dcd50 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/SubmissionControl.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/SubmissionControl.kt @@ -8,6 +8,22 @@ import soil.form.FormFieldNames import soil.form.FormRule import soil.form.SubmissionPolicy +/** + * Represents a submission control in a form. + * + * @param T The type of the form value. + * @property policy The policy of the submission. + * @property rule The rule to validate the form value. + * @property submit The function to submit the form. + * @property initialValue The initial value of the form. + * @property getValue The function to get the current form value. + * @property hasError The function to determine if the form has errors. + * @property getFieldKeys The function to get the keys of the form fields. + * @property isSubmitting The function to determine if the form is currently submitting. + * @property isSubmitted The function to determine if the form has been submitted. + * @property getSubmitCount The function to get the number of times submission process was called. + * @constructor Creates a submission control. + */ @Stable class SubmissionControl( val policy: SubmissionPolicy, @@ -21,6 +37,14 @@ class SubmissionControl( val isSubmitted: () -> Boolean, val getSubmitCount: () -> Int ) { + + /** + * Validates the form value. + * + * @param value The value to validate. Defaults to the current form value. + * @param dryRun Whether to perform a dry run. Defaults to `false`. If `true`, the form errors are not updated. + * @return `true` if the form value is valid; `false` otherwise. + */ fun validate(value: T, dryRun: Boolean = false): Boolean { return rule.test(value, dryRun) } From 63ef576b530b011ab7e03d11f64330762be9f1d0 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 27 May 2024 07:54:36 +0900 Subject: [PATCH 037/155] =?UTF-8?q?Thanks=20to=20Android=20Weekly=20for=20?= =?UTF-8?q?mentioning=20our=20library=20=F0=9F=98=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3cd66f6..6f87d5c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Thank you for featuring our library in the following sources: - [jetc.dev Newsletter Issue #212](https://jetc.dev/issues/212.html) - [Android Dev Notes #Twitter](https://twitter.com/androiddevnotes/status/1792409220484350109) +- [Android Weekly Issue #624](https://androidweekly.net/issues/issue-624) ## License From 49d011ec33a706c932ff834976ee8686a35160b6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 1 Jun 2024 16:58:43 +0900 Subject: [PATCH 038/155] Migrate to ViewModel for Compose Multiplatform --- gradle/libs.versions.toml | 5 +++-- soil-space/build.gradle.kts | 4 ++-- .../kotlin/soil/space/compose/AtomViewModel.kt | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) rename soil-space/src/{androidMain => commonMain}/kotlin/soil/space/compose/AtomViewModel.kt (97%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3d0c5a..42ffbc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ compose = "1.6.7" compose-multiplatform = "1.6.10" dokka = "1.9.20" jbx-core-bundle = "1.0.0" +jbx-lifecycle = "2.8.0" jbx-savedstate = "1.2.0" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" @@ -24,13 +25,13 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } -androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } -androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } compose-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "compose" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } jbx-core-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version.ref = "jbx-core-bundle" } +jbx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jbx-lifecycle" } +jbx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "jbx-lifecycle" } jbx-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jbx-savedstate" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index 328b159..d17cac2 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -47,14 +47,14 @@ kotlin { commonMain.dependencies { implementation(compose.runtime) implementation(compose.runtimeSaveable) + implementation(libs.jbx.lifecycle.viewmodel.compose) + implementation(libs.jbx.lifecycle.viewmodel.savedstate) api(libs.jbx.savedstate) api(libs.jbx.core.bundle) } androidMain.dependencies { implementation(libs.androidx.core) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.lifecycle.viewmodel.savedstate) } val skikoMain by creating { diff --git a/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomViewModel.kt similarity index 97% rename from soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt rename to soil-space/src/commonMain/kotlin/soil/space/compose/AtomViewModel.kt index 14fceb5..23dddf3 100644 --- a/soil-space/src/androidMain/kotlin/soil/space/compose/AtomViewModel.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/compose/AtomViewModel.kt @@ -3,9 +3,9 @@ package soil.space.compose -import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.core.bundle.Bundle import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner @@ -44,7 +44,7 @@ class AtomViewModel( */ @Composable fun rememberViewModelStore( - key: String? + key: String? = null ): AtomStore { val vm = viewModel( factory = viewModelFactory { From bc2cae8323e3937ba349e5cdf7a8f8bbc38c51ae Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 2 Jun 2024 15:09:31 +0900 Subject: [PATCH 039/155] Replace voyager with androidx.navigation --- gradle/libs.versions.toml | 2 + .../kotlin/soil/playground/router/NavLink.kt | 26 ++++ .../soil/playground/router/NavRouter.kt | 28 ++++ sample/composeApp/build.gradle.kts | 3 +- .../integration/voyager/ScreenExt.android.kt | 11 -- .../composeApp/src/commonMain/kotlin/App.kt | 79 +++++++---- .../src/commonMain/kotlin/Navigator.kt | 133 ++++++++++++++++++ .../integration/voyager/AtomScreenModel.kt | 9 -- .../soil/kmp/integration/voyager/ScreenExt.kt | 19 --- .../kotlin/soil/kmp/screen/HelloFormScreen.kt | 25 ++-- .../soil/kmp/screen/HelloQueryDetailScreen.kt | 108 ++++++++++++++ .../soil/kmp/screen/HelloQueryScreen.kt | 103 ++------------ .../soil/kmp/screen/HelloSpaceScreen.kt | 34 +++-- .../kotlin/soil/kmp/screen/HomeScreen.kt | 40 +++--- .../kotlin/soil/kmp/screen/NavScreen.kt | 20 +++ .../integration/voyager/ScreenExt.skiko.kt | 13 -- 16 files changed, 425 insertions(+), 228 deletions(-) create mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/router/NavLink.kt create mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/router/NavRouter.kt delete mode 100644 sample/composeApp/src/androidMain/kotlin/soil/kmp/integration/voyager/ScreenExt.android.kt create mode 100644 sample/composeApp/src/commonMain/kotlin/Navigator.kt delete mode 100644 sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/AtomScreenModel.kt delete mode 100644 sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/ScreenExt.kt create mode 100644 sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt create mode 100644 sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/NavScreen.kt delete mode 100644 sample/composeApp/src/skikoMain/kotlin/soil/kmp/integration/voyager/ScreenExt.skiko.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 42ffbc1..bb1f381 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ compose-multiplatform = "1.6.10" dokka = "1.9.20" jbx-core-bundle = "1.0.0" jbx-lifecycle = "2.8.0" +jbx-navigation = "2.7.0-alpha07" jbx-savedstate = "1.2.0" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" @@ -32,6 +33,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" jbx-core-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version.ref = "jbx-core-bundle" } jbx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jbx-lifecycle" } jbx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "jbx-lifecycle" } +jbx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "jbx-navigation" } jbx-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jbx-savedstate" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/router/NavLink.kt b/internal/playground/src/commonMain/kotlin/soil/playground/router/NavLink.kt new file mode 100644 index 0000000..c4f9c85 --- /dev/null +++ b/internal/playground/src/commonMain/kotlin/soil/playground/router/NavLink.kt @@ -0,0 +1,26 @@ +package soil.playground.router + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +inline fun NavLink( + to: String, + router: NavRouter = LocalNavRouter.current, + content: @Composable (NavLinkHandle) -> Unit +) { + val handle: NavLinkHandle = remember(to) { { router.push(to) } } + content(handle) +} + +@Composable +inline fun NavLink( + to: T, + router: NavRouter = LocalNavRouter.current, + content: @Composable (NavLinkHandle) -> Unit +) { + val handle: NavLinkHandle = remember(to) { { router.push(to) } } + content(handle) +} + +typealias NavLinkHandle = () -> Unit diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/router/NavRouter.kt b/internal/playground/src/commonMain/kotlin/soil/playground/router/NavRouter.kt new file mode 100644 index 0000000..1a088a6 --- /dev/null +++ b/internal/playground/src/commonMain/kotlin/soil/playground/router/NavRouter.kt @@ -0,0 +1,28 @@ +package soil.playground.router + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf + +@Stable +interface NavRouter { + fun push(route: String) + + fun push(route: T) + + fun back(): Boolean + + fun canBack(): Boolean +} + +interface NavRoute + +private val noRouter = object : NavRouter { + override fun push(route: String) = Unit + override fun push(route: T) = Unit + override fun back() = false + override fun canBack() = false +} + +val LocalNavRouter = staticCompositionLocalOf { + noRouter +} diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 198d3ab..eb70dc9 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -89,8 +89,7 @@ kotlin { implementation(libs.ktor.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.voyager.navigator) - implementation(libs.voyager.screenModel) + implementation(libs.jbx.navigation.compose) } androidMain.dependencies { diff --git a/sample/composeApp/src/androidMain/kotlin/soil/kmp/integration/voyager/ScreenExt.android.kt b/sample/composeApp/src/androidMain/kotlin/soil/kmp/integration/voyager/ScreenExt.android.kt deleted file mode 100644 index 9d97340..0000000 --- a/sample/composeApp/src/androidMain/kotlin/soil/kmp/integration/voyager/ScreenExt.android.kt +++ /dev/null @@ -1,11 +0,0 @@ -package soil.kmp.integration.voyager - -import androidx.compose.runtime.Composable -import cafe.adriel.voyager.core.screen.Screen -import soil.space.AtomStore -import soil.space.compose.rememberViewModelStore - -@Composable -actual fun Screen.rememberScreenStore(key: String?): AtomStore { - return rememberViewModelStore(key) -} diff --git a/sample/composeApp/src/commonMain/kotlin/App.kt b/sample/composeApp/src/commonMain/kotlin/App.kt index 0b72be9..f0619e1 100644 --- a/sample/composeApp/src/commonMain/kotlin/App.kt +++ b/sample/composeApp/src/commonMain/kotlin/App.kt @@ -1,4 +1,3 @@ -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -12,11 +11,12 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import cafe.adriel.voyager.navigator.CurrentScreen -import cafe.adriel.voyager.navigator.Navigator -import soil.kmp.screen.HomeScreen +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController import soil.playground.FeedbackAction import soil.playground.LocalFeedbackHost import soil.playground.style.AppTheme @@ -30,35 +30,54 @@ fun App() { } } +@Composable +private fun Content( + navController: NavHostController = rememberNavController() +) = withAppTheme { + val backStackEntry by navController.currentBackStackEntryAsState() + val navigator = remember(navController) { Navigator(navController) } + val canNavigateBack = remember(backStackEntry) { navigator.canBack() } + val hostState = remember { SnackbarHostState() } + val feedbackAction = remember { FeedbackAction(hostState) } + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + AppBar( + canNavigateBack = canNavigateBack, + navigateUp = { navigator.back() } + ) + }, + snackbarHost = { + SnackbarHost(hostState) + } + ) { innerPadding -> + CompositionLocalProvider(LocalFeedbackHost provides feedbackAction) { + NavRouterHost( + navigator = navigator, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun Content() = withAppTheme { - Navigator(HomeScreen) { navigator -> - val hostState = remember { SnackbarHostState() } - val feedbackAction = remember { FeedbackAction(hostState) } - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - if (navigator.canPop) { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = navigator::pop) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } - ) - } - }, - snackbarHost = { - SnackbarHost(hostState) - } - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - CompositionLocalProvider(LocalFeedbackHost provides feedbackAction) { - CurrentScreen() +fun AppBar( + canNavigateBack: Boolean, + navigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + TopAppBar( + title = { }, + modifier = modifier, + navigationIcon = { + if (canNavigateBack) { + IconButton(onClick = navigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } } - } + ) } diff --git a/sample/composeApp/src/commonMain/kotlin/Navigator.kt b/sample/composeApp/src/commonMain/kotlin/Navigator.kt new file mode 100644 index 0000000..2f35ac2 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/Navigator.kt @@ -0,0 +1,133 @@ +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import soil.kmp.screen.HelloFormScreen +import soil.kmp.screen.HelloQueryDetailScreen +import soil.kmp.screen.HelloQueryScreen +import soil.kmp.screen.HelloSpaceScreen +import soil.kmp.screen.HomeScreen +import soil.kmp.screen.NavScreen +import soil.playground.router.LocalNavRouter +import soil.playground.router.NavRoute +import soil.playground.router.NavRouter +import soil.space.compose.rememberViewModelStore + +@Stable +class Navigator( + val navController: NavHostController +) : NavRouter { + override fun push(route: String) { + navController.navigate(route) + } + + override fun push(route: T) { + when (val screen = route as NavScreen) { + is NavScreen.Home -> push(NavScreenDestination.Home()) + is NavScreen.HelloQuery -> push(NavScreenDestination.HelloQuery()) + is NavScreen.HelloQueryDetail -> push(NavScreenDestination.HelloQueryDetail(screen.postId)) + is NavScreen.HelloForm -> push(NavScreenDestination.HelloForm()) + is NavScreen.HelloSpace -> push(NavScreenDestination.HelloSpace()) + } + } + + override fun back(): Boolean { + return navController.popBackStack() + } + + override fun canBack(): Boolean { + return navController.previousBackStackEntry != null + } +} + +@Composable +fun NavRouterHost( + navigator: Navigator, + modifier: Modifier +) { + val startDestination = remember(NavScreen.root) { NavScreen.root.destination.route } + CompositionLocalProvider(LocalNavRouter provides navigator) { + NavHost( + navController = navigator.navController, + startDestination = startDestination, + modifier = modifier + ) { + composable( + route = NavScreenDestination.Home.route + ) { + HomeScreen() + } + composable( + route = NavScreenDestination.HelloQuery.route + ) { + HelloQueryScreen() + } + composable( + route = NavScreenDestination.HelloQueryDetail.route, + arguments = NavScreenDestination.HelloQueryDetail.arguments + ) { + val id = checkNotNull(it.arguments?.getInt(NavScreenDestination.HelloQueryDetail.id.name)) + HelloQueryDetailScreen(postId = id) + } + composable( + route = NavScreenDestination.HelloForm.route + ) { + HelloFormScreen() + } + composable( + route = NavScreenDestination.HelloSpace.route + ) { + val rootEntry = navigator.navController.getBackStackEntry(startDestination) + HelloSpaceScreen( + navStore = rememberViewModelStore(rootEntry) + ) + } + } + } +} + +private sealed class NavScreenDestination( + val route: String +) { + data object Home : NavScreenDestination("/home") { + operator fun invoke() = route + } + + data object HelloQuery : NavScreenDestination("/helloQuery") { + operator fun invoke() = route + } + + data object HelloQueryDetail : NavScreenDestination("/helloQuery/{id}") { + val arguments get() = listOf(id) + val id: NamedNavArgument + get() = navArgument("id") { + type = NavType.IntType + } + + operator fun invoke(postId: Int) = "/helloQuery/$postId" + } + + data object HelloForm : NavScreenDestination("/helloForm") { + operator fun invoke() = route + } + + data object HelloSpace : NavScreenDestination("/helloSpace") { + operator fun invoke() = route + } +} + +private val NavScreen.destination: NavScreenDestination + get() = when (this) { + is NavScreen.Home -> NavScreenDestination.Home + is NavScreen.HelloQuery -> NavScreenDestination.HelloQuery + is NavScreen.HelloQueryDetail -> NavScreenDestination.HelloQueryDetail + is NavScreen.HelloForm -> NavScreenDestination.HelloForm + is NavScreen.HelloSpace -> NavScreenDestination.HelloSpace + } diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/AtomScreenModel.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/AtomScreenModel.kt deleted file mode 100644 index 062d975..0000000 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/AtomScreenModel.kt +++ /dev/null @@ -1,9 +0,0 @@ -package soil.kmp.integration.voyager - -import cafe.adriel.voyager.core.model.ScreenModel -import soil.space.compose.AtomSaveableStore -import soil.space.AtomStore - -class AtomScreenModel( - val store: AtomStore = AtomSaveableStore() -) : ScreenModel diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/ScreenExt.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/ScreenExt.kt deleted file mode 100644 index b757ce7..0000000 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/integration/voyager/ScreenExt.kt +++ /dev/null @@ -1,19 +0,0 @@ -package soil.kmp.integration.voyager - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import cafe.adriel.voyager.core.model.rememberNavigatorScreenModel -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.Navigator -import soil.space.AtomStore - -@Composable -expect fun Screen.rememberScreenStore(key: String? = null): AtomStore - -@Composable -fun Navigator.rememberNavigatorScreenStore( - key: String? = null, -): AtomStore { - val model = rememberNavigatorScreenModel(key) { AtomScreenModel() } - return remember(model) { model.store } -} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt index 7e4facf..807fb90 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen import kotlinx.coroutines.delay import kotlinx.coroutines.launch import soil.form.compose.Controller @@ -44,20 +43,16 @@ import soil.playground.form.compose.rememberAsRadio import soil.playground.form.compose.rememberAsSelect import soil.playground.style.withAppTheme - -class HelloFormScreen : Screen { - - @Composable - override fun Content() { - val feedback = LocalFeedbackHost.current - val coroutineScope = rememberCoroutineScope() - HelloFormContent( - onSubmitted = { - coroutineScope.launch { feedback.showAlert("Form submitted successfully") } - }, - modifier = Modifier.fillMaxSize() - ) - } +@Composable +fun HelloFormScreen() { + val feedback = LocalFeedbackHost.current + val coroutineScope = rememberCoroutineScope() + HelloFormContent( + onSubmitted = { + coroutineScope.launch { feedback.showAlert("Form submitted successfully") } + }, + modifier = Modifier.fillMaxSize() + ) } // The form input fields are based on the Live Demo used in React Hook Form. diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt new file mode 100644 index 0000000..385c39a --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt @@ -0,0 +1,108 @@ +package soil.kmp.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import soil.playground.query.compose.ContentLoading +import soil.playground.query.compose.ContentUnavailable +import soil.playground.query.compose.PostDetailItem +import soil.playground.query.compose.PostUserDetailItem +import soil.playground.query.compose.rememberGetPostQuery +import soil.playground.query.compose.rememberGetUserPostsQuery +import soil.playground.query.compose.rememberGetUserQuery +import soil.playground.query.data.Post +import soil.playground.query.data.Posts +import soil.playground.query.data.User +import soil.query.compose.rememberQueriesErrorReset +import soil.query.compose.runtime.Await +import soil.query.compose.runtime.Catch +import soil.query.compose.runtime.ErrorBoundary +import soil.query.compose.runtime.Suspense + +@Composable +fun HelloQueryDetailScreen(postId: Int) { + HelloQueryScreenDetailTemplate { + PostDetailContent( + postId = postId, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +private fun HelloQueryScreenDetailTemplate( + content: @Composable () -> Unit +) { + ErrorBoundary( + modifier = Modifier.fillMaxSize(), + fallback = { + ContentUnavailable( + error = it.err, + reset = it.reset, + modifier = Modifier.matchParentSize() + ) + }, + onError = { e -> println(e.toString()) }, + onReset = rememberQueriesErrorReset() + ) { + Suspense( + fallback = { ContentLoading(modifier = Modifier.matchParentSize()) }, + modifier = Modifier.fillMaxSize(), + content = content + ) + } +} + +@Composable +private fun PostDetailContent( + postId: Int, + modifier: Modifier = Modifier +) { + PostDetailContainer(postId) { post -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + PostDetailItem(post, modifier = Modifier.fillMaxWidth()) + PostUserDetailContainer(userId = post.userId) { user, posts -> + PostUserDetailItem(user = user, posts = posts) + } + } + } +} + +@Composable +private fun PostDetailContainer( + postId: Int, + content: @Composable (Post) -> Unit +) { + val query = rememberGetPostQuery(postId) + Await(query) { post -> + content(post) + } + Catch(query) +} + +@Composable +private fun PostUserDetailContainer( + userId: Int, + content: @Composable (User, Posts) -> Unit +) { + val userQuery = rememberGetUserQuery(userId) + val postsQuery = rememberGetUserPostsQuery(userId) + Suspense( + fallback = { ContentLoading(modifier = Modifier.matchParentSize()) }, + modifier = Modifier.fillMaxWidth() + ) { + Await(userQuery, postsQuery) { user, posts -> + content(user, posts) + } + } + Catch(userQuery, postsQuery) +} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt index 6892cf9..214d7f5 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt @@ -1,39 +1,27 @@ package soil.kmp.screen import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import io.ktor.client.plugins.ResponseException import soil.playground.Alert import soil.playground.query.compose.ContentLoadMore import soil.playground.query.compose.ContentLoading import soil.playground.query.compose.ContentUnavailable -import soil.playground.query.compose.PostDetailItem import soil.playground.query.compose.PostListItem -import soil.playground.query.compose.PostUserDetailItem -import soil.playground.query.compose.rememberGetPostQuery import soil.playground.query.compose.rememberGetPostsQuery -import soil.playground.query.compose.rememberGetUserPostsQuery -import soil.playground.query.compose.rememberGetUserQuery import soil.playground.query.data.PageParam -import soil.playground.query.data.Post import soil.playground.query.data.Posts -import soil.playground.query.data.User +import soil.playground.router.NavLink import soil.playground.style.withAppTheme import soil.query.compose.rememberQueriesErrorReset import soil.query.compose.runtime.Await @@ -41,17 +29,10 @@ import soil.query.compose.runtime.Catch import soil.query.compose.runtime.ErrorBoundary import soil.query.compose.runtime.Suspense -class HelloQueryScreen : Screen { - - @Composable - override fun Content() { - HelloQueryScreenTemplate { - val navigator = LocalNavigator.currentOrThrow - HelloQueryContent( - onSelect = { navigator.push(PostDetailScreen(postId = it.id)) }, - modifier = Modifier.fillMaxSize() - ) - } +@Composable +fun HelloQueryScreen() { + HelloQueryScreenTemplate { + HelloQueryContent(modifier = Modifier.fillMaxSize()) } } @@ -81,7 +62,6 @@ private fun HelloQueryScreenTemplate( @Composable private fun HelloQueryContent( - onSelect: (Post) -> Unit, modifier: Modifier = Modifier ) = withAppTheme { ListSectionContainer { state -> @@ -91,11 +71,13 @@ private fun HelloQueryContent( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(state.posts, key = { it.id }) { post -> - PostListItem( - onClick = { onSelect(post) }, - post = post, - modifier = Modifier.fillMaxWidth() - ) + NavLink(to = NavScreen.HelloQueryDetail(post.id)) { + PostListItem( + onClick = it, + post = post, + modifier = Modifier.fillMaxWidth() + ) + } } val pageParam = state.loadMoreParam if (pageParam != null) { @@ -140,64 +122,3 @@ private data class ListSectionState( val loadMore: suspend (PageParam) -> Unit ) -private class PostDetailScreen( - val postId: Int -) : Screen { - @Composable - override fun Content() { - HelloQueryScreenTemplate { - PostDetailContent( - postId = postId, - modifier = Modifier.fillMaxSize() - ) - } - } -} - -@Composable -private fun PostDetailContent( - postId: Int, - modifier: Modifier = Modifier -) { - PostDetailContainer(postId) { post -> - Column( - modifier = modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - PostDetailItem(post, modifier = Modifier.fillMaxWidth()) - PostUserDetailContainer(userId = post.userId) { user, posts -> - PostUserDetailItem(user = user, posts = posts) - } - } - } -} - -@Composable -private fun PostDetailContainer( - postId: Int, - content: @Composable (Post) -> Unit -) { - val query = rememberGetPostQuery(postId) - Await(query) { post -> - content(post) - } - Catch(query) -} - -@Composable -private fun PostUserDetailContainer( - userId: Int, - content: @Composable (User, Posts) -> Unit -) { - val userQuery = rememberGetUserQuery(userId) - val postsQuery = rememberGetUserPostsQuery(userId) - Suspense( - fallback = { ContentLoading(modifier = Modifier.matchParentSize()) }, - modifier = Modifier.fillMaxWidth() - ) { - Await(userQuery, postsQuery) { user, posts -> - content(user, posts) - } - } - Catch(userQuery, postsQuery) -} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloSpaceScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloSpaceScreen.kt index 142ac17..66251a8 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloSpaceScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloSpaceScreen.kt @@ -16,33 +16,31 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow -import soil.kmp.integration.voyager.rememberNavigatorScreenStore -import soil.kmp.integration.voyager.rememberScreenStore import soil.playground.space.compose.Counter import soil.playground.style.withAppTheme +import soil.space.AtomStore import soil.space.atom import soil.space.atomScope import soil.space.compose.AtomRoot import soil.space.compose.rememberAtomState import soil.space.compose.rememberAtomValue +import soil.space.compose.rememberViewModelStore -class HelloSpaceScreen : Screen { - @Composable - override fun Content() { - val navigator = LocalNavigator.currentOrThrow - AtomRoot( - currentScreen to rememberScreenStore(), - navScreen to navigator.rememberNavigatorScreenStore(), - fallbackScope = { currentScreen } - ) { - HelloSpaceContent( - modifier = Modifier.fillMaxSize() - ) - } +@Composable +fun HelloSpaceScreen( + navStore: AtomStore +) { + AtomRoot( + currentScreen to rememberViewModelStore(), + navScreen to navStore, + fallbackScope = { currentScreen } + // If fallbackScope is set to navScreen, the value of Counter is preserved even if it navigates back and then forward again. + // fallbackScope = { navScreen } + ) { + HelloSpaceContent( + modifier = Modifier.fillMaxSize() + ) } } diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HomeScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HomeScreen.kt index 5ef89c1..18f57a1 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HomeScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HomeScreen.kt @@ -14,43 +14,43 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow +import soil.playground.router.NavLink -object HomeScreen : Screen { - - @Composable - override fun Content() { - val navigator = LocalNavigator.currentOrThrow - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() +@Composable +fun HomeScreen() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Column( + modifier = Modifier.padding(16.dp) + .widthIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier.padding(16.dp) - .widthIn(max = 360.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + NavLink(to = NavScreen.HelloQuery) { OutlinedButton( - onClick = { navigator.push(HelloQueryScreen()) }, + onClick = it, modifier = Modifier .fillMaxWidth() .height(80.dp) ) { Text("Query") } + } + NavLink(to = NavScreen.HelloForm) { OutlinedButton( - onClick = { navigator.push(HelloFormScreen()) }, + onClick = it, modifier = Modifier .fillMaxWidth() .height(80.dp) ) { Text("Form") } + } + NavLink(to = NavScreen.HelloSpace) { OutlinedButton( - onClick = { navigator.push(HelloSpaceScreen()) }, + onClick = it, modifier = Modifier .fillMaxWidth() .height(80.dp) diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/NavScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/NavScreen.kt new file mode 100644 index 0000000..9feca78 --- /dev/null +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/NavScreen.kt @@ -0,0 +1,20 @@ +package soil.kmp.screen + +import soil.playground.router.NavRoute + +sealed interface NavScreen : NavRoute { + + data object Home : NavScreen + + data object HelloQuery : NavScreen + + data class HelloQueryDetail(val postId: Int) : NavScreen + + data object HelloForm : NavScreen + + data object HelloSpace : NavScreen + + companion object { + val root: NavScreen = Home + } +} diff --git a/sample/composeApp/src/skikoMain/kotlin/soil/kmp/integration/voyager/ScreenExt.skiko.kt b/sample/composeApp/src/skikoMain/kotlin/soil/kmp/integration/voyager/ScreenExt.skiko.kt deleted file mode 100644 index 6be1013..0000000 --- a/sample/composeApp/src/skikoMain/kotlin/soil/kmp/integration/voyager/ScreenExt.skiko.kt +++ /dev/null @@ -1,13 +0,0 @@ -package soil.kmp.integration.voyager - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen -import soil.space.AtomStore - -@Composable -actual fun Screen.rememberScreenStore(key: String?): AtomStore { - val model = rememberScreenModel(tag = key) { AtomScreenModel() } - return remember(model) { model.store } -} From 3a009aaf69a73db8c3f6ec7d05ff076d62656a26 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 2 Jun 2024 06:31:16 +0000 Subject: [PATCH 040/155] Apply automatic changes --- .../src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt index 214d7f5..05e23fb 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt @@ -121,4 +121,3 @@ private data class ListSectionState( val loadMoreParam: PageParam?, val loadMore: suspend (PageParam) -> Unit ) - From 69453d7f8ebc00107def688ee5ca827bc433c946 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 2 Jun 2024 15:35:15 +0900 Subject: [PATCH 041/155] Remove workaround --- sample/composeApp/build.gradle.kts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index eb70dc9..2f210d1 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -75,11 +75,6 @@ kotlin { implementation(projects.internal.playground) implementation(compose.runtime) implementation(compose.foundation) - // FIXME: - // > Task :sample:composeApp:compileDevelopmentExecutableKotlinWasmJs FAILED - // e: Could not find "org.jetbrains.compose.material:material" in [/xxx/Library/Application Support/kotlin/daemon] - // ref. https://slack-chats.kotlinlang.org/t/14174274/hey-all-i-m-unable-to-get-a-successful-build-in-a-kmm-projec - implementation(compose.material) implementation(compose.material3) implementation(compose.ui) implementation(compose.components.resources) From a3a59dac05b25a7ccc9180677d592646ceb8c0ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Jun 2024 07:04:49 +0000 Subject: [PATCH 042/155] Bump up version to 1.0.0-alpha02 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1692440..0a37928 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.mpp.enableCInteropCommonization=true development=true #Product group=com.soil-kt.soil -version=1.0.0-alpha01 +version=1.0.0-alpha02 androidCompileSdk=34 androidTargetSdk=34 androidMinSdk=23 From 77a6638e405a9626221bac7a997f32567920cdc1 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 2 Jun 2024 16:54:19 +0900 Subject: [PATCH 043/155] Update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6f87d5c..b1ee34a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Soil](art/Logo.svg) -[![Release](https://img.shields.io/badge/Release-1.0.0--alpha01-62CC6A?style=for-the-badge)](https://github.com/soil-kt/soil) +[![Release](https://img.shields.io/maven-central/v/com.soil-kt.soil/query-core?style=for-the-badge&color=62CC6A)](https://github.com/soil-kt/soil) [![Kotlin](https://img.shields.io/badge/Kotlin-1.9.23-blue.svg?style=for-the-badge&logo=kotlin)](https://kotlinlang.org) # Compose-First Power Packs @@ -35,7 +35,7 @@ Soil is available on `mavenCentral()`. ```kts dependencies { - val soil = "1.0.0-alpha01" + val soil = "1.0.0-alpha02" implementation("com.soil-kt.soil:query-core:$soil") implementation("com.soil-kt.soil:query-compose:$soil") implementation("com.soil-kt.soil:query-compose-runtime:$soil") From bafa3b1c7ef2fb49f82e6843850b5c2d50883f34 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 9 Jun 2024 10:20:25 +0900 Subject: [PATCH 044/155] Fix usage of semantics within ContentVisibility Changed to the semantics modifier from clearAndSemantics. Content in ContentVisibility is now correctly recognized by UiAutomator. --- .../kotlin/soil/query/compose/runtime/ContentVisibility.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt index 2b560a0..eac9817 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ContentVisibility.kt @@ -8,8 +8,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics /** * A composable that can hide its [content] without removing it from the layout. @@ -32,7 +32,7 @@ fun ContentVisibility( Box( modifier = modifier .alpha(if (hidden) 0f else 1f) - .clearAndSetSemantics { + .semantics { if (hidden) { invisibleToUser() } From 4382b40a30686fa031250da18aab8e46b4179675 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 16 Jun 2024 19:00:43 +0900 Subject: [PATCH 045/155] Add testing module to use robolectric tests in androidUnitTest --- gradle/libs.versions.toml | 10 ++ internal/testing/build.gradle.kts | 101 ++++++++++++++++++ .../kotlin/soil/testing/UnitTest.kt | 9 ++ .../kotlin/soil/testing/UnitTest.kt | 7 ++ .../skikoMain/kotlin/soil/testing/UnitTest.kt | 3 + settings.gradle.kts | 3 +- 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 internal/testing/build.gradle.kts create mode 100644 internal/testing/src/androidMain/kotlin/soil/testing/UnitTest.kt create mode 100644 internal/testing/src/commonMain/kotlin/soil/testing/UnitTest.kt create mode 100644 internal/testing/src/skikoMain/kotlin/soil/testing/UnitTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb1f381..6b5b958 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,8 @@ androidx-activity = "1.8.2" androidx-annotation = "1.7.1" androidx-core = "1.12.0" androidx-lifecycle = "2.7.0" +androidx-test = "1.5.0" +androidx-test-ext-junit = "1.1.5" compose = "1.6.7" compose-multiplatform = "1.6.10" dokka = "1.9.20" @@ -13,19 +15,24 @@ jbx-core-bundle = "1.0.0" jbx-lifecycle = "2.8.0" jbx-navigation = "2.7.0-alpha07" jbx-savedstate = "1.2.0" +junit = "4.13.2" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" kotlinx-serialization = "1.6.3" ktor = "3.0.0-wasm2" maven-publish = "0.28.0" +robolectric = "4.12.2" spotless = "6.25.0" voyager = "1.1.0-alpha04" + [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } compose-ui-test-junit4-android = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "compose" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } @@ -35,7 +42,9 @@ jbx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:l jbx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "jbx-lifecycle" } jbx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "jbx-navigation" } jbx-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jbx-savedstate" } +junit = { module = "junit:junit", version.ref = "junit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -46,6 +55,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-screenModel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } diff --git a/internal/testing/build.gradle.kts b/internal/testing/build.gradle.kts new file mode 100644 index 0000000..d198a67 --- /dev/null +++ b/internal/testing/build.gradle.kts @@ -0,0 +1,101 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) +} + +val buildTarget = the() + +kotlin { + applyDefaultHierarchyTemplate() + + jvm() + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = buildTarget.javaVersion.get().toString() + } + } + publishLibraryVariants("release") + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + all { + languageSettings { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + // https://youtrack.jetbrains.com/issue/KT-61573 + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + + commonMain.dependencies { + implementation(libs.kotlin.test) + } + + androidMain.dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.androidx.test.core) + implementation(libs.androidx.test.ext.junit) + } + + val skikoMain by creating { + dependsOn(commonMain.get()) + } + + iosMain { + dependsOn(skikoMain) + } + + jvmMain { + dependsOn(skikoMain) + } + + named("wasmJsMain") { + dependsOn(skikoMain) + } + } +} + +android { + namespace = "soil.testing" + compileSdk = buildTarget.androidCompileSdk.get() + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + defaultConfig { + minSdk = buildTarget.androidMinSdk.get() + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = buildTarget.javaVersion.get() + targetCompatibility = buildTarget.javaVersion.get() + } + dependencies { + debugImplementation(libs.compose.ui.tooling) + } +} diff --git a/internal/testing/src/androidMain/kotlin/soil/testing/UnitTest.kt b/internal/testing/src/androidMain/kotlin/soil/testing/UnitTest.kt new file mode 100644 index 0000000..e1367e1 --- /dev/null +++ b/internal/testing/src/androidMain/kotlin/soil/testing/UnitTest.kt @@ -0,0 +1,9 @@ +package soil.testing + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(manifest= Config.NONE) +actual abstract class UnitTest diff --git a/internal/testing/src/commonMain/kotlin/soil/testing/UnitTest.kt b/internal/testing/src/commonMain/kotlin/soil/testing/UnitTest.kt new file mode 100644 index 0000000..b7cfce9 --- /dev/null +++ b/internal/testing/src/commonMain/kotlin/soil/testing/UnitTest.kt @@ -0,0 +1,7 @@ +package soil.testing + +/** + * Using android robolectric tests in KMP. + * ref. https://github.com/robolectric/robolectric/issues/7942 + */ +expect abstract class UnitTest() diff --git a/internal/testing/src/skikoMain/kotlin/soil/testing/UnitTest.kt b/internal/testing/src/skikoMain/kotlin/soil/testing/UnitTest.kt new file mode 100644 index 0000000..07fec1a --- /dev/null +++ b/internal/testing/src/skikoMain/kotlin/soil/testing/UnitTest.kt @@ -0,0 +1,3 @@ +package soil.testing + +actual abstract class UnitTest diff --git a/settings.gradle.kts b/settings.gradle.kts index 38470a1..89dc1ac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,5 +45,6 @@ include( // Private modules include( ":sample:composeApp", - ":internal:playground" + ":internal:playground", + ":internal:testing" ) From b25db87357bb590256cc8e120f75f0fecf3a7d62 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 23 Jun 2024 08:39:01 +0900 Subject: [PATCH 046/155] Add testing module to query-core --- soil-query-core/build.gradle.kts | 1 + .../src/commonTest/kotlin/soil/query/ExampleTest.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index d0c753e..7e3dfa4 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -39,6 +39,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(projects.internal.testing) } androidMain.dependencies { diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt index 7fac53d..fc25021 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt @@ -3,10 +3,11 @@ package soil.query +import soil.testing.UnitTest import kotlin.test.Test import kotlin.test.assertTrue -class ExampleTest { +class ExampleTest : UnitTest() { @Test fun sample() { From cac732a6ea0f6199a0213f9db8fdee43e15f5452 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 23 Jun 2024 08:51:43 +0900 Subject: [PATCH 047/155] Add testing module to query-compose --- soil-query-compose/build.gradle.kts | 5 +++++ .../src/commonTest/kotlin/soil/query/compose/ExampleTest.kt | 3 ++- soil-query-core/build.gradle.kts | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index 2bccd7c..92a8d46 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -58,6 +58,7 @@ kotlin { implementation(compose.runtime) implementation(compose.ui) implementation(compose.material) + implementation(projects.internal.testing) } jvmTest.dependencies { @@ -92,6 +93,10 @@ android { sourceCompatibility = buildTarget.javaVersion.get() targetCompatibility = buildTarget.javaVersion.get() } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } dependencies { debugImplementation(libs.compose.ui.tooling) } diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt index 3a5afce..67f780d 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt @@ -16,10 +16,11 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest +import soil.testing.UnitTest import kotlin.test.Test @OptIn(ExperimentalTestApi::class) -class ExampleTest { +class ExampleTest: UnitTest() { @Test fun myTest() = runComposeUiTest { diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index 7e3dfa4..dd56279 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -90,6 +90,10 @@ android { sourceCompatibility = buildTarget.javaVersion.get() targetCompatibility = buildTarget.javaVersion.get() } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } dependencies { debugImplementation(libs.compose.ui.tooling) } From eea0661bbdada130a600fc53a070368bb7318274 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 16 Jun 2024 14:19:43 +0900 Subject: [PATCH 048/155] Experimental support kotlinx.serialization with androidx.core.bundle --- buildSrc/src/main/kotlin/BuildModule.kt | 1 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + soil-serialization-bundle/build.gradle.kts | 79 ++++++++ soil-serialization-bundle/gradle.properties | 2 + .../serialization/bundle/BundleDecoder.kt | 101 ++++++++++ .../serialization/bundle/BundleEncoder.kt | 91 +++++++++ .../soil/serialization/bundle/Bundler.kt | 79 ++++++++ .../serialization/bundle/BundleDecoderTest.kt | 141 ++++++++++++++ .../serialization/bundle/BundleEncoderTest.kt | 174 ++++++++++++++++++ .../serialization/bundle/BundleTestData.kt | 97 ++++++++++ 11 files changed, 767 insertions(+) create mode 100644 soil-serialization-bundle/build.gradle.kts create mode 100644 soil-serialization-bundle/gradle.properties create mode 100644 soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleDecoder.kt create mode 100644 soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleEncoder.kt create mode 100644 soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/Bundler.kt create mode 100644 soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleDecoderTest.kt create mode 100644 soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleEncoderTest.kt create mode 100644 soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt diff --git a/buildSrc/src/main/kotlin/BuildModule.kt b/buildSrc/src/main/kotlin/BuildModule.kt index a015b9a..9bcca7a 100644 --- a/buildSrc/src/main/kotlin/BuildModule.kt +++ b/buildSrc/src/main/kotlin/BuildModule.kt @@ -3,5 +3,6 @@ val publicModules = setOf( "soil-query-compose", "soil-query-compose-runtime", "soil-form", + "soil-serialization-bundle", "soil-space" ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b5b958..c54bfb7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 89dc1ac..9a71e1f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,7 @@ include( ":soil-query-compose", ":soil-query-compose-runtime", ":soil-form", + ":soil-serialization-bundle", ":soil-space" ) diff --git a/soil-serialization-bundle/build.gradle.kts b/soil-serialization-bundle/build.gradle.kts new file mode 100644 index 0000000..6080f6e --- /dev/null +++ b/soil-serialization-bundle/build.gradle.kts @@ -0,0 +1,79 @@ +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) +} + +val buildTarget = the() + +kotlin { + applyDefaultHierarchyTemplate() + + jvm() + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = buildTarget.javaVersion.get().toString() + } + } + publishLibraryVariants("release") + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(libs.jbx.core.bundle) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(projects.internal.testing) + } + } +} + +android { + namespace = "soil.serialization.bundle" + compileSdk = buildTarget.androidCompileSdk.get() + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + defaultConfig { + minSdk = buildTarget.androidMinSdk.get() + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = buildTarget.javaVersion.get() + targetCompatibility = buildTarget.javaVersion.get() + } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } + dependencies { + debugImplementation(libs.compose.ui.tooling) + } +} diff --git a/soil-serialization-bundle/gradle.properties b/soil-serialization-bundle/gradle.properties new file mode 100644 index 0000000..f15c466 --- /dev/null +++ b/soil-serialization-bundle/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=serialization-bundle +POM_NAME=serialization-bundle diff --git a/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleDecoder.kt b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleDecoder.kt new file mode 100644 index 0000000..960a9ce --- /dev/null +++ b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleDecoder.kt @@ -0,0 +1,101 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import androidx.core.bundle.Bundle +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.SerializersModule + +@ExperimentalSerializationApi +@PublishedApi +internal class BundleDecoder( + private val bundler: Bundler, + private val savedBundle: Bundle, + descriptor: SerialDescriptor? = null +) : AbstractDecoder() { + + private var currentStructure: SerialDescriptor? = descriptor + private var currentStructureIndex: Int = if (descriptor != null) 0 else -1 + + override val serializersModule: SerializersModule = bundler.serializersModule + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + if (currentStructure == null) { + currentStructure = descriptor + currentStructureIndex = 0 + return this + } + val nestedBundle = savedBundle.getBundle(extractElementKey())!! + return BundleDecoder(bundler, nestedBundle, descriptor) + } + + override fun endStructure(descriptor: SerialDescriptor) = Unit + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + error("Should not be called when decodeSequentially=true") + } + + override fun decodeSequentially(): Boolean = true + + override fun decodeCollectionSize(descriptor: SerialDescriptor): Int { + return savedBundle.getInt(Bundler.COLLECTION_SIZE_KEY) + } + + override fun decodeBoolean(): Boolean { + return savedBundle.getBoolean(extractElementKey()) + } + + override fun decodeByte(): Byte { + return savedBundle.getByte(extractElementKey()) + } + + override fun decodeChar(): Char { + return savedBundle.getChar(extractElementKey()) + } + + override fun decodeDouble(): Double { + return savedBundle.getDouble(extractElementKey()) + } + + override fun decodeFloat(): Float { + return savedBundle.getFloat(extractElementKey()) + } + + override fun decodeInt(): Int { + return savedBundle.getInt(extractElementKey()) + } + + override fun decodeLong(): Long { + return savedBundle.getLong(extractElementKey()) + } + + override fun decodeShort(): Short { + return savedBundle.getShort(extractElementKey()) + } + + override fun decodeString(): String { + return savedBundle.getString(extractElementKey())!! + } + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int { + return decodeInt() + } + + override fun decodeNotNullMark(): Boolean { + val key = currentStructure?.getElementName(currentStructureIndex) + return savedBundle.containsKey(key) + } + + override fun decodeNull(): Nothing? { + currentStructureIndex++ + return null + } + + private fun extractElementKey(): String? { + return currentStructure?.getElementName(currentStructureIndex++) + } +} diff --git a/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleEncoder.kt b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleEncoder.kt new file mode 100644 index 0000000..25288d0 --- /dev/null +++ b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/BundleEncoder.kt @@ -0,0 +1,91 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import androidx.core.bundle.Bundle +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractEncoder +import kotlinx.serialization.encoding.CompositeEncoder +import kotlinx.serialization.modules.SerializersModule + +@ExperimentalSerializationApi +@PublishedApi +internal class BundleEncoder( + private val bundler: Bundler, + private val parentBundle: Bundle, + private val parentKey: String? = null +) : AbstractEncoder() { + + private val currentBundle = if (parentKey == null) parentBundle else Bundle() + private var currentKey: String? = null + + override val serializersModule: SerializersModule = bundler.serializersModule + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder { + val key = currentKey ?: return this + return BundleEncoder(bundler, currentBundle, key) + } + + override fun endStructure(descriptor: SerialDescriptor) { + val key = parentKey ?: return + parentBundle.putBundle(key, currentBundle) + } + + override fun beginCollection( + descriptor: SerialDescriptor, + collectionSize: Int + ): CompositeEncoder { + val encoder = super.beginCollection(descriptor, collectionSize) as BundleEncoder + encoder.currentBundle.putInt(Bundler.COLLECTION_SIZE_KEY, collectionSize) + return encoder + } + + override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + currentKey = descriptor.getElementName(index) + return true + } + + override fun encodeBoolean(value: Boolean) { + currentBundle.putBoolean(currentKey, value) + } + + override fun encodeByte(value: Byte) { + currentBundle.putByte(currentKey, value) + } + + override fun encodeChar(value: Char) { + currentBundle.putChar(currentKey, value) + } + + override fun encodeDouble(value: Double) { + currentBundle.putDouble(currentKey, value) + } + + override fun encodeFloat(value: Float) { + currentBundle.putFloat(currentKey, value) + } + + override fun encodeInt(value: Int) { + currentBundle.putInt(currentKey, value) + } + + override fun encodeLong(value: Long) { + currentBundle.putLong(currentKey, value) + } + + override fun encodeShort(value: Short) { + currentBundle.putShort(currentKey, value) + } + + override fun encodeString(value: String) { + currentBundle.putString(currentKey, value) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + encodeInt(index) + } + + override fun encodeNull() = Unit +} diff --git a/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/Bundler.kt b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/Bundler.kt new file mode 100644 index 0000000..4249663 --- /dev/null +++ b/soil-serialization-bundle/src/commonMain/kotlin/soil/serialization/bundle/Bundler.kt @@ -0,0 +1,79 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import androidx.core.bundle.Bundle +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialFormat +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer + +/** + * Implements [encoding][encodeToBundle] and [decoding][decodeFromBundle] classes to/from [androidx.core.bundle.Bundle](https://github.com/JetBrains/compose-multiplatform-core/tree/jb-main/core/core-bundle). + * + * Usage: + * + * ``` + * @Serializable + * class Data(val property1: String) + * + * @Serializable + * class DataHolder(val data: Data, val property2: String) + * + * val bundle = Bundler.encodeToBundle(DataHolder(Data("value1"), "value2")) + * + * val dataHolder = Bundler.decodeFromBundle(bundle) + * ``` + * + * @param serializersModule A [SerializersModule] which should contain registered serializers + * for [kotlinx.serialization.Contextual] and [kotlinx.serialization.Polymorphic] serialization, if you have any. + */ +open class Bundler( + override val serializersModule: SerializersModule +) : SerialFormat { + + /** + * Encodes the given [value] to a [Bundle] using the provided [serializer] of type [T]. + * + * @param T The type of the value to encode + * @param value The value to encode + * @param serializer The serializer to use + */ + @ExperimentalSerializationApi + inline fun encodeToBundle( + value: T, + serializer: SerializationStrategy = serializer() + ): Bundle { + val result = Bundle() + val encoder = BundleEncoder(this, result) + encoder.encodeSerializableValue(serializer, value) + return result + } + + /** + * Decodes a value from the given [bundle] using the provided [deserializer] of type [T]. + * + * @param T The type of the value to decode + * @param bundle The bundle to decode from + * @param deserializer The deserializer to use + */ + @ExperimentalSerializationApi + inline fun decodeFromBundle( + bundle: Bundle, + deserializer: DeserializationStrategy = serializer() + ): T { + val decoder = BundleDecoder(this, bundle) + return decoder.decodeSerializableValue(deserializer) + } + + /** + * The default instance of Bundler + */ + companion object : Bundler(serializersModule = EmptySerializersModule()) { + internal const val COLLECTION_SIZE_KEY = "\$size" + } +} diff --git a/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleDecoderTest.kt b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleDecoderTest.kt new file mode 100644 index 0000000..756507b --- /dev/null +++ b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleDecoderTest.kt @@ -0,0 +1,141 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalSerializationApi::class) +class BundleDecoderTest : UnitTest() { + @Test + fun decodeFromBundle_primitiveTypes() { + assertEquals(true, Bundler.decodeFromBundle(Bundler.encodeToBundle(true))) + assertEquals(8.toByte(), Bundler.decodeFromBundle(Bundler.encodeToBundle(8.toByte()))) + assertEquals('a', Bundler.decodeFromBundle(Bundler.encodeToBundle('a'))) + assertEquals(3.14, Bundler.decodeFromBundle(Bundler.encodeToBundle(3.14))) + assertEquals(3.14f, Bundler.decodeFromBundle(Bundler.encodeToBundle(3.14f))) + assertEquals(30, Bundler.decodeFromBundle(Bundler.encodeToBundle(30))) + assertEquals(30L, Bundler.decodeFromBundle(Bundler.encodeToBundle(30L))) + assertEquals(30.toShort(), Bundler.decodeFromBundle(Bundler.encodeToBundle(30.toShort()))) + assertEquals("hello", Bundler.decodeFromBundle(Bundler.encodeToBundle("hello"))) + assertEquals(EnumTestData.Bar, Bundler.decodeFromBundle(Bundler.encodeToBundle(EnumTestData.Bar))) + } + + @Test + fun decodeFromBundle_collectionList() { + val expected = listOf(30, 20, 10) + val actual = Bundler.decodeFromBundle>(Bundler.encodeToBundle(expected)) + assertEquals(expected, actual) + } + + @Test + fun decodeFromBundle_collectionMap() { + val expected = mapOf("k1" to 30, "k2" to 20) + val actual = Bundler.decodeFromBundle>(Bundler.encodeToBundle(expected)) + assertEquals(expected, actual) + } + + @Test + fun decodeFromBundle_classType() { + val expected = ObjectTestData("John", 30) + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.name, actual.name) + assertEquals(expected.age, actual.age) + } + + @Test + fun decodeFromBundle_optionalType() { + val expected = ObjectTestData(null, 30) + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.name, actual.name) + assertEquals(expected.age, actual.age) + } + + @Test + fun decodeFromBundle_sealedType() { + val expected = SealedTestData.Bar(30) + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected, actual) + } + + @Test + fun decodeFromBundle_polymorphicType() { + val expected = PolymorphicBarData(30) + val bundler = Bundler(serializersModule = SerializersModule { + polymorphic(PolymorphicTestData::class) { + subclass(PolymorphicFooData::class) + subclass(PolymorphicBarData::class) + } + }) + val polymorphicSerializer = PolymorphicSerializer(PolymorphicTestData::class) + val actual = bundler.decodeFromBundle( + bundler.encodeToBundle(expected, polymorphicSerializer), + polymorphicSerializer + ) + assertEquals(expected, actual) + } + + @Test + fun decodeFromBundle_testData1() { + val expected = TestData1(ObjectTestData("John", 30), "Tokyo") + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } + + @Test + fun decodeFromBundle_testData2() { + val expected = TestData2(EnumTestData.Foo, "Tokyo") + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } + + @Test + fun decodeFromBundle_testData3() { + val expected = TestData3(listOf(ObjectTestData("John", 30), ObjectTestData("Jane", 20)), "Tokyo") + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } + + @Test + fun decodeFromBundle_testData4() { + val expected = TestData4(SealedTestData.Foo("John"), "Tokyo") + val actual = Bundler.decodeFromBundle(Bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } + + @Test + fun decodeFromBundle_testData5() { + val expected = TestData5(PolymorphicBarData(30), "Tokyo") + val bundler = Bundler(serializersModule = SerializersModule { + polymorphic(PolymorphicTestData::class) { + subclass(PolymorphicFooData::class) + subclass(PolymorphicBarData::class) + } + }) + val actual = bundler.decodeFromBundle(bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } + + @Test + fun decodeFromBundle_testData6() { + val expected = TestData6(ContextualTestData(2024, 6, 23), "Tokyo") + val bundler = Bundler(serializersModule = SerializersModule { + contextual(ContextualTestData::class, ContextualTestDataAsStringSerializer) + }) + val actual = bundler.decodeFromBundle(bundler.encodeToBundle(expected)) + assertEquals(expected.data, actual.data) + assertEquals(expected.address, actual.address) + } +} diff --git a/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleEncoderTest.kt b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleEncoderTest.kt new file mode 100644 index 0000000..db15770 --- /dev/null +++ b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleEncoderTest.kt @@ -0,0 +1,174 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalSerializationApi::class) +class BundleEncoderTest : UnitTest() { + @Test + fun encodeToBundle_primitiveTypes() { + assertEquals(true, Bundler.encodeToBundle(true).getBoolean(null)) + assertEquals(8.toByte(), Bundler.encodeToBundle(8.toByte()).getByte(null)) + assertEquals('a', Bundler.encodeToBundle('a').getChar(null)) + assertEquals(3.14, Bundler.encodeToBundle(3.14).getDouble(null)) + assertEquals(3.14f, Bundler.encodeToBundle(3.14f).getFloat(null)) + assertEquals(30, Bundler.encodeToBundle(30).getInt(null)) + assertEquals(30L, Bundler.encodeToBundle(30L).getLong(null)) + assertEquals(30.toShort(), Bundler.encodeToBundle(30.toShort()).getShort(null)) + assertEquals("hello", Bundler.encodeToBundle("hello").getString(null)) + assertEquals(EnumTestData.Bar.ordinal, Bundler.encodeToBundle(EnumTestData.Bar).getInt(null)) + } + + @Test + fun encodeToBundle_collectionList() { + val list = listOf(30, 20, 10) + val actual = Bundler.encodeToBundle(list) + assertEquals(3, actual.getInt(Bundler.COLLECTION_SIZE_KEY)) + assertEquals(30, actual.getInt("0")) + assertEquals(20, actual.getInt("1")) + assertEquals(10, actual.getInt("2")) + } + + @Test + fun encodeToBundle_collectionMap() { + val map = mapOf("k1" to 30, "k2" to 20) + val actual = Bundler.encodeToBundle(map) + assertEquals(2, actual.getInt(Bundler.COLLECTION_SIZE_KEY)) + assertEquals("k1", actual.getString("0")) + assertEquals(30, actual.getInt("1")) + assertEquals("k2", actual.getString("2")) + assertEquals(20, actual.getInt("3")) + } + + @Test + fun encodeToBundle_classType() { + val obj = ObjectTestData("John", 30) + val actual = Bundler.encodeToBundle(obj) + assertEquals("John", actual.getString("name")) + assertEquals(30, actual.getInt("age")) + } + + @Test + fun encodeToBundle_optionalType() { + val obj = ObjectTestData(null, 30) + val actual = Bundler.encodeToBundle(obj) + assertEquals(false, actual.containsKey("name")) + assertEquals(30, actual.getInt("age")) + } + + @Test + fun encodeToBundle_sealedType() { + val obj = SealedTestData.Bar(30) + val actual = Bundler.encodeToBundle(obj) + assertEquals(true, actual.containsKey("type")) + assertEquals(true, actual.containsKey("value")) + val nested = actual.getBundle("value")!! + assertEquals(30, nested.getInt("age")) + } + + @Test + fun encodeToBundle_polymorphicType() { + val obj = PolymorphicBarData(30) + val bundler = Bundler(serializersModule = SerializersModule { + polymorphic(PolymorphicTestData::class) { + subclass(PolymorphicFooData::class) + subclass(PolymorphicBarData::class) + } + }) + val polymorphicSerializer = PolymorphicSerializer(PolymorphicTestData::class) + val actual = bundler.encodeToBundle(obj, polymorphicSerializer) + assertEquals(true, actual.containsKey("type")) + assertEquals(true, actual.containsKey("value")) + val nested = actual.getBundle("value")!! + assertEquals(30, nested.getInt("age")) + } + + @Test + fun encodeToBundle_testData1() { + val data = TestData1(ObjectTestData("John", 30), "Tokyo") + val actual = Bundler.encodeToBundle(data) + assertEquals(true, actual.containsKey("data")) + val nested = actual.getBundle("data")!! + assertEquals("John", nested.getString("name")) + assertEquals(30, nested.getInt("age")) + assertEquals("Tokyo", actual.getString("address")) + } + + @Test + fun encodeToBundle_testData2() { + val data = TestData2(EnumTestData.Bar, "Tokyo") + val actual = Bundler.encodeToBundle(data) + assertEquals(EnumTestData.Bar.ordinal, actual.getInt("data")) + assertEquals("Tokyo", actual.getString("address")) + } + + @Test + fun encodeToBundle_testData3() { + val data = TestData3(listOf(ObjectTestData("John", 30), ObjectTestData("Jane", 20)), "Tokyo") + val actual = Bundler.encodeToBundle(data) + assertEquals(true, actual.containsKey("data")) + val nested = actual.getBundle("data")!! + assertEquals(2, nested.getInt(Bundler.COLLECTION_SIZE_KEY)) + assertEquals(true, nested.containsKey("0")) + val nestedData1 = nested.getBundle("0")!! + assertEquals("John", nestedData1.getString("name")) + assertEquals(30, nestedData1.getInt("age")) + assertEquals(true, nested.containsKey("1")) + val nestedData2 = nested.getBundle("1")!! + assertEquals("Jane", nestedData2.getString("name")) + assertEquals(20, nestedData2.getInt("age")) + assertEquals("Tokyo", actual.getString("address")) + } + + @Test + fun encodeToBundle_testData4() { + val data = TestData4(SealedTestData.Bar(30), "Tokyo") + val actual = Bundler.encodeToBundle(data) + assertEquals(true, actual.containsKey("data")) + val nested = actual.getBundle("data")!! + assertEquals(true, nested.containsKey("type")) + assertEquals(true, nested.containsKey("value")) + val nestedValue = nested.getBundle("value")!! + assertEquals(30, nestedValue.getInt("age")) + assertEquals("Tokyo", actual.getString("address")) + } + + @Test + fun encodeToBundle_testData5() { + val data = TestData5(PolymorphicBarData(30), "Tokyo") + val bundler = Bundler(serializersModule = SerializersModule { + polymorphic(PolymorphicTestData::class) { + subclass(PolymorphicFooData::class) + subclass(PolymorphicBarData::class) + } + }) + val actual = bundler.encodeToBundle(data) + assertEquals(true, actual.containsKey("data")) + val nested = actual.getBundle("data")!! + assertEquals(true, nested.containsKey("type")) + assertEquals(true, nested.containsKey("value")) + val nestedValue = nested.getBundle("value")!! + assertEquals(30, nestedValue.getInt("age")) + assertEquals("Tokyo", actual.getString("address")) + } + + @Test + fun encodeToBundle_testData6() { + val data = TestData6(ContextualTestData(2024, 6, 23), "Tokyo") + val bundler = Bundler(serializersModule = SerializersModule { + contextual(ContextualTestData::class, ContextualTestDataAsStringSerializer) + }) + val actual = bundler.encodeToBundle(data) + assertEquals("2024-6-23", actual.getString("data")) + assertEquals("Tokyo", actual.getString("address")) + } +} diff --git a/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt new file mode 100644 index 0000000..8a0eded --- /dev/null +++ b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt @@ -0,0 +1,97 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.serialization.bundle + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +data class TestData1( + val data: ObjectTestData, + val address: String +) + +@Serializable +data class TestData2( + val data: EnumTestData, + val address: String +) + +@Serializable +data class TestData3( + val data: List, + val address: String +) + +@Serializable +data class TestData4( + val data: SealedTestData, + val address: String +) + + +@Serializable +data class TestData5( + @Polymorphic val data: PolymorphicTestData, + val address: String +) + +@Serializable +data class TestData6( + @Contextual val data: ContextualTestData, + val address: String +) + + +@Serializable +data class ObjectTestData( + val name: String?, + val age: Int +) + +enum class EnumTestData { + Foo, Bar +} + +@Serializable +sealed class SealedTestData { + @Serializable + data class Foo(val name: String) : SealedTestData() + + @Serializable + data class Bar(val age: Int) : SealedTestData() +} + +abstract class PolymorphicTestData + +@Serializable +data class PolymorphicFooData(val name: String) : PolymorphicTestData() + +@Serializable +data class PolymorphicBarData(val age: Int) : PolymorphicTestData() + +data class ContextualTestData( + val year: Int, + val month: Int, + val day: Int +) + +object ContextualTestDataAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ContextualTestData", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: ContextualTestData) { + encoder.encodeString("${value.year}-${value.month}-${value.day}") + } + + override fun deserialize(decoder: Decoder): ContextualTestData { + val parts = decoder.decodeString().split("-") + return ContextualTestData(year = parts[0].toInt(), month = parts[1].toInt(), day = parts[2].toInt()) + } +} From a40ebd2b91c83ae7c7d3b6f1c9b8d6834ff45925 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 23 Jun 2024 16:25:10 +0900 Subject: [PATCH 049/155] Integrate with serialization-bundle The form and space modules now have an optional API that allows you to use kotlin.serialization instead of implementing Parcelable. refs: #35 --- soil-form/build.gradle.kts | 1 + .../kotlin/soil/form/compose/Form.kt | 52 +++++++++++++++++++ soil-serialization-bundle/build.gradle.kts | 4 +- soil-space/build.gradle.kts | 1 + .../src/commonMain/kotlin/soil/space/Atom.kt | 42 +++++++++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/soil-form/build.gradle.kts b/soil-form/build.gradle.kts index 665beb3..0de66e2 100644 --- a/soil-form/build.gradle.kts +++ b/soil-form/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(libs.kotlinx.coroutines.core) + api(projects.soilSerializationBundle) } val skikoMain by creating { diff --git a/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt b/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt index aee1758..26fb116 100644 --- a/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt +++ b/soil-form/src/commonMain/kotlin/soil/form/compose/Form.kt @@ -17,9 +17,13 @@ import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.toMutableStateMap import androidx.compose.ui.Modifier +import androidx.core.bundle.Bundle import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer import soil.form.FieldErrors import soil.form.FieldName import soil.form.FieldValidateOn @@ -27,6 +31,7 @@ import soil.form.FormFieldNames import soil.form.FormPolicy import soil.form.FormRule import soil.form.FormValidationException +import soil.serialization.bundle.Bundler /** * A Form to manage the state and actions of input fields, and create a child block of [FormScope]. @@ -151,6 +156,53 @@ fun Form( } } +/** + * Create an [Saver] for Kotlin Serialization. + * + * Usage: + * + * ```kotlin + * @Serializable + * data class FormData( + * val firstName: String = "", + * val lastName: String = "", + * val email: String = "", + * val mobileNumber: String = "", + * val title: Title? = null, + * val developer: Boolean? = null + * ) + * + * enum class Title { + * Mr, + * Mrs, + * Miss, + * Dr, + * } + * + * Form( + * onSubmit = { .. }, + * initialValue = FormData(), + * modifier = modifier, + * saver = serializationSaver() + * ) { .. } + * ``` + * + * @param T The type of the value to save and restore. + * @param serializer The serializer to use for the value. + * @param bundler The bundler to encode and decode the value. Default is [Bundler]. + * @return The [Saver] for the value. + */ +@ExperimentalSerializationApi +inline fun serializationSaver( + serializer: KSerializer = serializer(), + bundler: Bundler = Bundler +): Saver { + return Saver( + save = { value -> bundler.encodeToBundle(value, serializer) }, + restore = { bundle -> bundler.decodeFromBundle(bundle as Bundle, serializer) } + ) +} + @Suppress("UNCHECKED_CAST") private val errorsSaver = mapSaver( save = { stateMap -> stateMap.toMap() }, diff --git a/soil-serialization-bundle/build.gradle.kts b/soil-serialization-bundle/build.gradle.kts index 6080f6e..3ee869f 100644 --- a/soil-serialization-bundle/build.gradle.kts +++ b/soil-serialization-bundle/build.gradle.kts @@ -34,8 +34,8 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(libs.kotlinx.serialization.core) - implementation(libs.jbx.core.bundle) + api(libs.kotlinx.serialization.core) + api(libs.jbx.core.bundle) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index d17cac2..9030dae 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { implementation(libs.jbx.lifecycle.viewmodel.savedstate) api(libs.jbx.savedstate) api(libs.jbx.core.bundle) + api(projects.soilSerializationBundle) } androidMain.dependencies { diff --git a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt index 3fb9365..3c6565d 100644 --- a/soil-space/src/commonMain/kotlin/soil/space/Atom.kt +++ b/soil-space/src/commonMain/kotlin/soil/space/Atom.kt @@ -6,6 +6,10 @@ package soil.space import androidx.compose.runtime.Immutable import androidx.core.bundle.Bundle +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import soil.serialization.bundle.Bundler import kotlin.jvm.JvmName /** @@ -259,6 +263,44 @@ interface AtomSaver { typealias AtomSaverKey = String +/** + * Create an [AtomSaver] for Kotlin Serialization. + * + * Usage: + * + * ```kotlin + * @Serializable + * data class CounterData( + * val value: Int = 0 + * ) + * + * @OptIn(ExperimentalSerializationApi::class) + * private val counterAtom = atom(CounterData(), saver = serializationSaver("counter")) + *``` + * + * @param T The type of the value to save and restore. + * @param key The key to be used to save and restore the value. + * @param serializer The serializer to use for the value. + * @param bundler The bundler to encode and decode the value. Default is [Bundler]. + * @return The [AtomSaver] for the value. + */ +@ExperimentalSerializationApi +inline fun serializationSaver( + key: AtomSaverKey, + serializer: KSerializer = serializer(), + bundler: Bundler = Bundler +): AtomSaver { + return object : AtomSaver { + override fun save(bundle: Bundle, value: T) { + bundle.putBundle(key, bundler.encodeToBundle(value, serializer)) + } + + override fun restore(bundle: Bundle): T? { + return bundle.getBundle(key)?.let { bundler.decodeFromBundle(it, serializer) } + } + } +} + @PublishedApi internal fun stringSaver(key: AtomSaverKey): AtomSaver { return object : AtomSaver { From 6d61fd8ea2b446fbbab9b5e7feed25b8c26f8f08 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 26 Jun 2024 07:51:42 +0900 Subject: [PATCH 050/155] Improve key referencing to avoid ConcurrentModificationException For example, all key lookups occur in a filter search when updating a query as a side effect after performing a mutation. This has been resolved by copying to a new Set instance whenever there is an attempt to call the keys property of Map. --- .../src/commonMain/kotlin/soil/query/SwrCache.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index fd56204..0616f9d 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -106,10 +106,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun vacuum() { clearCache() // NOTE: Releases items that are active due to keepAliveTime but have no subscribers. - queryStore.keys.asSequence() + queryStore.keys.toSet() + .asSequence() .filter { id -> queryStore[id]?.ping()?.not() ?: false } .forEach { id -> queryStore.remove(id)?.close() } - mutationStore.keys.asSequence() + mutationStore.keys.toSet() + .asSequence() .filter { id -> mutationStore[id]?.ping()?.not() ?: false } .forEach { id -> mutationStore.remove(id)?.close() } } @@ -588,7 +590,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie QueryFilterType.Active -> queryStore.keys QueryFilterType.Inactive -> queryCache.keys } - queryIds.asSequence() + queryIds.toSet() + .asSequence() .filter { id -> if (keys.isNullOrEmpty()) true else keys.any { id.tags.contains(it) } From aee715bdc2f38007e48eac7d91584512c9a0def9 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 13:49:04 +0900 Subject: [PATCH 051/155] Add onError function to Query/Mutation Options The onError callback function is useful for error logging reporting and feedback you commonly want to handle on the client. --- .../commonMain/kotlin/soil/query/InfiniteQueryCommand.kt | 2 ++ .../src/commonMain/kotlin/soil/query/MutationCommand.kt | 3 ++- .../src/commonMain/kotlin/soil/query/MutationOptions.kt | 6 ++++++ .../src/commonMain/kotlin/soil/query/QueryCommand.kt | 3 ++- .../src/commonMain/kotlin/soil/query/QueryOptions.kt | 6 ++++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index c90fee8..aeb04fb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -85,6 +85,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) + .onFailure { options.onError?.invoke(it, state, key.id) } } /** @@ -104,4 +105,5 @@ suspend inline fun QueryCommand.Context>.dispatchRevali .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) + .onFailure { options.onError?.invoke(it, state, key.id) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 951eb2f..7c98e17 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -30,7 +30,7 @@ interface MutationCommand { interface Context { val receiver: MutationReceiver val options: MutationOptions - val state: MutationState + val state: MutationModel val dispatch: MutationDispatch val notifier: MutationNotifier } @@ -90,6 +90,7 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( key.onQueryUpdate(variable, data)?.let(notifier::onMutateSuccess) } .onFailure { dispatch(MutationAction.MutateFailure(it)) } + .onFailure { options.onError?.invoke(it, state, key.id) } } internal fun MutationCommand.Context.onRetryCallback( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 89f9b1a..5fd9ff3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -7,6 +7,7 @@ import soil.query.internal.ActorOptions import soil.query.internal.LoggerFn import soil.query.internal.LoggingOptions import soil.query.internal.RetryOptions +import soil.query.internal.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -27,6 +28,11 @@ data class MutationOptions( */ val isStrictMode: Boolean = false, + /** + * This callback function will be called if some mutation encounters an error. + */ + val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = null, + // ----- ActorOptions ----- // override val keepAliveTime: Duration = 5.seconds, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index f78984b..a297aaf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -32,7 +32,7 @@ interface QueryCommand { interface Context { val receiver: QueryReceiver val options: QueryOptions - val state: QueryState + val state: QueryModel val dispatch: QueryDispatch } } @@ -103,6 +103,7 @@ suspend inline fun QueryCommand.Context.dispatchFetchResult(key: QueryKey .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) + .onFailure { options.onError?.invoke(it, state, key.id) } } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index 467b079..3c6ae4d 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -8,6 +8,7 @@ import soil.query.internal.LoggerFn import soil.query.internal.LoggingOptions import soil.query.internal.RetryOptions import soil.query.internal.Retryable +import soil.query.internal.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -60,6 +61,11 @@ data class QueryOptions( */ val revalidateOnFocus: Boolean = true, + /** + * This callback function will be called if some query encounters an error. + */ + val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = null, + // ----- ActorOptions ----- // override val keepAliveTime: Duration = 5.seconds, From 5ed969ff4e38c95cc64ed3afaa6e94266973c2d8 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 14:21:59 +0900 Subject: [PATCH 052/155] Refactor the type of QueryOptions Change the type of query options to comply with the Kotlin library guidelines. --- .../kotlin/soil/query/InfiniteQueryRef.kt | 1 + .../kotlin/soil/query/QueryOptions.kt | 138 ++++++++++++++---- .../commonMain/kotlin/soil/query/QueryRef.kt | 1 + .../commonMain/kotlin/soil/query/SwrCache.kt | 14 +- .../kotlin/soil/query/QueryOptionsTest.kt | 129 ++++++++++++++++ 5 files changed, 250 insertions(+), 33 deletions(-) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 1d03f5f..cb54d90 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch */ class InfiniteQueryRef( val key: InfiniteQueryKey, + val options: QueryOptions, query: Query> ) : Query> by query { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index 3c6ae4d..701c0c6 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -18,17 +18,17 @@ import kotlin.time.Duration.Companion.seconds /** * [QueryOptions] providing settings related to the internal behavior of an [Query]. */ -data class QueryOptions( +interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { /** * The duration after which the returned value of the fetch function block is considered stale. */ - val staleTime: Duration = Duration.ZERO, + val staleTime: Duration /** * The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. */ - val gcTime: Duration = 5.minutes, + val gcTime: Duration /** * Maximum window time on prefetch processing. @@ -36,14 +36,14 @@ data class QueryOptions( * If this time is exceeded, the [kotlinx.coroutines.CoroutineScope] within the prefetch processing will terminate, * but the command processing within the Actor will continue as long as [keepAliveTime] is set. */ - val prefetchWindowTime: Duration = 1.seconds, + val prefetchWindowTime: Duration /** * Determines whether query processing needs to be paused based on error. * * @see [shouldPause] */ - val pauseDurationAfter: ((Throwable) -> Duration?)? = null, + val pauseDurationAfter: ((Throwable) -> Duration?)? /** * Automatically revalidate active [Query] when the network reconnects. @@ -51,7 +51,7 @@ data class QueryOptions( * **Note:** * This setting is only effective when [soil.query.internal.NetworkConnectivity] is available. */ - val revalidateOnReconnect: Boolean = true, + val revalidateOnReconnect: Boolean /** * Automatically revalidate active [Query] when the window is refocused. @@ -59,27 +59,113 @@ data class QueryOptions( * **Note:** * This setting is only effective when [soil.query.internal.WindowVisibility] is available. */ - val revalidateOnFocus: Boolean = true, + val revalidateOnFocus: Boolean /** * This callback function will be called if some query encounters an error. */ - val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = null, - - // ----- ActorOptions ----- // - override val keepAliveTime: Duration = 5.seconds, - - // ----- LoggingOptions ----- // - override val logger: LoggerFn? = null, - - // ----- RetryOptions ----- // - override val shouldRetry: (Throwable) -> Boolean = { e -> - e is Retryable && e.canRetry - }, - override val retryCount: Int = 3, - override val retryInitialInterval: Duration = 500.milliseconds, - override val retryMaxInterval: Duration = 30.seconds, - override val retryMultiplier: Double = 1.5, - override val retryRandomizationFactor: Double = 0.5, - override val retryRandomizer: Random = Random -) : ActorOptions, LoggingOptions, RetryOptions + val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? + + companion object Default : QueryOptions { + override val staleTime: Duration = Duration.ZERO + override val gcTime: Duration = 5.minutes + override val prefetchWindowTime: Duration = 1.seconds + override val pauseDurationAfter: ((Throwable) -> Duration?)? = null + override val revalidateOnReconnect: Boolean = true + override val revalidateOnFocus: Boolean = true + override val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = null + + // ----- ActorOptions ----- // + override val keepAliveTime: Duration = 5.seconds + + // ----- LoggingOptions ----- // + override val logger: LoggerFn? = null + + // ----- RetryOptions ----- // + override val shouldRetry: (Throwable) -> Boolean = { e -> + e is Retryable && e.canRetry + } + override val retryCount: Int = 3 + override val retryInitialInterval: Duration = 500.milliseconds + override val retryMaxInterval: Duration = 30.seconds + override val retryMultiplier: Double = 1.5 + override val retryRandomizationFactor: Double = 0.5 + override val retryRandomizer: Random = Random + } +} + +fun QueryOptions( + staleTime: Duration = QueryOptions.staleTime, + gcTime: Duration = QueryOptions.gcTime, + prefetchWindowTime: Duration = QueryOptions.prefetchWindowTime, + pauseDurationAfter: ((Throwable) -> Duration?)? = QueryOptions.pauseDurationAfter, + revalidateOnReconnect: Boolean = QueryOptions.revalidateOnReconnect, + revalidateOnFocus: Boolean = QueryOptions.revalidateOnFocus, + onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = QueryOptions.onError, + keepAliveTime: Duration = QueryOptions.keepAliveTime, + logger: LoggerFn? = QueryOptions.logger, + shouldRetry: (Throwable) -> Boolean = QueryOptions.shouldRetry, + retryCount: Int = QueryOptions.retryCount, + retryInitialInterval: Duration = QueryOptions.retryInitialInterval, + retryMaxInterval: Duration = QueryOptions.retryMaxInterval, + retryMultiplier: Double = QueryOptions.retryMultiplier, + retryRandomizationFactor: Double = QueryOptions.retryRandomizationFactor, + retryRandomizer: Random = QueryOptions.retryRandomizer, +): QueryOptions { + return object : QueryOptions { + override val staleTime: Duration = staleTime + override val gcTime: Duration = gcTime + override val prefetchWindowTime: Duration = prefetchWindowTime + override val pauseDurationAfter: ((Throwable) -> Duration?)? = pauseDurationAfter + override val revalidateOnReconnect: Boolean = revalidateOnReconnect + override val revalidateOnFocus: Boolean = revalidateOnFocus + override val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = onError + override val keepAliveTime: Duration = keepAliveTime + override val logger: LoggerFn? = logger + override val shouldRetry: (Throwable) -> Boolean = shouldRetry + override val retryCount: Int = retryCount + override val retryInitialInterval: Duration = retryInitialInterval + override val retryMaxInterval: Duration = retryMaxInterval + override val retryMultiplier: Double = retryMultiplier + override val retryRandomizationFactor: Double = retryRandomizationFactor + override val retryRandomizer: Random = retryRandomizer + } +} + +fun QueryOptions.copy( + staleTime: Duration = this.staleTime, + gcTime: Duration = this.gcTime, + prefetchWindowTime: Duration = this.prefetchWindowTime, + pauseDurationAfter: ((Throwable) -> Duration?)? = this.pauseDurationAfter, + revalidateOnReconnect: Boolean = this.revalidateOnReconnect, + revalidateOnFocus: Boolean = this.revalidateOnFocus, + onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = this.onError, + keepAliveTime: Duration = this.keepAliveTime, + logger: LoggerFn? = this.logger, + shouldRetry: (Throwable) -> Boolean = this.shouldRetry, + retryCount: Int = this.retryCount, + retryInitialInterval: Duration = this.retryInitialInterval, + retryMaxInterval: Duration = this.retryMaxInterval, + retryMultiplier: Double = this.retryMultiplier, + retryRandomizationFactor: Double = this.retryRandomizationFactor, + retryRandomizer: Random = this.retryRandomizer, +): QueryOptions { + return QueryOptions( + staleTime = staleTime, + gcTime = gcTime, + prefetchWindowTime = prefetchWindowTime, + pauseDurationAfter = pauseDurationAfter, + revalidateOnReconnect = revalidateOnReconnect, + revalidateOnFocus = revalidateOnFocus, + onError = onError, + keepAliveTime = keepAliveTime, + logger = logger, + shouldRetry = shouldRetry, + retryCount = retryCount, + retryInitialInterval = retryInitialInterval, + retryMaxInterval = retryMaxInterval, + retryMultiplier = retryMultiplier, + retryRandomizationFactor = retryRandomizationFactor, + retryRandomizer = retryRandomizer + ) +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 78c1efe..328a8c2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch */ class QueryRef( val key: QueryKey, + val options: QueryOptions, query: Query ) : Query by query { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 0616f9d..5f890ac 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -292,7 +292,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } return QueryRef( key = key, - query = query + query = query, + options = options ) } @@ -385,7 +386,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } return InfiniteQueryRef( key = key, - query = query + query = query, + options = options ) } @@ -421,11 +423,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override fun prefetchQuery(key: QueryKey) { coroutineScope.launch { val query = getQuery(key) - val options = key.options ?: defaultQueryOptions val revision = query.state.value.revision val job = launch { query.start(this) } try { - withTimeout(options.prefetchWindowTime) { + withTimeout(query.options.prefetchWindowTime) { query.state.first { it.revision != revision || !it.isStaled() } } } finally { @@ -437,11 +438,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override fun prefetchInfiniteQuery(key: InfiniteQueryKey) { coroutineScope.launch { val query = getInfiniteQuery(key) - val options = key.options ?: defaultQueryOptions val revision = query.state.value.revision val job = launch { query.start(this) } try { - withTimeout(options.prefetchWindowTime) { + withTimeout(query.options.prefetchWindowTime) { query.state.first { it.revision != revision || !it.isStaled() } } } finally { @@ -722,7 +722,7 @@ data class SwrCachePolicy( /** * Default [QueryOptions] applied to [Query]. */ - val queryOptions: QueryOptions = QueryOptions(), + val queryOptions: QueryOptions = QueryOptions, /** * Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt new file mode 100644 index 0000000..93be8bc --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt @@ -0,0 +1,129 @@ +package soil.query + +import soil.testing.UnitTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class QueryOptionsTest : UnitTest() { + + @Test + fun factory_default() { + val actual = QueryOptions() + assertEquals(QueryOptions.Default.staleTime, actual.staleTime) + assertEquals(QueryOptions.Default.gcTime, actual.gcTime) + assertEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) + assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) + assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) + assertEquals(QueryOptions.Default.onError, actual.onError) + assertEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(QueryOptions.Default.logger, actual.logger) + assertEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(QueryOptions.Default.retryCount, actual.retryCount) + assertEquals(QueryOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(QueryOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(QueryOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(QueryOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(QueryOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun factory_specifyingArguments() { + val actual = QueryOptions( + staleTime = 1000.seconds, + gcTime = 2000.seconds, + prefetchWindowTime = 3000.seconds, + pauseDurationAfter = { null }, + revalidateOnReconnect = false, + revalidateOnFocus = false, + onError = { _, _, _ -> }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + assertNotEquals(QueryOptions.Default.staleTime, actual.staleTime) + assertNotEquals(QueryOptions.Default.gcTime, actual.gcTime) + assertNotEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertNotEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) + assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) + assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) + assertNotEquals(QueryOptions.Default.onError, actual.onError) + assertNotEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(QueryOptions.Default.logger, actual.logger) + assertNotEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(QueryOptions.Default.retryCount, actual.retryCount) + assertNotEquals(QueryOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(QueryOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(QueryOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(QueryOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(QueryOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_default() { + val actual = QueryOptions.Default.copy() + assertEquals(QueryOptions.Default.staleTime, actual.staleTime) + assertEquals(QueryOptions.Default.gcTime, actual.gcTime) + assertEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) + assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) + assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) + assertEquals(QueryOptions.Default.onError, actual.onError) + assertEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(QueryOptions.Default.logger, actual.logger) + assertEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(QueryOptions.Default.retryCount, actual.retryCount) + assertEquals(QueryOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(QueryOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(QueryOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(QueryOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(QueryOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_override() { + val actual = QueryOptions.copy( + staleTime = 1000.seconds, + gcTime = 2000.seconds, + prefetchWindowTime = 3000.seconds, + pauseDurationAfter = { null }, + revalidateOnReconnect = false, + revalidateOnFocus = false, + onError = { _, _, _ -> }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + assertNotEquals(QueryOptions.Default.staleTime, actual.staleTime) + assertNotEquals(QueryOptions.Default.gcTime, actual.gcTime) + assertNotEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertNotEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) + assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) + assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) + assertNotEquals(QueryOptions.Default.onError, actual.onError) + assertNotEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(QueryOptions.Default.logger, actual.logger) + assertNotEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(QueryOptions.Default.retryCount, actual.retryCount) + assertNotEquals(QueryOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(QueryOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(QueryOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(QueryOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(QueryOptions.Default.retryRandomizer, actual.retryRandomizer) + } +} From 8b59dc94d2be7ef1180b7a5d87bcfa555e406827 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 15:05:29 +0900 Subject: [PATCH 053/155] Allows shared QueryOptions to be overridden for each key --- .../kotlin/soil/query/InfiniteQueryKey.kt | 8 ++--- .../kotlin/soil/query/QueryClient.kt | 1 + .../commonMain/kotlin/soil/query/QueryKey.kt | 32 ++++++++++++++++--- .../commonMain/kotlin/soil/query/SwrCache.kt | 4 +-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index 38cfaf0..818b63c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -41,11 +41,11 @@ interface InfiniteQueryKey { val loadMoreParam: (chunks: QueryChunks) -> S? /** - * Configure the Query [options]. + * Function to configure the [QueryOptions]. * * If unspecified, the default value of [SwrCachePolicy] is used. */ - val options: QueryOptions? + fun onConfigureOptions(): QueryOptionsOverride? = null /** * Function to specify placeholder data. @@ -117,14 +117,12 @@ fun buildInfiniteQueryKey( id: InfiniteQueryId, fetch: suspend QueryReceiver.(param: S) -> T, initialParam: () -> S, - loadMoreParam: (QueryChunks) -> S?, - options: QueryOptions? = null + loadMoreParam: (QueryChunks) -> S? ): InfiniteQueryKey { return object : InfiniteQueryKey { override val id: InfiniteQueryId = id override val fetch: suspend QueryReceiver.(param: S) -> T = fetch override val initialParam: () -> S = initialParam override val loadMoreParam: (QueryChunks) -> S? = loadMoreParam - override val options: QueryOptions? = options } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index b9a81f7..619df00 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -126,3 +126,4 @@ typealias QueryPlaceholderData = QueryReadonlyClient.() -> T? typealias QueryEffect = QueryMutableClient.() -> Unit typealias QueryRecoverData = (error: Throwable) -> T +typealias QueryOptionsOverride = (QueryOptions) -> QueryOptions diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index cc44e8f..19d0bb2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -26,17 +26,31 @@ interface QueryKey { val fetch: suspend QueryReceiver.() -> T /** - * Configure the Query [options]. + * Function to configure the [QueryOptions]. * * If unspecified, the default value of [SwrCachePolicy] is used. + * + * ```kotlin + * override fun onConfigureOptions(): QueryOptionsOverride = { options -> + * options.copy(gcTime = Duration.ZERO) + * } + * ``` */ - val options: QueryOptions? + fun onConfigureOptions(): QueryOptionsOverride? = null /** * Function to specify placeholder data. * * You can specify placeholder data instead of the initial loading state. * + * ```kotlin + * override fun onPlaceholderData(): QueryPlaceholderData = { + * getInfiniteQueryData(GetUsersKey.Id())?.let { + * it.chunkedData.firstOrNull { user -> user.id == userId } + * } + * } + * ``` + * * @see QueryPlaceholderData */ fun onPlaceholderData(): QueryPlaceholderData? = null @@ -46,6 +60,16 @@ interface QueryKey { * * Depending on the type of exception that occurred during data retrieval, it is possible to recover it as normal data. * + * ```kotlin + * override fun onRecoverData(): QueryRecoverData> = { err -> + * if (err is ClientRequestException && err.response.status.value == 404) { + * emptyList() + * } else { + * throw err + * } + * } + * ``` + * * @see QueryRecoverData */ fun onRecoverData(): QueryRecoverData? = null @@ -94,12 +118,10 @@ open class QueryId( */ fun buildQueryKey( id: QueryId, - fetch: suspend QueryReceiver.() -> T, - options: QueryOptions? = null + fetch: suspend QueryReceiver.() -> T ): QueryKey { return object : QueryKey { override val id: QueryId = id override val fetch: suspend QueryReceiver.() -> T = fetch - override val options: QueryOptions? = options } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 5f890ac..daffd38 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -274,7 +274,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie @Suppress("UNCHECKED_CAST") override fun getQuery(key: QueryKey): QueryRef { val id = key.id - val options = key.options ?: defaultQueryOptions + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions var query = queryStore[id] as? ManagedQuery if (query == null) { query = newQuery( @@ -367,7 +367,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie key: InfiniteQueryKey ): InfiniteQueryRef { val id = key.id - val options = key.options ?: defaultQueryOptions + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions var query = queryStore[id] as? ManagedQuery> if (query == null) { query = newInfiniteQuery( From 696190323f4959358fd2a13e96b6b8cb7ff014b6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 15:13:34 +0900 Subject: [PATCH 054/155] Refactor the type of MutationOptions --- .../kotlin/soil/query/MutationOptions.kt | 106 ++++++++++++++---- .../kotlin/soil/query/MutationRef.kt | 1 + .../commonMain/kotlin/soil/query/SwrCache.kt | 3 +- .../kotlin/soil/query/MutationOptionsTest.kt | 105 +++++++++++++++++ 4 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 5fd9ff3..983e4eb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -16,35 +16,101 @@ import kotlin.time.Duration.Companion.seconds /** * [MutationOptions] providing settings related to the internal behavior of an [Mutation]. */ -data class MutationOptions( +interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { /** * Only allows mutate to execute once while active (until reset). */ - val isOneShot: Boolean = false, + val isOneShot: Boolean /** * Requires revision match as a precondition for executing mutate. */ - val isStrictMode: Boolean = false, + val isStrictMode: Boolean /** * This callback function will be called if some mutation encounters an error. */ - val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = null, - - // ----- ActorOptions ----- // - override val keepAliveTime: Duration = 5.seconds, - - // ----- LoggingOptions ----- // - override val logger: LoggerFn? = null, - - // ----- RetryOptions ----- // - override val shouldRetry: (Throwable) -> Boolean = { false }, - override val retryCount: Int = 3, - override val retryInitialInterval: Duration = 500.milliseconds, - override val retryMaxInterval: Duration = 30.seconds, - override val retryMultiplier: Double = 1.5, - override val retryRandomizationFactor: Double = 0.5, - override val retryRandomizer: Random = Random -) : ActorOptions, LoggingOptions, RetryOptions + val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? + + companion object Default : MutationOptions { + override val isOneShot: Boolean = false + override val isStrictMode: Boolean = false + override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = null + + // ----- ActorOptions ----- // + override val keepAliveTime: Duration = 5.seconds + + // ----- LoggingOptions ----- // + override val logger: LoggerFn? = null + + // ----- RetryOptions ----- // + override val shouldRetry: (Throwable) -> Boolean = { false } + override val retryCount: Int = 3 + override val retryInitialInterval: Duration = 500.milliseconds + override val retryMaxInterval: Duration = 30.seconds + override val retryMultiplier: Double = 1.5 + override val retryRandomizationFactor: Double = 0.5 + override val retryRandomizer: Random = Random + } +} + +fun MutationOptions( + isOneShot: Boolean = MutationOptions.isOneShot, + isStrictMode: Boolean = MutationOptions.isStrictMode, + onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = MutationOptions.onError, + keepAliveTime: Duration = MutationOptions.keepAliveTime, + logger: LoggerFn? = MutationOptions.logger, + shouldRetry: (Throwable) -> Boolean = MutationOptions.shouldRetry, + retryCount: Int = MutationOptions.retryCount, + retryInitialInterval: Duration = MutationOptions.retryInitialInterval, + retryMaxInterval: Duration = MutationOptions.retryMaxInterval, + retryMultiplier: Double = MutationOptions.retryMultiplier, + retryRandomizationFactor: Double = MutationOptions.retryRandomizationFactor, + retryRandomizer: Random = MutationOptions.retryRandomizer +): MutationOptions { + return object : MutationOptions { + override val isOneShot: Boolean = isOneShot + override val isStrictMode: Boolean = isStrictMode + override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = onError + override val keepAliveTime: Duration = keepAliveTime + override val logger: LoggerFn? = logger + override val shouldRetry: (Throwable) -> Boolean = shouldRetry + override val retryCount: Int = retryCount + override val retryInitialInterval: Duration = retryInitialInterval + override val retryMaxInterval: Duration = retryMaxInterval + override val retryMultiplier: Double = retryMultiplier + override val retryRandomizationFactor: Double = retryRandomizationFactor + override val retryRandomizer: Random = retryRandomizer + } +} + +fun MutationOptions.copy( + isOneShot: Boolean = this.isOneShot, + isStrictMode: Boolean = this.isStrictMode, + onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = this.onError, + keepAliveTime: Duration = this.keepAliveTime, + logger: LoggerFn? = this.logger, + shouldRetry: (Throwable) -> Boolean = this.shouldRetry, + retryCount: Int = this.retryCount, + retryInitialInterval: Duration = this.retryInitialInterval, + retryMaxInterval: Duration = this.retryMaxInterval, + retryMultiplier: Double = this.retryMultiplier, + retryRandomizationFactor: Double = this.retryRandomizationFactor, + retryRandomizer: Random = this.retryRandomizer +): MutationOptions { + return MutationOptions( + isOneShot = isOneShot, + isStrictMode = isStrictMode, + onError = onError, + keepAliveTime = keepAliveTime, + logger = logger, + shouldRetry = shouldRetry, + retryCount = retryCount, + retryInitialInterval = retryInitialInterval, + retryMaxInterval = retryMaxInterval, + retryMultiplier = retryMultiplier, + retryRandomizationFactor = retryRandomizationFactor, + retryRandomizer = retryRandomizer + ) +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 3dbdbdd..0c98d1c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch */ class MutationRef( val key: MutationKey, + val options: MutationOptions, mutation: Mutation ) : Mutation by mutation { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index daffd38..0a1a792 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -209,6 +209,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } return MutationRef( key = key, + options = options, mutation = mutation ) } @@ -707,7 +708,7 @@ data class SwrCachePolicy( /** * Default [MutationOptions] applied to [Mutation]. */ - val mutationOptions: MutationOptions = MutationOptions(), + val mutationOptions: MutationOptions = MutationOptions, /** * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt new file mode 100644 index 0000000..b878d2b --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -0,0 +1,105 @@ +package soil.query + +import soil.testing.UnitTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class MutationOptionsTest : UnitTest() { + + @Test + fun factory_default() { + val actual = MutationOptions() + assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) + assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(MutationOptions.Default.logger, actual.logger) + assertEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(MutationOptions.Default.retryCount, actual.retryCount) + assertEquals(MutationOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(MutationOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(MutationOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(MutationOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(MutationOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun factory_specifyingArguments() { + val actual = MutationOptions( + isOneShot = true, + isStrictMode = true, + onError = { _, _, _ -> }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) + assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(MutationOptions.Default.logger, actual.logger) + assertNotEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(MutationOptions.Default.retryCount, actual.retryCount) + assertNotEquals(MutationOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(MutationOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(MutationOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(MutationOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(MutationOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_default() { + val actual = MutationOptions.Default.copy() + assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) + assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(MutationOptions.Default.logger, actual.logger) + assertEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(MutationOptions.Default.retryCount, actual.retryCount) + assertEquals(MutationOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(MutationOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(MutationOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(MutationOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(MutationOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_override() { + val actual = MutationOptions.Default.copy( + isOneShot = true, + isStrictMode = true, + onError = { _, _, _ -> }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) + assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(MutationOptions.Default.logger, actual.logger) + assertNotEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(MutationOptions.Default.retryCount, actual.retryCount) + assertNotEquals(MutationOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(MutationOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(MutationOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(MutationOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(MutationOptions.Default.retryRandomizer, actual.retryRandomizer) + } +} From 34925d209727503715488b3cc8757bce3b47066d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 15:21:43 +0900 Subject: [PATCH 055/155] Allows shared MutationOptions to be overridden for each key --- .../kotlin/soil/query/MutationClient.kt | 2 ++ .../kotlin/soil/query/MutationKey.kt | 22 ++++++++++++++----- .../commonMain/kotlin/soil/query/SwrCache.kt | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt index 0642b98..93cc0e7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt @@ -20,3 +20,5 @@ interface MutationClient { key: MutationKey ): MutationRef } + +typealias MutationOptionsOverride = (MutationOptions) -> MutationOptions diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt index 7382e7f..980f16c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt @@ -30,17 +30,29 @@ interface MutationKey { val mutate: suspend MutationReceiver.(variable: S) -> T /** - * Configure the Mutation [options]. + * Function to configure the [MutationOptions]. * - * If unspecified, the default value of [MutationOptions] is used. + * If unspecified, the default value of [SwrCachePolicy] is used. + * + * ```kotlin + * override fun onConfigureOptions(): MutationOptionsOverride? = { options -> + * options.copy(isOneShot = true) + * } + * ``` */ - val options: MutationOptions? + fun onConfigureOptions(): MutationOptionsOverride? = null /** * Function to update the query cache after the mutation is executed. * * This is often referred to as ["Pessimistic Updates"](https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates). * + * ```kotlin + * override fun onQueryUpdate(variable: PostForm, data: Post): QueryEffect = { + * invalidateQueriesBy(GetPostsKey.Id()) + * } + * ``` + * * @param variable The variable to be mutated. * @param data The data returned by the mutation. */ @@ -106,12 +118,10 @@ open class MutationId( */ fun buildMutationKey( id: MutationId = MutationId.auto(), - mutate: suspend MutationReceiver.(variable: S) -> T, - options: MutationOptions? = null + mutate: suspend MutationReceiver.(variable: S) -> T ): MutationKey { return object : MutationKey { override val id: MutationId = id override val mutate: suspend MutationReceiver.(S) -> T = mutate - override val options: MutationOptions? = options } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 0a1a792..0a4a561 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -191,7 +191,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie key: MutationKey ): MutationRef { val id = key.id - val options = key.options ?: defaultMutationOptions + val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions var mutation = mutationStore[id] as? ManagedMutation if (mutation == null) { mutation = newMutation( From c343c305d716c98907e82d59d7f43bb8c957f3f6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 06:48:57 +0000 Subject: [PATCH 056/155] Apply automatic changes --- .../src/commonTest/kotlin/soil/query/MutationOptionsTest.kt | 3 +++ .../src/commonTest/kotlin/soil/query/QueryOptionsTest.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt index b878d2b..2cd582d 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query import soil.testing.UnitTest diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt index 93be8bc..94ab2f6 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query import soil.testing.UnitTest From c6a3e8bee67c1e9a0e8412b4d39bb05b0f20eacf Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 16:00:59 +0900 Subject: [PATCH 057/155] Remove unnecessary voyager --- gradle/libs.versions.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c54bfb7..7601cfb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,6 @@ ktor = "3.0.0-wasm2" maven-publish = "0.28.0" robolectric = "4.12.2" spotless = "6.25.0" -voyager = "1.1.0-alpha04" [libraries] @@ -57,8 +56,6 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } -voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } -voyager-screenModel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } [plugins] From 20738db20fa65fd8bfa5e765118f6819ea46755f Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 16:07:52 +0900 Subject: [PATCH 058/155] Bump compose-multiplatform to 1.6.11 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7601cfb..9d3938c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidx-lifecycle = "2.7.0" androidx-test = "1.5.0" androidx-test-ext-junit = "1.1.5" compose = "1.6.7" -compose-multiplatform = "1.6.10" +compose-multiplatform = "1.6.11" dokka = "1.9.20" jbx-core-bundle = "1.0.0" jbx-lifecycle = "2.8.0" From 087db3fd234150b3a0d4174dab745f123a134324 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 30 Jun 2024 07:26:26 +0000 Subject: [PATCH 059/155] Bump up version to 1.0.0-alpha03 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0a37928..a821ca7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.mpp.enableCInteropCommonization=true development=true #Product group=com.soil-kt.soil -version=1.0.0-alpha02 +version=1.0.0-alpha03 androidCompileSdk=34 androidTargetSdk=34 androidMinSdk=23 From d69389c690be38e60fa69ed0c82b793095a3ca29 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 30 Jun 2024 16:28:53 +0900 Subject: [PATCH 060/155] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b1ee34a..78e0860 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Soil is available on `mavenCentral()`. ```kts dependencies { - val soil = "1.0.0-alpha02" + val soil = "1.0.0-alpha03" implementation("com.soil-kt.soil:query-core:$soil") implementation("com.soil-kt.soil:query-compose:$soil") implementation("com.soil-kt.soil:query-compose-runtime:$soil") From b7fd4ca807a09e91238e7719da51f0b8827bce63 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 10:46:59 +0900 Subject: [PATCH 061/155] Add custom flow operator --- .../kotlin/soil/query/internal/FlowExt.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt new file mode 100644 index 0000000..685efec --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt @@ -0,0 +1,77 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import kotlin.time.Duration + +@OptIn(FlowPreview::class) +internal fun Flow.chunkedWithTimeout( + size: Int, + duration: Duration +): Flow> { + require(size > 0) { "'size' should be greater than 0" } + require(duration > Duration.ZERO) { "'duration' should be greater than 0" } + return flow { + coroutineScope { + val upstreamValues = produceIn(this) + val chunks = ArrayList(size) + val ticker = MutableSharedFlow(extraBufferCapacity = size) + val tickerTimeout = ticker + .debounce(duration) + .toReceiveChannel(this) + try { + while (isActive) { + var isTimeout = false + + select { + upstreamValues.onReceive { value -> + chunks.add(value) + ticker.emit(Unit) + } + tickerTimeout.onReceive { + isTimeout = true + } + } + + if (chunks.size == size || (isTimeout && chunks.isNotEmpty())) { + emit(chunks.toList()) + chunks.clear() + } + } + } catch (e: ClosedReceiveChannelException) { + if (chunks.isNotEmpty()) { + emit(chunks.toList()) + } + } finally { + tickerTimeout.cancel() + } + } + } +} + +private fun Flow.toReceiveChannel(scope: CoroutineScope): ReceiveChannel { + val channel = Channel(Channel.BUFFERED) + scope.launch { + try { + collect { value -> channel.send(value) } + } finally { + channel.close() + } + } + return channel +} From 7bfe9571dd2c62489ffe183e461fc1655e6438d7 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 11:03:49 +0900 Subject: [PATCH 062/155] Add new actor logic --- .../kotlin/soil/query/internal/Actor.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt new file mode 100644 index 0000000..6359381 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt @@ -0,0 +1,93 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.time.Duration + +/** + * An actor represents a launch entry point for processing a query or mutation command. + */ +interface Actor { + + /** + * Launches the actor. + * + * The Actor will continue to run as long as any one of the invoked [scope]s is valid. + * + * **Note:** + * Currently, This function must be called from the main(UI) thread. + * If you call it from a thread other than the main thread, the internal counter value will be out of sync. + * + * @param scope The scope in which the actor will run + */ + fun launchIn(scope: CoroutineScope) +} + +internal typealias ActorSequenceNumber = Int + +internal class ActorBlockRunner( + private val scope: CoroutineScope, + private val options: ActorOptions, + private val onTimeout: (ActorSequenceNumber) -> Unit, + private val block: suspend () -> Unit +) : Actor { + + var seq: ActorSequenceNumber = 0 + private set + + private var launchedCount: Int = 0 + private var hasActiveScope: Boolean = false + private var runningJob: Job? = null + private var cancellationJob: Job? = null + + override fun launchIn(scope: CoroutineScope) { + seq++ + scope.launch(start = CoroutineStart.UNDISPATCHED) { + cancellationJob?.cancelAndJoin() + cancellationJob = null + suspendCancellableCoroutine { continuation -> + launchedCount++ + if (!hasActiveScope && launchedCount > 0) { + hasActiveScope = true + start() + } + continuation.invokeOnCancellation { + launchedCount-- + if (hasActiveScope && launchedCount <= 0) { + hasActiveScope = false + stop() + } + } + } + } + } + + private fun start() { + if (runningJob?.isActive == true) { + return + } + runningJob = scope.launch { + block() + } + } + + private fun stop() { + if (cancellationJob?.isActive == true) { + return + } + cancellationJob = scope.launch { + if (options.keepAliveTime >= Duration.ZERO) { + delay(options.keepAliveTime) + } + onTimeout(seq) + } + } +} From 47c21e8a987b38fba61cc34080a5fe8954dfc2db Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 11:21:16 +0900 Subject: [PATCH 063/155] Switched to a new Actor implementation --- .../query/compose/InfiniteQueryComposable.kt | 11 +- .../soil/query/compose/MutationComposable.kt | 6 +- .../soil/query/compose/QueryComposable.kt | 11 +- .../kotlin/soil/query/compose/Util.kt | 21 +- .../kotlin/soil/query/InfiniteQueryRef.kt | 11 +- .../commonMain/kotlin/soil/query/Mutation.kt | 9 +- .../kotlin/soil/query/MutationRef.kt | 12 +- .../src/commonMain/kotlin/soil/query/Query.kt | 9 +- .../commonMain/kotlin/soil/query/QueryRef.kt | 15 +- .../commonMain/kotlin/soil/query/SwrCache.kt | 180 ++++++++++-------- 10 files changed, 141 insertions(+), 144 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index f48d292..a7bef2a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import soil.query.InfiniteQueryKey import soil.query.InfiniteQueryRef import soil.query.QueryChunks @@ -29,10 +30,11 @@ fun rememberInfiniteQuery( key: InfiniteQueryKey, client: QueryClient = LocalSwrClient.current ): InfiniteQueryObject, S> { - val query = remember(key) { client.getInfiniteQuery(key) } + val scope = rememberCoroutineScope() + val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start(this) + query.start() } return remember(query, state) { state.toInfiniteObject(query = query, select = { it }) @@ -55,10 +57,11 @@ fun rememberInfiniteQuery( select: (chunks: QueryChunks) -> U, client: QueryClient = LocalSwrClient.current ): InfiniteQueryObject { - val query = remember(key) { client.getInfiniteQuery(key) } + val scope = rememberCoroutineScope() + val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start(this) + query.start() } return remember(query, state) { state.toInfiniteObject(query = query, select = select) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index f80f3f0..c3cef01 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import soil.query.MutationClient import soil.query.MutationKey import soil.query.MutationRef @@ -28,10 +29,11 @@ fun rememberMutation( key: MutationKey, client: MutationClient = LocalSwrClient.current ): MutationObject { - val mutation = remember(key) { client.getMutation(key) } + val scope = rememberCoroutineScope() + val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } } val state by mutation.state.collectAsState() LaunchedEffect(mutation) { - mutation.start(this) + mutation.start() } return remember(mutation, state) { state.toObject(mutation = mutation) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index 480cf53..af235ad 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import soil.query.QueryClient import soil.query.QueryKey import soil.query.QueryRef @@ -27,10 +28,11 @@ fun rememberQuery( key: QueryKey, client: QueryClient = LocalSwrClient.current ): QueryObject { - val query = remember(key) { client.getQuery(key) } + val scope = rememberCoroutineScope() + val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start(this) + query.start() } return remember(query, state) { state.toObject(query = query, select = { it }) @@ -53,10 +55,11 @@ fun rememberQuery( select: (T) -> U, client: QueryClient = LocalSwrClient.current ): QueryObject { - val query = remember(key) { client.getQuery(key) } + val scope = rememberCoroutineScope() + val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start(this) + query.start() } return remember(query, state) { state.toObject(query = query, select = select) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 569bc35..8003bbd 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -4,9 +4,8 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import kotlinx.coroutines.flow.launchIn +import androidx.compose.runtime.rememberCoroutineScope import soil.query.InfiniteQueryKey import soil.query.MutationClient import soil.query.MutationKey @@ -50,10 +49,8 @@ fun KeepAlive( key: QueryKey<*>, client: QueryClient = LocalSwrClient.current ) { - val query = remember(key) { client.getQuery(key) } - LaunchedEffect(Unit) { - query.actor.launchIn(this) - } + val scope = rememberCoroutineScope() + remember(key) { client.getQuery(key).also { it.launchIn(scope) } } } /** @@ -69,10 +66,8 @@ fun KeepAlive( key: InfiniteQueryKey<*, *>, client: QueryClient = LocalSwrClient.current ) { - val query = remember(key) { client.getInfiniteQuery(key) } - LaunchedEffect(Unit) { - query.actor.launchIn(this) - } + val scope = rememberCoroutineScope() + remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } } /** @@ -88,8 +83,6 @@ fun KeepAlive( key: MutationKey<*, *>, client: MutationClient = LocalSwrClient.current ) { - val query = remember(key) { client.getMutation(key) } - LaunchedEffect(Unit) { - query.actor.launchIn(this) - } + val scope = rememberCoroutineScope() + remember(key) { client.getMutation(key).also { it.launchIn(scope) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index cb54d90..513da51 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -26,15 +26,10 @@ class InfiniteQueryRef( * Starts the [Query]. * * This function must be invoked when a new mount point (subscriber) is added. - * - * @param scope The [CoroutineScope] to launch the [Query] actor. */ - fun start(scope: CoroutineScope) { - actor.launchIn(scope = scope) - scope.launch { - command.send(InfiniteQueryCommands.Connect(key)) - event.collect(::handleEvent) - } + suspend fun start() { + command.send(InfiniteQueryCommands.Connect(key)) + event.collect(::handleEvent) } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index 83046e4..30f3879 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -4,21 +4,16 @@ package soil.query import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import soil.query.internal.Actor /** * Mutation as the base interface for an [MutationClient] implementations. * * @param T Type of the return value from the mutation. */ -interface Mutation { - - /** - * Coroutine [Flow] to launch the actor. - */ - val actor: Flow<*> +interface Mutation : Actor { /** * [Shared Flow][SharedFlow] to receive mutation [events][MutationEvent]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 0c98d1c..39c9f27 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -3,11 +3,8 @@ package soil.query -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.launch /** * A reference to a [Mutation] for [MutationKey]. @@ -28,14 +25,9 @@ class MutationRef( * Starts the [Mutation]. * * This function must be invoked when a new mount point (subscriber) is added. - * - * @param scope The [CoroutineScope] to launch the [Mutation] actor. */ - fun start(scope: CoroutineScope) { - actor.launchIn(scope = scope) - scope.launch { - event.collect(::handleEvent) - } + suspend fun start() { + event.collect(::handleEvent) } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 496701f..27330fd 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -4,21 +4,16 @@ package soil.query import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import soil.query.internal.Actor /** * Query as the base interface for an [QueryClient] implementations. * * @param T Type of the return value from the query. */ -interface Query { - - /** - * Coroutine [Flow] to launch the actor. - */ - val actor: Flow<*> +interface Query: Actor { /** * [Shared Flow][SharedFlow] to receive query [events][QueryEvent]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 328a8c2..e015911 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -3,10 +3,6 @@ package soil.query -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.launch - /** * A reference to an [Query] for [QueryKey]. * @@ -25,15 +21,10 @@ class QueryRef( * Starts the [Query]. * * This function must be invoked when a new mount point (subscriber) is added. - * - * @param scope The [CoroutineScope] to launch the [Query] actor. */ - fun start(scope: CoroutineScope) { - actor.launchIn(scope = scope) - scope.launch { - command.send(QueryCommands.Connect(key)) - event.collect(::handleEvent) - } + suspend fun start() { + command.send(QueryCommands.Connect(key)) + event.collect(::handleEvent) } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 0a4a561..922ca6b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -3,6 +3,7 @@ package soil.query +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,19 +15,22 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_CHUNK_SIZE +import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_INTERVAL +import soil.query.internal.ActorBlockRunner +import soil.query.internal.ActorSequenceNumber import soil.query.internal.MemoryPressure import soil.query.internal.MemoryPressureLevel import soil.query.internal.NetworkConnectivity @@ -36,11 +40,12 @@ import soil.query.internal.TimeBasedCache import soil.query.internal.UniqueId import soil.query.internal.WindowVisibility import soil.query.internal.WindowVisibilityEvent +import soil.query.internal.chunkedWithTimeout import soil.query.internal.epoch -import soil.query.internal.newSharingStarted import soil.query.internal.vvv import kotlin.coroutines.CoroutineContext import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -75,14 +80,26 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private val queryStore: MutableMap> = policy.queryStore private val queryCache: TimeBasedCache> = policy.queryCache - private val coroutineScope: CoroutineScope = CoroutineScope( context = newCoroutineContext(policy.coroutineScope) ) + private val gcFlow: MutableSharedFlow<() -> Unit> = MutableSharedFlow() + private var mountedIds: Set = emptySet() private var mountedScope: CoroutineScope? = null + init { + gcFlow + .chunkedWithTimeout(size = policy.gcChunkSize, duration = policy.gcInterval) + .onEach { actions -> + withContext(policy.mainDispatcher) { + actions.forEach { it() } + } + } + .launchIn(coroutineScope) + } + /** * Releases data in memory based on the specified [level]. */ @@ -197,14 +214,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie mutation = newMutation( id = id, options = options, - initialValue = MutationState(), - onActive = { - options.vvv(id) { "inactive -> active" } - }, - onInactive = { - options.vvv(id) { "inactive <- active" } - closeMutation(id) - } + initialValue = MutationState() ).also { mutationStore[id] = it } } return MutationRef( @@ -217,9 +227,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun newMutation( id: UniqueId, options: MutationOptions, - initialValue: MutationState, - onActive: () -> Unit = {}, - onInactive: () -> Unit = {} + initialValue: MutationState ): ManagedMutation { val scope = CoroutineScope(newCoroutineContext(coroutineScope)) val event = MutableSharedFlow( @@ -234,7 +242,13 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } val notifier = MutationNotifier { effect -> perform(effect) } val command = Channel>() - val actor = flow { + val actor = ActorBlockRunner( + scope = scope, + options = options, + onTimeout = { seq -> + scope.launch { gcFlow.emit { closeMutation(id, seq) } } + } + ) { for (c in command) { options.vvv(id) { "next command $c" } c.handle( @@ -247,13 +261,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) ) } - }.shareIn( - scope = scope, - started = options.newSharingStarted( - onActive = onActive, - onInactive = onInactive - ) - ) + } return ManagedMutation( id = id, options = options, @@ -267,9 +275,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } @Suppress("UNCHECKED_CAST") - private fun closeMutation(id: UniqueId) { - val mutation = mutationStore.remove(id) as? ManagedMutation ?: return - mutation.close() + private fun closeMutation(id: UniqueId, seq: ActorSequenceNumber) { + val mutation = mutationStore[id] as? ManagedMutation ?: return + if (mutation.actor.seq == seq) { + mutationStore.remove(id) + mutation.close() + } } @Suppress("UNCHECKED_CAST") @@ -281,14 +292,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie query = newQuery( id = id, options = options, - initialValue = queryCache[key.id] as? QueryState ?: newQueryState(key), - onActive = { - options.vvv(id) { "inactive -> active" } - }, - onInactive = { - options.vvv(id) { "inactive <- active" } - closeQuery(id) - } + initialValue = queryCache[key.id] as? QueryState ?: newQueryState(key) ).also { queryStore[id] = it } } return QueryRef( @@ -301,9 +305,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun newQuery( id: UniqueId, options: QueryOptions, - initialValue: QueryState, - onActive: () -> Unit = {}, - onInactive: () -> Unit = {} + initialValue: QueryState ): ManagedQuery { val scope = CoroutineScope(newCoroutineContext(coroutineScope)) val event = MutableSharedFlow( @@ -317,18 +319,18 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie state.value = reducer(state.value, action) } val command = Channel>() - val actor = flow { + val actor = ActorBlockRunner( + scope = scope, + options = options, + onTimeout = { seq -> + scope.launch { gcFlow.emit { closeQuery(id, seq) } } + }, + ) { for (c in command) { options.vvv(id) { "next command $c" } c.handle(ctx = ManagedQueryContext(queryReceiver, options, state.value, dispatch)) } - }.shareIn( - scope = scope, - started = options.newSharingStarted( - onActive = onActive, - onInactive = onInactive - ) - ) + } return ManagedQuery( id = id, options = options, @@ -352,14 +354,21 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } @Suppress("UNCHECKED_CAST") - private fun closeQuery(id: UniqueId) { - val query = queryStore.remove(id) as? ManagedQuery ?: return - query.close() + private fun closeQuery(id: UniqueId, seq: ActorSequenceNumber) { + val query = queryStore[id] as? ManagedQuery ?: return + if (query.actor.seq == seq) { + queryStore.remove(id) + query.close() + saveToCache(query) + } + } + + private fun saveToCache(query: ManagedQuery) { val lastValue = query.state.value val ttl = query.options.gcTime if (lastValue.isSuccess && !lastValue.isPlaceholderData && ttl.isPositive()) { - queryCache.set(id, lastValue, ttl) - query.options.vvv(id) { "cached(ttl=$ttl)" } + queryCache.set(query.id, lastValue, ttl) + query.options.vvv(query.id) { "cached(ttl=$ttl)" } } } @@ -374,15 +383,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie query = newInfiniteQuery( id = id, options = options, - initialValue = queryCache[id] as? QueryState> - ?: newInfiniteQueryState(key), - onActive = { - options.vvv(id) { "inactive -> active" } - }, - onInactive = { - options.vvv(id) { "inactive <- active" } - closeQuery>(id) - } + initialValue = queryCache[id] as? QueryState> ?: newInfiniteQueryState(key) ).also { queryStore[id] = it } } return InfiniteQueryRef( @@ -395,16 +396,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun newInfiniteQuery( id: UniqueId, options: QueryOptions, - initialValue: QueryState>, - onActive: () -> Unit = {}, - onInactive: () -> Unit = {} + initialValue: QueryState> ): ManagedQuery> { return newQuery( id = id, options = options, - initialValue = initialValue, - onActive = onActive, - onInactive = onInactive + initialValue = initialValue ) } @@ -420,12 +417,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) } - override fun prefetchQuery(key: QueryKey) { + val scope = CoroutineScope(policy.mainDispatcher) + val query = getQuery(key).also { it.launchIn(scope) } coroutineScope.launch { - val query = getQuery(key) val revision = query.state.value.revision - val job = launch { query.start(this) } + val job = scope.launch { query.start() } try { withTimeout(query.options.prefetchWindowTime) { query.state.first { it.revision != revision || !it.isStaled() } @@ -437,10 +434,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } override fun prefetchInfiniteQuery(key: InfiniteQueryKey) { + val scope = CoroutineScope(policy.mainDispatcher) + val query = getInfiniteQuery(key).also { it.launchIn(scope) } coroutineScope.launch { - val query = getInfiniteQuery(key) val revision = query.state.value.revision - val job = launch { query.start(this) } + val job = scope.launch { query.start() } try { withTimeout(query.options.prefetchWindowTime) { query.state.first { it.revision != revision || !it.isStaled() } @@ -616,16 +614,21 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie return model?.let(predicate) ?: false } - data class ManagedMutation( + data class ManagedMutation internal constructor( val id: UniqueId, val options: MutationOptions, val scope: CoroutineScope, val dispatch: MutationDispatch, - override val actor: Flow<*>, + internal val actor: ActorBlockRunner, override val event: MutableSharedFlow, override val state: StateFlow>, override val command: SendChannel> ) : Mutation { + + override fun launchIn(scope: CoroutineScope) { + actor.launchIn(scope) + } + fun close() { scope.cancel() command.close() @@ -644,16 +647,21 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val notifier: MutationNotifier ) : MutationCommand.Context - data class ManagedQuery( + data class ManagedQuery internal constructor( val id: UniqueId, val options: QueryOptions, val scope: CoroutineScope, val dispatch: QueryDispatch, - override val actor: Flow<*>, + internal val actor: ActorBlockRunner, override val event: MutableSharedFlow, override val state: StateFlow>, override val command: SendChannel> ) : Query { + + override fun launchIn(scope: CoroutineScope) { + actor.launchIn(scope) + } + fun close() { scope.cancel() command.close() @@ -705,6 +713,14 @@ data class SwrCachePolicy( */ val coroutineScope: CoroutineScope, + /** + * [CoroutineDispatcher] for the main thread. + * + * **Note:** + * Some garbage collection processes are safely synchronized with the caller using the main thread. + */ + val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + /** * Default [MutationOptions] applied to [Mutation]. */ @@ -781,10 +797,22 @@ data class SwrCachePolicy( */ val windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( predicate = { it.isStaled() } - ) + ), + + /** + * The chunk size for garbage collection. Default is [DEFAULT_GC_CHUNK_SIZE]. + */ + val gcChunkSize: Int = DEFAULT_GC_CHUNK_SIZE, + + /** + * The interval for garbage collection. Default is [DEFAULT_GC_INTERVAL]. + */ + val gcInterval: Duration = DEFAULT_GC_INTERVAL ) { companion object { const val DEFAULT_CAPACITY = 50 + const val DEFAULT_GC_CHUNK_SIZE = 10 + val DEFAULT_GC_INTERVAL: Duration = 500.milliseconds } } From df7515cdf189b5134f3ea266ece4f7ae962d5d2e Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 12:19:32 +0900 Subject: [PATCH 064/155] Add test for new actor --- .../commonMain/kotlin/soil/query/QueryRef.kt | 2 +- .../kotlin/soil/query/KeepAliveTest.kt | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 soil-query-core/src/jvmTest/kotlin/soil/query/KeepAliveTest.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index e015911..a0eba1c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -40,7 +40,7 @@ class QueryRef( /** * Resumes the [Query]. */ - private suspend fun resume() { + internal suspend fun resume() { command.send(QueryCommands.Connect(key, state.value.revision)) } diff --git a/soil-query-core/src/jvmTest/kotlin/soil/query/KeepAliveTest.kt b/soil-query-core/src/jvmTest/kotlin/soil/query/KeepAliveTest.kt new file mode 100644 index 0000000..eb17597 --- /dev/null +++ b/soil-query-core/src/jvmTest/kotlin/soil/query/KeepAliveTest.kt @@ -0,0 +1,96 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield +import soil.testing.UnitTest +import java.util.UUID +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +// Some of the methods required for testing are only implemented in the coroutine-jvm, +// so we implement them in a jvmTest folder. +class KeepAliveTest : UnitTest() { + + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + private val mainThreadSurrogate = newSingleThreadContext("UI thread") + + @OptIn(ExperimentalCoroutinesApi::class) + @BeforeTest + fun setUp() { + Dispatchers.setMain(mainThreadSurrogate) + } + + @Test + fun testZero() = testRun( + keepAliveTime = Duration.ZERO, + callerDelay = 300.milliseconds + ) + + @Test + fun testShort() = testRun( + keepAliveTime = 300.milliseconds, + callerDelay = 300.milliseconds + ) + + @Test + fun testLong() = testRun( + keepAliveTime = 1.seconds, + callerDelay = 300.milliseconds + ) + + private fun testRun( + keepAliveTime: Duration, + callerDelay: Duration, + times: Int = 30 + ) { + runBlocking { + val swrScope = SwrCacheScope() + val swrClient = SwrCache( + policy = SwrCachePolicy( + coroutineScope = swrScope, + queryOptions = QueryOptions( + keepAliveTime = keepAliveTime, + logger = { println(it) } + ) + ) + ) + repeat(times) { + val scope = CoroutineScope(Dispatchers.Main + Job()) + val query = swrClient.getQuery(GetTestQueryKey()).also { it.launchIn(scope) } + yield() + scope.launch { + query.resume() + }.join() + scope.cancel() + delay(callerDelay) + } + } + } + + class GetTestQueryKey : QueryKey by buildQueryKey( + id = Id, + fetch = { + UUID.randomUUID().toString() + } + ) { + object Id : QueryId( + namespace = "test/query" + ) + } +} From 36819a68de05ff0aea30ed457321a0a701997f28 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 13:14:25 +0900 Subject: [PATCH 065/155] Change the definition of memory pressure to two levels --- .../soil/query/AndroidMemoryPressure.kt | 17 ++++++------- .../commonMain/kotlin/soil/query/SwrCache.kt | 25 ++++++------------- .../soil/query/internal/MemoryPressure.kt | 7 +----- .../kotlin/soil/query/IosMemoryPressure.kt | 2 +- 4 files changed, 16 insertions(+), 35 deletions(-) diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt index 7afdca0..8edaf49 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt @@ -18,10 +18,10 @@ import soil.query.internal.MemoryPressureLevel * | Android Trim Level | MemoryPressureLevel | * |:-------------------------------|:----------------------| * | TRIM_MEMORY_UI_HIDDEN | Low | + * | TRIM_MEMORY_BACKGROUND | Low | * | TRIM_MEMORY_MODERATE | Low | * | TRIM_MEMORY_RUNNING_MODERATE | Low | - * | TRIM_MEMORY_BACKGROUND | High | - * | TRIM_MEMORY_RUNNING_LOW | High | + * | TRIM_MEMORY_RUNNING_LOW | Low | * | TRIM_MEMORY_COMPLETE | Critical | * | TRIM_MEMORY_RUNNING_CRITICAL | Critical | * @@ -50,25 +50,22 @@ class AndroidMemoryPressure( override fun onConfigurationChanged(newConfig: Configuration) = Unit override fun onLowMemory() { - observer.onReceive(MemoryPressureLevel.Critical) + observer.onReceive(MemoryPressureLevel.High) } override fun onTrimMemory(level: Int) { when (level) { ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN, - ComponentCallbacks2.TRIM_MEMORY_MODERATE, - ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> { - observer.onReceive(MemoryPressureLevel.Low) - } - ComponentCallbacks2.TRIM_MEMORY_BACKGROUND, + ComponentCallbacks2.TRIM_MEMORY_MODERATE, + ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { - observer.onReceive(MemoryPressureLevel.High) + observer.onReceive(MemoryPressureLevel.Low) } ComponentCallbacks2.TRIM_MEMORY_COMPLETE, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { - observer.onReceive(MemoryPressureLevel.Critical) + observer.onReceive(MemoryPressureLevel.High) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 922ca6b..9c4b2bf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -56,7 +56,6 @@ import kotlin.time.Duration.Companion.seconds * - Inactive: When there are no references and past the [QueryOptions.keepAliveTime] period * * [Query] in the Active state does not disappear from memory unless one of the following conditions is met: - * - [vacuum] is executed due to memory pressure * - [removeQueries] is explicitly called * * On the other hand, [Query] in the Inactive state gradually disappears from memory when one of the following conditions is met: @@ -106,9 +105,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie @Suppress("MemberVisibilityCanBePrivate") fun gc(level: MemoryPressureLevel = MemoryPressureLevel.Low) { when (level) { - MemoryPressureLevel.Low -> coroutineScope.launch { evictCache() } - MemoryPressureLevel.High -> coroutineScope.launch { clearCache() } - MemoryPressureLevel.Critical -> coroutineScope.launch { vacuum() } + MemoryPressureLevel.Low -> evictCache() + MemoryPressureLevel.High -> clearCache() } } @@ -120,19 +118,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie queryCache.clear() } - private fun vacuum() { - clearCache() - // NOTE: Releases items that are active due to keepAliveTime but have no subscribers. - queryStore.keys.toSet() - .asSequence() - .filter { id -> queryStore[id]?.ping()?.not() ?: false } - .forEach { id -> queryStore.remove(id)?.close() } - mutationStore.keys.toSet() - .asSequence() - .filter { id -> mutationStore[id]?.ping()?.not() ?: false } - .forEach { id -> mutationStore.remove(id)?.close() } - } - // ----- SwrClient ----- // override val defaultMutationOptions: MutationOptions = policy.mutationOptions override val defaultQueryOptions: QueryOptions = policy.queryOptions @@ -165,7 +150,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private suspend fun observeMemoryPressure() { if (policy.memoryPressure == MemoryPressure.Unsupported) return policy.memoryPressure.asFlow() - .collect(::gc) + .collect { level -> + withContext(policy.mainDispatcher) { + gc(level) + } + } } private suspend fun observeNetworkConnectivity() { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt index 3eea789..5fac586 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt @@ -69,10 +69,5 @@ enum class MemoryPressureLevel { /** * Indicates moderate memory pressure. */ - High, - - /** - * Indicates severe memory pressure. - */ - Critical + High } diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt index c1f4b7e..b8a78c6 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt @@ -75,7 +75,7 @@ class IosMemoryPressure : MemoryPressure { @Suppress("unused", "UNUSED_PARAMETER") @ObjCAction fun appDidReceiveMemoryWarning(arg: NSNotification) { - observer.onReceive(MemoryPressureLevel.Critical) + observer.onReceive(MemoryPressureLevel.High) } } } From 5f65baf9613627151ca909a9c6b6cd2f3734bb5c Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 13:21:54 +0900 Subject: [PATCH 066/155] Remove unnecessary ping event --- .../soil/query/compose/MutationComposable.kt | 4 ---- .../kotlin/soil/query/InfiniteQueryRef.kt | 5 ----- .../src/commonMain/kotlin/soil/query/Mutation.kt | 13 ------------- .../commonMain/kotlin/soil/query/MutationRef.kt | 15 --------------- .../src/commonMain/kotlin/soil/query/Query.kt | 3 +-- .../src/commonMain/kotlin/soil/query/QueryRef.kt | 1 - .../src/commonMain/kotlin/soil/query/SwrCache.kt | 14 -------------- 7 files changed, 1 insertion(+), 54 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index c3cef01..dff0c52 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -4,7 +4,6 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -32,9 +31,6 @@ fun rememberMutation( val scope = rememberCoroutineScope() val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } } val state by mutation.state.collectAsState() - LaunchedEffect(mutation) { - mutation.start() - } return remember(mutation, state) { state.toObject(mutation = mutation) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 513da51..c0a6efe 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -3,10 +3,6 @@ package soil.query -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.launch - /** * A reference to an [Query] for [InfiniteQueryKey]. * @@ -60,7 +56,6 @@ class InfiniteQueryRef( when (e) { QueryEvent.Invalidate -> invalidate() QueryEvent.Resume -> resume() - QueryEvent.Ping -> Unit } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index 30f3879..f0f3f36 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -4,7 +4,6 @@ package soil.query import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import soil.query.internal.Actor @@ -15,11 +14,6 @@ import soil.query.internal.Actor */ interface Mutation : Actor { - /** - * [Shared Flow][SharedFlow] to receive mutation [events][MutationEvent]. - */ - val event: SharedFlow - /** * [State Flow][StateFlow] to receive the current state of the mutation. */ @@ -30,10 +24,3 @@ interface Mutation : Actor { */ val command: SendChannel> } - -/** - * Events occurring in the mutation. - */ -enum class MutationEvent { - Ping -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 39c9f27..8ca0389 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -21,15 +21,6 @@ class MutationRef( mutation: Mutation ) : Mutation by mutation { - /** - * Starts the [Mutation]. - * - * This function must be invoked when a new mount point (subscriber) is added. - */ - suspend fun start() { - event.collect(::handleEvent) - } - /** * Mutates the variable. * @@ -64,10 +55,4 @@ class MutationRef( suspend fun reset() { command.send(MutationCommands.Reset()) } - - private fun handleEvent(e: MutationEvent) { - when (e) { - MutationEvent.Ping -> Unit - } - } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 27330fd..8b1b8fa 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -36,6 +36,5 @@ interface Query: Actor { */ enum class QueryEvent { Invalidate, - Resume, - Ping + Resume } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index a0eba1c..9907774 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -48,7 +48,6 @@ class QueryRef( when (e) { QueryEvent.Invalidate -> invalidate() QueryEvent.Resume -> resume() - QueryEvent.Ping -> Unit } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 9c4b2bf..a630b92 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -219,10 +219,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie initialValue: MutationState ): ManagedMutation { val scope = CoroutineScope(newCoroutineContext(coroutineScope)) - val event = MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) val state = MutableStateFlow(initialValue) val reducer = createMutationReducer() val dispatch: MutationDispatch = { action -> @@ -257,7 +253,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie scope = scope, dispatch = dispatch, actor = actor, - event = event, state = state, command = command ) @@ -609,7 +604,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie val scope: CoroutineScope, val dispatch: MutationDispatch, internal val actor: ActorBlockRunner, - override val event: MutableSharedFlow, override val state: StateFlow>, override val command: SendChannel> ) : Mutation { @@ -622,10 +616,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie scope.cancel() command.close() } - - fun ping(): Boolean { - return event.tryEmit(MutationEvent.Ping) - } } data class ManagedMutationContext( @@ -665,10 +655,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie event.tryEmit(QueryEvent.Resume) } - fun ping(): Boolean { - return event.tryEmit(QueryEvent.Ping) - } - fun forceUpdate(data: T) { dispatch(QueryAction.ForceUpdate(data = data, dataUpdatedAt = epoch())) } From 9bdacf8d668622085a797d263c063f3235e16777 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 14:14:59 +0900 Subject: [PATCH 067/155] Add an option to execute side effects synchronously --- .../src/commonMain/kotlin/soil/query/compose/Util.kt | 2 +- .../commonMain/kotlin/soil/query/MutationCommand.kt | 11 +++++++++-- .../commonMain/kotlin/soil/query/MutationNotifier.kt | 4 +++- .../commonMain/kotlin/soil/query/MutationOptions.kt | 10 ++++++++++ .../src/commonMain/kotlin/soil/query/SwrCache.kt | 4 ++-- .../src/commonMain/kotlin/soil/query/SwrClient.kt | 4 +++- .../kotlin/soil/query/MutationOptionsTest.kt | 6 ++++++ 7 files changed, 34 insertions(+), 7 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 8003bbd..8123aa9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -28,7 +28,7 @@ fun rememberQueriesErrorReset( filter: ResumeQueriesFilter = remember { ResumeQueriesFilter(predicate = { it.isFailure }) }, client: SwrClient = LocalSwrClient.current ): QueriesErrorReset { - val reset = remember(client) { + val reset = remember<() -> Unit>(client) { { client.perform { resumeQueries(filter) } } } return reset diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 7c98e17..7b8a99c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -3,6 +3,8 @@ package soil.query +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import soil.query.internal.RetryCallback import soil.query.internal.RetryFn import soil.query.internal.UniqueId @@ -86,8 +88,13 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( ) { mutate(key, variable) .onSuccess { data -> - dispatch(MutationAction.MutateSuccess(data)) - key.onQueryUpdate(variable, data)?.let(notifier::onMutateSuccess) + val job = key.onQueryUpdate(variable, data)?.let(notifier::onMutate) + withContext(NonCancellable) { + if (job != null && options.shouldExecuteEffectSynchronously) { + job.join() + } + dispatch(MutationAction.MutateSuccess(data)) + } } .onFailure { dispatch(MutationAction.MutateFailure(it)) } .onFailure { options.onError?.invoke(it, state, key.id) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt index 2d0dead..076ee62 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationNotifier.kt @@ -3,6 +3,8 @@ package soil.query +import kotlinx.coroutines.Job + /** * MutationNotifier is used to notify the mutation result. */ @@ -17,5 +19,5 @@ fun interface MutationNotifier { * * @param sideEffects The side effects of the mutation for related queries. */ - fun onMutateSuccess(sideEffects: QueryEffect) + fun onMutate(sideEffects: QueryEffect): Job } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 983e4eb..1c395b5 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -33,10 +33,16 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { */ val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? + /** + * Whether the query side effect should be synchronous. If true, side effect will be executed synchronously. + */ + val shouldExecuteEffectSynchronously: Boolean + companion object Default : MutationOptions { override val isOneShot: Boolean = false override val isStrictMode: Boolean = false override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = null + override val shouldExecuteEffectSynchronously: Boolean = false // ----- ActorOptions ----- // override val keepAliveTime: Duration = 5.seconds @@ -59,6 +65,7 @@ fun MutationOptions( isOneShot: Boolean = MutationOptions.isOneShot, isStrictMode: Boolean = MutationOptions.isStrictMode, onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = MutationOptions.onError, + shouldExecuteEffectSynchronously: Boolean = MutationOptions.shouldExecuteEffectSynchronously, keepAliveTime: Duration = MutationOptions.keepAliveTime, logger: LoggerFn? = MutationOptions.logger, shouldRetry: (Throwable) -> Boolean = MutationOptions.shouldRetry, @@ -73,6 +80,7 @@ fun MutationOptions( override val isOneShot: Boolean = isOneShot override val isStrictMode: Boolean = isStrictMode override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = onError + override val shouldExecuteEffectSynchronously: Boolean = shouldExecuteEffectSynchronously override val keepAliveTime: Duration = keepAliveTime override val logger: LoggerFn? = logger override val shouldRetry: (Throwable) -> Boolean = shouldRetry @@ -89,6 +97,7 @@ fun MutationOptions.copy( isOneShot: Boolean = this.isOneShot, isStrictMode: Boolean = this.isStrictMode, onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = this.onError, + shouldExecuteEffectSynchronously: Boolean = this.shouldExecuteEffectSynchronously, keepAliveTime: Duration = this.keepAliveTime, logger: LoggerFn? = this.logger, shouldRetry: (Throwable) -> Boolean = this.shouldRetry, @@ -103,6 +112,7 @@ fun MutationOptions.copy( isOneShot = isOneShot, isStrictMode = isStrictMode, onError = onError, + shouldExecuteEffectSynchronously = shouldExecuteEffectSynchronously, keepAliveTime = keepAliveTime, logger = logger, shouldRetry = shouldRetry, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index a630b92..fcf8098 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -122,8 +122,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val defaultMutationOptions: MutationOptions = policy.mutationOptions override val defaultQueryOptions: QueryOptions = policy.queryOptions - override fun perform(sideEffects: QueryEffect) { - coroutineScope.launch { + override fun perform(sideEffects: QueryEffect): Job { + return coroutineScope.launch { with(this@SwrCache) { sideEffects() } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt index 9cfbf6b..be59482 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt @@ -3,6 +3,8 @@ package soil.query +import kotlinx.coroutines.Job + /** * An all-in-one [SwrClient] integrating [MutationClient] and [QueryClient] for library users. * @@ -13,7 +15,7 @@ interface SwrClient : MutationClient, QueryClient { /** * Executes side effects for queries. */ - fun perform(sideEffects: QueryEffect) + fun perform(sideEffects: QueryEffect): Job /** * Executes initialization procedures based on events. diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt index 2cd582d..7ab54a5 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -18,6 +18,7 @@ class MutationOptionsTest : UnitTest() { assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(MutationOptions.Default.logger, actual.logger) assertEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) @@ -35,6 +36,7 @@ class MutationOptionsTest : UnitTest() { isOneShot = true, isStrictMode = true, onError = { _, _, _ -> }, + shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, @@ -48,6 +50,7 @@ class MutationOptionsTest : UnitTest() { assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(MutationOptions.Default.logger, actual.logger) assertNotEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) @@ -65,6 +68,7 @@ class MutationOptionsTest : UnitTest() { assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(MutationOptions.Default.logger, actual.logger) assertEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) @@ -82,6 +86,7 @@ class MutationOptionsTest : UnitTest() { isOneShot = true, isStrictMode = true, onError = { _, _, _ -> }, + shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, @@ -95,6 +100,7 @@ class MutationOptionsTest : UnitTest() { assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(MutationOptions.Default.logger, actual.logger) assertNotEquals(MutationOptions.Default.shouldRetry, actual.shouldRetry) From b0e0a92f55daacdc74648e74ab053e312726ba20 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 14:41:26 +0900 Subject: [PATCH 068/155] Remove the code related to the old actor logic --- .../soil/query/internal/ActorOptions.kt | 19 +------- .../query/internal/ActorSharingStarted.kt | 44 ------------------- 2 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt index 849e502..51317bd 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt @@ -9,7 +9,7 @@ import kotlin.time.Duration * Interface providing settings related to the internal behavior of an Actor. * * The actual Actor is managed as a Coroutine Flow for each [UniqueId]. - * Using [newSharingStarted], it remains active only when there are subscribers. + * Using [Actor], it remains active only when there are subscribers. */ interface ActorOptions { @@ -24,20 +24,3 @@ interface ActorOptions { */ val keepAliveTime: Duration } - -/** - * Creates a new [ActorSharingStarted] specific for Actor. - * - * @param onActive Callback handler to notify when becoming active. - * @param onInactive Callback handler to notify when becoming inactive. - */ -fun ActorOptions.newSharingStarted( - onActive: ActorCallback? = null, - onInactive: ActorCallback? = null -): ActorSharingStarted { - return ActorSharingStarted( - keepAliveTime = keepAliveTime, - onActive = onActive, - onInactive = onInactive - ) -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt deleted file mode 100644 index c435431..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorSharingStarted.kt +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query.internal - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingCommand -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.onEach -import kotlin.time.Duration - -/** - * Implementation of [SharingStarted] for Actor's custom [SharingStarted.WhileSubscribed] implementations. - * - * @see [ActorOptions.newSharingStarted] - */ -class ActorSharingStarted( - keepAliveTime: Duration, - private val onActive: ActorCallback? = null, - private val onInactive: ActorCallback? = null -) : SharingStarted { - - private val whileSubscribed = SharingStarted.WhileSubscribed( - stopTimeoutMillis = keepAliveTime.inWholeMilliseconds, - replayExpirationMillis = Duration.INFINITE.inWholeMilliseconds - ) - - override fun command( - subscriptionCount: StateFlow - ): Flow = whileSubscribed.command(subscriptionCount) - .onEach { - when (it) { - SharingCommand.START -> onActive?.invoke() - SharingCommand.STOP -> onInactive?.invoke() - else -> Unit - } - } -} - -/** - * Callback handler to notify based on the active state of the Actor. - */ -typealias ActorCallback = () -> Unit From 4ed19c5e36a1295cd9dea6eac64605f01d9508f5 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 16:08:47 +0900 Subject: [PATCH 069/155] Improve the results of synchronously executed Mutations. When `isOneShot` in MutationOptions is set to false, multiple calls might not receive the intended results (there was a possibility of referencing the state of the next execution). By using a callback, the execution results of the Mutation can be awaited on the calling side, ensuring that the results of the call are reliably referenced. --- .../kotlin/soil/query/MutationClient.kt | 1 + .../kotlin/soil/query/MutationCommand.kt | 6 +++++- .../kotlin/soil/query/MutationCommands.kt | 7 +++++-- .../commonMain/kotlin/soil/query/MutationRef.kt | 17 +++++------------ .../query/internal/CompletableDeferredExt.kt | 14 ++++++++++++++ 5 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt index 93cc0e7..555f160 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt @@ -22,3 +22,4 @@ interface MutationClient { } typealias MutationOptionsOverride = (MutationOptions) -> MutationOptions +typealias MutationCallback = (Result) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 7b8a99c..18e69ce 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -84,7 +84,8 @@ suspend fun MutationCommand.Context.mutate( */ suspend inline fun MutationCommand.Context.dispatchMutateResult( key: MutationKey, - variable: S + variable: S, + noinline callback: MutationCallback? ) { mutate(key, variable) .onSuccess { data -> @@ -98,6 +99,9 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( } .onFailure { dispatch(MutationAction.MutateFailure(it)) } .onFailure { options.onError?.invoke(it, state, key.id) } + .also { + callback?.invoke(it) + } } internal fun MutationCommand.Context.onRetryCallback( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index 684e377..004544b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.internal.vvv +import kotlin.coroutines.cancellation.CancellationException /** * Mutation commands are used to update the [mutation state][MutationState]. @@ -24,16 +25,18 @@ sealed class MutationCommands : MutationCommand { data class Mutate( val key: MutationKey, val variable: S, - val revision: String + val revision: String, + val callback: MutationCallback? = null ) : MutationCommands() { override suspend fun handle(ctx: MutationCommand.Context) { if (!ctx.shouldMutate(revision)) { ctx.options.vvv(key.id) { "skip mutation(shouldMutate=false)" } + callback?.invoke(Result.failure(CancellationException("skip mutation"))) return } ctx.dispatch(MutationAction.Mutating) - ctx.dispatchMutateResult(key, variable) + ctx.dispatchMutateResult(key, variable, callback) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 8ca0389..d5156cb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -3,8 +3,8 @@ package soil.query -import kotlinx.coroutines.flow.dropWhile -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CompletableDeferred +import soil.query.internal.toResultCallback /** * A reference to a [Mutation] for [MutationKey]. @@ -28,16 +28,9 @@ class MutationRef( * @return The result of the mutation. */ suspend fun mutate(variable: S): T { - mutateAsync(variable) - val submittedAt = state.value.submittedAt - val result = state.dropWhile { it.submittedAt <= submittedAt }.first() - if (result.isSuccess) { - return result.data!! - } else if (result.isFailure) { - throw result.error!! - } else { - error("Unexpected ${result.status}") - } + val deferred = CompletableDeferred() + command.send(MutationCommands.Mutate(key, variable, state.value.revision, deferred.toResultCallback())) + return deferred.await() } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt new file mode 100644 index 0000000..b8a23e5 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt @@ -0,0 +1,14 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.internal + +import kotlinx.coroutines.CompletableDeferred + +internal fun CompletableDeferred.toResultCallback(): (Result) -> Unit { + return { result -> + result + .onSuccess { complete(it) } + .onFailure { completeExceptionally(it) } + } +} From 48b947e89cdfaa8a993073c2f6079a15f92a6724 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 17:00:19 +0900 Subject: [PATCH 070/155] Improve the results of synchronously executed Queries. The prefetch internal processing can now be handled synchronously. By using a callback, the execution results of the Mutation can be awaited on the calling side, ensuring that the results of the call are reliably referenced. --- .../kotlin/soil/query/InfiniteQueryCommand.kt | 8 ++++-- .../soil/query/InfiniteQueryCommands.kt | 23 ++++++++++------ .../kotlin/soil/query/InfiniteQueryRef.kt | 20 ++++++++++++++ .../kotlin/soil/query/QueryClient.kt | 6 +++-- .../kotlin/soil/query/QueryCommand.kt | 6 ++++- .../kotlin/soil/query/QueryCommands.kt | 13 ++++++--- .../commonMain/kotlin/soil/query/QueryRef.kt | 20 ++++++++++++++ .../commonMain/kotlin/soil/query/SwrCache.kt | 27 ++++++++----------- 8 files changed, 90 insertions(+), 33 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index aeb04fb..92588d3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -77,7 +77,8 @@ suspend fun QueryCommand.Context>.revalidate( */ suspend inline fun QueryCommand.Context>.dispatchFetchChunksResult( key: InfiniteQueryKey, - variable: S + variable: S, + noinline callback: QueryCallback>? = null ) { fetch(key, variable) .map { QueryChunk(it, variable) } @@ -86,6 +87,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) .onFailure { options.onError?.invoke(it, state, key.id) } + .also { callback?.invoke(it) } } /** @@ -99,11 +101,13 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC */ suspend inline fun QueryCommand.Context>.dispatchRevalidateChunksResult( key: InfiniteQueryKey, - chunks: QueryChunks + chunks: QueryChunks, + noinline callback: QueryCallback>? = null ) { revalidate(key, chunks) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) .onFailure { options.onError?.invoke(it, state, key.id) } + .also { callback?.invoke(it) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index e5d926e..455d321 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.internal.vvv +import kotlin.coroutines.cancellation.CancellationException /** * Query command for [InfiniteQueryKey]. @@ -23,19 +24,21 @@ sealed class InfiniteQueryCommands : QueryCommand> { */ data class Connect( val key: InfiniteQueryKey, - val revision: String? = null + val revision: String? = null, + val callback: QueryCallback>? = null ) : InfiniteQueryCommands() { override suspend fun handle(ctx: QueryCommand.Context>) { if (!ctx.shouldFetch(revision)) { ctx.options.vvv(key.id) { "skip fetch(shouldFetch=false)" } + callback?.invoke(Result.failure(CancellationException("skip fetch"))) return } ctx.dispatch(QueryAction.Fetching()) val chunks = ctx.state.data if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { - ctx.dispatchFetchChunksResult(key, key.initialParam()) + ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { - ctx.dispatchRevalidateChunksResult(key, chunks) + ctx.dispatchRevalidateChunksResult(key, chunks, callback) } } } @@ -50,19 +53,21 @@ sealed class InfiniteQueryCommands : QueryCommand> { */ data class Invalidate( val key: InfiniteQueryKey, - val revision: String + val revision: String, + val callback: QueryCallback>? = null ) : InfiniteQueryCommands() { override suspend fun handle(ctx: QueryCommand.Context>) { if (ctx.state.revision != revision) { ctx.options.vvv(key.id) { "skip fetch(revision is not matched)" } + callback?.invoke(Result.failure(CancellationException("skip fetch"))) return } ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) val chunks = ctx.state.data if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { - ctx.dispatchFetchChunksResult(key, key.initialParam()) + ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { - ctx.dispatchRevalidateChunksResult(key, chunks) + ctx.dispatchRevalidateChunksResult(key, chunks, callback) } } } @@ -75,17 +80,19 @@ sealed class InfiniteQueryCommands : QueryCommand> { */ data class LoadMore( val key: InfiniteQueryKey, - val param: S + val param: S, + val callback: QueryCallback>? = null ) : InfiniteQueryCommands() { override suspend fun handle(ctx: QueryCommand.Context>) { val chunks = ctx.state.data if (param != key.loadMoreParam(chunks.orEmpty())) { ctx.options.vvv(key.id) { "skip fetch(param is changed)" } + callback?.invoke(Result.failure(CancellationException("skip fetch"))) return } ctx.dispatch(QueryAction.Fetching()) - ctx.dispatchFetchChunksResult(key, param) + ctx.dispatchFetchChunksResult(key, param, callback) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index c0a6efe..93f9c55 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -3,6 +3,10 @@ package soil.query +import kotlinx.coroutines.CompletableDeferred +import soil.query.internal.toResultCallback +import kotlin.coroutines.cancellation.CancellationException + /** * A reference to an [Query] for [InfiniteQueryKey]. * @@ -28,6 +32,22 @@ class InfiniteQueryRef( event.collect(::handleEvent) } + /** + * Prefetches the [Query]. + */ + suspend fun prefetch(): Boolean { + val deferred = CompletableDeferred>() + command.send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + return try { + deferred.await() + true + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + false + } + } + /** * Invalidates the [Query]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index 619df00..25af22c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -3,6 +3,7 @@ package soil.query +import kotlinx.coroutines.Job import soil.query.internal.UniqueId /** @@ -32,7 +33,7 @@ interface QueryClient { * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. */ - fun prefetchQuery(key: QueryKey) + fun prefetchQuery(key: QueryKey): Job /** * Prefetches the infinite query by the specified [InfiniteQueryKey]. @@ -41,7 +42,7 @@ interface QueryClient { * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. */ - fun prefetchInfiniteQuery(key: InfiniteQueryKey) + fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job } /** @@ -127,3 +128,4 @@ typealias QueryEffect = QueryMutableClient.() -> Unit typealias QueryRecoverData = (error: Throwable) -> T typealias QueryOptionsOverride = (QueryOptions) -> QueryOptions +typealias QueryCallback = (Result) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index a297aaf..03a127c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -98,12 +98,16 @@ suspend fun QueryCommand.Context.fetch( * * @param key Instance of a class implementing [QueryKey]. */ -suspend inline fun QueryCommand.Context.dispatchFetchResult(key: QueryKey) { +suspend inline fun QueryCommand.Context.dispatchFetchResult( + key: QueryKey, + noinline callback: QueryCallback? = null +) { fetch(key) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) .onFailure { options.onError?.invoke(it, state, key.id) } + .also { callback?.invoke(it) } } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 2c33ad9..41ec51b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.internal.vvv +import kotlin.coroutines.cancellation.CancellationException /** * Query command for [QueryKey]. @@ -22,16 +23,18 @@ sealed class QueryCommands : QueryCommand { */ data class Connect( val key: QueryKey, - val revision: String? = null + val revision: String? = null, + val callback: QueryCallback? = null ) : QueryCommands() { override suspend fun handle(ctx: QueryCommand.Context) { if (!ctx.shouldFetch(revision)) { ctx.options.vvv(key.id) { "skip fetch(shouldFetch=false)" } + callback?.invoke(Result.failure(CancellationException("skip fetch"))) return } ctx.dispatch(QueryAction.Fetching()) - ctx.dispatchFetchResult(key) + ctx.dispatchFetchResult(key, callback) } } @@ -45,16 +48,18 @@ sealed class QueryCommands : QueryCommand { */ data class Invalidate( val key: QueryKey, - val revision: String + val revision: String, + val callback: QueryCallback? = null ) : QueryCommands() { override suspend fun handle(ctx: QueryCommand.Context) { if (ctx.state.revision != revision) { ctx.options.vvv(key.id) { "skip fetch(revision is not matched)" } + callback?.invoke(Result.failure(CancellationException("skip fetch"))) return } ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) - ctx.dispatchFetchResult(key) + ctx.dispatchFetchResult(key, callback) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 9907774..9c5dab3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -3,6 +3,10 @@ package soil.query +import kotlinx.coroutines.CompletableDeferred +import soil.query.internal.toResultCallback +import kotlin.coroutines.cancellation.CancellationException + /** * A reference to an [Query] for [QueryKey]. * @@ -27,6 +31,22 @@ class QueryRef( event.collect(::handleEvent) } + /** + * Prefetches the [Query]. + */ + suspend fun prefetch(): Boolean { + val deferred = CompletableDeferred() + command.send(QueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + return try { + deferred.await() + true + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + false + } + } + /** * Invalidates the [Query]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index fcf8098..53c0519 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -20,13 +20,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_CHUNK_SIZE import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_INTERVAL import soil.query.internal.ActorBlockRunner @@ -401,34 +400,30 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) } - override fun prefetchQuery(key: QueryKey) { + override fun prefetchQuery(key: QueryKey): Job { val scope = CoroutineScope(policy.mainDispatcher) val query = getQuery(key).also { it.launchIn(scope) } - coroutineScope.launch { - val revision = query.state.value.revision - val job = scope.launch { query.start() } + return coroutineScope.launch { try { - withTimeout(query.options.prefetchWindowTime) { - query.state.first { it.revision != revision || !it.isStaled() } + withTimeoutOrNull(query.options.prefetchWindowTime) { + query.prefetch() } } finally { - job.cancel() + scope.cancel() } } } - override fun prefetchInfiniteQuery(key: InfiniteQueryKey) { + override fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job { val scope = CoroutineScope(policy.mainDispatcher) val query = getInfiniteQuery(key).also { it.launchIn(scope) } - coroutineScope.launch { - val revision = query.state.value.revision - val job = scope.launch { query.start() } + return coroutineScope.launch { try { - withTimeout(query.options.prefetchWindowTime) { - query.state.first { it.revision != revision || !it.isStaled() } + withTimeoutOrNull(query.options.prefetchWindowTime) { + query.prefetch() } } finally { - job.cancel() + scope.cancel() } } } From b6212741d8df5f51b188e2121928d5fe8d0ee37f Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 18:16:45 +0900 Subject: [PATCH 071/155] Remove unintended dependencies --- soil-query-compose/build.gradle.kts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index 92a8d46..2bcd66d 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -1,6 +1,4 @@ import org.jetbrains.compose.ExperimentalComposeLibrary -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl plugins { @@ -24,16 +22,6 @@ kotlin { } } publishLibraryVariants("release") - - // ref. https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html - @OptIn(ExperimentalKotlinGradlePluginApi::class) - instrumentedTestVariant { - sourceSetTree.set(KotlinSourceSetTree.test) - dependencies { - implementation(libs.compose.ui.test.junit4.android) - debugImplementation(libs.compose.ui.test.manifest) - } - } } iosX64() @@ -61,6 +49,13 @@ kotlin { implementation(projects.internal.testing) } + val androidUnitTest by getting { + dependencies { + implementation(libs.compose.ui.test.junit4.android) + implementation(libs.compose.ui.test.manifest) + } + } + jvmTest.dependencies { implementation(compose.desktop.currentOs) } From eee60ea02430bcb324c324df3f19d6b8e829e604 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 28 Jul 2024 19:22:02 +0900 Subject: [PATCH 072/155] Fix a runtime error caused by a coding mistake. --- .../commonMain/kotlin/soil/query/compose/runtime/Loadable.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt index 2608272..5702591 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt @@ -26,7 +26,7 @@ sealed class Loadable : QueryModel { */ @Immutable data object Pending : Loadable() { - override val data: Nothing = throw IllegalStateException("Pending") + override val data: Nothing get() = error("Pending") override val dataUpdatedAt: Long = 0 override val dataStaleAt: Long = 0 override val error: Throwable? = null @@ -61,7 +61,7 @@ sealed class Loadable : QueryModel { data class Rejected( override val error: Throwable ) : Loadable() { - override val data: Nothing = throw IllegalStateException("Rejected") + override val data: Nothing get() = error("Rejected") override val dataUpdatedAt: Long = 0 override val dataStaleAt: Long = 0 override val errorUpdatedAt: Long = epoch() From 4bedd3835fb5d639b20a3f65e4d3190556867aa3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Jul 2024 11:15:58 +0000 Subject: [PATCH 073/155] Bump up version to 1.0.0-alpha04 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a821ca7..f3d2d7e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ kotlin.mpp.enableCInteropCommonization=true development=true #Product group=com.soil-kt.soil -version=1.0.0-alpha03 +version=1.0.0-alpha04 androidCompileSdk=34 androidTargetSdk=34 androidMinSdk=23 From 09a4b6da29cbfb0bd735070fce2dc6554d934cfa Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 29 Jul 2024 07:53:14 +0900 Subject: [PATCH 074/155] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78e0860..6118849 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Soil is available on `mavenCentral()`. ```kts dependencies { - val soil = "1.0.0-alpha03" + val soil = "1.0.0-alpha04" implementation("com.soil-kt.soil:query-core:$soil") implementation("com.soil-kt.soil:query-compose:$soil") implementation("com.soil-kt.soil:query-compose-runtime:$soil") From 787ce660f10cbec2bd2c64a6e93bb25ad7df4063 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 11:24:58 +0900 Subject: [PATCH 075/155] Rename package `query.internal` to `query.core` in soil-query-core --- .../soil/query/compose/runtime/Await.kt | 2 +- .../soil/query/compose/runtime/Catch.kt | 2 +- .../soil/query/compose/runtime/Loadable.kt | 2 +- .../soil/query/compose/SwrClientProvider.kt | 2 +- .../soil/query/AndroidMemoryPressure.kt | 4 +-- .../soil/query/AndroidNetworkConnectivity.kt | 4 +-- .../soil/query/AndroidWindowVisibility.kt | 4 +-- .../{internal => core}/Platform.android.kt | 2 +- .../kotlin/soil/query/InfiniteQueryCommand.kt | 4 +-- .../soil/query/InfiniteQueryCommands.kt | 2 +- .../kotlin/soil/query/InfiniteQueryKey.kt | 4 +-- .../kotlin/soil/query/InfiniteQueryRef.kt | 2 +- .../commonMain/kotlin/soil/query/Mutation.kt | 2 +- .../kotlin/soil/query/MutationAction.kt | 2 +- .../kotlin/soil/query/MutationCommand.kt | 10 +++---- .../kotlin/soil/query/MutationCommands.kt | 2 +- .../kotlin/soil/query/MutationKey.kt | 6 ++-- .../kotlin/soil/query/MutationOptions.kt | 10 +++---- .../kotlin/soil/query/MutationRef.kt | 2 +- .../src/commonMain/kotlin/soil/query/Query.kt | 2 +- .../kotlin/soil/query/QueryClient.kt | 2 +- .../kotlin/soil/query/QueryCommand.kt | 14 +++++----- .../kotlin/soil/query/QueryCommands.kt | 2 +- .../kotlin/soil/query/QueryFilter.kt | 2 +- .../commonMain/kotlin/soil/query/QueryKey.kt | 4 +-- .../kotlin/soil/query/QueryModel.kt | 2 +- .../kotlin/soil/query/QueryOptions.kt | 16 +++++------ .../commonMain/kotlin/soil/query/QueryRef.kt | 2 +- .../commonMain/kotlin/soil/query/SwrCache.kt | 28 +++++++++---------- .../commonMain/kotlin/soil/query/SwrClient.kt | 6 ++-- .../soil/query/{internal => core}/Actor.kt | 2 +- .../query/{internal => core}/ActorOptions.kt | 2 +- .../CompletableDeferredExt.kt | 2 +- .../soil/query/{internal => core}/FlowExt.kt | 2 +- .../soil/query/{internal => core}/Logging.kt | 2 +- .../{internal => core}/LoggingOptions.kt | 2 +- .../{internal => core}/MemoryPressure.kt | 2 +- .../{internal => core}/NetworkConnectivity.kt | 2 +- .../{internal => core}/NetworkOptions.kt | 2 +- .../soil/query/{internal => core}/Platform.kt | 2 +- .../query/{internal => core}/PriorityQueue.kt | 2 +- .../soil/query/{internal => core}/Retry.kt | 2 +- .../query/{internal => core}/RetryOptions.kt | 2 +- .../{internal => core}/TimeBasedCache.kt | 2 +- .../soil/query/{internal => core}/UniqueId.kt | 2 +- .../{internal => core}/WindowVisibility.kt | 2 +- .../kotlin/soil/query/IosMemoryPressure.kt | 4 +-- .../kotlin/soil/query/IosWindowVisiblity.kt | 4 +-- .../query/{internal => core}/Platform.ios.kt | 2 +- .../query/{internal => core}/Platform.jvm.kt | 2 +- .../soil/query/WasmJsNetworkConnectivity.kt | 4 +-- .../soil/query/WasmJsWindowVisibility.kt | 6 ++-- .../{internal => core}/Platform.wasmJs.kt | 2 +- 53 files changed, 102 insertions(+), 102 deletions(-) rename soil-query-core/src/androidMain/kotlin/soil/query/{internal => core}/Platform.android.kt (89%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/Actor.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/ActorOptions.kt (97%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/CompletableDeferredExt.kt (92%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/FlowExt.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/Logging.kt (93%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/LoggingOptions.kt (94%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/MemoryPressure.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/NetworkConnectivity.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/NetworkOptions.kt (84%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/Platform.kt (93%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/PriorityQueue.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/Retry.kt (97%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/RetryOptions.kt (98%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/TimeBasedCache.kt (99%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/UniqueId.kt (97%) rename soil-query-core/src/commonMain/kotlin/soil/query/{internal => core}/WindowVisibility.kt (98%) rename soil-query-core/src/iosMain/kotlin/soil/query/{internal => core}/Platform.ios.kt (92%) rename soil-query-core/src/jvmMain/kotlin/soil/query/{internal => core}/Platform.jvm.kt (89%) rename soil-query-core/src/wasmJsMain/kotlin/soil/query/{internal => core}/Platform.wasmJs.kt (95%) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt index a5e4beb..bce6709 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt @@ -17,7 +17,7 @@ import soil.query.compose.QueryLoadingErrorObject import soil.query.compose.QueryLoadingObject import soil.query.compose.QueryRefreshErrorObject import soil.query.compose.QuerySuccessObject -import soil.query.internal.uuid +import soil.query.core.uuid /** * Await for a [QueryModel] to be fulfilled. diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt index 64060d1..5730c57 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import soil.query.QueryModel -import soil.query.internal.uuid +import soil.query.core.uuid /** * Catch for a [QueryModel] to be rejected. diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt index 5702591..5ab6e04 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Stable import soil.query.QueryFetchStatus import soil.query.QueryModel import soil.query.QueryStatus -import soil.query.internal.epoch +import soil.query.core.epoch /** * Promise-like data structure that represents the state of a value that is being loaded. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt index 886abb0..fed1bd9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.staticCompositionLocalOf import soil.query.SwrClient -import soil.query.internal.uuid +import soil.query.core.uuid /** * Provides a [SwrClient] to the [content] over [LocalSwrClient] diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt index 8edaf49..5073535 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidMemoryPressure.kt @@ -6,8 +6,8 @@ package soil.query import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration -import soil.query.internal.MemoryPressure -import soil.query.internal.MemoryPressureLevel +import soil.query.core.MemoryPressure +import soil.query.core.MemoryPressureLevel /** * Implementation of [MemoryPressure] for Android. diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt index 906bc66..11fdb0b 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidNetworkConnectivity.kt @@ -9,8 +9,8 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import androidx.annotation.RequiresPermission -import soil.query.internal.NetworkConnectivity -import soil.query.internal.NetworkConnectivityEvent +import soil.query.core.NetworkConnectivity +import soil.query.core.NetworkConnectivityEvent /** * Implementation of [NetworkConnectivity] for Android. diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt index ee98311..7b73440 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/AndroidWindowVisibility.kt @@ -8,8 +8,8 @@ import android.os.Looper import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import soil.query.internal.WindowVisibility -import soil.query.internal.WindowVisibilityEvent +import soil.query.core.WindowVisibility +import soil.query.core.WindowVisibilityEvent /** * Implementation of [WindowVisibility] for Android. diff --git a/soil-query-core/src/androidMain/kotlin/soil/query/internal/Platform.android.kt b/soil-query-core/src/androidMain/kotlin/soil/query/core/Platform.android.kt similarity index 89% rename from soil-query-core/src/androidMain/kotlin/soil/query/internal/Platform.android.kt rename to soil-query-core/src/androidMain/kotlin/soil/query/core/Platform.android.kt index 6b48989..c70160b 100644 --- a/soil-query-core/src/androidMain/kotlin/soil/query/internal/Platform.android.kt +++ b/soil-query-core/src/androidMain/kotlin/soil/query/core/Platform.android.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import java.util.UUID diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index 92588d3..54f11fd 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -3,8 +3,8 @@ package soil.query -import soil.query.internal.RetryFn -import soil.query.internal.exponentialBackOff +import soil.query.core.RetryFn +import soil.query.core.exponentialBackOff import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index 455d321..dc2162a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.vvv +import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index 818b63c..2f26eb7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -3,8 +3,8 @@ package soil.query -import soil.query.internal.SurrogateKey -import soil.query.internal.UniqueId +import soil.query.core.SurrogateKey +import soil.query.core.UniqueId /** * [InfiniteQueryKey] for managing [Query] associated with [id]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 93f9c55..14845b2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -4,7 +4,7 @@ package soil.query import kotlinx.coroutines.CompletableDeferred -import soil.query.internal.toResultCallback +import soil.query.core.toResultCallback import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index f0f3f36..9a8266e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -5,7 +5,7 @@ package soil.query import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.StateFlow -import soil.query.internal.Actor +import soil.query.core.Actor /** * Mutation as the base interface for an [MutationClient] implementations. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt index 5e494f3..a7bc239 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.epoch +import soil.query.core.epoch /** * Mutation actions are used to update the [mutation state][MutationState]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 18e69ce..df369a5 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -5,11 +5,11 @@ package soil.query import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext -import soil.query.internal.RetryCallback -import soil.query.internal.RetryFn -import soil.query.internal.UniqueId -import soil.query.internal.exponentialBackOff -import soil.query.internal.vvv +import soil.query.core.RetryCallback +import soil.query.core.RetryFn +import soil.query.core.UniqueId +import soil.query.core.exponentialBackOff +import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index 004544b..26bb912 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.vvv +import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt index 980f16c..240528d 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt @@ -3,9 +3,9 @@ package soil.query -import soil.query.internal.SurrogateKey -import soil.query.internal.UniqueId -import soil.query.internal.uuid +import soil.query.core.SurrogateKey +import soil.query.core.UniqueId +import soil.query.core.uuid /** * Interface for mutations key. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 1c395b5..9679d2b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -3,11 +3,11 @@ package soil.query -import soil.query.internal.ActorOptions -import soil.query.internal.LoggerFn -import soil.query.internal.LoggingOptions -import soil.query.internal.RetryOptions -import soil.query.internal.UniqueId +import soil.query.core.ActorOptions +import soil.query.core.LoggerFn +import soil.query.core.LoggingOptions +import soil.query.core.RetryOptions +import soil.query.core.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index d5156cb..a9642ad 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -4,7 +4,7 @@ package soil.query import kotlinx.coroutines.CompletableDeferred -import soil.query.internal.toResultCallback +import soil.query.core.toResultCallback /** * A reference to a [Mutation] for [MutationKey]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 8b1b8fa..3675217 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -6,7 +6,7 @@ package soil.query import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import soil.query.internal.Actor +import soil.query.core.Actor /** * Query as the base interface for an [QueryClient] implementations. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index 25af22c..69a4b31 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -4,7 +4,7 @@ package soil.query import kotlinx.coroutines.Job -import soil.query.internal.UniqueId +import soil.query.core.UniqueId /** * A Query client, which allows you to make queries actor and handle [QueryKey] and [InfiniteQueryKey]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index 03a127c..d4f7139 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -3,13 +3,13 @@ package soil.query -import soil.query.internal.RetryCallback -import soil.query.internal.RetryFn -import soil.query.internal.UniqueId -import soil.query.internal.epoch -import soil.query.internal.exponentialBackOff -import soil.query.internal.toEpoch -import soil.query.internal.vvv +import soil.query.core.RetryCallback +import soil.query.core.RetryFn +import soil.query.core.UniqueId +import soil.query.core.epoch +import soil.query.core.exponentialBackOff +import soil.query.core.toEpoch +import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 41ec51b..7243988 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.vvv +import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt index 40ecba8..c899987 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryFilter.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.SurrogateKey +import soil.query.core.SurrogateKey /** * Interface for filtering side effect queries by [QueryMutableClient]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index 19d0bb2..829c30c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -3,8 +3,8 @@ package soil.query -import soil.query.internal.SurrogateKey -import soil.query.internal.UniqueId +import soil.query.core.SurrogateKey +import soil.query.core.UniqueId /** * [QueryKey] for managing [Query] associated with [id]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt index 679ad62..58af703 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt @@ -3,7 +3,7 @@ package soil.query -import soil.query.internal.epoch +import soil.query.core.epoch /** * Data model for the state handled by [QueryKey] or [InfiniteQueryKey]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index 701c0c6..657f82b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -3,12 +3,12 @@ package soil.query -import soil.query.internal.ActorOptions -import soil.query.internal.LoggerFn -import soil.query.internal.LoggingOptions -import soil.query.internal.RetryOptions -import soil.query.internal.Retryable -import soil.query.internal.UniqueId +import soil.query.core.ActorOptions +import soil.query.core.LoggerFn +import soil.query.core.LoggingOptions +import soil.query.core.RetryOptions +import soil.query.core.Retryable +import soil.query.core.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -49,7 +49,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { * Automatically revalidate active [Query] when the network reconnects. * * **Note:** - * This setting is only effective when [soil.query.internal.NetworkConnectivity] is available. + * This setting is only effective when [soil.query.core.NetworkConnectivity] is available. */ val revalidateOnReconnect: Boolean @@ -57,7 +57,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { * Automatically revalidate active [Query] when the window is refocused. * * **Note:** - * This setting is only effective when [soil.query.internal.WindowVisibility] is available. + * This setting is only effective when [soil.query.core.WindowVisibility] is available. */ val revalidateOnFocus: Boolean diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 9c5dab3..0807888 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -4,7 +4,7 @@ package soil.query import kotlinx.coroutines.CompletableDeferred -import soil.query.internal.toResultCallback +import soil.query.core.toResultCallback import kotlin.coroutines.cancellation.CancellationException /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 53c0519..365ca91 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -28,20 +28,20 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_CHUNK_SIZE import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_INTERVAL -import soil.query.internal.ActorBlockRunner -import soil.query.internal.ActorSequenceNumber -import soil.query.internal.MemoryPressure -import soil.query.internal.MemoryPressureLevel -import soil.query.internal.NetworkConnectivity -import soil.query.internal.NetworkConnectivityEvent -import soil.query.internal.SurrogateKey -import soil.query.internal.TimeBasedCache -import soil.query.internal.UniqueId -import soil.query.internal.WindowVisibility -import soil.query.internal.WindowVisibilityEvent -import soil.query.internal.chunkedWithTimeout -import soil.query.internal.epoch -import soil.query.internal.vvv +import soil.query.core.ActorBlockRunner +import soil.query.core.ActorSequenceNumber +import soil.query.core.MemoryPressure +import soil.query.core.MemoryPressureLevel +import soil.query.core.NetworkConnectivity +import soil.query.core.NetworkConnectivityEvent +import soil.query.core.SurrogateKey +import soil.query.core.TimeBasedCache +import soil.query.core.UniqueId +import soil.query.core.WindowVisibility +import soil.query.core.WindowVisibilityEvent +import soil.query.core.chunkedWithTimeout +import soil.query.core.epoch +import soil.query.core.vvv import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt index be59482..6ff78e8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt @@ -22,9 +22,9 @@ interface SwrClient : MutationClient, QueryClient { * * Features dependent on the platform are lazily initialized. * The following features work correctly by notifying the start of [SwrClient] usage for each mount: - * - [soil.query.internal.NetworkConnectivity] - * - [soil.query.internal.MemoryPressure] - * - [soil.query.internal.WindowVisibility] + * - [soil.query.core.NetworkConnectivity] + * - [soil.query.core.MemoryPressure] + * - [soil.query.core.WindowVisibility] * * @param id Unique string for each mount point. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt index 6359381..0ea23ef 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Actor.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ActorOptions.kt similarity index 97% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/ActorOptions.kt index 51317bd..462944b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/ActorOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ActorOptions.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlin.time.Duration diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt similarity index 92% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt index b8a23e5..8b3c843 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/CompletableDeferredExt.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.CompletableDeferred diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt index 685efec..b457eac 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/FlowExt.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Logging.kt similarity index 93% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/Logging.kt index 8a00ecf..89de6d8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Logging.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Logging.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core /** * Logger functional interface to output log messages. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/LoggingOptions.kt similarity index 94% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/LoggingOptions.kt index ab4ddfa..9d94218 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/LoggingOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/LoggingOptions.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core /** * Interface providing settings for logging output for debugging purposes. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/MemoryPressure.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/MemoryPressure.kt index 5fac586..8e606b1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/MemoryPressure.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/MemoryPressure.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkConnectivity.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkConnectivity.kt index 2de2554..c9f0f25 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkConnectivity.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkConnectivity.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkOptions.kt similarity index 84% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkOptions.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkOptions.kt index dcbaba9..d48f62b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/NetworkOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/NetworkOptions.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core interface NetworkOptions { val isNetworkError: ((Throwable) -> Boolean)? diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Platform.kt similarity index 93% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/Platform.kt index 57a225e..ba1cfd8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Platform.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Platform.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlin.time.Duration diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/PriorityQueue.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/PriorityQueue.kt index 310c484..44e7b8f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/PriorityQueue.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/PriorityQueue.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core // TODO: Significantly slower than java.util.PriorityQueue. // Consider rewriting the implementation later. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt similarity index 97% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt index 3bf1586..7938f9e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/Retry.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlin.time.Duration diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt index 7150a5e..5471b81 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/RetryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.delay import kotlin.coroutines.cancellation.CancellationException diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt similarity index 99% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt index eb643bb..0b11794 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/TimeBasedCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlin.time.Duration diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/UniqueId.kt similarity index 97% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/UniqueId.kt index 451f052..ad780be 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/UniqueId.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/UniqueId.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core /** * Interface for unique identifiers. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/WindowVisibility.kt similarity index 98% rename from soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/WindowVisibility.kt index 73b78cb..f69b6c1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/internal/WindowVisibility.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/WindowVisibility.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt index b8a78c6..8f5243e 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/IosMemoryPressure.kt @@ -12,8 +12,8 @@ import platform.Foundation.NSSelectorFromString import platform.UIKit.UIApplicationDidEnterBackgroundNotification import platform.UIKit.UIApplicationDidReceiveMemoryWarningNotification import platform.darwin.NSObject -import soil.query.internal.MemoryPressure -import soil.query.internal.MemoryPressureLevel +import soil.query.core.MemoryPressure +import soil.query.core.MemoryPressureLevel /** * Implementation of [MemoryPressure] for iOS. diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt b/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt index bf29c91..338199a 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/IosWindowVisiblity.kt @@ -12,8 +12,8 @@ import platform.Foundation.NSSelectorFromString import platform.UIKit.UIApplicationDidBecomeActiveNotification import platform.UIKit.UIApplicationWillResignActiveNotification import platform.darwin.NSObject -import soil.query.internal.WindowVisibility -import soil.query.internal.WindowVisibilityEvent +import soil.query.core.WindowVisibility +import soil.query.core.WindowVisibilityEvent /** * Implementation of [WindowVisibility] for iOS. diff --git a/soil-query-core/src/iosMain/kotlin/soil/query/internal/Platform.ios.kt b/soil-query-core/src/iosMain/kotlin/soil/query/core/Platform.ios.kt similarity index 92% rename from soil-query-core/src/iosMain/kotlin/soil/query/internal/Platform.ios.kt rename to soil-query-core/src/iosMain/kotlin/soil/query/core/Platform.ios.kt index 3c7cfb2..880c2a2 100644 --- a/soil-query-core/src/iosMain/kotlin/soil/query/internal/Platform.ios.kt +++ b/soil-query-core/src/iosMain/kotlin/soil/query/core/Platform.ios.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import platform.Foundation.NSDate import platform.Foundation.NSUUID diff --git a/soil-query-core/src/jvmMain/kotlin/soil/query/internal/Platform.jvm.kt b/soil-query-core/src/jvmMain/kotlin/soil/query/core/Platform.jvm.kt similarity index 89% rename from soil-query-core/src/jvmMain/kotlin/soil/query/internal/Platform.jvm.kt rename to soil-query-core/src/jvmMain/kotlin/soil/query/core/Platform.jvm.kt index 6b48989..c70160b 100644 --- a/soil-query-core/src/jvmMain/kotlin/soil/query/internal/Platform.jvm.kt +++ b/soil-query-core/src/jvmMain/kotlin/soil/query/core/Platform.jvm.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core import java.util.UUID diff --git a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt index 08ce2b6..973ee37 100644 --- a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt +++ b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsNetworkConnectivity.kt @@ -5,8 +5,8 @@ package soil.query import kotlinx.browser.window import org.w3c.dom.events.Event -import soil.query.internal.NetworkConnectivity -import soil.query.internal.NetworkConnectivityEvent +import soil.query.core.NetworkConnectivity +import soil.query.core.NetworkConnectivityEvent /** * Implementation of [NetworkConnectivity] for WasmJs. diff --git a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt index a7fd282..0e137e7 100644 --- a/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt +++ b/soil-query-core/src/wasmJsMain/kotlin/soil/query/WasmJsWindowVisibility.kt @@ -5,9 +5,9 @@ package soil.query import kotlinx.browser.document import org.w3c.dom.events.Event -import soil.query.internal.WindowVisibility -import soil.query.internal.WindowVisibilityEvent -import soil.query.internal.document as documentAlt +import soil.query.core.WindowVisibility +import soil.query.core.WindowVisibilityEvent +import soil.query.core.document as documentAlt /** * Implementation of [WindowVisibility] for WasmJs. diff --git a/soil-query-core/src/wasmJsMain/kotlin/soil/query/internal/Platform.wasmJs.kt b/soil-query-core/src/wasmJsMain/kotlin/soil/query/core/Platform.wasmJs.kt similarity index 95% rename from soil-query-core/src/wasmJsMain/kotlin/soil/query/internal/Platform.wasmJs.kt rename to soil-query-core/src/wasmJsMain/kotlin/soil/query/core/Platform.wasmJs.kt index 5069064..99e93d3 100644 --- a/soil-query-core/src/wasmJsMain/kotlin/soil/query/internal/Platform.wasmJs.kt +++ b/soil-query-core/src/wasmJsMain/kotlin/soil/query/core/Platform.wasmJs.kt @@ -1,7 +1,7 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 -package soil.query.internal +package soil.query.core actual fun epoch(): Long { return Date.now().toString().toLong() / 1000 From 93fcd805f6c2f9040089d69df34dfc82f4656dcc Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 15:34:16 +0900 Subject: [PATCH 076/155] Add a unified feedback mechanism to show error notifications. Tanstack Query and SWR have implemented a unified error feedback mechanism for the UI. - https://tkdodo.eu/blog/react-query-error-handling - https://swr.vercel.app/docs/error-handling#global-error-report Similarly, I have implemented a mechanism to notify errors via a backchannel, separate from the error information that can be determined from the state of Query or Mutation. --- .../kotlin/soil/query/InfiniteQueryCommand.kt | 4 +- .../kotlin/soil/query/MutationCommand.kt | 12 +- .../kotlin/soil/query/MutationError.kt | 36 +++++ .../kotlin/soil/query/MutationOptions.kt | 11 +- .../kotlin/soil/query/QueryCommand.kt | 12 +- .../kotlin/soil/query/QueryError.kt | 36 +++++ .../kotlin/soil/query/QueryOptions.kt | 11 +- .../commonMain/kotlin/soil/query/SwrCache.kt | 33 ++++- .../commonMain/kotlin/soil/query/SwrClient.kt | 10 ++ .../kotlin/soil/query/core/ErrorRecord.kt | 19 +++ .../kotlin/soil/query/core/ErrorRelay.kt | 132 ++++++++++++++++++ 11 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index 54f11fd..4253f9a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -86,7 +86,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { options.onError?.invoke(it, state, key.id) } + .onFailure { reportQueryError(it, key.id) } .also { callback?.invoke(it) } } @@ -108,6 +108,6 @@ suspend inline fun QueryCommand.Context>.dispatchRevali .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { options.onError?.invoke(it, state, key.id) } + .onFailure { reportQueryError(it, key.id) } .also { callback?.invoke(it) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index df369a5..3153145 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -35,6 +35,7 @@ interface MutationCommand { val state: MutationModel val dispatch: MutationDispatch val notifier: MutationNotifier + val relay: MutationErrorRelay? } } @@ -98,12 +99,21 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( } } .onFailure { dispatch(MutationAction.MutateFailure(it)) } - .onFailure { options.onError?.invoke(it, state, key.id) } + .onFailure { reportMutationError(it, key.id) } .also { callback?.invoke(it) } } +fun MutationCommand.Context.reportMutationError(error: Throwable, id: UniqueId) { + if (options.onError == null && relay == null) { + return + } + val record = MutationError(error, id, state) + options.onError?.invoke(record) + relay?.invoke(record) +} + internal fun MutationCommand.Context.onRetryCallback( id: UniqueId, ): RetryCallback? { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt new file mode 100644 index 0000000..19f4fbf --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt @@ -0,0 +1,36 @@ +package soil.query + +import soil.query.core.ErrorRecord +import soil.query.core.UniqueId + +/** + * Mutation error information that can be received via a back-channel. + */ +class MutationError @PublishedApi internal constructor( + override val exception: Throwable, + override val key: UniqueId, + + /** + * The mutation state that caused the error. + */ + val model: MutationModel<*> +) : ErrorRecord { + + override fun toString(): String { + return """ + MutationError( + message=${exception.message}, + key=$key, + model={ + dataUpdatedAt=${model.dataUpdatedAt}, + errorUpdatedAt=${model.errorUpdatedAt}, + status=${model.status}, + mutatedCount=${model.mutatedCount}, + submittedAt=${model.submittedAt}, + } + ) + """.trimIndent() + } +} + +internal typealias MutationErrorRelay = (MutationError) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 9679d2b..c9ec24b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -7,7 +7,6 @@ import soil.query.core.ActorOptions import soil.query.core.LoggerFn import soil.query.core.LoggingOptions import soil.query.core.RetryOptions -import soil.query.core.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -31,7 +30,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { /** * This callback function will be called if some mutation encounters an error. */ - val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? + val onError: ((MutationError) -> Unit)? /** * Whether the query side effect should be synchronous. If true, side effect will be executed synchronously. @@ -41,7 +40,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { companion object Default : MutationOptions { override val isOneShot: Boolean = false override val isStrictMode: Boolean = false - override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = null + override val onError: ((MutationError) -> Unit)? = null override val shouldExecuteEffectSynchronously: Boolean = false // ----- ActorOptions ----- // @@ -64,7 +63,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { fun MutationOptions( isOneShot: Boolean = MutationOptions.isOneShot, isStrictMode: Boolean = MutationOptions.isStrictMode, - onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = MutationOptions.onError, + onError: ((MutationError) -> Unit)? = MutationOptions.onError, shouldExecuteEffectSynchronously: Boolean = MutationOptions.shouldExecuteEffectSynchronously, keepAliveTime: Duration = MutationOptions.keepAliveTime, logger: LoggerFn? = MutationOptions.logger, @@ -79,7 +78,7 @@ fun MutationOptions( return object : MutationOptions { override val isOneShot: Boolean = isOneShot override val isStrictMode: Boolean = isStrictMode - override val onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = onError + override val onError: ((MutationError) -> Unit)? = onError override val shouldExecuteEffectSynchronously: Boolean = shouldExecuteEffectSynchronously override val keepAliveTime: Duration = keepAliveTime override val logger: LoggerFn? = logger @@ -96,7 +95,7 @@ fun MutationOptions( fun MutationOptions.copy( isOneShot: Boolean = this.isOneShot, isStrictMode: Boolean = this.isStrictMode, - onError: ((Throwable, MutationModel<*>, UniqueId) -> Unit)? = this.onError, + onError: ((MutationError) -> Unit)? = this.onError, shouldExecuteEffectSynchronously: Boolean = this.shouldExecuteEffectSynchronously, keepAliveTime: Duration = this.keepAliveTime, logger: LoggerFn? = this.logger, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index d4f7139..bbc7803 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -34,6 +34,7 @@ interface QueryCommand { val options: QueryOptions val state: QueryModel val dispatch: QueryDispatch + val relay: QueryErrorRelay? } } @@ -106,7 +107,7 @@ suspend inline fun QueryCommand.Context.dispatchFetchResult( .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { options.onError?.invoke(it, state, key.id) } + .onFailure { reportQueryError(it, key.id) } .also { callback?.invoke(it) } } @@ -140,6 +141,15 @@ fun QueryCommand.Context.dispatchFetchFailure(error: Throwable) { dispatch(action) } +fun QueryCommand.Context.reportQueryError(error: Throwable, id: UniqueId) { + if (options.onError == null && relay == null) { + return + } + val record = QueryError(error, id, state) + options.onError?.invoke(record) + relay?.invoke(record) +} + internal fun QueryCommand.Context.onRetryCallback( id: UniqueId, ): RetryCallback? { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt new file mode 100644 index 0000000..d347254 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt @@ -0,0 +1,36 @@ +package soil.query + +import soil.query.core.ErrorRecord +import soil.query.core.UniqueId + +/** + * Query error information that can be received via a back-channel. + */ +class QueryError @PublishedApi internal constructor( + override val exception: Throwable, + override val key: UniqueId, + + /** + * The query model that caused the error. + */ + val model: QueryModel<*> +) : ErrorRecord { + + override fun toString(): String { + return """ + QueryError( + message=${exception.message}, + key=$key, + model={ + dataUpdatedAt=${model.dataUpdatedAt}, + dataStaleAt=${model.dataStaleAt}, + errorUpdatedAt=${model.errorUpdatedAt}, + status=${model.status}, + isInvalidated=${model.isInvalidated}, + } + ) + """.trimIndent() + } +} + +internal typealias QueryErrorRelay = (QueryError) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index 657f82b..a6ce4c8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -8,7 +8,6 @@ import soil.query.core.LoggerFn import soil.query.core.LoggingOptions import soil.query.core.RetryOptions import soil.query.core.Retryable -import soil.query.core.UniqueId import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -64,7 +63,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { /** * This callback function will be called if some query encounters an error. */ - val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? + val onError: ((QueryError) -> Unit)? companion object Default : QueryOptions { override val staleTime: Duration = Duration.ZERO @@ -73,7 +72,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { override val pauseDurationAfter: ((Throwable) -> Duration?)? = null override val revalidateOnReconnect: Boolean = true override val revalidateOnFocus: Boolean = true - override val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = null + override val onError: ((QueryError) -> Unit)? = null // ----- ActorOptions ----- // override val keepAliveTime: Duration = 5.seconds @@ -101,7 +100,7 @@ fun QueryOptions( pauseDurationAfter: ((Throwable) -> Duration?)? = QueryOptions.pauseDurationAfter, revalidateOnReconnect: Boolean = QueryOptions.revalidateOnReconnect, revalidateOnFocus: Boolean = QueryOptions.revalidateOnFocus, - onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = QueryOptions.onError, + onError: ((QueryError) -> Unit)? = QueryOptions.onError, keepAliveTime: Duration = QueryOptions.keepAliveTime, logger: LoggerFn? = QueryOptions.logger, shouldRetry: (Throwable) -> Boolean = QueryOptions.shouldRetry, @@ -119,7 +118,7 @@ fun QueryOptions( override val pauseDurationAfter: ((Throwable) -> Duration?)? = pauseDurationAfter override val revalidateOnReconnect: Boolean = revalidateOnReconnect override val revalidateOnFocus: Boolean = revalidateOnFocus - override val onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = onError + override val onError: ((QueryError) -> Unit)? = onError override val keepAliveTime: Duration = keepAliveTime override val logger: LoggerFn? = logger override val shouldRetry: (Throwable) -> Boolean = shouldRetry @@ -139,7 +138,7 @@ fun QueryOptions.copy( pauseDurationAfter: ((Throwable) -> Duration?)? = this.pauseDurationAfter, revalidateOnReconnect: Boolean = this.revalidateOnReconnect, revalidateOnFocus: Boolean = this.revalidateOnFocus, - onError: ((Throwable, QueryModel<*>, UniqueId) -> Unit)? = this.onError, + onError: ((QueryError) -> Unit)? = this.onError, keepAliveTime: Duration = this.keepAliveTime, logger: LoggerFn? = this.logger, shouldRetry: (Throwable) -> Boolean = this.shouldRetry, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 365ca91..eac05dc 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -30,6 +31,8 @@ import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_CHUNK_SIZE import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_INTERVAL import soil.query.core.ActorBlockRunner import soil.query.core.ActorSequenceNumber +import soil.query.core.ErrorRecord +import soil.query.core.ErrorRelay import soil.query.core.MemoryPressure import soil.query.core.MemoryPressureLevel import soil.query.core.NetworkConnectivity @@ -118,9 +121,13 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } // ----- SwrClient ----- // + override val defaultMutationOptions: MutationOptions = policy.mutationOptions override val defaultQueryOptions: QueryOptions = policy.queryOptions + override val errorRelay: Flow + get() = policy.errorRelay?.receiveAsFlow() ?: error("policy.errorRelay is not configured :(") + override fun perform(sideEffects: QueryEffect): Job { return coroutineScope.launch { with(this@SwrCache) { sideEffects() } @@ -225,6 +232,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie state.value = reducer(state.value, action) } val notifier = MutationNotifier { effect -> perform(effect) } + val relay: MutationErrorRelay? = policy.errorRelay?.let { it::send } val command = Channel>() val actor = ActorBlockRunner( scope = scope, @@ -241,7 +249,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie options = options, state = state.value, dispatch = dispatch, - notifier = notifier + notifier = notifier, + relay = relay ) ) } @@ -301,6 +310,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie options.vvv(id) { "dispatching $action" } state.value = reducer(state.value, action) } + val relay: QueryErrorRelay? = policy.errorRelay?.let { it::send } val command = Channel>() val actor = ActorBlockRunner( scope = scope, @@ -311,7 +321,15 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) { for (c in command) { options.vvv(id) { "next command $c" } - c.handle(ctx = ManagedQueryContext(queryReceiver, options, state.value, dispatch)) + c.handle( + ctx = ManagedQueryContext( + receiver = queryReceiver, + options = options, + state = state.value, + dispatch = dispatch, + relay = relay + ) + ) } } return ManagedQuery( @@ -618,7 +636,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val options: MutationOptions, override val state: MutationState, override val dispatch: MutationDispatch, - override val notifier: MutationNotifier + override val notifier: MutationNotifier, + override val relay: MutationErrorRelay? ) : MutationCommand.Context data class ManagedQuery internal constructor( @@ -659,7 +678,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val receiver: QueryReceiver, override val options: QueryOptions, override val state: QueryState, - override val dispatch: QueryDispatch + override val dispatch: QueryDispatch, + override val relay: QueryErrorRelay? ) : QueryCommand.Context companion object { @@ -726,6 +746,11 @@ data class SwrCachePolicy( */ val queryCache: TimeBasedCache> = TimeBasedCache(DEFAULT_CAPACITY), + /** + * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. + */ + val errorRelay: ErrorRelay? = null, + /** * Receiving events of memory pressure. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt index 6ff78e8..003e453 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt @@ -4,6 +4,8 @@ package soil.query import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import soil.query.core.ErrorRecord /** * An all-in-one [SwrClient] integrating [MutationClient] and [QueryClient] for library users. @@ -12,6 +14,14 @@ import kotlinx.coroutines.Job */ interface SwrClient : MutationClient, QueryClient { + /** + * Provides a unified feedback mechanism for all Query/Mutation errors that occur within the client. + * + * For example, by collecting errors on the foreground screen, + * you can display an error message on the screen using a Toast or similar when an error occurs. + */ + val errorRelay: Flow + /** * Executes side effects for queries. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt new file mode 100644 index 0000000..d0d48be --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt @@ -0,0 +1,19 @@ +package soil.query.core + +/** + * Error information that can be received via a back-channel + * such as [ErrorRelay] or `onError` options for Query/Mutation. + */ +interface ErrorRecord { + /** + * The details of the error. + */ + val exception: Throwable + + /** + * Key information that caused the error. + * + * NOTE: Defining an ID with a custom interface, such as metadata, can be helpful when receiving error information. + */ + val key: UniqueId +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt new file mode 100644 index 0000000..fa18249 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt @@ -0,0 +1,132 @@ +package soil.query.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * An interface for relaying error information via a back-channel + * when an error occurs during execution, regardless of Query/Mutation. + */ +interface ErrorRelay { + + /** + * Sends error information to the relay destination. + * + * @param error The error information. + */ + fun send(error: ErrorRecord) + + /** + * Provides a Flow for receiving error information. + */ + fun receiveAsFlow(): Flow + + companion object { + + /** + * Creates an ErrorRelay that relays error information to one of the destinations. + * + * NOTE: Only the latest error information is retained while there are no destinations. + * + * @param scope CoroutineScope for the relay. + * @param policy The relay policy for error information. + */ + @Suppress("SpellCheckingInspection") + fun newAnycast( + scope: CoroutineScope, + policy: ErrorRelayPolicy = ErrorRelayPolicy.None + ): ErrorRelay = ErrorRelayBuiltInAnycast(scope, policy) + } +} + +/** + * A policy for relaying error information. + */ +interface ErrorRelayPolicy { + + /** + * Determines whether to suppress error information. + */ + val shouldSuppressError: (ErrorRecord) -> Boolean + + /** + * Determines whether the error information is equal. + */ + val areErrorsEqual: (ErrorRecord, ErrorRecord) -> Boolean + + /** + * A companion object that provides a default implementation of the ErrorRelayPolicy. + */ + companion object None : ErrorRelayPolicy { + override val shouldSuppressError: (ErrorRecord) -> Boolean = { false } + override val areErrorsEqual: (ErrorRecord, ErrorRecord) -> Boolean = { _, _ -> false } + } +} + +private typealias ErrorToken = String + +@Suppress("SpellCheckingInspection") +internal class ErrorRelayBuiltInAnycast( + private val scope: CoroutineScope, + private val policy: ErrorRelayPolicy +) : ErrorRelay { + + private val mutex = Mutex() + private val upstream = Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val downstream = Channel( + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private var current: Pair? = null + + init { + scope.launch { + for (error in upstream) { + val next = mutex.withLock { + val unfulfilled = current + if (unfulfilled != null && policy.areErrorsEqual(unfulfilled.second, error)) { + return@withLock null + } + val token = uuid() + current = token to error + token + } + if (next != null) { + downstream.send(next) + } + } + } + } + + override fun send(error: ErrorRecord) { + if (policy.shouldSuppressError(error)) { + return + } + scope.launch { upstream.send(error) } + } + + override fun receiveAsFlow(): Flow = flow { + for (next in downstream) { + consume(next)?.let { emit(it) } + } + } + + private suspend fun consume(next: ErrorToken): ErrorRecord? { + return mutex.withLock { + current?.let { (token, info) -> + if (token == next) { + current = null + return@withLock info + } + } + null + } + } +} From 52cc0f3f714693888608f5b4ec9b63c8e7b7bd2e Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 06:57:49 +0000 Subject: [PATCH 077/155] Apply automatic changes --- .../src/commonMain/kotlin/soil/query/MutationError.kt | 3 +++ soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt | 3 +++ .../src/commonMain/kotlin/soil/query/core/ErrorRecord.kt | 3 +++ .../src/commonMain/kotlin/soil/query/core/ErrorRelay.kt | 3 +++ 4 files changed, 12 insertions(+) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt index 19f4fbf..887bc82 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query import soil.query.core.ErrorRecord diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt index d347254..a65e87c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query import soil.query.core.ErrorRecord diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt index d0d48be..f028a87 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.core /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt index fa18249..46e3a51 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRelay.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.core import kotlinx.coroutines.CoroutineScope From 2ac1dc1bf6997f9b4af81faf8377ac24cdbb3ee5 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 16:34:43 +0900 Subject: [PATCH 078/155] Fix a failed test. --- .../src/commonTest/kotlin/soil/query/MutationOptionsTest.kt | 4 ++-- .../src/commonTest/kotlin/soil/query/QueryOptionsTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt index 7ab54a5..126457f 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -35,7 +35,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions( isOneShot = true, isStrictMode = true, - onError = { _, _, _ -> }, + onError = { }, shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, @@ -85,7 +85,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions.Default.copy( isOneShot = true, isStrictMode = true, - onError = { _, _, _ -> }, + onError = { }, shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt index 94ab2f6..639eff9 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt @@ -42,7 +42,7 @@ class QueryOptionsTest : UnitTest() { pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, - onError = { _, _, _ -> }, + onError = { }, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, @@ -101,7 +101,7 @@ class QueryOptionsTest : UnitTest() { pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, - onError = { _, _, _ -> }, + onError = { }, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, From 47949e5ef17913784b92405895ea66c2d6c9887e Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 16:41:20 +0900 Subject: [PATCH 079/155] Use `produceIn` instead `toReceiveChannel` of custom operator --- .../kotlin/soil/query/core/FlowExt.kt | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt index b457eac..7992ba1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt @@ -3,11 +3,8 @@ package soil.query.core -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,7 +12,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.produceIn import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlin.time.Duration @@ -33,7 +29,7 @@ internal fun Flow.chunkedWithTimeout( val ticker = MutableSharedFlow(extraBufferCapacity = size) val tickerTimeout = ticker .debounce(duration) - .toReceiveChannel(this) + .produceIn(this) try { while (isActive) { var isTimeout = false @@ -63,15 +59,3 @@ internal fun Flow.chunkedWithTimeout( } } } - -private fun Flow.toReceiveChannel(scope: CoroutineScope): ReceiveChannel { - val channel = Channel(Channel.BUFFERED) - scope.launch { - try { - collect { value -> channel.send(value) } - } finally { - channel.close() - } - } - return channel -} From bd24eb2cd56bdaa7b3468adbf4ab3d94fc1a1197 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 4 Aug 2024 16:53:40 +0900 Subject: [PATCH 080/155] Improve differences in command implementation between Query/Mutation --- .../kotlin/soil/query/MutationAction.kt | 13 +++---- .../kotlin/soil/query/MutationCommand.kt | 34 +++++++++++++++++-- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt index a7bc239..8b029f3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt @@ -3,8 +3,6 @@ package soil.query -import soil.query.core.epoch - /** * Mutation actions are used to update the [mutation state][MutationState]. * @@ -30,7 +28,7 @@ sealed interface MutationAction { */ data class MutateSuccess( val data: T, - val dataUpdatedAt: Long? = null + val dataUpdatedAt: Long ) : MutationAction /** @@ -41,7 +39,7 @@ sealed interface MutationAction { */ data class MutateFailure( val error: Throwable, - val errorUpdatedAt: Long? = null + val errorUpdatedAt: Long ) : MutationAction } @@ -71,13 +69,12 @@ fun createMutationReducer(): MutationReducer = { state, action -> } is MutationAction.MutateSuccess -> { - val updatedAt = action.dataUpdatedAt ?: epoch() state.copy( status = MutationStatus.Success, data = action.data, - dataUpdatedAt = updatedAt, + dataUpdatedAt = action.dataUpdatedAt, error = null, - errorUpdatedAt = updatedAt, + errorUpdatedAt = action.dataUpdatedAt, mutatedCount = state.mutatedCount + 1 ) } @@ -86,7 +83,7 @@ fun createMutationReducer(): MutationReducer = { state, action -> state.copy( status = MutationStatus.Failure, error = action.error, - errorUpdatedAt = action.errorUpdatedAt ?: epoch() + errorUpdatedAt = action.errorUpdatedAt ) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 3153145..0ab3e0f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.withContext import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId +import soil.query.core.epoch import soil.query.core.exponentialBackOff import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException @@ -95,16 +96,45 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( if (job != null && options.shouldExecuteEffectSynchronously) { job.join() } - dispatch(MutationAction.MutateSuccess(data)) + dispatchMutateSuccess(data) } } - .onFailure { dispatch(MutationAction.MutateFailure(it)) } + .onFailure(::dispatchMutateFailure) .onFailure { reportMutationError(it, key.id) } .also { callback?.invoke(it) } } +/** + * Dispatches the mutate success. + * + * @param data The mutation returned data. + */ +fun MutationCommand.Context.dispatchMutateSuccess(data: T) { + val currentAt = epoch() + val action = MutationAction.MutateSuccess( + data = data, + dataUpdatedAt = currentAt + ) + dispatch(action) +} + +/** + * Dispatches the mutate failure. + * + * @param error The mutation error. + */ +fun MutationCommand.Context.dispatchMutateFailure(error: Throwable) { + val currentAt = epoch() + val action = MutationAction.MutateFailure( + error = error, + errorUpdatedAt = currentAt + ) + dispatch(action) +} + + fun MutationCommand.Context.reportMutationError(error: Throwable, id: UniqueId) { if (options.onError == null && relay == null) { return From 6584476a724d0b7cd7325a7ce37f2897cb07641a Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 10 Aug 2024 17:07:45 +0900 Subject: [PATCH 081/155] Change to interface for better testability. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implementation of XxxRef, which previously returned concrete classes directly from the Query and Mutation client interfaces, has been switched to returning them via an interface. This makes it easier to create mocks for previews and tests. --- .../query/compose/InfiniteQueryComposable.kt | 7 +- .../soil/query/compose/MutationComposable.kt | 3 + .../soil/query/compose/QueryComposable.kt | 6 +- .../kotlin/soil/query/InfiniteQueryRef.kt | 92 ++++++------------- .../kotlin/soil/query/MutationRef.kt | 68 +++++++------- .../commonMain/kotlin/soil/query/QueryRef.kt | 80 +++++----------- .../commonMain/kotlin/soil/query/SwrCache.kt | 22 ++--- .../kotlin/soil/query/SwrInfiniteQuery.kt | 56 +++++++++++ .../kotlin/soil/query/SwrMutation.kt | 26 ++++++ .../commonMain/kotlin/soil/query/SwrQuery.kt | 56 +++++++++++ .../kotlin/soil/query/core/Actor.kt | 6 +- 11 files changed, 254 insertions(+), 168 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index a7bef2a..73e2f7c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -15,6 +15,9 @@ import soil.query.QueryChunks import soil.query.QueryClient import soil.query.QueryState import soil.query.QueryStatus +import soil.query.invalidate +import soil.query.loadMore +import soil.query.resume /** * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. @@ -34,7 +37,7 @@ fun rememberInfiniteQuery( val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start() + query.resume() } return remember(query, state) { state.toInfiniteObject(query = query, select = { it }) @@ -61,7 +64,7 @@ fun rememberInfiniteQuery( val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start() + query.resume() } return remember(query, state) { state.toInfiniteObject(query = query, select = select) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index dff0c52..b986d04 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -13,6 +13,9 @@ import soil.query.MutationKey import soil.query.MutationRef import soil.query.MutationState import soil.query.MutationStatus +import soil.query.mutate +import soil.query.mutateAsync +import soil.query.reset /** * Remember a [MutationObject] and subscribes to the mutation state of [key]. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index af235ad..345c9c3 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -14,6 +14,8 @@ import soil.query.QueryKey import soil.query.QueryRef import soil.query.QueryState import soil.query.QueryStatus +import soil.query.invalidate +import soil.query.resume /** * Remember a [QueryObject] and subscribes to the query state of [key]. @@ -32,7 +34,7 @@ fun rememberQuery( val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start() + query.resume() } return remember(query, state) { state.toObject(query = query, select = { it }) @@ -59,7 +61,7 @@ fun rememberQuery( val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } val state by query.state.collectAsState() LaunchedEffect(query) { - query.start() + query.resume() } return remember(query, state) { state.toObject(query = query, select = select) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 14845b2..bc5467b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -3,79 +3,47 @@ package soil.query -import kotlinx.coroutines.CompletableDeferred -import soil.query.core.toResultCallback -import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.flow.StateFlow +import soil.query.core.Actor /** - * A reference to an [Query] for [InfiniteQueryKey]. + * A reference to an Query for [InfiniteQueryKey]. * * @param T Type of data to retrieve. * @param S Type of parameter. - * @property key Instance of a class implementing [InfiniteQueryKey]. - * @param query Transparently referenced [Query]. - * @constructor Creates an [InfiniteQueryRef]. */ -class InfiniteQueryRef( - val key: InfiniteQueryKey, - val options: QueryOptions, - query: Query> -) : Query> by query { +interface InfiniteQueryRef : Actor { - /** - * Starts the [Query]. - * - * This function must be invoked when a new mount point (subscriber) is added. - */ - suspend fun start() { - command.send(InfiniteQueryCommands.Connect(key)) - event.collect(::handleEvent) - } - - /** - * Prefetches the [Query]. - */ - suspend fun prefetch(): Boolean { - val deferred = CompletableDeferred>() - command.send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) - return try { - deferred.await() - true - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - false - } - } + val key: InfiniteQueryKey + val options: QueryOptions + val state: StateFlow>> /** - * Invalidates the [Query]. - * - * Calling this function will invalidate the retrieved data of the [Query], - * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + * Sends a [QueryCommand] to the Actor. */ - suspend fun invalidate() { - command.send(InfiniteQueryCommands.Invalidate(key, state.value.revision)) - } + suspend fun send(command: QueryCommand>) +} - /** - * Resumes the [Query]. - */ - private suspend fun resume() { - command.send(InfiniteQueryCommands.Connect(key, state.value.revision)) - } +/** + * Invalidates the Query. + * + * Calling this function will invalidate the retrieved data of the Query, + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ +suspend fun InfiniteQueryRef.invalidate() { + send(InfiniteQueryCommands.Invalidate(key, state.value.revision)) +} - /** - * Fetches data for the [InfiniteQueryKey] using the value of [param]. - */ - suspend fun loadMore(param: S) { - command.send(InfiniteQueryCommands.LoadMore(key, param)) - } +/** + * Resumes the Query. + */ +suspend fun InfiniteQueryRef.resume() { + send(InfiniteQueryCommands.Connect(key, state.value.revision)) +} - private suspend fun handleEvent(e: QueryEvent) { - when (e) { - QueryEvent.Invalidate -> invalidate() - QueryEvent.Resume -> resume() - } - } +/** + * Fetches data for the [InfiniteQueryKey] using the value of [param]. + */ +suspend fun InfiniteQueryRef.loadMore(param: S) { + send(InfiniteQueryCommands.LoadMore(key, param)) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index a9642ad..3dcac0a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -4,48 +4,52 @@ package soil.query import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.StateFlow +import soil.query.core.Actor import soil.query.core.toResultCallback /** - * A reference to a [Mutation] for [MutationKey]. + * A reference to a Mutation for [MutationKey]. * * @param T Type of the return value from the mutation. * @param S Type of the variable to be mutated. - * @property key Instance of a class implementing [MutationKey]. - * @param mutation The mutation to perform. - * @constructor Creates a [MutationRef]. */ -class MutationRef( - val key: MutationKey, - val options: MutationOptions, - mutation: Mutation -) : Mutation by mutation { +interface MutationRef : Actor { - /** - * Mutates the variable. - * - * @param variable The variable to be mutated. - * @return The result of the mutation. - */ - suspend fun mutate(variable: S): T { - val deferred = CompletableDeferred() - command.send(MutationCommands.Mutate(key, variable, state.value.revision, deferred.toResultCallback())) - return deferred.await() - } + val key: MutationKey + val options: MutationOptions + val state: StateFlow> /** - * Mutates the variable asynchronously. - * - * @param variable The variable to be mutated. + * Sends a [MutationCommand] to the Actor. */ - suspend fun mutateAsync(variable: S) { - command.send(MutationCommands.Mutate(key, variable, state.value.revision)) - } + suspend fun send(command: MutationCommand) +} - /** - * Resets the mutation state. - */ - suspend fun reset() { - command.send(MutationCommands.Reset()) - } +/** + * Mutates the variable. + * + * @param variable The variable to be mutated. + * @return The result of the mutation. + */ +suspend fun MutationRef.mutate(variable: S): T { + val deferred = CompletableDeferred() + send(MutationCommands.Mutate(key, variable, state.value.revision, deferred.toResultCallback())) + return deferred.await() +} + +/** + * Mutates the variable asynchronously. + * + * @param variable The variable to be mutated. + */ +suspend fun MutationRef.mutateAsync(variable: S) { + send(MutationCommands.Mutate(key, variable, state.value.revision)) +} + +/** + * Resets the mutation state. + */ +suspend fun MutationRef.reset() { + send(MutationCommands.Reset()) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 0807888..464857b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -3,71 +3,39 @@ package soil.query -import kotlinx.coroutines.CompletableDeferred -import soil.query.core.toResultCallback -import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.flow.StateFlow +import soil.query.core.Actor /** - * A reference to an [Query] for [QueryKey]. + * A reference to an Query for [QueryKey]. * * @param T Type of data to retrieve. - * @property key Instance of a class implementing [QueryKey]. - * @param query Transparently referenced [Query]. - * @constructor Creates an [QueryRef] */ -class QueryRef( - val key: QueryKey, - val options: QueryOptions, - query: Query -) : Query by query { +interface QueryRef : Actor { - /** - * Starts the [Query]. - * - * This function must be invoked when a new mount point (subscriber) is added. - */ - suspend fun start() { - command.send(QueryCommands.Connect(key)) - event.collect(::handleEvent) - } + val key: QueryKey + val options: QueryOptions + val state: StateFlow> /** - * Prefetches the [Query]. + * Sends a [QueryCommand] to the Actor. */ - suspend fun prefetch(): Boolean { - val deferred = CompletableDeferred() - command.send(QueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) - return try { - deferred.await() - true - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - false - } - } - - /** - * Invalidates the [Query]. - * - * Calling this function will invalidate the retrieved data of the [Query], - * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. - */ - suspend fun invalidate() { - command.send(QueryCommands.Invalidate(key, state.value.revision)) - } + suspend fun send(command: QueryCommand) +} - /** - * Resumes the [Query]. - */ - internal suspend fun resume() { - command.send(QueryCommands.Connect(key, state.value.revision)) - } +/** + * Invalidates the Query. + * + * Calling this function will invalidate the retrieved data of the Query, + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ +suspend fun QueryRef.invalidate() { + send(QueryCommands.Invalidate(key, state.value.revision)) +} - private suspend fun handleEvent(e: QueryEvent) { - when (e) { - QueryEvent.Invalidate -> invalidate() - QueryEvent.Resume -> resume() - } - } +/** + * Resumes the Query. + */ +suspend fun QueryRef.resume() { + send(QueryCommands.Connect(key, state.value.revision)) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index eac05dc..f721ade 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -212,7 +212,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie initialValue = MutationState() ).also { mutationStore[id] = it } } - return MutationRef( + return SwrMutation( key = key, options = options, mutation = mutation @@ -287,10 +287,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie initialValue = queryCache[key.id] as? QueryState ?: newQueryState(key) ).also { queryStore[id] = it } } - return QueryRef( + return SwrQuery( key = key, - query = query, - options = options + options = options, + query = query ) } @@ -387,10 +387,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie initialValue = queryCache[id] as? QueryState> ?: newInfiniteQueryState(key) ).also { queryStore[id] = it } } - return InfiniteQueryRef( + return SwrInfiniteQuery( key = key, - query = query, - options = options + options = options, + query = query ) } @@ -621,8 +621,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val command: SendChannel> ) : Mutation { - override fun launchIn(scope: CoroutineScope) { - actor.launchIn(scope) + override fun launchIn(scope: CoroutineScope): Job { + return actor.launchIn(scope) } fun close() { @@ -651,8 +651,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val command: SendChannel> ) : Query { - override fun launchIn(scope: CoroutineScope) { - actor.launchIn(scope) + override fun launchIn(scope: CoroutineScope): Job { + return actor.launchIn(scope) } fun close() { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt new file mode 100644 index 0000000..20b8893 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -0,0 +1,56 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.core.toResultCallback + +internal class SwrInfiniteQuery( + override val key: InfiniteQueryKey, + override val options: QueryOptions, + private val query: Query> +) : InfiniteQueryRef { + + override val state: StateFlow>> + get() = query.state + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.launchIn(this) + query.event.collect(::handleEvent) + } + } + + override suspend fun send(command: QueryCommand>) { + query.command.send(command) + } + + private suspend fun handleEvent(e: QueryEvent) { + when (e) { + QueryEvent.Invalidate -> invalidate() + QueryEvent.Resume -> resume() + } + } +} + +/** + * Prefetches the Query. + */ +internal suspend fun InfiniteQueryRef.prefetch(): Boolean { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + return try { + deferred.await() + true + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + false + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt new file mode 100644 index 0000000..3f6d565 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt @@ -0,0 +1,26 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow + +internal class SwrMutation( + override val key: MutationKey, + override val options: MutationOptions, + private val mutation: Mutation +) : MutationRef { + + override val state: StateFlow> + get() = mutation.state + + override fun launchIn(scope: CoroutineScope): Job { + return mutation.launchIn(scope) + } + + override suspend fun send(command: MutationCommand) { + mutation.command.send(command) + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt new file mode 100644 index 0000000..171676f --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt @@ -0,0 +1,56 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.core.toResultCallback +import kotlin.coroutines.cancellation.CancellationException + +internal class SwrQuery( + override val key: QueryKey, + override val options: QueryOptions, + private val query: Query +) : QueryRef { + + override val state: StateFlow> + get() = query.state + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.launchIn(this) + query.event.collect(::handleEvent) + } + } + + override suspend fun send(command: QueryCommand) { + query.command.send(command) + } + + private suspend fun handleEvent(e: QueryEvent) { + when (e) { + QueryEvent.Invalidate -> invalidate() + QueryEvent.Resume -> resume() + } + } +} + +/** + * Prefetches the Query. + */ +internal suspend fun QueryRef.prefetch(): Boolean { + val deferred = CompletableDeferred() + send(QueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + return try { + deferred.await() + true + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + false + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt index 0ea23ef..2aef81c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Actor.kt @@ -28,7 +28,7 @@ interface Actor { * * @param scope The scope in which the actor will run */ - fun launchIn(scope: CoroutineScope) + fun launchIn(scope: CoroutineScope): Job } internal typealias ActorSequenceNumber = Int @@ -48,9 +48,9 @@ internal class ActorBlockRunner( private var runningJob: Job? = null private var cancellationJob: Job? = null - override fun launchIn(scope: CoroutineScope) { + override fun launchIn(scope: CoroutineScope): Job { seq++ - scope.launch(start = CoroutineStart.UNDISPATCHED) { + return scope.launch(start = CoroutineStart.UNDISPATCHED) { cancellationJob?.cancelAndJoin() cancellationJob = null suspendCancellableCoroutine { continuation -> From abe5190234b9fc3a2bd7b7ddddbf8762d394c8be Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 08:55:35 +0900 Subject: [PATCH 082/155] CompositionLocal has been split up to improve testability. Switch the reference destination of Query and Mutation. All are set by SwrClientProvider at the same time. - LocalSwrClient - LocalQueryClient (new) - LocalMutationClient (new) --- .../query/compose/InfiniteQueryComposable.kt | 8 +++---- .../soil/query/compose/MutationComposable.kt | 4 ++-- .../soil/query/compose/QueryComposable.kt | 8 +++---- .../soil/query/compose/SwrClientProvider.kt | 22 ++++++++++++++++++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 73e2f7c..c943163 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -25,13 +25,13 @@ import soil.query.resume * @param T Type of data to retrieve. * @param S Type of parameter. * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [InfiniteQueryObject] each the query state changed. */ @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } @@ -51,14 +51,14 @@ fun rememberInfiniteQuery( * @param S Type of parameter. * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. * @param select A function to select data from [QueryChunks]. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [InfiniteQueryObject] with selected data each the query state changed. */ @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, select: (chunks: QueryChunks) -> U, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index b986d04..2c47357 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -23,13 +23,13 @@ import soil.query.reset * @param T Type of the return value from the mutation. * @param S Type of the variable to be mutated. * @param key The [MutationKey] for managing [mutation][soil.query.Mutation] associated with [id][soil.query.MutationId]. - * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalMutationClient]. * @return A [MutationObject] each the mutation state changed. */ @Composable fun rememberMutation( key: MutationKey, - client: MutationClient = LocalSwrClient.current + client: MutationClient = LocalMutationClient.current ): MutationObject { val scope = rememberCoroutineScope() val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index 345c9c3..ca4fbfd 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -22,13 +22,13 @@ import soil.query.resume * * @param T Type of data to retrieve. * @param key The [QueryKey] for managing [query][soil.query.Query]. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [QueryObject] each the query state changed. */ @Composable fun rememberQuery( key: QueryKey, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } @@ -48,14 +48,14 @@ fun rememberQuery( * @param U Type of selected data. * @param key The [QueryKey] for managing [query][soil.query.Query]. * @param select A function to select data from [T]. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [QueryObject] with selected data each the query state changed. */ @Composable fun rememberQuery( key: QueryKey, select: (T) -> U, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt index fed1bd9..0b5852c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.staticCompositionLocalOf +import soil.query.MutationClient +import soil.query.QueryClient import soil.query.SwrClient import soil.query.core.uuid @@ -21,7 +23,11 @@ fun SwrClientProvider( client: SwrClient, content: @Composable () -> Unit ) { - CompositionLocalProvider(LocalSwrClient provides client) { + CompositionLocalProvider( + LocalSwrClient provides client, + LocalQueryClient provides client, + LocalMutationClient provides client + ) { content() } DisposableEffect(client) { @@ -39,3 +45,17 @@ fun SwrClientProvider( val LocalSwrClient = staticCompositionLocalOf { error("CompositionLocal 'SwrClient' not present") } + +/** + * CompositionLocal for [QueryClient]. + */ +val LocalQueryClient = staticCompositionLocalOf { + error("CompositionLocal 'QueryClient' not present") +} + +/** + * CompositionLocal for [MutationClient]. + */ +val LocalMutationClient = staticCompositionLocalOf { + error("CompositionLocal 'MutationClient' not present") +} From 64ab05b32e531517b2483bc779abaf5ed1dc78ec Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Tue, 6 Aug 2024 08:14:25 +0900 Subject: [PATCH 083/155] Fix a NPE --- .../kotlin/soil/query/compose/runtime/Await.kt | 3 ++- .../kotlin/soil/query/compose/InfiniteQueryComposable.kt | 8 ++++---- .../kotlin/soil/query/compose/MutationComposable.kt | 5 +++-- .../kotlin/soil/query/compose/QueryComposable.kt | 9 +++++---- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt index bce6709..1c5100e 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt @@ -244,6 +244,7 @@ inline fun Await( * @param state The [QueryModel] to await. * @param content The content to display when the query is fulfilled. */ +@Suppress("UNCHECKED_CAST") @Composable fun AwaitHandler( state: QueryModel, @@ -262,7 +263,7 @@ fun AwaitHandler( else -> { if (state.isSuccess || (state.isFailure && state.dataUpdatedAt > 0)) { - content(state.data!!) + content(state.data as T) } } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index c943163..a90e48e 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -91,7 +91,7 @@ private fun QueryState>.toInfiniteObject( ) QueryStatus.Success -> InfiniteQuerySuccessObject( - data = select(data!!), + data = select(data as QueryChunks), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, error = error, @@ -106,10 +106,10 @@ private fun QueryState>.toInfiniteObject( QueryStatus.Failure -> if (dataUpdatedAt > 0) { InfiniteQueryRefreshErrorObject( - data = select(data!!), + data = select(data as QueryChunks), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, - error = error!!, + error = error as Throwable, errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, @@ -123,7 +123,7 @@ private fun QueryState>.toInfiniteObject( data = data?.let(select), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, - error = error!!, + error = error as Throwable, errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 2c47357..0b2b37c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -39,6 +39,7 @@ fun rememberMutation( } } +@Suppress("UNCHECKED_CAST") private fun MutationState.toObject( mutation: MutationRef, ): MutationObject { @@ -66,7 +67,7 @@ private fun MutationState.toObject( ) MutationStatus.Success -> MutationSuccessObject( - data = data!!, + data = data as T, dataUpdatedAt = dataUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, @@ -79,7 +80,7 @@ private fun MutationState.toObject( MutationStatus.Failure -> MutationErrorObject( data = data, dataUpdatedAt = dataUpdatedAt, - error!!, + error = error as Throwable, errorUpdatedAt = errorUpdatedAt, mutatedCount = mutatedCount, mutate = mutation::mutate, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index ca4fbfd..e3371f9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -68,6 +68,7 @@ fun rememberQuery( } } +@Suppress("UNCHECKED_CAST") private fun QueryState.toObject( query: QueryRef, select: (T) -> U @@ -86,7 +87,7 @@ private fun QueryState.toObject( ) QueryStatus.Success -> QuerySuccessObject( - data = select(data!!), + data = select(data as T), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, error = error, @@ -99,10 +100,10 @@ private fun QueryState.toObject( QueryStatus.Failure -> if (dataUpdatedAt > 0) { QueryRefreshErrorObject( - data = select(data!!), + data = select(data as T), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, - error = error!!, + error = error as Throwable, errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, @@ -114,7 +115,7 @@ private fun QueryState.toObject( data = data?.let(select), dataUpdatedAt = dataUpdatedAt, dataStaleAt = dataStaleAt, - error = error!!, + error = error as Throwable, errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, From 6b8f762f67311095ded3291c7ec2857f82f9776c Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 09:26:45 +0900 Subject: [PATCH 084/155] Switch the reference destination of Query and Mutation. Some of the switching was missing. ref: #56 --- .../src/commonMain/kotlin/soil/query/compose/Util.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt index 8123aa9..1b0642d 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/Util.kt @@ -42,12 +42,12 @@ fun rememberQueriesErrorReset( * For example, it can prevent data for a related query from becoming inactive, moving out of cache over time, such as when transitioning to a previous screen. * * @param key The [QueryKey] to keep alive. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. */ @Composable fun KeepAlive( key: QueryKey<*>, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ) { val scope = rememberCoroutineScope() remember(key) { client.getQuery(key).also { it.launchIn(scope) } } @@ -57,14 +57,14 @@ fun KeepAlive( * Keep the infinite query alive. * * @param key The [InfiniteQueryKey] to keep alive. - * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * * @see KeepAlive */ @Composable fun KeepAlive( key: InfiniteQueryKey<*, *>, - client: QueryClient = LocalSwrClient.current + client: QueryClient = LocalQueryClient.current ) { val scope = rememberCoroutineScope() remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } @@ -74,14 +74,14 @@ fun KeepAlive( * Keep the mutation alive. * * @param key The [MutationKey] to keep alive. - * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalSwrClient]. + * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalMutationClient]. * * @see KeepAlive */ @Composable fun KeepAlive( key: MutationKey<*, *>, - client: MutationClient = LocalSwrClient.current + client: MutationClient = LocalMutationClient.current ) { val scope = rememberCoroutineScope() remember(key) { client.getMutation(key).also { it.launchIn(scope) } } From a3895c057b52f7950a6625c70ad3a6216e64b696 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 10:07:56 +0900 Subject: [PATCH 085/155] Add `LoadMoreEffect` to playground sample module. We've switched the sample code to a component that allows for smoother additional infinite query retrieval. --- .../query/compose/ContentLoadMore.kt | 21 ----------- .../query/compose/LoadMoreEffect.kt | 35 +++++++++++++++++++ .../soil/kmp/screen/HelloQueryScreen.kt | 18 +++++++--- 3 files changed, 48 insertions(+), 26 deletions(-) delete mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/compose/ContentLoadMore.kt create mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/ContentLoadMore.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/ContentLoadMore.kt deleted file mode 100644 index 18e062c..0000000 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/ContentLoadMore.kt +++ /dev/null @@ -1,21 +0,0 @@ -package soil.playground.query.compose - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ContentLoadMore( - onLoadMore: suspend (param: T) -> Unit, - pageParam: T -) { - ContentLoading( - modifier = Modifier.fillMaxWidth(), - size = 20.dp - ) - LaunchedEffect(Unit) { - onLoadMore(pageParam) - } -} diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt new file mode 100644 index 0000000..e040722 --- /dev/null +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt @@ -0,0 +1,35 @@ +package soil.playground.query.compose + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(FlowPreview::class) +@Composable +inline fun LoadMoreEffect( + state: LazyListState, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + crossinline predicate: (totalCount: Int, lastIndex: Int) -> Boolean = { totalCount, lastIndex -> + totalCount > 0 && lastIndex > totalCount - 5 + } +) { + LaunchedEffect(state, loadMore, loadMoreParam) { + if (loadMoreParam == null) return@LaunchedEffect + snapshotFlow { + val totalCount = state.layoutInfo.totalItemsCount + val lastIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + predicate(totalCount, lastIndex) + } + .debounce(250.milliseconds) + .filter { it } + .collect { + loadMore(loadMoreParam) + } + } +} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt index 05e23fb..ff9c556 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -14,9 +15,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.ktor.client.plugins.ResponseException import soil.playground.Alert -import soil.playground.query.compose.ContentLoadMore import soil.playground.query.compose.ContentLoading import soil.playground.query.compose.ContentUnavailable +import soil.playground.query.compose.LoadMoreEffect import soil.playground.query.compose.PostListItem import soil.playground.query.compose.rememberGetPostsQuery import soil.playground.query.data.PageParam @@ -65,8 +66,10 @@ private fun HelloQueryContent( modifier: Modifier = Modifier ) = withAppTheme { ListSectionContainer { state -> + val lazyListState = rememberLazyListState() LazyColumn( modifier = modifier, + state = lazyListState, contentPadding = PaddingValues(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -80,15 +83,20 @@ private fun HelloQueryContent( } } val pageParam = state.loadMoreParam - if (pageParam != null) { + if (state.posts.isNotEmpty() && pageParam != null) { item(pageParam, contentType = "loading") { - ContentLoadMore( - onLoadMore = state.loadMore, - pageParam = pageParam + ContentLoading( + modifier = Modifier.fillMaxWidth(), + size = 20.dp ) } } } + LoadMoreEffect( + state = lazyListState, + loadMore = state.loadMore, + loadMoreParam = state.loadMoreParam + ) } } From a4504ca52fb87a498d2090fa06fa42d1107c907d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 10:43:33 +0900 Subject: [PATCH 086/155] Change some classes to package scope internally. Initially, I planned to increase the implementation of my own queries in other advanced packages. However, since I have not yet decided how to implement them, I will review unnecessary public scopes for now. --- .../commonMain/kotlin/soil/query/Mutation.kt | 2 +- .../src/commonMain/kotlin/soil/query/Query.kt | 4 +-- .../commonMain/kotlin/soil/query/SwrCache.kt | 25 ++++++------------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index 9a8266e..d668ea1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -12,7 +12,7 @@ import soil.query.core.Actor * * @param T Type of the return value from the mutation. */ -interface Mutation : Actor { +internal interface Mutation : Actor { /** * [State Flow][StateFlow] to receive the current state of the mutation. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 3675217..f38c474 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -13,7 +13,7 @@ import soil.query.core.Actor * * @param T Type of the return value from the query. */ -interface Query: Actor { +internal interface Query: Actor { /** * [Shared Flow][SharedFlow] to receive query [events][QueryEvent]. @@ -34,7 +34,7 @@ interface Query: Actor { /** * Events occurring in the query. */ -enum class QueryEvent { +internal enum class QueryEvent { Invalidate, Resume } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index f721ade..2c15685 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -73,12 +73,13 @@ import kotlin.time.Duration.Companion.seconds */ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClient { + @Suppress("unused") constructor(coroutineScope: CoroutineScope) : this(SwrCachePolicy(coroutineScope)) private val mutationReceiver = policy.mutationReceiver - private val mutationStore: MutableMap> = policy.mutationStore + private val mutationStore: MutableMap> = mutableMapOf() private val queryReceiver = policy.queryReceiver - private val queryStore: MutableMap> = policy.queryStore + private val queryStore: MutableMap> = mutableMapOf() private val queryCache: TimeBasedCache> = policy.queryCache private val coroutineScope: CoroutineScope = CoroutineScope( @@ -259,7 +260,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie id = id, options = options, scope = scope, - dispatch = dispatch, actor = actor, state = state, command = command @@ -611,11 +611,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie return model?.let(predicate) ?: false } - data class ManagedMutation internal constructor( + internal class ManagedMutation( val id: UniqueId, val options: MutationOptions, val scope: CoroutineScope, - val dispatch: MutationDispatch, internal val actor: ActorBlockRunner, override val state: StateFlow>, override val command: SendChannel> @@ -631,7 +630,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } - data class ManagedMutationContext( + internal class ManagedMutationContext( override val receiver: MutationReceiver, override val options: MutationOptions, override val state: MutationState, @@ -640,7 +639,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val relay: MutationErrorRelay? ) : MutationCommand.Context - data class ManagedQuery internal constructor( + internal class ManagedQuery( val id: UniqueId, val options: QueryOptions, val scope: CoroutineScope, @@ -674,7 +673,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } - data class ManagedQueryContext( + internal class ManagedQueryContext( override val receiver: QueryReceiver, override val options: QueryOptions, override val state: QueryState, @@ -721,11 +720,6 @@ data class SwrCachePolicy( */ val mutationReceiver: MutationReceiver = MutationReceiver, - /** - * Management of active [Mutation] instances. - */ - val mutationStore: MutableMap> = mutableMapOf(), - /** * Default [QueryOptions] applied to [Query]. */ @@ -736,11 +730,6 @@ data class SwrCachePolicy( */ val queryReceiver: QueryReceiver = QueryReceiver, - /** - * Management of active [Query] instances. - */ - val queryStore: MutableMap> = mutableMapOf(), - /** * Management of cached data for inactive [Query] instances. */ From 97ddf287e73c4f55656e4cfcd70b09ae26c45585 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 13:53:39 +0900 Subject: [PATCH 087/155] Introducing QueryCacheBuilder Instead of directly referencing `TimeBasedCache`, we have now implemented a wrapped `QueryCache` type using the `QueryCacheBuilder`, which provides the functionality to pre-build the cache and simplifies the process. --- .../kotlin/soil/query/MutationState.kt | 46 ++++++++- .../kotlin/soil/query/QueryCacheBuilder.kt | 95 +++++++++++++++++++ .../kotlin/soil/query/QueryState.kt | 46 ++++++++- .../commonMain/kotlin/soil/query/SwrCache.kt | 5 +- .../kotlin/soil/query/core/TimeBasedCache.kt | 2 +- 5 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index 24f11e8..556c839 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -3,14 +3,56 @@ package soil.query +import soil.query.core.epoch + /** * State for managing the execution result of [Mutation]. */ -data class MutationState( +data class MutationState internal constructor( override val data: T? = null, override val dataUpdatedAt: Long = 0, override val error: Throwable? = null, override val errorUpdatedAt: Long = 0, override val status: MutationStatus = MutationStatus.Idle, override val mutatedCount: Int = 0 -) : MutationModel +) : MutationModel { + companion object { + + /** + * Creates a new [MutationState] with the [MutationStatus.Success] status. + * + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param mutatedCount The number of times the data was mutated. + */ + fun success( + data: T, + dataUpdatedAt: Long = epoch(), + mutatedCount: Int = 1 + ): MutationState { + return MutationState( + data = data, + dataUpdatedAt = dataUpdatedAt, + status = MutationStatus.Success, + mutatedCount = mutatedCount + ) + } + + /** + * Creates a new [MutationState] with the [MutationStatus.Failure] status. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. Default is the current epoch. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch() + ): MutationState { + return MutationState( + error = error, + errorUpdatedAt = errorUpdatedAt, + status = MutationStatus.Failure + ) + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt new file mode 100644 index 0000000..d07c2ca --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt @@ -0,0 +1,95 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.TimeBasedCache +import soil.query.core.UniqueId +import soil.query.core.epoch +import kotlin.time.Duration + +typealias QueryCache = TimeBasedCache> + +/** + * Creates a new query cache. + */ +fun QueryCache(capacity: Int = 50): QueryCache { + return QueryCache(capacity) +} + +/** + * Builder for creating a query cache. + */ +interface QueryCacheBuilder { + + /** + * Puts the query data into the cache. + * + * @param id Unique identifier for the query. + * @param data Data to store. + * @param dataUpdatedAt Timestamp when the data was updated. Default is the current epoch time. + * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt] + */ + fun put( + id: QueryId, + data: T, + dataUpdatedAt: Long = epoch(), + dataStaleAt: Long = dataUpdatedAt, + ttl: Duration = Duration.INFINITE + ) + + /** + * Puts the infinite-query data into the cache. + * + * @param id Unique identifier for the infinite query. + * @param data Data to store. + * @param dataUpdatedAt Timestamp when the data was updated. Default is the current epoch time. + * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt] + */ + fun put( + id: InfiniteQueryId, + data: QueryChunks, + dataUpdatedAt: Long = epoch(), + dataStaleAt: Long = dataUpdatedAt, + ttl: Duration = Duration.INFINITE + ) +} + +/** + * Creates a new query cache with the specified [capacity] and applies the [block] to the builder. + * + * ```kotlin + * val cache = QueryCacheBuilder { + * put(GetUserKey.Id(userId), user) + * .. + * } + * ``` + */ +@Suppress("FunctionName") +fun QueryCacheBuilder(capacity: Int = 50, block: QueryCacheBuilder.() -> Unit): QueryCache { + return DefaultQueryCacheBuilder(capacity).apply(block).build() +} + +internal class DefaultQueryCacheBuilder(capacity: Int) : QueryCacheBuilder { + private val cache = QueryCache(capacity) + + override fun put( + id: QueryId, + data: T, + dataUpdatedAt: Long, + dataStaleAt: Long, + ttl: Duration + ) = cache.set(id, QueryState.success(data, dataUpdatedAt, dataStaleAt), ttl) + + override fun put( + id: InfiniteQueryId, + data: QueryChunks, + dataUpdatedAt: Long, + dataStaleAt: Long, + ttl: Duration + ) = cache.set(id, QueryState.success(data, dataUpdatedAt, dataStaleAt), ttl) + + fun build(): QueryCache { + return cache + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index cca191d..91ca310 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -3,10 +3,12 @@ package soil.query +import soil.query.core.epoch + /** * State for managing the execution result of [Query]. */ -data class QueryState( +data class QueryState internal constructor( override val data: T? = null, override val dataUpdatedAt: Long = 0, override val dataStaleAt: Long = 0, @@ -16,4 +18,44 @@ data class QueryState( override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle, override val isInvalidated: Boolean = false, override val isPlaceholderData: Boolean = false -) : QueryModel +) : QueryModel { + companion object { + + /** + * Creates a new [QueryState] with the [QueryStatus.Success] status. + * + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt]. + */ + fun success( + data: T, + dataUpdatedAt: Long = epoch(), + dataStaleAt: Long = dataUpdatedAt + ): QueryState { + return QueryState( + data = data, + dataUpdatedAt = dataUpdatedAt, + dataStaleAt = dataStaleAt, + status = QueryStatus.Success + ) + } + + /** + * Creates a new [QueryState] with the [QueryStatus.Failure] status. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. Default is the current epoch. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch() + ): QueryState { + return QueryState( + error = error, + errorUpdatedAt = errorUpdatedAt, + status = QueryStatus.Failure + ) + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 2c15685..3800ac3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -80,7 +80,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private val mutationStore: MutableMap> = mutableMapOf() private val queryReceiver = policy.queryReceiver private val queryStore: MutableMap> = mutableMapOf() - private val queryCache: TimeBasedCache> = policy.queryCache + private val queryCache: QueryCache = policy.queryCache private val coroutineScope: CoroutineScope = CoroutineScope( context = newCoroutineContext(policy.coroutineScope) @@ -733,7 +733,7 @@ data class SwrCachePolicy( /** * Management of cached data for inactive [Query] instances. */ - val queryCache: TimeBasedCache> = TimeBasedCache(DEFAULT_CAPACITY), + val queryCache: QueryCache = QueryCache(), /** * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. @@ -794,7 +794,6 @@ data class SwrCachePolicy( val gcInterval: Duration = DEFAULT_GC_INTERVAL ) { companion object { - const val DEFAULT_CAPACITY = 50 const val DEFAULT_GC_CHUNK_SIZE = 10 val DEFAULT_GC_INTERVAL: Duration = 500.milliseconds } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt index 0b11794..ec83040 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt @@ -14,7 +14,7 @@ import kotlin.time.Duration * @property time A function that returns the current time in seconds since the epoch. * @constructor Creates a new time-based cache with the specified capacity and time function. */ -class TimeBasedCache( +class TimeBasedCache internal constructor( private val capacity: Int, private val time: () -> Long = { epoch() } ) { From ee23e5412ce1d668bbf57110741e4d6af0e8d3fb Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 15:25:20 +0900 Subject: [PATCH 088/155] Tiny fix --- .../src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt index d07c2ca..4a6895a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt @@ -14,7 +14,7 @@ typealias QueryCache = TimeBasedCache> * Creates a new query cache. */ fun QueryCache(capacity: Int = 50): QueryCache { - return QueryCache(capacity) + return TimeBasedCache(capacity) } /** From d1eef3168efa48d75a70e9a5e7685b8a2b238bb6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 16:09:50 +0900 Subject: [PATCH 089/155] Add a testing module for soil-query We provide a testing package that enhances testability by introducing a mocking mechanism for executing query and mutation blocks. This capability enables the execution of tests without the need for actual API communication, thereby increasing test independence. --- buildSrc/src/main/kotlin/BuildModule.kt | 1 + settings.gradle.kts | 1 + soil-query-test/build.gradle.kts | 77 ++++++++++ soil-query-test/gradle.properties | 2 + .../soil/query/test/FakeInfiniteQueryKey.kt | 19 +++ .../kotlin/soil/query/test/FakeMutationKey.kt | 19 +++ .../kotlin/soil/query/test/FakeQueryKey.kt | 19 +++ .../kotlin/soil/query/test/TestSwrClient.kt | 102 +++++++++++++ .../soil/query/test/TestSwrClientTest.kt | 138 ++++++++++++++++++ 9 files changed, 378 insertions(+) create mode 100644 soil-query-test/build.gradle.kts create mode 100644 soil-query-test/gradle.properties create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/FakeInfiniteQueryKey.kt create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/FakeMutationKey.kt create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/FakeQueryKey.kt create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt create mode 100644 soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt diff --git a/buildSrc/src/main/kotlin/BuildModule.kt b/buildSrc/src/main/kotlin/BuildModule.kt index 9bcca7a..e85a4c2 100644 --- a/buildSrc/src/main/kotlin/BuildModule.kt +++ b/buildSrc/src/main/kotlin/BuildModule.kt @@ -2,6 +2,7 @@ val publicModules = setOf( "soil-query-core", "soil-query-compose", "soil-query-compose-runtime", + "soil-query-test", "soil-form", "soil-serialization-bundle", "soil-space" diff --git a/settings.gradle.kts b/settings.gradle.kts index 9a71e1f..c661dc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include( ":soil-query-core", ":soil-query-compose", ":soil-query-compose-runtime", + ":soil-query-test", ":soil-form", ":soil-serialization-bundle", ":soil-space" diff --git a/soil-query-test/build.gradle.kts b/soil-query-test/build.gradle.kts new file mode 100644 index 0000000..33d0d11 --- /dev/null +++ b/soil-query-test/build.gradle.kts @@ -0,0 +1,77 @@ +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) +} + +val buildTarget = the() + +kotlin { + applyDefaultHierarchyTemplate() + + jvm() + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = buildTarget.javaVersion.get().toString() + } + } + publishLibraryVariants("release") + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain.dependencies { + api(libs.kotlinx.coroutines.core) + api(projects.soilQueryCore) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(projects.internal.testing) + } + } +} + +android { + namespace = "soil.query.test" + compileSdk = buildTarget.androidCompileSdk.get() + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + defaultConfig { + minSdk = buildTarget.androidMinSdk.get() + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = buildTarget.javaVersion.get() + targetCompatibility = buildTarget.javaVersion.get() + } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } +} diff --git a/soil-query-test/gradle.properties b/soil-query-test/gradle.properties new file mode 100644 index 0000000..fe8314e --- /dev/null +++ b/soil-query-test/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=query-test +POM_NAME=query-test diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeInfiniteQueryKey.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeInfiniteQueryKey.kt new file mode 100644 index 0000000..d98f3ce --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeInfiniteQueryKey.kt @@ -0,0 +1,19 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import soil.query.InfiniteQueryKey +import soil.query.QueryReceiver + +/** + * Creates a fake infinite query key that returns the result of the given [mock] function. + */ +class FakeInfiniteQueryKey( + private val target: InfiniteQueryKey, + private val mock: FakeInfiniteQueryFetch +) : InfiniteQueryKey by target { + override val fetch: suspend QueryReceiver.(param: S) -> T = { mock(it) } +} + +typealias FakeInfiniteQueryFetch = suspend (param: S) -> T diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeMutationKey.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeMutationKey.kt new file mode 100644 index 0000000..f9323cb --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeMutationKey.kt @@ -0,0 +1,19 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import soil.query.MutationKey +import soil.query.MutationReceiver + +/** + * Creates a fake mutation key that returns the result of the given [mock] function. + */ +class FakeMutationKey( + private val target: MutationKey, + private val mock: FakeMutationMutate +) : MutationKey by target { + override val mutate: suspend MutationReceiver.(variable: S) -> T = { mock(it) } +} + +typealias FakeMutationMutate = suspend (variable: S) -> T diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeQueryKey.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeQueryKey.kt new file mode 100644 index 0000000..b121543 --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeQueryKey.kt @@ -0,0 +1,19 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import soil.query.QueryKey +import soil.query.QueryReceiver + +/** + * Creates a fake query key that returns the result of the given [mock] function. + */ +class FakeQueryKey( + private val target: QueryKey, + private val mock: FakeQueryFetch +) : QueryKey by target { + override val fetch: suspend QueryReceiver.() -> T = { mock() } +} + +typealias FakeQueryFetch = suspend () -> T diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt new file mode 100644 index 0000000..0e49c38 --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt @@ -0,0 +1,102 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.InfiniteQueryRef +import soil.query.MutationId +import soil.query.MutationKey +import soil.query.MutationRef +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryRef +import soil.query.SwrClient + +/** + * This extended interface of the [SwrClient] provides the capability to mock specific queries and mutations for the purpose of testing. + * By registering certain keys as mocks, you can control the behavior of these specific keys while the rest of the keys function normally. + * This allows for more targeted and precise testing of your application. + * + * ```kotlin + * val client = SwrCache(..) + * val testClient = client.test() + * testClient.mock(MyQueryId) { "returned fake data" } + * + * testClient.doSomething() + * ``` + */ +interface TestSwrClient : SwrClient { + + /** + * Mocks the mutation process corresponding to [MutationId]. + */ + fun mock(id: MutationId, mutate: FakeMutationMutate) + + /** + * Mocks the query process corresponding to [QueryId]. + */ + fun mock(id: QueryId, fetch: FakeQueryFetch) + + /** + * Mocks the query process corresponding to [InfiniteQueryId]. + */ + fun mock(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) +} + +/** + * Switches [SwrClient] to a test interface. + */ +fun SwrClient.test(): TestSwrClient = TestSwrClientImpl(this) + +internal class TestSwrClientImpl( + private val target: SwrClient +) : TestSwrClient, SwrClient by target { + + private val mockMutations = mutableMapOf, FakeMutationMutate<*, *>>() + private val mockQueries = mutableMapOf, FakeQueryFetch<*>>() + private val mockInfiniteQueries = mutableMapOf, FakeInfiniteQueryFetch<*, *>>() + + override fun mock(id: MutationId, mutate: FakeMutationMutate) { + mockMutations[id] = mutate + } + + override fun mock(id: QueryId, fetch: FakeQueryFetch) { + mockQueries[id] = fetch + } + + override fun mock(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) { + mockInfiniteQueries[id] = fetch + } + + @Suppress("UNCHECKED_CAST") + override fun getMutation(key: MutationKey): MutationRef { + val mock = mockMutations[key.id] as? FakeMutationMutate + return if (mock != null) { + target.getMutation(FakeMutationKey(key, mock)) + } else { + target.getMutation(key) + } + } + + @Suppress("UNCHECKED_CAST") + override fun getQuery(key: QueryKey): QueryRef { + val mock = mockQueries[key.id] as? FakeQueryFetch + return if (mock != null) { + target.getQuery(FakeQueryKey(key, mock)) + } else { + target.getQuery(key) + } + } + + @Suppress("UNCHECKED_CAST") + override fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef { + val mock = mockInfiniteQueries[key.id] as? FakeInfiniteQueryFetch + return if (mock != null) { + target.getInfiniteQuery(FakeInfiniteQueryKey(key, mock)) + } else { + target.getInfiniteQuery(key) + } + } +} diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt new file mode 100644 index 0000000..1d642a7 --- /dev/null +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt @@ -0,0 +1,138 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.completeWith +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import soil.query.InfiniteQueryCommands +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.InfiniteQueryRef +import soil.query.MutationId +import soil.query.MutationKey +import soil.query.QueryChunks +import soil.query.QueryCommands +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryRef +import soil.query.SwrCache +import soil.query.SwrCachePolicy +import soil.query.buildInfiniteQueryKey +import soil.query.buildMutationKey +import soil.query.buildQueryKey +import soil.query.mutate +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TestSwrClientTest : UnitTest() { + + @Test + fun testMutation() = runTest { + val client = SwrCache( + policy = SwrCachePolicy( + coroutineScope = backgroundScope, + mainDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + ) + val testClient = client.test().apply { + mock(ExampleMutationKey.Id) { + "Hello, World!" + } + } + val key = ExampleMutationKey() + val mutation = testClient.getMutation(key).also { it.launchIn(backgroundScope) } + mutation.mutate(0) + assertEquals("Hello, World!", mutation.state.value.data) + } + + @Test + fun testQuery() = runTest { + val client = SwrCache( + policy = SwrCachePolicy( + coroutineScope = backgroundScope, + mainDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + ) + val testClient = client.test().apply { + mock(ExampleQueryKey.Id) { + "Hello, World!" + } + } + val key = ExampleQueryKey() + val query = testClient.getQuery(key).also { it.launchIn(backgroundScope) } + query.test() + assertEquals("Hello, World!", query.state.value.data) + } + + @Test + fun testInfiniteQuery() = runTest { + val client = SwrCache( + policy = SwrCachePolicy( + coroutineScope = backgroundScope, + mainDispatcher = UnconfinedTestDispatcher(testScheduler) + ) + ) + val testClient = client.test().apply { + mock(ExampleInfiniteQueryKey.Id) { + "Hello, World!" + } + } + val key = ExampleInfiniteQueryKey() + val query = testClient.getInfiniteQuery(key).also { it.launchIn(backgroundScope) } + query.test() + assertEquals("Hello, World!", query.state.value.data?.first()?.data) + } +} + +private class ExampleMutationKey : MutationKey by buildMutationKey( + id = Id, + mutate = { + error("Not implemented") + } +) { + object Id : MutationId( + namespace = "mutation/example" + ) +} + +private class ExampleQueryKey : QueryKey by buildQueryKey( + id = Id, + fetch = { + error("Not implemented") + } +) { + object Id : QueryId( + namespace = "query/example" + ) +} + +private class ExampleInfiniteQueryKey : InfiniteQueryKey by buildInfiniteQueryKey( + id = Id, + fetch = { + error("Not implemented") + }, + initialParam = { 0 }, + loadMoreParam = { null } +) { + object Id : InfiniteQueryId( + namespace = "infinite-query/example" + ) +} + +private suspend fun QueryRef.test(): T { + val deferred = CompletableDeferred() + send(QueryCommands.Connect(key, callback = deferred::completeWith)) + return deferred.await() +} + +private suspend fun InfiniteQueryRef.test(): QueryChunks { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Connect(key, callback = deferred::completeWith)) + return deferred.await() +} From 5de57fdaf3b564c374204772b0dacf355b619c4f Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 11 Aug 2024 16:47:27 +0900 Subject: [PATCH 090/155] Use `completeWith` instead `toResultCallback` of custom extension. I had implemented an unnecessary custom extension, so I switched standard `completeWith` extension function. --- .../commonMain/kotlin/soil/query/MutationRef.kt | 4 ++-- .../kotlin/soil/query/SwrInfiniteQuery.kt | 4 ++-- .../src/commonMain/kotlin/soil/query/SwrQuery.kt | 4 ++-- .../soil/query/core/CompletableDeferredExt.kt | 14 -------------- 4 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 3dcac0a..65a71c3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -4,9 +4,9 @@ package soil.query import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor -import soil.query.core.toResultCallback /** * A reference to a Mutation for [MutationKey]. @@ -34,7 +34,7 @@ interface MutationRef : Actor { */ suspend fun MutationRef.mutate(variable: S): T { val deferred = CompletableDeferred() - send(MutationCommands.Mutate(key, variable, state.value.revision, deferred.toResultCallback())) + send(MutationCommands.Mutate(key, variable, state.value.revision, deferred::completeWith)) return deferred.await() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt index 20b8893..6bcf293 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -7,9 +7,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import soil.query.core.toResultCallback internal class SwrInfiniteQuery( override val key: InfiniteQueryKey, @@ -44,7 +44,7 @@ internal class SwrInfiniteQuery( */ internal suspend fun InfiniteQueryRef.prefetch(): Boolean { val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) return try { deferred.await() true diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt index 171676f..034b730 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt @@ -6,9 +6,9 @@ package soil.query import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import soil.query.core.toResultCallback import kotlin.coroutines.cancellation.CancellationException internal class SwrQuery( @@ -44,7 +44,7 @@ internal class SwrQuery( */ internal suspend fun QueryRef.prefetch(): Boolean { val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, state.value.revision, deferred.toResultCallback())) + send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) return try { deferred.await() true diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt deleted file mode 100644 index 8b3c843..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/CompletableDeferredExt.kt +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query.core - -import kotlinx.coroutines.CompletableDeferred - -internal fun CompletableDeferred.toResultCallback(): (Result) -> Unit { - return { result -> - result - .onSuccess { complete(it) } - .onFailure { completeExceptionally(it) } - } -} From 1985aa998439aca1c1ebac3e9c7b57544a5a34d8 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 17 Aug 2024 15:03:35 +0900 Subject: [PATCH 091/155] Add a Ktor receiver module for soil-query We have developed a new Ktor receiver module for the Soil Query application. This module has been reimplemented from the existing Playground module and is now available as a public module. By integrating this module into your project, you can seamlessly incorporate the Ktor client functionalities in Soil Query. --- build.gradle.kts | 2 +- buildSrc/src/main/kotlin/BuildModule.kt | 15 +-- gradle/libs.versions.toml | 2 +- internal/playground/build.gradle.kts | 1 + .../soil/playground/query/KtorReceiver.kt | 9 -- .../query/key/albums/GetAlbumPhotosKey.kt | 8 +- .../query/key/albums/GetAlbumsKey.kt | 8 +- .../query/key/posts/CreatePostKey.kt | 10 +- .../query/key/posts/DeletePostKey.kt | 8 +- .../query/key/posts/GetPostCommentsKey.kt | 8 +- .../playground/query/key/posts/GetPostKey.kt | 8 +- .../playground/query/key/posts/GetPostsKey.kt | 8 +- .../query/key/posts/UpdatePostKey.kt | 8 +- .../query/key/users/GetUserAlbumsKey.kt | 8 +- .../playground/query/key/users/GetUserKey.kt | 8 +- .../query/key/users/GetUserPostsKey.kt | 8 +- .../query/key/users/GetUserTodosKey.kt | 10 +- .../playground/query/key/users/GetUsersKey.kt | 8 +- sample/composeApp/build.gradle.kts | 1 + .../kotlin/soil/kmp/SoilApplication.kt | 2 +- .../composeApp/src/desktopMain/kotlin/main.kt | 2 +- .../src/iosMain/kotlin/MainViewController.kt | 2 +- .../composeApp/src/wasmJsMain/kotlin/main.kt | 2 +- settings.gradle.kts | 1 + soil-query-receivers/ktor/build.gradle.kts | 71 +++++++++++ soil-query-receivers/ktor/gradle.properties | 2 + .../soil/query/receivers/ktor/KtorReceiver.kt | 70 +++++++++++ .../soil/query/receivers/ktor/builders.kt | 112 ++++++++++++++++++ 28 files changed, 312 insertions(+), 90 deletions(-) delete mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/KtorReceiver.kt create mode 100644 soil-query-receivers/ktor/build.gradle.kts create mode 100644 soil-query-receivers/ktor/gradle.properties create mode 100644 soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt create mode 100644 soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt diff --git a/build.gradle.kts b/build.gradle.kts index 28e5589..32981b8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,7 @@ allprojects { endWithNewline() } - if (project.name in publicModules) { + if (project.path in publicModules) { kotlin { // ref. https://github.com/diffplug/spotless/tree/main/plugin-gradle#how-can-i-enforce-formatting-gradually-aka-ratchet // ratchetFrom = "origin/main" diff --git a/buildSrc/src/main/kotlin/BuildModule.kt b/buildSrc/src/main/kotlin/BuildModule.kt index e85a4c2..0dfb7ac 100644 --- a/buildSrc/src/main/kotlin/BuildModule.kt +++ b/buildSrc/src/main/kotlin/BuildModule.kt @@ -1,9 +1,10 @@ val publicModules = setOf( - "soil-query-core", - "soil-query-compose", - "soil-query-compose-runtime", - "soil-query-test", - "soil-form", - "soil-serialization-bundle", - "soil-space" + ":soil-query-core", + ":soil-query-compose", + ":soil-query-compose-runtime", + ":soil-query-receivers:ktor", + ":soil-query-test", + ":soil-form", + ":soil-serialization-bundle", + ":soil-space" ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d3938c..ab699b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ junit = "4.13.2" kotlin = "1.9.23" kotlinx-coroutines = "1.8.0" kotlinx-serialization = "1.6.3" -ktor = "3.0.0-wasm2" +ktor = "3.0.0-beta-2" maven-publish = "0.28.0" robolectric = "4.12.2" spotless = "6.25.0" diff --git a/internal/playground/build.gradle.kts b/internal/playground/build.gradle.kts index 5136f1e..dd99cff 100644 --- a/internal/playground/build.gradle.kts +++ b/internal/playground/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { implementation(projects.soilQueryCore) implementation(projects.soilQueryCompose) implementation(projects.soilQueryComposeRuntime) + implementation(projects.soilQueryReceivers.ktor) implementation(projects.soilForm) implementation(projects.soilSpace) implementation(compose.runtime) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/KtorReceiver.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/KtorReceiver.kt deleted file mode 100644 index be41bba..0000000 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/KtorReceiver.kt +++ /dev/null @@ -1,9 +0,0 @@ -package soil.playground.query - -import io.ktor.client.HttpClient -import soil.query.MutationReceiver -import soil.query.QueryReceiver - -class KtorReceiver( - val client: HttpClient -) : QueryReceiver, MutationReceiver diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt index d8ea3c1..2235fca 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.albums import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.PageParam import soil.playground.query.data.Photos import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetAlbumPhotosKey(albumId: Int) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetAlbumPhotosKey(albumId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(albumId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/albums/$albumId/photos") { + get("https://jsonplaceholder.typicode.com/albums/$albumId/photos") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt index 108c7ff..07f291c 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.albums import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.Albums import soil.playground.query.data.PageParam import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetAlbumsKey : InfiniteQueryKey by buildInfiniteQueryKey( +class GetAlbumsKey : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/albums") { + get("https://jsonplaceholder.typicode.com/albums") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt index 9619346..e65399b 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt @@ -3,22 +3,20 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody -import soil.playground.query.KtorReceiver import soil.playground.query.data.Post import soil.query.MutationKey import soil.query.QueryEffect -import soil.query.buildMutationKey +import soil.query.receivers.ktor.buildKtorMutationKey -class CreatePostKey : MutationKey by buildMutationKey( +class CreatePostKey : MutationKey by buildKtorMutationKey( /* id = MutationId.auto(), */ mutate = { body -> - this as KtorReceiver - client.post("https://jsonplaceholder.typicode.com/posts") { + post("https://jsonplaceholder.typicode.com/posts") { setBody(body) }.body() } ) { - override fun onQueryUpdate(variable: PostForm, data: Post): QueryEffect= { + override fun onQueryUpdate(variable: PostForm, data: Post): QueryEffect = { invalidateQueriesBy(GetPostsKey.Id()) } } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt index ca8f634..4fe84c2 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt @@ -1,16 +1,14 @@ package soil.playground.query.key.posts import io.ktor.client.request.delete -import soil.playground.query.KtorReceiver import soil.query.MutationKey import soil.query.QueryEffect -import soil.query.buildMutationKey +import soil.query.receivers.ktor.buildKtorMutationKey -class DeletePostKey : MutationKey by buildMutationKey( +class DeletePostKey : MutationKey by buildKtorMutationKey( /* id = MutationId.auto(), */ mutate = { postId -> - this as KtorReceiver - client.delete("https://jsonplaceholder.typicode.com/posts/$postId") + delete("https://jsonplaceholder.typicode.com/posts/$postId") } ) { override fun onQueryUpdate(variable: Int, data: Unit): QueryEffect = { diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt index 70af328..083c862 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.Comments import soil.playground.query.data.PageParam import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetPostCommentsKey(private val postId: Int) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetPostCommentsKey(private val postId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(postId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/posts/$postId/comments") { + get("https://jsonplaceholder.typicode.com/posts/$postId/comments") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt index e972e83..b4d9806 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt @@ -2,19 +2,17 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.get -import soil.playground.query.KtorReceiver import soil.playground.query.data.Post import soil.query.QueryId import soil.query.QueryKey import soil.query.QueryPlaceholderData -import soil.query.buildQueryKey import soil.query.chunkedData +import soil.query.receivers.ktor.buildKtorQueryKey -class GetPostKey(private val postId: Int) : QueryKey by buildQueryKey( +class GetPostKey(private val postId: Int) : QueryKey by buildKtorQueryKey( id = Id(postId), fetch = { - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/posts/$postId").body() + get("https://jsonplaceholder.typicode.com/posts/$postId").body() } ) { diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt index 0edc3af..00ba1c9 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt @@ -3,21 +3,19 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.PageParam import soil.playground.query.data.Posts import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey // NOTE: userId // Filtering resources // ref. https://jsonplaceholder.typicode.com/guide/ -class GetPostsKey(userId: Int? = null) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetPostsKey(userId: Int? = null) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(userId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/posts") { + get("https://jsonplaceholder.typicode.com/posts") { parameter("_start", param.offset) parameter("_limit", param.limit) if (userId != null) { diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt index 7cabd9a..55f232c 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.put import io.ktor.client.request.setBody -import soil.playground.query.KtorReceiver import soil.playground.query.data.Post import soil.query.MutationKey import soil.query.QueryEffect -import soil.query.buildMutationKey import soil.query.modifyData +import soil.query.receivers.ktor.buildKtorMutationKey -class UpdatePostKey : MutationKey by buildMutationKey( +class UpdatePostKey : MutationKey by buildKtorMutationKey( /* id = MutationId.auto(), */ mutate = { body -> - this as KtorReceiver - client.put("https://jsonplaceholder.typicode.com/posts/${body.id}") { + put("https://jsonplaceholder.typicode.com/posts/${body.id}") { setBody(body) }.body() } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt index a0f8e43..1a11898 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.Albums import soil.playground.query.data.PageParam import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetUserAlbumsKey(userId: Int) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetUserAlbumsKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(userId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/users/$userId/albums") { + get("https://jsonplaceholder.typicode.com/users/$userId/albums") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt index 5940f82..01aab05 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt @@ -2,19 +2,17 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get -import soil.playground.query.KtorReceiver import soil.playground.query.data.User import soil.query.QueryId import soil.query.QueryKey import soil.query.QueryPlaceholderData -import soil.query.buildQueryKey import soil.query.chunkedData +import soil.query.receivers.ktor.buildKtorQueryKey -class GetUserKey(private val userId: Int) : QueryKey by buildQueryKey( +class GetUserKey(private val userId: Int) : QueryKey by buildKtorQueryKey( id = Id(userId), fetch = { - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/users/$userId").body() + get("https://jsonplaceholder.typicode.com/users/$userId").body() } ) { diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt index 71a1ba9..34ccbec 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.PageParam import soil.playground.query.data.Posts import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetUserPostsKey(userId: Int) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetUserPostsKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(userId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/users/$userId/posts") { + get("https://jsonplaceholder.typicode.com/users/$userId/posts") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt index f4f175e..8760397 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt @@ -3,20 +3,16 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import io.ktor.client.request.url -import soil.playground.query.KtorReceiver import soil.playground.query.data.PageParam import soil.playground.query.data.Todos -import soil.playground.query.data.Users import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetUserTodosKey(userId: Int) : InfiniteQueryKey by buildInfiniteQueryKey( +class GetUserTodosKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(userId), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/users/$userId/todos") { + get("https://jsonplaceholder.typicode.com/users/$userId/todos") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt index b92cc6c..9df94fb 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt @@ -3,18 +3,16 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter -import soil.playground.query.KtorReceiver import soil.playground.query.data.PageParam import soil.playground.query.data.Users import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.buildInfiniteQueryKey +import soil.query.receivers.ktor.buildKtorInfiniteQueryKey -class GetUsersKey : InfiniteQueryKey by buildInfiniteQueryKey( +class GetUsersKey : InfiniteQueryKey by buildKtorInfiniteQueryKey( id = Id(), fetch = { param -> - this as KtorReceiver - client.get("https://jsonplaceholder.typicode.com/users") { + get("https://jsonplaceholder.typicode.com/users") { parameter("_start", param.offset) parameter("_limit", param.limit) }.body() diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 2f210d1..4267a84 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -70,6 +70,7 @@ kotlin { implementation(projects.soilQueryCore) implementation(projects.soilQueryCompose) implementation(projects.soilQueryComposeRuntime) + implementation(projects.soilQueryReceivers.ktor) implementation(projects.soilForm) implementation(projects.soilSpace) implementation(projects.internal.playground) diff --git a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt index 2c6dd76..4bd22a2 100644 --- a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt +++ b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt @@ -5,7 +5,6 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.playground.query.KtorReceiver import soil.query.AndroidMemoryPressure import soil.query.AndroidNetworkConnectivity import soil.query.AndroidWindowVisibility @@ -13,6 +12,7 @@ import soil.query.SwrCache import soil.query.SwrCachePolicy import soil.query.SwrCacheScope import soil.query.SwrClient +import soil.query.receivers.ktor.KtorReceiver class SoilApplication : Application(), SwrClientFactory { diff --git a/sample/composeApp/src/desktopMain/kotlin/main.kt b/sample/composeApp/src/desktopMain/kotlin/main.kt index 2ce894d..f0f1180 100644 --- a/sample/composeApp/src/desktopMain/kotlin/main.kt +++ b/sample/composeApp/src/desktopMain/kotlin/main.kt @@ -4,11 +4,11 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.playground.query.KtorReceiver import soil.query.SwrCache import soil.query.SwrCachePolicy import soil.query.SwrCacheScope import soil.query.compose.SwrClientProvider +import soil.query.receivers.ktor.KtorReceiver private val ktorReceiver: KtorReceiver = KtorReceiver(client = createHttpClient { install(ContentNegotiation) { diff --git a/sample/composeApp/src/iosMain/kotlin/MainViewController.kt b/sample/composeApp/src/iosMain/kotlin/MainViewController.kt index 12ba97c..eafee18 100644 --- a/sample/composeApp/src/iosMain/kotlin/MainViewController.kt +++ b/sample/composeApp/src/iosMain/kotlin/MainViewController.kt @@ -3,13 +3,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.playground.query.KtorReceiver import soil.query.IosMemoryPressure import soil.query.IosWindowVisibility import soil.query.SwrCache import soil.query.SwrCachePolicy import soil.query.SwrCacheScope import soil.query.compose.SwrClientProvider +import soil.query.receivers.ktor.KtorReceiver private val ktorReceiver: KtorReceiver = KtorReceiver(client = createHttpClient { install(ContentNegotiation) { diff --git a/sample/composeApp/src/wasmJsMain/kotlin/main.kt b/sample/composeApp/src/wasmJsMain/kotlin/main.kt index 6c9ce09..b45deec 100644 --- a/sample/composeApp/src/wasmJsMain/kotlin/main.kt +++ b/sample/composeApp/src/wasmJsMain/kotlin/main.kt @@ -4,13 +4,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.playground.query.KtorReceiver import soil.query.SwrCache import soil.query.SwrCachePolicy import soil.query.SwrCacheScope import soil.query.WasmJsNetworkConnectivity import soil.query.WasmJsWindowVisibility import soil.query.compose.SwrClientProvider +import soil.query.receivers.ktor.KtorReceiver private val ktorReceiver = KtorReceiver(client = createHttpClient { install(ContentNegotiation) { diff --git a/settings.gradle.kts b/settings.gradle.kts index c661dc5..a19456d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include( ":soil-query-core", ":soil-query-compose", ":soil-query-compose-runtime", + ":soil-query-receivers:ktor", ":soil-query-test", ":soil-form", ":soil-serialization-bundle", diff --git a/soil-query-receivers/ktor/build.gradle.kts b/soil-query-receivers/ktor/build.gradle.kts new file mode 100644 index 0000000..195a1ad --- /dev/null +++ b/soil-query-receivers/ktor/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.maven.publish) + alias(libs.plugins.dokka) +} + +val buildTarget = the() + +kotlin { + applyDefaultHierarchyTemplate() + + jvm() + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = buildTarget.javaVersion.get().toString() + } + } + publishLibraryVariants("release") + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain.dependencies { + implementation(projects.soilQueryCore) + implementation(libs.ktor.core) + } + } +} + +android { + namespace = "soil.query.receivers.ktor" + compileSdk = buildTarget.androidCompileSdk.get() + + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") + + defaultConfig { + minSdk = buildTarget.androidMinSdk.get() + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = buildTarget.javaVersion.get() + targetCompatibility = buildTarget.javaVersion.get() + } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } +} diff --git a/soil-query-receivers/ktor/gradle.properties b/soil-query-receivers/ktor/gradle.properties new file mode 100644 index 0000000..5b31ca2 --- /dev/null +++ b/soil-query-receivers/ktor/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=query-receivers-ktor +POM_NAME=query-receivers-ktor diff --git a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt new file mode 100644 index 0000000..1e14475 --- /dev/null +++ b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt @@ -0,0 +1,70 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.receivers.ktor + +import io.ktor.client.HttpClient +import soil.query.MutationReceiver +import soil.query.QueryReceiver + +/** + * A receiver that uses Ktor to send queries and mutations. + * + * The `Receiver` interface can be specified as a parameter for [soil.query.SwrCachePolicy] when creating an instance of [soil.query.SwrCache]. + * + * ```kotlin + * val ktorClient = HttpClient { + * install(ContentNegotiation) { + * json() + * } + * } + * val receiver = KtorReceiver(client = ktorClient) + * val swrCache = SwrCache(policy = SwrCachePolicy( + * .., + * queryReceiver = receiver, + * mutationReceiver = receiver, + * .. + * )) + * ``` + * + * If you use multiple receivers, create a single receiver that inherits from each of them. + * + * ```kotlin + * class CustomReceiver( + * ktorClient: HttpClient, + * anotherClient: AnotherClient + * ): KtorReceiver by KtorReceiver(ktorClient), AnotherReceiver by AnotherReceiver(anotherClient) + * ``` + * + * By setting the receiver, you can use the builder functions designed for [KtorReceiver] when defining query and mutation keys. + * The `fetch/mutate` function block changes to the receiver type of the [HttpClient], allowing direct calls to [HttpClient]'s API. + * + * ```kotlin + * class MyQueryKey: QueryKey by buildKtorQueryKey( + * id = QueryId("myQuery"), + * fetch = { /* HttpClient.() -> String */ + * get("https://example.com").body() + * } + * ) + * ``` + * + * @see buildKtorQueryKey + * @see buildKtorInfiniteQueryKey + * @see buildKtorMutationKey + */ +interface KtorReceiver : QueryReceiver, MutationReceiver { + val ktorClient: HttpClient +} + +/** + * Creates a new receiver that uses the given [client] to send queries and mutations. + * + * @param client The Ktor client to use. + */ +fun KtorReceiver(client: HttpClient): KtorReceiver { + return KtorReceiverImpl(client) +} + +internal class KtorReceiverImpl( + override val ktorClient: HttpClient +) : KtorReceiver diff --git a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt new file mode 100644 index 0000000..7558ac9 --- /dev/null +++ b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt @@ -0,0 +1,112 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.receivers.ktor + +import io.ktor.client.HttpClient +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.MutationId +import soil.query.MutationKey +import soil.query.QueryChunks +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.buildInfiniteQueryKey +import soil.query.buildMutationKey +import soil.query.buildQueryKey + +/** + * A delegation function to build a [MutationKey] for Ktor. + * + * ```kotlin + * class CreatePostKey : MutationKey by buildKtorMutationKey( + * mutate = { body -> + * post("https://jsonplaceholder.typicode.com/posts") { + * setBody(body) + * }.body() + * } + * ) + * ``` + * + * **Note:** + * [KtorReceiver] is required to use the builder functions designed for [KtorReceiver]. + * + * @param id The identifier of the mutation key. + * @param mutate The mutation function that sends a request to the server. + */ +inline fun buildKtorMutationKey( + id: MutationId = MutationId.auto(), + crossinline mutate: suspend HttpClient.(variable: S) -> T +): MutationKey = buildMutationKey( + id = id, + mutate = { + check(this is KtorReceiver) { "KtorReceiver isn't available. Did you forget to set it up?" } + with(ktorClient) { mutate(it) } + } +) + +/** + * A delegation function to build a [QueryKey] for Ktor. + * + * ```kotlin + * class GetPostKey(private val postId: Int) : QueryKey by buildKtorQueryKey( + * id = .., + * fetch = { + * get("https://jsonplaceholder.typicode.com/posts/$postId").body() + * } + * ) + * ``` + * + * **Note:** + * [KtorReceiver] is required to use the builder functions designed for [KtorReceiver]. + * + * @param id The identifier of the query key. + * @param fetch The query function that sends a request to the server. + */ +inline fun buildKtorQueryKey( + id: QueryId, + crossinline fetch: suspend HttpClient.() -> T +): QueryKey = buildQueryKey( + id = id, + fetch = { + check(this is KtorReceiver) { "KtorReceiver isn't available. Did you forget to set it up?" } + with(ktorClient) { fetch() } + } +) + +/** + * A delegation function to build an [InfiniteQueryKey] for Ktor. + * + * ```kotlin + * class GetUserPostsKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( + * id = .., + * fetch = { param -> + * get("https://jsonplaceholder.typicode.com/users/$userId/posts") { + * parameter("_start", param.offset) + * parameter("_limit", param.limit) + * }.body() + * }, + * ... + * ) + * ``` + * + * **Note:** + * [KtorReceiver] is required to use the builder functions designed for [KtorReceiver]. + * + * @param id The identifier of the infinite query key. + * @param fetch The query function that sends a request to the server. + */ +inline fun buildKtorInfiniteQueryKey( + id: InfiniteQueryId, + crossinline fetch: suspend HttpClient.(param: S) -> T, + noinline initialParam: () -> S, + noinline loadMoreParam: (QueryChunks) -> S? +): InfiniteQueryKey = buildInfiniteQueryKey( + id = id, + fetch = { param -> + check(this is KtorReceiver) { "KtorReceiver isn't available. Did you forget to set it up?" } + with(ktorClient) { fetch(param) } + }, + initialParam = initialParam, + loadMoreParam = loadMoreParam +) From d132158ce570727b3e18d043d41e8b7002e16e7a Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 17 Aug 2024 15:49:52 +0900 Subject: [PATCH 092/155] Improve the readability of the SwrCache class The scheduling logic for batch tasks, originally implemented in the SwrCache class, has been extracted into a new class called BatchScheduler. This separation of concerns allows for better maintainability and reusability of code. Additionally, the SwrCachePolicy class has been moved to its own file. This separation of classes into individual files makes the codebase more navigable and easier to understand. refs: #44 --- .../commonMain/kotlin/soil/query/SwrCache.kt | 138 +----------------- .../kotlin/soil/query/SwrCachePolicy.kt | 121 +++++++++++++++ .../kotlin/soil/query/core/BatchScheduler.kt | 75 ++++++++++ 3 files changed, 201 insertions(+), 133 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 3800ac3..e57d818 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -3,7 +3,6 @@ package soil.query -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,18 +20,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_CHUNK_SIZE -import soil.query.SwrCachePolicy.Companion.DEFAULT_GC_INTERVAL import soil.query.core.ActorBlockRunner import soil.query.core.ActorSequenceNumber +import soil.query.core.BatchScheduler import soil.query.core.ErrorRecord -import soil.query.core.ErrorRelay import soil.query.core.MemoryPressure import soil.query.core.MemoryPressureLevel import soil.query.core.NetworkConnectivity @@ -42,13 +38,9 @@ import soil.query.core.TimeBasedCache import soil.query.core.UniqueId import soil.query.core.WindowVisibility import soil.query.core.WindowVisibilityEvent -import soil.query.core.chunkedWithTimeout import soil.query.core.epoch import soil.query.core.vvv import kotlin.coroutines.CoroutineContext -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * Implementation of the [SwrClient] interface. @@ -81,25 +73,16 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private val queryReceiver = policy.queryReceiver private val queryStore: MutableMap> = mutableMapOf() private val queryCache: QueryCache = policy.queryCache - + private val batchScheduler: BatchScheduler = policy.batchScheduler private val coroutineScope: CoroutineScope = CoroutineScope( context = newCoroutineContext(policy.coroutineScope) ) - private val gcFlow: MutableSharedFlow<() -> Unit> = MutableSharedFlow() - private var mountedIds: Set = emptySet() private var mountedScope: CoroutineScope? = null init { - gcFlow - .chunkedWithTimeout(size = policy.gcChunkSize, duration = policy.gcInterval) - .onEach { actions -> - withContext(policy.mainDispatcher) { - actions.forEach { it() } - } - } - .launchIn(coroutineScope) + batchScheduler.start(coroutineScope) } /** @@ -239,7 +222,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie scope = scope, options = options, onTimeout = { seq -> - scope.launch { gcFlow.emit { closeMutation(id, seq) } } + scope.launch { batchScheduler.post { closeMutation(id, seq) } } } ) { for (c in command) { @@ -316,7 +299,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie scope = scope, options = options, onTimeout = { seq -> - scope.launch { gcFlow.emit { closeQuery(id, seq) } } + scope.launch { batchScheduler.post { closeQuery(id, seq) } } }, ) { for (c in command) { @@ -688,117 +671,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } -/** - * Policy for the [SwrCache]. - */ -data class SwrCachePolicy( - - /** - * [CoroutineScope] for coroutines executed on the [SwrCache]. - * - * **Note:** - * The [SwrCache] internals are not thread-safe. - * Always use a scoped implementation such as [SwrCacheScope] or [kotlinx.coroutines.MainScope] with limited concurrency. - */ - val coroutineScope: CoroutineScope, - - /** - * [CoroutineDispatcher] for the main thread. - * - * **Note:** - * Some garbage collection processes are safely synchronized with the caller using the main thread. - */ - val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, - - /** - * Default [MutationOptions] applied to [Mutation]. - */ - val mutationOptions: MutationOptions = MutationOptions, - - /** - * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. - */ - val mutationReceiver: MutationReceiver = MutationReceiver, - - /** - * Default [QueryOptions] applied to [Query]. - */ - val queryOptions: QueryOptions = QueryOptions, - - /** - * Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. - */ - val queryReceiver: QueryReceiver = QueryReceiver, - - /** - * Management of cached data for inactive [Query] instances. - */ - val queryCache: QueryCache = QueryCache(), - - /** - * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. - */ - val errorRelay: ErrorRelay? = null, - - /** - * Receiving events of memory pressure. - */ - val memoryPressure: MemoryPressure = MemoryPressure.Unsupported, - - /** - * Receiving events of network connectivity. - */ - val networkConnectivity: NetworkConnectivity = NetworkConnectivity.Unsupported, - - /** - * The delay time to resume queries after network connectivity is reconnected. - * - * **Note:** - * This setting is only effective when [networkConnectivity] is available. - */ - val networkResumeAfterDelay: Duration = 2.seconds, - - /** - * The specified filter to resume queries after network connectivity is reconnected. - * - * **Note:** - * This setting is only effective when [networkConnectivity] is available. - */ - val networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( - predicate = { it.isFailure } - ), - - /** - * Receiving events of window visibility. - */ - val windowVisibility: WindowVisibility = WindowVisibility.Unsupported, - - /** - * The specified filter to resume queries after window visibility is refocused. - * - * **Note:** - * This setting is only effective when [windowVisibility] is available. - */ - val windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( - predicate = { it.isStaled() } - ), - - /** - * The chunk size for garbage collection. Default is [DEFAULT_GC_CHUNK_SIZE]. - */ - val gcChunkSize: Int = DEFAULT_GC_CHUNK_SIZE, - - /** - * The interval for garbage collection. Default is [DEFAULT_GC_INTERVAL]. - */ - val gcInterval: Duration = DEFAULT_GC_INTERVAL -) { - companion object { - const val DEFAULT_GC_CHUNK_SIZE = 10 - val DEFAULT_GC_INTERVAL: Duration = 500.milliseconds - } -} - /** * [CoroutineScope] with limited concurrency for [SwrCache]. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt new file mode 100644 index 0000000..8d19f1f --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt @@ -0,0 +1,121 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import soil.query.core.BatchScheduler +import soil.query.core.ErrorRelay +import soil.query.core.MemoryPressure +import soil.query.core.NetworkConnectivity +import soil.query.core.WindowVisibility +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Policy for the [SwrCache]. + */ +class SwrCachePolicy( + + /** + * [CoroutineScope] for coroutines executed on the [SwrCache]. + * + * **Note:** + * The [SwrCache] internals are not thread-safe. + * Always use a scoped implementation such as [SwrCacheScope] or [kotlinx.coroutines.MainScope] with limited concurrency. + */ + val coroutineScope: CoroutineScope, + + /** + * [CoroutineDispatcher] for the main thread. + * + * **Note:** + * Some processes are safely synchronized with the caller using the main thread. + * When unit testing, please replace it with a test Dispatcher. + */ + val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + + /** + * Default [MutationOptions] applied to [Mutation]. + */ + val mutationOptions: MutationOptions = MutationOptions, + + /** + * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. + */ + val mutationReceiver: MutationReceiver = MutationReceiver, + + /** + * Default [QueryOptions] applied to [Query]. + */ + val queryOptions: QueryOptions = QueryOptions, + + /** + * Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. + */ + val queryReceiver: QueryReceiver = QueryReceiver, + + /** + * Management of cached data for inactive [Query] instances. + */ + val queryCache: QueryCache = QueryCache(), + + /** + * Scheduler for batching tasks. + * + * **Note:** + * This is used for internal processes such as moving inactive query caches. + * Please avoid changing this unless you need to substitute it for testing purposes. + */ + val batchScheduler: BatchScheduler = BatchScheduler.default(mainDispatcher), + + /** + * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. + */ + val errorRelay: ErrorRelay? = null, + + /** + * Receiving events of memory pressure. + */ + val memoryPressure: MemoryPressure = MemoryPressure, + + /** + * Receiving events of network connectivity. + */ + val networkConnectivity: NetworkConnectivity = NetworkConnectivity, + + /** + * The delay time to resume queries after network connectivity is reconnected. + * + * **Note:** + * This setting is only effective when [networkConnectivity] is available. + */ + val networkResumeAfterDelay: Duration = 2.seconds, + + /** + * The specified filter to resume queries after network connectivity is reconnected. + * + * **Note:** + * This setting is only effective when [networkConnectivity] is available. + */ + val networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + predicate = { it.isFailure } + ), + + /** + * Receiving events of window visibility. + */ + val windowVisibility: WindowVisibility = WindowVisibility, + + /** + * The specified filter to resume queries after window visibility is refocused. + * + * **Note:** + * This setting is only effective when [windowVisibility] is available. + */ + val windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + predicate = { it.isStaled() } + ) +) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt new file mode 100644 index 0000000..25a26f1 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt @@ -0,0 +1,75 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Scheduler for batching tasks. + */ +interface BatchScheduler { + + /** + * Start the scheduler. + */ + fun start(scope: CoroutineScope): Job + + /** + * Post a task to the scheduler. + */ + suspend fun post(task: BatchTask) + + companion object { + + /** + * Create a new [BatchScheduler] with built-in scheduler implementation. + * + * @param dispatcher Coroutine dispatcher for the main thread. + * @param interval Interval for batching tasks. + * @param chunkSize Maximum number of tasks to execute in a batch. + */ + fun default( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + interval: Duration = 500.milliseconds, + chunkSize: Int = 10 + ): BatchScheduler { + return DefaultBatchScheduler(dispatcher, interval, chunkSize) + } + } +} + +typealias BatchTask = () -> Unit + +internal class DefaultBatchScheduler( + private val dispatcher: CoroutineDispatcher, + private val interval: Duration, + private val chunkSize: Int +) : BatchScheduler { + + private val batchFlow: MutableSharedFlow = MutableSharedFlow() + + override fun start(scope: CoroutineScope): Job { + return batchFlow + .chunkedWithTimeout(size = chunkSize, duration = interval) + .onEach { tasks -> + withContext(dispatcher) { + tasks.forEach { it() } + } + } + .launchIn(scope) + } + + override suspend fun post(task: BatchTask) { + batchFlow.emit(task) + } +} From 5f6368485710341dd92fa58a2d6c0653dcb53c6d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 17 Aug 2024 17:03:43 +0900 Subject: [PATCH 093/155] Improve the state-based model for type safety in the internal code. There was an issue in the Soil Query code where forced unwrapping was being used, which was addressed in PR #57 recently. In this PR, I have revisited the handling of nullable data types, identified as the root cause, and introduced a new `Reply` type to eliminate the mismatch between `T?` and the type definition expected by users. Since the existing `data` property is likely referenced by users, the Reply model has been defined under a different property name to maintain backward compatibility. - data (unchanged) - dataUpdatedAt -> replyUpdatedAt (change) - reply (new) refs: #57 --- .../soil/query/compose/runtime/Await.kt | 209 +++--------------- .../soil/query/compose/runtime/Catch.kt | 74 +++---- .../soil/query/compose/runtime/Loadable.kt | 37 +--- .../query/compose/InfiniteQueryComposable.kt | 43 ++-- .../soil/query/compose/InfiniteQueryObject.kt | 44 ++-- .../soil/query/compose/MutationComposable.kt | 19 +- .../soil/query/compose/MutationObject.kt | 36 ++- .../soil/query/compose/QueryComposable.kt | 37 ++-- .../kotlin/soil/query/compose/QueryObject.kt | 44 ++-- .../kotlin/soil/query/InfiniteQueryCommand.kt | 3 +- .../soil/query/InfiniteQueryCommands.kt | 10 +- .../kotlin/soil/query/MutationAction.kt | 10 +- .../kotlin/soil/query/MutationError.kt | 2 +- .../kotlin/soil/query/MutationModel.kt | 36 ++- .../kotlin/soil/query/MutationState.kt | 11 +- .../kotlin/soil/query/QueryAction.kt | 12 +- .../kotlin/soil/query/QueryError.kt | 6 +- .../kotlin/soil/query/QueryModel.kt | 46 ++-- .../kotlin/soil/query/QueryState.kt | 30 ++- .../commonMain/kotlin/soil/query/SwrCache.kt | 24 +- .../kotlin/soil/query/core/DataModel.kt | 32 +++ .../kotlin/soil/query/core/Reply.kt | 101 +++++++++ .../soil/query/test/TestSwrClientTest.kt | 7 +- 23 files changed, 449 insertions(+), 424 deletions(-) create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/DataModel.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt index 1c5100e..05a1592 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Await.kt @@ -7,40 +7,34 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import soil.query.QueryFetchStatus -import soil.query.QueryModel -import soil.query.compose.InfiniteQueryLoadingErrorObject -import soil.query.compose.InfiniteQueryLoadingObject -import soil.query.compose.InfiniteQueryRefreshErrorObject -import soil.query.compose.InfiniteQuerySuccessObject -import soil.query.compose.QueryLoadingErrorObject -import soil.query.compose.QueryLoadingObject -import soil.query.compose.QueryRefreshErrorObject -import soil.query.compose.QuerySuccessObject +import soil.query.core.DataModel +import soil.query.core.Reply +import soil.query.core.combine import soil.query.core.uuid /** - * Await for a [QueryModel] to be fulfilled. + * Await for a [DataModel] to be fulfilled. * * The content will be displayed when the query is fulfilled. * The await will be managed by the [AwaitHost]. * * @param T Type of data to retrieve. - * @param state The [QueryModel] to await. + * @param state The [DataModel] to await. * @param key The key to identify the await. * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. * @param content The content to display when the query is fulfilled. */ @Composable inline fun Await( - state: QueryModel, + state: DataModel, key: Any? = null, host: AwaitHost = LocalAwaitHost.current, crossinline content: @Composable (data: T) -> Unit ) { val id = remember(key) { key ?: uuid() } - AwaitHandler(state) { data -> - content(data) + when (val reply = state.reply) { + is Reply.Some -> content(reply.value) + is Reply.None -> Unit } LaunchedEffect(id, state) { host[id] = state.isAwaited() @@ -53,32 +47,31 @@ inline fun Await( } /** - * Await for two [QueryModel] to be fulfilled. + * Await for two [DataModel] to be fulfilled. * * The content will be displayed when the queries are fulfilled. * The await will be managed by the [AwaitHost]. * * @param T1 Type of data to retrieve. * @param T2 Type of data to retrieve. - * @param state1 The first [QueryModel] to await. - * @param state2 The second [QueryModel] to await. + * @param state1 The first [DataModel] to await. + * @param state2 The second [DataModel] to await. * @param key The key to identify the await. * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. * @param content The content to display when the queries are fulfilled. */ @Composable inline fun Await( - state1: QueryModel, - state2: QueryModel, + state1: DataModel, + state2: DataModel, key: Any? = null, host: AwaitHost = LocalAwaitHost.current, crossinline content: @Composable (data1: T1, data2: T2) -> Unit ) { val id = remember(key) { key ?: uuid() } - AwaitHandler(state1) { d1 -> - AwaitHandler(state2) { d2 -> - content(d1, d2) - } + when (val reply = Reply.combine(state1.reply, state2.reply, ::Pair)) { + is Reply.Some -> content(reply.value.first, reply.value.second) + is Reply.None -> Unit } LaunchedEffect(id, state1, state2) { host[id] = listOf(state1, state2).any { it.isAwaited() } @@ -91,7 +84,7 @@ inline fun Await( } /** - * Await for three [QueryModel] to be fulfilled. + * Await for three [DataModel] to be fulfilled. * * The content will be displayed when the queries are fulfilled. * The await will be managed by the [AwaitHost]. @@ -99,29 +92,26 @@ inline fun Await( * @param T1 Type of data to retrieve. * @param T2 Type of data to retrieve. * @param T3 Type of data to retrieve. - * @param state1 The first [QueryModel] to await. - * @param state2 The second [QueryModel] to await. - * @param state3 The third [QueryModel] to await. + * @param state1 The first [DataModel] to await. + * @param state2 The second [DataModel] to await. + * @param state3 The third [DataModel] to await. * @param key The key to identify the await. * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. * @param content The content to display when the queries are fulfilled. */ @Composable inline fun Await( - state1: QueryModel, - state2: QueryModel, - state3: QueryModel, + state1: DataModel, + state2: DataModel, + state3: DataModel, key: Any? = null, host: AwaitHost = LocalAwaitHost.current, crossinline content: @Composable (data1: T1, data2: T2, data3: T3) -> Unit ) { val id = remember(key) { key ?: uuid() } - AwaitHandler(state1) { d1 -> - AwaitHandler(state2) { d2 -> - AwaitHandler(state3) { d3 -> - content(d1, d2, d3) - } - } + when (val reply = Reply.combine(state1.reply, state2.reply, state3.reply, ::Triple)) { + is Reply.Some -> content(reply.value.first, reply.value.second, reply.value.third) + is Reply.None -> Unit } LaunchedEffect(id, state1, state2, state3) { host[id] = listOf(state1, state2, state3).any { it.isAwaited() } @@ -132,148 +122,3 @@ inline fun Await( } } } - -/** - * Await for four [QueryModel] to be fulfilled. - * - * The content will be displayed when the queries are fulfilled. - * The await will be managed by the [AwaitHost]. - * - * @param T1 Type of data to retrieve. - * @param T2 Type of data to retrieve. - * @param T3 Type of data to retrieve. - * @param T4 Type of data to retrieve. - * @param state1 The first [QueryModel] to await. - * @param state2 The second [QueryModel] to await. - * @param state3 The third [QueryModel] to await. - * @param state4 The fourth [QueryModel] to await. - * @param key The key to identify the await. - * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. - * @param content The content to display when the queries are fulfilled. - */ -@Composable -inline fun Await( - state1: QueryModel, - state2: QueryModel, - state3: QueryModel, - state4: QueryModel, - key: Any? = null, - host: AwaitHost = LocalAwaitHost.current, - crossinline content: @Composable (data1: T1, data2: T2, data3: T3, data4: T4) -> Unit -) { - val id = remember(key) { key ?: uuid() } - AwaitHandler(state1) { d1 -> - AwaitHandler(state2) { d2 -> - AwaitHandler(state3) { d3 -> - AwaitHandler(state4) { d4 -> - content(d1, d2, d3, d4) - } - } - } - } - LaunchedEffect(id, state1, state2, state3, state4) { - host[id] = listOf(state1, state2, state3, state4).any { it.isAwaited() } - } - DisposableEffect(id) { - onDispose { - host.remove(id) - } - } -} - -/** - * Await for five [QueryModel] to be fulfilled. - * - * The content will be displayed when the queries are fulfilled. - * The await will be managed by the [AwaitHost]. - * - * @param T1 Type of data to retrieve. - * @param T2 Type of data to retrieve. - * @param T3 Type of data to retrieve. - * @param T4 Type of data to retrieve. - * @param T5 Type of data to retrieve. - * @param state1 The first [QueryModel] to await. - * @param state2 The second [QueryModel] to await. - * @param state3 The third [QueryModel] to await. - * @param state4 The fourth [QueryModel] to await. - * @param state5 The fifth [QueryModel] to await. - * @param key The key to identify the await. - * @param host The [AwaitHost] to manage the await. By default, it uses the [LocalAwaitHost]. - * @param content The content to display when the queries are fulfilled. - */ -@Composable -inline fun Await( - state1: QueryModel, - state2: QueryModel, - state3: QueryModel, - state4: QueryModel, - state5: QueryModel, - key: Any? = null, - host: AwaitHost = LocalAwaitHost.current, - crossinline content: @Composable (data1: T1, data2: T2, data3: T3, data4: T4, data5: T5) -> Unit -) { - val id = remember(key) { key ?: uuid() } - AwaitHandler(state1) { d1 -> - AwaitHandler(state2) { d2 -> - AwaitHandler(state3) { d3 -> - AwaitHandler(state4) { d4 -> - AwaitHandler(state5) { d5 -> - content(d1, d2, d3, d4, d5) - } - } - } - } - } - LaunchedEffect(id, state1, state2, state3, state4) { - host[id] = listOf(state1, state2, state3, state4).any { it.isAwaited() } - } - DisposableEffect(id) { - onDispose { - host.remove(id) - } - } -} - -/** - * Await for [QueryModel] to be fulfilled. - * - * This function is part of the [Await]. - * It is used to handle the [QueryModel] state and display the content when the query is fulfilled. - * - * @param T Type of data to retrieve. - * @param state The [QueryModel] to await. - * @param content The content to display when the query is fulfilled. - */ -@Suppress("UNCHECKED_CAST") -@Composable -fun AwaitHandler( - state: QueryModel, - content: @Composable (data: T) -> Unit -) { - when (state) { - is QuerySuccessObject -> content(state.data) - is QueryRefreshErrorObject -> content(state.data) - is QueryLoadingErrorObject, - is QueryLoadingObject -> Unit - - is InfiniteQuerySuccessObject -> content(state.data) - is InfiniteQueryRefreshErrorObject -> content(state.data) - is InfiniteQueryLoadingErrorObject, - is InfiniteQueryLoadingObject -> Unit - - else -> { - if (state.isSuccess || (state.isFailure && state.dataUpdatedAt > 0)) { - content(state.data as T) - } - } - } -} - -/** - * Returns true if the [QueryModel] is awaited. - */ -fun QueryModel<*>.isAwaited(): Boolean { - return isPending - || (isFailure && fetchStatus == QueryFetchStatus.Fetching) - || (isInvalidated && fetchStatus == QueryFetchStatus.Fetching) -} diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt index 5730c57..e66d02f 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Catch.kt @@ -7,19 +7,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import soil.query.QueryModel +import soil.query.core.DataModel import soil.query.core.uuid /** - * Catch for a [QueryModel] to be rejected. + * Catch for a [DataModel] to be rejected. * - * @param state The [QueryModel] to catch. + * @param state The [DataModel] to catch. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state: QueryModel<*>, + state: DataModel<*>, isEnabled: Boolean = true, content: @Composable CatchScope.(err: Throwable) -> Unit = { Throw(error = it) } ) { @@ -32,17 +32,17 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param state1 The first [QueryModel] to catch. - * @param state2 The second [QueryModel] to catch. + * @param state1 The first [DataModel] to catch. + * @param state2 The second [DataModel] to catch. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state1: QueryModel<*>, - state2: QueryModel<*>, + state1: DataModel<*>, + state2: DataModel<*>, isEnabled: Boolean = true, content: @Composable CatchScope.(err: Throwable) -> Unit = { Throw(error = it) } ) { @@ -56,19 +56,19 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param state1 The first [QueryModel] to catch. - * @param state2 The second [QueryModel] to catch. - * @param state3 The third [QueryModel] to catch. + * @param state1 The first [DataModel] to catch. + * @param state2 The second [DataModel] to catch. + * @param state3 The third [DataModel] to catch. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state1: QueryModel<*>, - state2: QueryModel<*>, - state3: QueryModel<*>, + state1: DataModel<*>, + state2: DataModel<*>, + state3: DataModel<*>, isEnabled: Boolean = true, content: @Composable CatchScope.(err: Throwable) -> Unit = { Throw(error = it) } ) { @@ -83,15 +83,15 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param states The [QueryModel]s to catch. + * @param states The [DataModel]s to catch. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - vararg states: QueryModel<*>, + vararg states: DataModel<*>, isEnabled: Boolean = true, content: @Composable CatchScope.(err: Throwable) -> Unit = { Throw(error = it) } ) { @@ -104,16 +104,16 @@ fun Catch( } /** - * Catch for a [QueryModel] to be rejected. + * Catch for a [DataModel] to be rejected. * - * @param state The [QueryModel] to catch. + * @param state The [DataModel] to catch. * @param filterIsInstance A function to filter the error. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state: QueryModel<*>, + state: DataModel<*>, filterIsInstance: (err: Throwable) -> T?, isEnabled: Boolean = true, content: @Composable CatchScope.(err: T) -> Unit = { Throw(error = it) } @@ -125,18 +125,18 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param state1 The first [QueryModel] to catch. - * @param state2 The second [QueryModel] to catch. + * @param state1 The first [DataModel] to catch. + * @param state2 The second [DataModel] to catch. * @param filterIsInstance A function to filter the error. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state1: QueryModel<*>, - state2: QueryModel<*>, + state1: DataModel<*>, + state2: DataModel<*>, filterIsInstance: (err: Throwable) -> T?, isEnabled: Boolean = true, content: @Composable CatchScope.(err: T) -> Unit = { Throw(error = it) } @@ -151,20 +151,20 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param state1 The first [QueryModel] to catch. - * @param state2 The second [QueryModel] to catch. - * @param state3 The third [QueryModel] to catch. + * @param state1 The first [DataModel] to catch. + * @param state2 The second [DataModel] to catch. + * @param state3 The third [DataModel] to catch. * @param filterIsInstance A function to filter the error. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - state1: QueryModel<*>, - state2: QueryModel<*>, - state3: QueryModel<*>, + state1: DataModel<*>, + state2: DataModel<*>, + state3: DataModel<*>, filterIsInstance: (err: Throwable) -> T?, isEnabled: Boolean = true, content: @Composable CatchScope.(err: T) -> Unit = { Throw(error = it) } @@ -179,16 +179,16 @@ fun Catch( } /** - * Catch for any [QueryModel]s to be rejected. + * Catch for any [DataModel]s to be rejected. * - * @param states The [QueryModel]s to catch. + * @param states The [DataModel]s to catch. * @param filterIsInstance A function to filter the error. * @param isEnabled Whether to catch the error. * @param content The content to display when the query is rejected. By default, it [throws][CatchScope.Throw] the error. */ @Composable fun Catch( - vararg states: QueryModel<*>, + vararg states: DataModel<*>, filterIsInstance: (err: Throwable) -> T?, isEnabled: Boolean = true, content: @Composable CatchScope.(err: T) -> Unit = { Throw(error = it) } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt index 5ab6e04..ec10755 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Loadable.kt @@ -5,9 +5,8 @@ package soil.query.compose.runtime import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import soil.query.QueryFetchStatus -import soil.query.QueryModel -import soil.query.QueryStatus +import soil.query.core.DataModel +import soil.query.core.Reply import soil.query.core.epoch /** @@ -19,22 +18,19 @@ import soil.query.core.epoch * @param T The type of the value that has been loaded. */ @Stable -sealed class Loadable : QueryModel { +sealed class Loadable : DataModel { + + override fun isAwaited(): Boolean = this == Pending /** * Represents the state of a value that is being loaded. */ @Immutable data object Pending : Loadable() { - override val data: Nothing get() = error("Pending") - override val dataUpdatedAt: Long = 0 - override val dataStaleAt: Long = 0 + override val reply: Reply = Reply.None + override val replyUpdatedAt: Long = 0 override val error: Throwable? = null override val errorUpdatedAt: Long = 0 - override val status: QueryStatus = QueryStatus.Pending - override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Fetching - override val isInvalidated: Boolean = false - override val isPlaceholderData: Boolean = false } /** @@ -42,16 +38,12 @@ sealed class Loadable : QueryModel { */ @Immutable data class Fulfilled( - override val data: T + val data: T ) : Loadable() { - override val dataUpdatedAt: Long = epoch() - override val dataStaleAt: Long = Long.MAX_VALUE + override val reply: Reply = Reply.some(data) + override val replyUpdatedAt: Long = epoch() override val error: Throwable? = null override val errorUpdatedAt: Long = 0 - override val status: QueryStatus = QueryStatus.Success - override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle - override val isInvalidated: Boolean = false - override val isPlaceholderData: Boolean = false } /** @@ -61,13 +53,8 @@ sealed class Loadable : QueryModel { data class Rejected( override val error: Throwable ) : Loadable() { - override val data: Nothing get() = error("Rejected") - override val dataUpdatedAt: Long = 0 - override val dataStaleAt: Long = 0 + override val reply: Reply = Reply.None + override val replyUpdatedAt: Long = 0 override val errorUpdatedAt: Long = epoch() - override val status: QueryStatus = QueryStatus.Failure - override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle - override val isInvalidated: Boolean = false - override val isPlaceholderData: Boolean = false } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index a90e48e..4d26c73 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -15,6 +15,9 @@ import soil.query.QueryChunks import soil.query.QueryClient import soil.query.QueryState import soil.query.QueryStatus +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.query.core.map import soil.query.invalidate import soil.query.loadMore import soil.query.resume @@ -77,11 +80,11 @@ private fun QueryState>.toInfiniteObject( ): InfiniteQueryObject { return when (status) { QueryStatus.Pending -> InfiniteQueryLoadingObject( - data = data?.let(select), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, @@ -91,46 +94,46 @@ private fun QueryState>.toInfiniteObject( ) QueryStatus.Success -> InfiniteQuerySuccessObject( - data = select(data as QueryChunks), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(data!!) + loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) ) - QueryStatus.Failure -> if (dataUpdatedAt > 0) { - InfiniteQueryRefreshErrorObject( - data = select(data as QueryChunks), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, - error = error as Throwable, + QueryStatus.Failure -> if (reply.isNone) { + InfiniteQueryLoadingErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(data!!) + loadMoreParam = null ) } else { - InfiniteQueryLoadingErrorObject( - data = data?.let(select), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, - error = error as Throwable, + InfiniteQueryRefreshErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, - loadMoreParam = null + loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) ) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt index 3b0f86a..c96bc4c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt @@ -8,6 +8,9 @@ import androidx.compose.runtime.Stable import soil.query.QueryFetchStatus import soil.query.QueryModel import soil.query.QueryStatus +import soil.query.core.Reply +import soil.query.core.getOrNull +import soil.query.core.getOrThrow /** * A InfiniteQueryObject represents [QueryModel]s interface for infinite fetching data using a retrieval method known as "infinite scroll." @@ -18,6 +21,11 @@ import soil.query.QueryStatus @Stable sealed interface InfiniteQueryObject : QueryModel { + /** + * The return value from the data source. (Backward compatibility with QueryModel) + */ + val data: T? + /** * Refreshes the data. */ @@ -42,12 +50,12 @@ sealed interface InfiniteQueryObject : QueryModel { * @constructor Creates a [InfiniteQueryLoadingObject]. */ @Immutable -data class InfiniteQueryLoadingObject( - override val data: T?, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class InfiniteQueryLoadingObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, @@ -56,6 +64,7 @@ data class InfiniteQueryLoadingObject( override val loadMoreParam: S? ) : InfiniteQueryObject { override val status: QueryStatus = QueryStatus.Pending + override val data: T? get() = reply.getOrNull() } /** @@ -66,12 +75,12 @@ data class InfiniteQueryLoadingObject( * @constructor Creates a [InfiniteQueryLoadingErrorObject]. */ @Immutable -data class InfiniteQueryLoadingErrorObject( - override val data: T?, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class InfiniteQueryLoadingErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, @@ -80,6 +89,7 @@ data class InfiniteQueryLoadingErrorObject( override val loadMoreParam: S? ) : InfiniteQueryObject { override val status: QueryStatus = QueryStatus.Failure + override val data: T? get() = reply.getOrNull() } /** @@ -90,12 +100,12 @@ data class InfiniteQueryLoadingErrorObject( * @constructor Creates a [InfiniteQuerySuccessObject]. */ @Immutable -data class InfiniteQuerySuccessObject( - override val data: T, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class InfiniteQuerySuccessObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, @@ -104,6 +114,7 @@ data class InfiniteQuerySuccessObject( override val loadMoreParam: S? ) : InfiniteQueryObject { override val status: QueryStatus = QueryStatus.Success + override val data: T get() = reply.getOrThrow() } /** @@ -116,12 +127,12 @@ data class InfiniteQuerySuccessObject( * @constructor Creates a [InfiniteQueryRefreshErrorObject]. */ @Immutable -data class InfiniteQueryRefreshErrorObject( - override val data: T, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class InfiniteQueryRefreshErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, @@ -130,4 +141,5 @@ data class InfiniteQueryRefreshErrorObject( override val loadMoreParam: S? ) : InfiniteQueryObject { override val status: QueryStatus = QueryStatus.Failure + override val data: T get() = reply.getOrThrow() } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 0b2b37c..dd74337 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -39,14 +39,13 @@ fun rememberMutation( } } -@Suppress("UNCHECKED_CAST") private fun MutationState.toObject( mutation: MutationRef, ): MutationObject { return when (status) { MutationStatus.Idle -> MutationIdleObject( - data = data, - dataUpdatedAt = dataUpdatedAt, + reply = reply, + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, mutatedCount = mutatedCount, @@ -56,8 +55,8 @@ private fun MutationState.toObject( ) MutationStatus.Pending -> MutationLoadingObject( - data = data, - dataUpdatedAt = dataUpdatedAt, + reply = reply, + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, mutatedCount = mutatedCount, @@ -67,8 +66,8 @@ private fun MutationState.toObject( ) MutationStatus.Success -> MutationSuccessObject( - data = data as T, - dataUpdatedAt = dataUpdatedAt, + reply = reply, + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, mutatedCount = mutatedCount, @@ -78,9 +77,9 @@ private fun MutationState.toObject( ) MutationStatus.Failure -> MutationErrorObject( - data = data, - dataUpdatedAt = dataUpdatedAt, - error = error as Throwable, + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), errorUpdatedAt = errorUpdatedAt, mutatedCount = mutatedCount, mutate = mutation::mutate, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt index ceefa8b..2e73bcf 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt @@ -7,6 +7,9 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import soil.query.MutationModel import soil.query.MutationStatus +import soil.query.core.Reply +import soil.query.core.getOrNull +import soil.query.core.getOrThrow /** * A MutationObject represents [MutationModel]s interface for mutating data. @@ -17,6 +20,11 @@ import soil.query.MutationStatus @Stable sealed interface MutationObject : MutationModel { + /** + * The return value from the data source. (Backward compatibility with MutationModel) + */ + val data: T? + /** * Mutates the variable. */ @@ -40,9 +48,9 @@ sealed interface MutationObject : MutationModel { * @param S Type of the variable to be mutated. */ @Immutable -data class MutationIdleObject( - override val data: T?, - override val dataUpdatedAt: Long, +data class MutationIdleObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, override val mutatedCount: Int, @@ -51,6 +59,7 @@ data class MutationIdleObject( override val reset: suspend () -> Unit ) : MutationObject { override val status: MutationStatus = MutationStatus.Idle + override val data: T? get() = reply.getOrNull() } /** @@ -60,9 +69,9 @@ data class MutationIdleObject( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationLoadingObject( - override val data: T?, - override val dataUpdatedAt: Long, +data class MutationLoadingObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, override val mutatedCount: Int, @@ -71,6 +80,7 @@ data class MutationLoadingObject( override val reset: suspend () -> Unit ) : MutationObject { override val status: MutationStatus = MutationStatus.Pending + override val data: T? get() = reply.getOrNull() } /** @@ -80,9 +90,9 @@ data class MutationLoadingObject( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationErrorObject( - override val data: T?, - override val dataUpdatedAt: Long, +data class MutationErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable, override val errorUpdatedAt: Long, override val mutatedCount: Int, @@ -91,6 +101,7 @@ data class MutationErrorObject( override val reset: suspend () -> Unit ) : MutationObject { override val status: MutationStatus = MutationStatus.Failure + override val data: T? get() = reply.getOrNull() } /** @@ -100,9 +111,9 @@ data class MutationErrorObject( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationSuccessObject( - override val data: T, - override val dataUpdatedAt: Long, +data class MutationSuccessObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, override val mutatedCount: Int, @@ -111,4 +122,5 @@ data class MutationSuccessObject( override val reset: suspend () -> Unit ) : MutationObject { override val status: MutationStatus = MutationStatus.Success + override val data: T get() = reply.getOrThrow() } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index e3371f9..e281d3c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -14,6 +14,8 @@ import soil.query.QueryKey import soil.query.QueryRef import soil.query.QueryState import soil.query.QueryStatus +import soil.query.core.isNone +import soil.query.core.map import soil.query.invalidate import soil.query.resume @@ -68,18 +70,17 @@ fun rememberQuery( } } -@Suppress("UNCHECKED_CAST") private fun QueryState.toObject( query: QueryRef, select: (T) -> U ): QueryObject { return when (status) { QueryStatus.Pending -> QueryLoadingObject( - data = data?.let(select), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, @@ -87,35 +88,35 @@ private fun QueryState.toObject( ) QueryStatus.Success -> QuerySuccessObject( - data = select(data as T), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, error = error, errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) - QueryStatus.Failure -> if (dataUpdatedAt > 0) { - QueryRefreshErrorObject( - data = select(data as T), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, - error = error as Throwable, + QueryStatus.Failure -> if (reply.isNone) { + QueryLoadingErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) } else { - QueryLoadingErrorObject( - data = data?.let(select), - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, - error = error as Throwable, + QueryRefreshErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + staleAt = staleAt, errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt index 6532c07..f967797 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt @@ -8,6 +8,9 @@ import androidx.compose.runtime.Stable import soil.query.QueryFetchStatus import soil.query.QueryModel import soil.query.QueryStatus +import soil.query.core.Reply +import soil.query.core.getOrNull +import soil.query.core.getOrThrow /** @@ -18,6 +21,11 @@ import soil.query.QueryStatus @Stable sealed interface QueryObject : QueryModel { + /** + * The return value from the data source. (Backward compatibility with QueryModel) + */ + val data: T? + /** * Refreshes the data. */ @@ -30,18 +38,19 @@ sealed interface QueryObject : QueryModel { * @param T Type of data to retrieve. */ @Immutable -data class QueryLoadingObject( - override val data: T?, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class QueryLoadingObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Pending + override val data: T? get() = reply.getOrNull() } /** @@ -50,18 +59,19 @@ data class QueryLoadingObject( * @param T Type of data to retrieve. */ @Immutable -data class QueryLoadingErrorObject( - override val data: T?, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class QueryLoadingErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Failure + override val data: T? get() = reply.getOrNull() } /** @@ -70,18 +80,19 @@ data class QueryLoadingErrorObject( * @param T Type of data to retrieve. */ @Immutable -data class QuerySuccessObject( - override val data: T, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class QuerySuccessObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable?, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Success + override val data: T get() = reply.getOrThrow() } /** @@ -93,16 +104,17 @@ data class QuerySuccessObject( * @constructor Creates a [QueryRefreshErrorObject]. */ @Immutable -data class QueryRefreshErrorObject( - override val data: T, - override val dataUpdatedAt: Long, - override val dataStaleAt: Long, +data class QueryRefreshErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, override val error: Throwable, override val errorUpdatedAt: Long, + override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Failure + override val data: T get() = reply.getOrThrow() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index 4253f9a..f4ce0ed 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.RetryFn import soil.query.core.exponentialBackOff +import soil.query.core.getOrElse import kotlin.coroutines.cancellation.CancellationException /** @@ -82,7 +83,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC ) { fetch(key, variable) .map { QueryChunk(it, variable) } - .map { chunk -> state.data.orEmpty() + chunk } + .map { chunk -> state.reply.getOrElse { emptyList() } + chunk } .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index dc2162a..0d53b34 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -3,6 +3,8 @@ package soil.query +import soil.query.core.getOrElse +import soil.query.core.getOrNull import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException @@ -34,7 +36,7 @@ sealed class InfiniteQueryCommands : QueryCommand> { return } ctx.dispatch(QueryAction.Fetching()) - val chunks = ctx.state.data + val chunks = ctx.state.reply.getOrNull() if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { @@ -63,7 +65,7 @@ sealed class InfiniteQueryCommands : QueryCommand> { return } ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) - val chunks = ctx.state.data + val chunks = ctx.state.reply.getOrNull() if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { @@ -84,8 +86,8 @@ sealed class InfiniteQueryCommands : QueryCommand> { val callback: QueryCallback>? = null ) : InfiniteQueryCommands() { override suspend fun handle(ctx: QueryCommand.Context>) { - val chunks = ctx.state.data - if (param != key.loadMoreParam(chunks.orEmpty())) { + val chunks = ctx.state.reply.getOrElse { emptyList() } + if (param != key.loadMoreParam(chunks)) { ctx.options.vvv(key.id) { "skip fetch(param is changed)" } callback?.invoke(Result.failure(CancellationException("skip fetch"))) return diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt index 8b029f3..7695d09 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt @@ -3,6 +3,8 @@ package soil.query +import soil.query.core.Reply + /** * Mutation actions are used to update the [mutation state][MutationState]. * @@ -53,8 +55,8 @@ fun createMutationReducer(): MutationReducer = { state, action -> when (action) { is MutationAction.Reset -> { state.copy( - data = null, - dataUpdatedAt = 0, + reply = Reply.none(), + replyUpdatedAt = 0, error = null, errorUpdatedAt = 0, status = MutationStatus.Idle, @@ -71,8 +73,8 @@ fun createMutationReducer(): MutationReducer = { state, action -> is MutationAction.MutateSuccess -> { state.copy( status = MutationStatus.Success, - data = action.data, - dataUpdatedAt = action.dataUpdatedAt, + reply = Reply(action.data), + replyUpdatedAt = action.dataUpdatedAt, error = null, errorUpdatedAt = action.dataUpdatedAt, mutatedCount = state.mutatedCount + 1 diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt index 887bc82..bb544e9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt @@ -25,7 +25,7 @@ class MutationError @PublishedApi internal constructor( message=${exception.message}, key=$key, model={ - dataUpdatedAt=${model.dataUpdatedAt}, + replyUpdatedAt=${model.replyUpdatedAt}, errorUpdatedAt=${model.errorUpdatedAt}, status=${model.status}, mutatedCount=${model.mutatedCount}, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt index 2e32dcb..c8ff7af 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationModel.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.DataModel import kotlin.math.max /** @@ -12,27 +13,7 @@ import kotlin.math.max * * @param T Type of the return value from the mutation. */ -interface MutationModel { - - /** - * The return value from the mutation. - */ - val data: T? - - /** - * The timestamp when the data was updated. - */ - val dataUpdatedAt: Long - - /** - * The error that occurred. - */ - val error: Throwable? - - /** - * The timestamp when the error occurred. - */ - val errorUpdatedAt: Long +interface MutationModel : DataModel { /** * The status of the mutation. @@ -47,12 +28,12 @@ interface MutationModel { /** * The revision of the currently snapshot. */ - val revision: String get() = "d-$dataUpdatedAt/e-$errorUpdatedAt" + val revision: String get() = "d-$replyUpdatedAt/e-$errorUpdatedAt" /** * The timestamp when the mutation was submitted. */ - val submittedAt: Long get() = max(dataUpdatedAt, errorUpdatedAt) + val submittedAt: Long get() = max(replyUpdatedAt, errorUpdatedAt) /** * Returns `true` if the mutation is idle, `false` otherwise. @@ -78,6 +59,15 @@ interface MutationModel { * Returns `true` if the mutation has been mutated, `false` otherwise. */ val isMutated: Boolean get() = mutatedCount > 0 + + /** + * Returns true if the [MutationModel] is awaited. + * + * @see DataModel.isAwaited + */ + override fun isAwaited(): Boolean { + return isPending + } } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index 556c839..ebb9cb9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -3,14 +3,15 @@ package soil.query +import soil.query.core.Reply import soil.query.core.epoch /** * State for managing the execution result of [Mutation]. */ -data class MutationState internal constructor( - override val data: T? = null, - override val dataUpdatedAt: Long = 0, +data class MutationState internal constructor( + override val reply: Reply = Reply.None, + override val replyUpdatedAt: Long = 0, override val error: Throwable? = null, override val errorUpdatedAt: Long = 0, override val status: MutationStatus = MutationStatus.Idle, @@ -31,8 +32,8 @@ data class MutationState internal constructor( mutatedCount: Int = 1 ): MutationState { return MutationState( - data = data, - dataUpdatedAt = dataUpdatedAt, + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, status = MutationStatus.Success, mutatedCount = mutatedCount ) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt index 49a0c49..0c7e965 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt @@ -3,6 +3,8 @@ package soil.query +import soil.query.core.Reply + /** * Query actions are used to update the [query state][QueryState]. * @@ -79,11 +81,11 @@ fun createQueryReducer(): QueryReducer = { state, action -> is QueryAction.FetchSuccess -> { state.copy( - data = action.data, - dataUpdatedAt = action.dataUpdatedAt, - dataStaleAt = action.dataStaleAt, + reply = Reply(action.data), + replyUpdatedAt = action.dataUpdatedAt, error = null, errorUpdatedAt = action.dataUpdatedAt, + staleAt = action.dataStaleAt, status = QueryStatus.Success, fetchStatus = QueryFetchStatus.Idle, isInvalidated = false, @@ -108,8 +110,8 @@ fun createQueryReducer(): QueryReducer = { state, action -> is QueryAction.ForceUpdate -> { state.copy( - data = action.data, - dataUpdatedAt = action.dataUpdatedAt + reply = Reply(action.data), + replyUpdatedAt = action.dataUpdatedAt ) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt index a65e87c..76d241c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt @@ -25,11 +25,11 @@ class QueryError @PublishedApi internal constructor( message=${exception.message}, key=$key, model={ - dataUpdatedAt=${model.dataUpdatedAt}, - dataStaleAt=${model.dataStaleAt}, + replyUpdatedAt=${model.replyUpdatedAt}, errorUpdatedAt=${model.errorUpdatedAt}, + staleAt=${model.staleAt}, status=${model.status}, - isInvalidated=${model.isInvalidated}, + isInvalidated=${model.isInvalidated} } ) """.trimIndent() diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt index 58af703..2c249cf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt @@ -3,7 +3,9 @@ package soil.query +import soil.query.core.DataModel import soil.query.core.epoch +import soil.query.core.isNone /** * Data model for the state handled by [QueryKey] or [InfiniteQueryKey]. @@ -12,32 +14,12 @@ import soil.query.core.epoch * * @param T Type of data to retrieve. */ -interface QueryModel { - - /** - * The return value from the query. - */ - val data: T? - - /** - * The timestamp when the data was updated. - */ - val dataUpdatedAt: Long +interface QueryModel : DataModel { /** * The timestamp when the data is considered stale. */ - val dataStaleAt: Long - - /** - * The error that occurred. - */ - val error: Throwable? - - /** - * The timestamp when the error occurred. - */ - val errorUpdatedAt: Long + val staleAt: Long /** * The status of the query. @@ -62,7 +44,7 @@ interface QueryModel { /** * The revision of the currently snapshot. */ - val revision: String get() = "d-$dataUpdatedAt/e-$errorUpdatedAt" + val revision: String get() = "d-$replyUpdatedAt/e-$errorUpdatedAt" /** * Returns `true` if the query is pending, `false` otherwise. @@ -79,11 +61,16 @@ interface QueryModel { */ val isFailure: Boolean get() = status == QueryStatus.Failure + /** + * Returns `true` if the query is fetching, `false` otherwise. + */ + val isFetching: Boolean get() = fetchStatus == QueryFetchStatus.Fetching + /** * Returns `true` if the query is staled, `false` otherwise. */ fun isStaled(currentAt: Long = epoch()): Boolean { - return dataStaleAt < currentAt + return staleAt < currentAt } /** @@ -96,6 +83,17 @@ interface QueryModel { is QueryFetchStatus.Fetching -> false } } + + /** + * Returns true if the [QueryModel] is awaited. + * + * @see DataModel.isAwaited + */ + override fun isAwaited(): Boolean { + return isPending + || (isFailure && isFetching && reply.isNone) + || (isInvalidated && isFetching) + } } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index 91ca310..060611f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -3,22 +3,38 @@ package soil.query +import soil.query.core.Reply import soil.query.core.epoch /** * State for managing the execution result of [Query]. */ -data class QueryState internal constructor( - override val data: T? = null, - override val dataUpdatedAt: Long = 0, - override val dataStaleAt: Long = 0, +data class QueryState internal constructor( + override val reply: Reply = Reply.None, + override val replyUpdatedAt: Long = 0, override val error: Throwable? = null, override val errorUpdatedAt: Long = 0, + override val staleAt: Long = 0, override val status: QueryStatus = QueryStatus.Pending, override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle, override val isInvalidated: Boolean = false, override val isPlaceholderData: Boolean = false ) : QueryModel { + + /** + * Workaround: + * The following warning appeared when updating the [reply] property within [SwrCache.setQueryData], + * so I replaced the update process with a method that includes type information. + * ref. https://youtrack.jetbrains.com/issue/KT-49404 + */ + internal fun patch( + data: T, + dataUpdatedAt: Long = epoch() + ): QueryState = copy( + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt + ) + companion object { /** @@ -34,9 +50,9 @@ data class QueryState internal constructor( dataStaleAt: Long = dataUpdatedAt ): QueryState { return QueryState( - data = data, - dataUpdatedAt = dataUpdatedAt, - dataStaleAt = dataStaleAt, + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, + staleAt = dataStaleAt, status = QueryStatus.Success ) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index e57d818..31fb8c9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -33,12 +33,14 @@ import soil.query.core.MemoryPressure import soil.query.core.MemoryPressureLevel import soil.query.core.NetworkConnectivity import soil.query.core.NetworkConnectivityEvent +import soil.query.core.Reply import soil.query.core.SurrogateKey import soil.query.core.TimeBasedCache import soil.query.core.UniqueId import soil.query.core.WindowVisibility import soil.query.core.WindowVisibilityEvent import soil.query.core.epoch +import soil.query.core.getOrNull import soil.query.core.vvv import kotlin.coroutines.CoroutineContext @@ -331,7 +333,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie val onPlaceholderData = key.onPlaceholderData() ?: return QueryState() val placeholderData = with(this) { onPlaceholderData() } ?: return QueryState() return QueryState( - data = placeholderData, + reply = Reply(placeholderData), status = QueryStatus.Success, isPlaceholderData = true ) @@ -395,7 +397,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie val onPlaceholderData = key.onPlaceholderData() ?: return QueryState() val placeholderData = with(this) { onPlaceholderData() } ?: return QueryState() return QueryState( - data = placeholderData, + reply = Reply(placeholderData), status = QueryStatus.Success, isPlaceholderData = true ) @@ -435,11 +437,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override fun getQueryData(id: QueryId): T? { val query = queryStore[id] as? ManagedQuery if (query != null) { - return query.state.value.data + return query.state.value.reply.getOrNull() } val state = queryCache[id] as? QueryState if (state != null) { - return state.data + return state.reply.getOrNull() } return null } @@ -448,11 +450,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override fun getInfiniteQueryData(id: InfiniteQueryId): QueryChunks? { val query = queryStore[id] as? ManagedQuery> if (query != null) { - return query.state.value.data + return query.state.value.reply.getOrNull() } val state = queryCache[id] as? QueryState> if (state != null) { - return state.data + return state.reply.getOrNull() } return null } @@ -471,7 +473,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun setQueryData(id: QueryId, data: T) { val query = queryStore[id] as? ManagedQuery query?.forceUpdate(data) - queryCache.swap(id) { copy(data = data) } + queryCache.swap(id) { + this as QueryState + patch(data) + } } override fun updateInfiniteQueryData( @@ -488,7 +493,10 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun setInfiniteQueryData(id: InfiniteQueryId, data: QueryChunks) { val query = queryStore[id] as? ManagedQuery> query?.forceUpdate(data) - queryCache.swap(id) { copy(data = data) } + queryCache.swap(id) { + this as QueryState> + patch(data) + } } override fun invalidateQueries(filter: InvalidateQueriesFilter) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/DataModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/DataModel.kt new file mode 100644 index 0000000..f3e91f3 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/DataModel.kt @@ -0,0 +1,32 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +interface DataModel { + + /** + * The return value from the data source. + */ + val reply: Reply + + /** + * The timestamp when the data was updated. + */ + val replyUpdatedAt: Long + + /** + * The error that occurred. + */ + val error: Throwable? + + /** + * The timestamp when the error occurred. + */ + val errorUpdatedAt: Long + + /** + * Returns true if the [DataModel] is awaited. + */ + fun isAwaited(): Boolean +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt new file mode 100644 index 0000000..862dc33 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt @@ -0,0 +1,101 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +/** + * Represents a reply from a query or mutation. + * + * [None] indicates that there is no reply yet. + */ +sealed interface Reply { + data object None : Reply + data class Some internal constructor(val value: T) : Reply + + companion object { + internal inline operator fun invoke(value: T): Reply = Some(value) + + fun none(): Reply = None + fun some(value: T): Reply = Some(value) + } +} + +/** + * Returns true if the reply is [Reply.None]. + */ +val Reply.isNone: Boolean get() = this is Reply.None + +/** + * Returns the value of the [Reply.Some] instance, or throws an error if there is no reply yet ([Reply.None]). + */ +fun Reply.getOrThrow(): T = when (this) { + is Reply.None -> error("Reply is none.") + is Reply.Some -> value +} + +/** + * Returns the value of the [Reply.Some] instance, or null if there is no reply yet ([Reply.None]). + */ +fun Reply.getOrNull(): T? = when (this) { + is Reply.None -> null + is Reply.Some -> value +} + +/** + * Returns the value of the [Reply.Some] instance, or the result of the [default] function if there is no reply yet ([Reply.None]). + */ +fun Reply.getOrElse(default: () -> T): T = when (this) { + is Reply.None -> default() + is Reply.Some -> value +} + +/** + * Transforms the value of the [Reply.Some] instance using the provided [transform] function, + * or returns [Reply.None] if there is no reply yet ([Reply.None]). + */ +inline fun Reply.map(transform: (T) -> R): Reply = when (this) { + is Reply.None -> Reply.none() + is Reply.Some -> Reply.some(transform(value)) +} + +/** + * Combines two [Reply] instances using the provided [transform] function. + * If either [Reply] has no reply yet ([Reply.None]), returns [Reply.None]. + */ +inline fun Reply.Companion.combine( + r1: Reply, + r2: Reply, + transform: (T1, T2) -> R +): Reply { + return when { + r1.isNone || r2.isNone -> none() + else -> some( + transform( + r1.getOrThrow(), + r2.getOrThrow() + ) + ) + } +} + +/** + * Combines three [Reply] instances using the provided [transform] function. + * If any [Reply] has no reply yet ([Reply.None]), returns [Reply.None]. + */ +inline fun Reply.Companion.combine( + r1: Reply, + r2: Reply, + r3: Reply, + transform: (T1, T2, T3) -> R +): Reply { + return when { + r1.isNone || r2.isNone || r3.isNone -> none() + else -> some( + transform( + r1.getOrThrow(), + r2.getOrThrow(), + r3.getOrThrow() + ) + ) + } +} diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt index 1d642a7..fbcbc44 100644 --- a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt @@ -24,6 +24,7 @@ import soil.query.SwrCachePolicy import soil.query.buildInfiniteQueryKey import soil.query.buildMutationKey import soil.query.buildQueryKey +import soil.query.core.getOrThrow import soil.query.mutate import soil.testing.UnitTest import kotlin.test.Test @@ -48,7 +49,7 @@ class TestSwrClientTest : UnitTest() { val key = ExampleMutationKey() val mutation = testClient.getMutation(key).also { it.launchIn(backgroundScope) } mutation.mutate(0) - assertEquals("Hello, World!", mutation.state.value.data) + assertEquals("Hello, World!", mutation.state.value.reply.getOrThrow()) } @Test @@ -67,7 +68,7 @@ class TestSwrClientTest : UnitTest() { val key = ExampleQueryKey() val query = testClient.getQuery(key).also { it.launchIn(backgroundScope) } query.test() - assertEquals("Hello, World!", query.state.value.data) + assertEquals("Hello, World!", query.state.value.reply.getOrThrow()) } @Test @@ -86,7 +87,7 @@ class TestSwrClientTest : UnitTest() { val key = ExampleInfiniteQueryKey() val query = testClient.getInfiniteQuery(key).also { it.launchIn(backgroundScope) } query.test() - assertEquals("Hello, World!", query.state.value.data?.first()?.data) + assertEquals("Hello, World!", query.state.value.reply.getOrThrow().first().data) } } From ea65ab8082633e314cac713403d59a9fa60a478b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 18 Aug 2024 09:34:35 +0900 Subject: [PATCH 094/155] Replace `onPlaceholderData` with `onInitialData` I have renamed `onPlaceholderData` to `onInitialData` and shifted its role to that of an initial data set. We have decided to redesign the placeholder to be applied on a component basis in the future. In the TanStack Query, which we reference as a benchmark, it is designed to be used differently according to the application. - [Initial Query Data](https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data) - [Placeholder Query Data](https://tanstack.com/query/latest/docs/framework/react/guides/placeholder-query-data) The `onPlaceholderData` implemented in Soil Query did not align perfectly with either, having a position somewhat in between. From a usability perspective, it was deemed preferable to align the role more closely with Initial Query Data, and to prepare a new implementation for the placeholder. --- .../playground/query/key/posts/GetPostKey.kt | 4 +-- .../playground/query/key/users/GetUserKey.kt | 4 +-- .../query/compose/InfiniteQueryComposable.kt | 4 --- .../soil/query/compose/InfiniteQueryObject.kt | 4 --- .../soil/query/compose/QueryComposable.kt | 4 --- .../kotlin/soil/query/compose/QueryObject.kt | 4 --- .../soil/query/InfiniteQueryCommands.kt | 4 +-- .../kotlin/soil/query/InfiniteQueryKey.kt | 9 ------- .../kotlin/soil/query/QueryAction.kt | 3 +-- .../kotlin/soil/query/QueryClient.kt | 4 +-- .../kotlin/soil/query/QueryCommand.kt | 1 - .../commonMain/kotlin/soil/query/QueryKey.kt | 10 +++---- .../kotlin/soil/query/QueryModel.kt | 5 ---- .../kotlin/soil/query/QueryState.kt | 3 +-- .../commonMain/kotlin/soil/query/SwrCache.kt | 27 ++++--------------- 15 files changed, 20 insertions(+), 70 deletions(-) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt index b4d9806..0730bb7 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt @@ -4,8 +4,8 @@ import io.ktor.client.call.body import io.ktor.client.request.get import soil.playground.query.data.Post import soil.query.QueryId +import soil.query.QueryInitialData import soil.query.QueryKey -import soil.query.QueryPlaceholderData import soil.query.chunkedData import soil.query.receivers.ktor.buildKtorQueryKey @@ -16,7 +16,7 @@ class GetPostKey(private val postId: Int) : QueryKey by buildKtorQueryKey( } ) { - override fun onPlaceholderData(): QueryPlaceholderData = { + override fun onInitialData(): QueryInitialData = { getInfiniteQueryData(GetPostsKey.Id())?.let { it.chunkedData.firstOrNull { post -> post.id == postId } } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt index 01aab05..6e3f596 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt @@ -4,8 +4,8 @@ import io.ktor.client.call.body import io.ktor.client.request.get import soil.playground.query.data.User import soil.query.QueryId +import soil.query.QueryInitialData import soil.query.QueryKey -import soil.query.QueryPlaceholderData import soil.query.chunkedData import soil.query.receivers.ktor.buildKtorQueryKey @@ -16,7 +16,7 @@ class GetUserKey(private val userId: Int) : QueryKey by buildKtorQueryKey( } ) { - override fun onPlaceholderData(): QueryPlaceholderData = { + override fun onInitialData(): QueryInitialData = { getInfiniteQueryData(GetUsersKey.Id())?.let { it.chunkedData.firstOrNull { user -> user.id == userId } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 4d26c73..a2e489a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -87,7 +87,6 @@ private fun QueryState>.toInfiniteObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, loadMoreParam = null @@ -101,7 +100,6 @@ private fun QueryState>.toInfiniteObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) @@ -116,7 +114,6 @@ private fun QueryState>.toInfiniteObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, loadMoreParam = null @@ -130,7 +127,6 @@ private fun QueryState>.toInfiniteObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate, loadMore = query::loadMore, loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt index c96bc4c..d5abcb3 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt @@ -58,7 +58,6 @@ data class InfiniteQueryLoadingObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit, override val loadMore: suspend (param: S) -> Unit, override val loadMoreParam: S? @@ -83,7 +82,6 @@ data class InfiniteQueryLoadingErrorObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit, override val loadMore: suspend (param: S) -> Unit, override val loadMoreParam: S? @@ -108,7 +106,6 @@ data class InfiniteQuerySuccessObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit, override val loadMore: suspend (param: S) -> Unit, override val loadMoreParam: S? @@ -135,7 +132,6 @@ data class InfiniteQueryRefreshErrorObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit, override val loadMore: suspend (param: S) -> Unit, override val loadMoreParam: S? diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index e281d3c..2564dbb 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -83,7 +83,6 @@ private fun QueryState.toObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) @@ -95,7 +94,6 @@ private fun QueryState.toObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) @@ -108,7 +106,6 @@ private fun QueryState.toObject( staleAt = staleAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) } else { @@ -120,7 +117,6 @@ private fun QueryState.toObject( errorUpdatedAt = errorUpdatedAt, fetchStatus = fetchStatus, isInvalidated = isInvalidated, - isPlaceholderData = isPlaceholderData, refresh = query::invalidate ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt index f967797..dc5c7b7 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt @@ -46,7 +46,6 @@ data class QueryLoadingObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Pending @@ -67,7 +66,6 @@ data class QueryLoadingErrorObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Failure @@ -88,7 +86,6 @@ data class QuerySuccessObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Success @@ -112,7 +109,6 @@ data class QueryRefreshErrorObject internal constructor( override val staleAt: Long, override val fetchStatus: QueryFetchStatus, override val isInvalidated: Boolean, - override val isPlaceholderData: Boolean, override val refresh: suspend () -> Unit ) : QueryObject { override val status: QueryStatus = QueryStatus.Failure diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index 0d53b34..f4367e2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -37,7 +37,7 @@ sealed class InfiniteQueryCommands : QueryCommand> { } ctx.dispatch(QueryAction.Fetching()) val chunks = ctx.state.reply.getOrNull() - if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { + if (chunks.isNullOrEmpty()) { ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { ctx.dispatchRevalidateChunksResult(key, chunks, callback) @@ -66,7 +66,7 @@ sealed class InfiniteQueryCommands : QueryCommand> { } ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) val chunks = ctx.state.reply.getOrNull() - if (chunks.isNullOrEmpty() || ctx.state.isPlaceholderData) { + if (chunks.isNullOrEmpty()) { ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) } else { ctx.dispatchRevalidateChunksResult(key, chunks, callback) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index 2f26eb7..8cc9a29 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -47,15 +47,6 @@ interface InfiniteQueryKey { */ fun onConfigureOptions(): QueryOptionsOverride? = null - /** - * Function to specify placeholder data. - * - * You can specify placeholder data instead of the initial loading state. - * - * @see QueryPlaceholderData - */ - fun onPlaceholderData(): QueryPlaceholderData>? = null - /** * Function to convert specific exceptions as data. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt index 0c7e965..605a667 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt @@ -88,8 +88,7 @@ fun createQueryReducer(): QueryReducer = { state, action -> staleAt = action.dataStaleAt, status = QueryStatus.Success, fetchStatus = QueryFetchStatus.Idle, - isInvalidated = false, - isPlaceholderData = false + isInvalidated = false ) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index 69a4b31..e6f4b54 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -48,7 +48,7 @@ interface QueryClient { /** * Interface for directly accessing retrieved [Query] data by [QueryClient]. * - * [QueryPlaceholderData] is designed to calculate initial data from other [Query]. + * [QueryInitialData] is designed to calculate initial data from other [Query]. * This is useful when the type included in the list on the overview screen matches the type of content on the detailed screen. */ interface QueryReadonlyClient { @@ -123,7 +123,7 @@ interface QueryMutableClient : QueryReadonlyClient { fun resumeQueriesBy(vararg ids: U) } -typealias QueryPlaceholderData = QueryReadonlyClient.() -> T? +typealias QueryInitialData = QueryReadonlyClient.() -> T? typealias QueryEffect = QueryMutableClient.() -> Unit typealias QueryRecoverData = (error: Throwable) -> T diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index bbc7803..fd6db80 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -51,7 +51,6 @@ fun QueryCommand.Context.shouldFetch(revision: String? = null): Boolean { return false } return state.isInvalidated - || state.isPlaceholderData || state.isPending || state.isStaled() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index 829c30c..4c0c1cc 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -39,21 +39,21 @@ interface QueryKey { fun onConfigureOptions(): QueryOptionsOverride? = null /** - * Function to specify placeholder data. + * Function to specify initial data. * - * You can specify placeholder data instead of the initial loading state. + * You can specify initial data instead of the initial loading state. * * ```kotlin - * override fun onPlaceholderData(): QueryPlaceholderData = { + * override fun onInitialData(): QueryInitialData = { * getInfiniteQueryData(GetUsersKey.Id())?.let { * it.chunkedData.firstOrNull { user -> user.id == userId } * } * } * ``` * - * @see QueryPlaceholderData + * @see QueryInitialData */ - fun onPlaceholderData(): QueryPlaceholderData? = null + fun onInitialData(): QueryInitialData? = null /** * Function to convert specific exceptions as data. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt index 2c249cf..0c2a142 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryModel.kt @@ -36,11 +36,6 @@ interface QueryModel : DataModel { */ val isInvalidated: Boolean - /** - * Returns `true` if the query is placeholder data, `false` otherwise. - */ - val isPlaceholderData: Boolean - /** * The revision of the currently snapshot. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index 060611f..3b5c9b9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -17,8 +17,7 @@ data class QueryState internal constructor( override val staleAt: Long = 0, override val status: QueryStatus = QueryStatus.Pending, override val fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle, - override val isInvalidated: Boolean = false, - override val isPlaceholderData: Boolean = false + override val isInvalidated: Boolean = false ) : QueryModel { /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 31fb8c9..cc0d62d 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -33,7 +33,6 @@ import soil.query.core.MemoryPressure import soil.query.core.MemoryPressureLevel import soil.query.core.NetworkConnectivity import soil.query.core.NetworkConnectivityEvent -import soil.query.core.Reply import soil.query.core.SurrogateKey import soil.query.core.TimeBasedCache import soil.query.core.UniqueId @@ -330,13 +329,9 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } private fun newQueryState(key: QueryKey): QueryState { - val onPlaceholderData = key.onPlaceholderData() ?: return QueryState() - val placeholderData = with(this) { onPlaceholderData() } ?: return QueryState() - return QueryState( - reply = Reply(placeholderData), - status = QueryStatus.Success, - isPlaceholderData = true - ) + val onInitialData = key.onInitialData() ?: return QueryState() + val initialData = with(this) { onInitialData() } ?: return QueryState() + return QueryState.success(data = initialData, dataUpdatedAt = 0) } @Suppress("UNCHECKED_CAST") @@ -352,7 +347,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private fun saveToCache(query: ManagedQuery) { val lastValue = query.state.value val ttl = query.options.gcTime - if (lastValue.isSuccess && !lastValue.isPlaceholderData && ttl.isPositive()) { + if (lastValue.isSuccess && ttl.isPositive()) { queryCache.set(query.id, lastValue, ttl) query.options.vvv(query.id) { "cached(ttl=$ttl)" } } @@ -369,7 +364,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie query = newInfiniteQuery( id = id, options = options, - initialValue = queryCache[id] as? QueryState> ?: newInfiniteQueryState(key) + initialValue = queryCache[id] as? QueryState> ?: QueryState() ).also { queryStore[id] = it } } return SwrInfiniteQuery( @@ -391,18 +386,6 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) } - private fun newInfiniteQueryState( - key: InfiniteQueryKey - ): QueryState> { - val onPlaceholderData = key.onPlaceholderData() ?: return QueryState() - val placeholderData = with(this) { onPlaceholderData() } ?: return QueryState() - return QueryState( - reply = Reply(placeholderData), - status = QueryStatus.Success, - isPlaceholderData = true - ) - } - override fun prefetchQuery(key: QueryKey): Job { val scope = CoroutineScope(policy.mainDispatcher) val query = getQuery(key).also { it.launchIn(scope) } From 1bd4412f90caccd021f8cf40b2938f53a3199b55 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 18 Aug 2024 15:17:57 +0900 Subject: [PATCH 095/155] Implements the QueryCachingStrategy In addition to the default behavior provided by Stale-While-Revalidate, two experimental strategies are now available: 1. Cache-First: This strategy avoids requesting data re-fetch as long as valid cached data is available. It prioritizes using the cached data over network requests. 2. Network-First: This strategy maintains the initial loading state until data is re-fetched, regardless of the presence of valid cached data. This ensures that the most up-to-date data is always displayed. To further customize the behavior, developers can create a class that implements the `QueryCachingStrategy` interface. However, please note that the experimental nature of this API means that the interface may undergo significant changes in future versions. In future updates, we plan to provide additional options for more granular control over the behavior at the component level. --- soil-query-compose/build.gradle.kts | 1 + .../query/compose/InfiniteQueryComposable.kt | 22 +-- .../soil/query/compose/MutationComposable.kt | 4 +- .../query/compose/QueryCachingStrategy.kt | 151 ++++++++++++++++++ .../soil/query/compose/QueryComposable.kt | 22 +-- .../kotlin/soil/query/InfiniteQueryRef.kt | 15 +- .../kotlin/soil/query/MutationState.kt | 7 + .../commonMain/kotlin/soil/query/QueryRef.kt | 11 +- .../kotlin/soil/query/QueryState.kt | 7 + .../commonMain/kotlin/soil/query/SwrCache.kt | 4 +- .../kotlin/soil/query/SwrInfiniteQuery.kt | 19 --- .../commonMain/kotlin/soil/query/SwrQuery.kt | 19 --- .../annotation/ExperimentalSoilQueryApi.kt | 12 ++ .../core/{FlowExt.kt => CoroutineExt.kt} | 12 ++ 14 files changed, 222 insertions(+), 84 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt rename soil-query-core/src/commonMain/kotlin/soil/query/core/{FlowExt.kt => CoroutineExt.kt} (89%) diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index 2bcd66d..cebba1c 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { commonMain.dependencies { api(projects.soilQueryCore) implementation(compose.runtime) + implementation(compose.runtimeSaveable) } commonTest.dependencies { diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index a2e489a..80119e1 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -4,9 +4,6 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.InfiniteQueryKey @@ -20,7 +17,6 @@ import soil.query.core.isNone import soil.query.core.map import soil.query.invalidate import soil.query.loadMore -import soil.query.resume /** * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. @@ -34,17 +30,12 @@ import soil.query.resume @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, + strategy: QueryCachingStrategy = QueryCachingStrategy, client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } - val state by query.state.collectAsState() - LaunchedEffect(query) { - query.resume() - } - return remember(query, state) { - state.toInfiniteObject(query = query, select = { it }) - } + return strategy.collectAsState(query).toInfiniteObject(query = query, select = { it }) } /** @@ -61,17 +52,12 @@ fun rememberInfiniteQuery( fun rememberInfiniteQuery( key: InfiniteQueryKey, select: (chunks: QueryChunks) -> U, + strategy: QueryCachingStrategy = QueryCachingStrategy, client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } - val state by query.state.collectAsState() - LaunchedEffect(query) { - query.resume() - } - return remember(query, state) { - state.toInfiniteObject(query = query, select = select) - } + return strategy.collectAsState(query).toInfiniteObject(query = query, select = select) } private fun QueryState>.toInfiniteObject( diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index dd74337..640a18d 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -34,9 +34,7 @@ fun rememberMutation( val scope = rememberCoroutineScope() val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } } val state by mutation.state.collectAsState() - return remember(mutation, state) { - state.toObject(mutation = mutation) - } + return state.toObject(mutation = mutation) } private fun MutationState.toObject( diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt new file mode 100644 index 0000000..8d6c653 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt @@ -0,0 +1,151 @@ +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.StateFlow +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.UniqueId +import soil.query.core.isNone +import soil.query.resume + +/** + * A mechanism to finely adjust the behavior of the query results on a component basis in Composable functions. + * + * In addition to the default behavior provided by Stale-While-Revalidate, two experimental strategies are now available: + * + * 1. Cache-First: + * This strategy avoids requesting data re-fetch as long as valid cached data is available. + * It prioritizes using the cached data over network requests. + * + * 2. Network-First: + * This strategy maintains the initial loading state until data is re-fetched, regardless of the presence of valid cached data. + * This ensures that the most up-to-date data is always displayed. + * + * If you want to customize further, please create a class implementing [QueryCachingStrategy]. + * However, as this is an experimental API, the interface may change significantly in future versions. + * + * In future updates, we plan to provide additional options for more granular control over the behavior at the component level. + * + * Background: + * During in-app development, there are scenarios where returning cached data first can lead to issues. + * For example, if the externally updated data state is not accurately reflected on the screen, inconsistencies can occur. + * This is particularly problematic in processes that automatically redirect to other screens based on the data state. + * + * On the other hand, there are situations where data re-fetching should be suppressed to minimize data traffic. + * In such cases, setting a long staleTime in QueryOptions is not sufficient, as specific conditions for reducing data traffic may persist. + */ +@Stable +interface QueryCachingStrategy { + @Composable + fun collectAsState(query: QueryRef): QueryState + + @Composable + fun collectAsState(query: InfiniteQueryRef): QueryState> + + companion object Default : QueryCachingStrategy by StaleWhileRevalidate { + + @Suppress("FunctionName") + @ExperimentalSoilQueryApi + fun CacheFirst(): QueryCachingStrategy = CacheFirst + + @Suppress("FunctionName") + @ExperimentalSoilQueryApi + fun NetworkFirst(): QueryCachingStrategy = NetworkFirst + } +} + +@Stable +private object StaleWhileRevalidate : QueryCachingStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + return collectAsState(key = query.key.id, flow = query.state, resume = query::resume) + } + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + return collectAsState(key = query.key.id, flow = query.state, resume = query::resume) + } + + @Composable + private inline fun collectAsState( + key: UniqueId, + flow: StateFlow>, + crossinline resume: suspend () -> Unit + ): QueryState { + val state by flow.collectAsState() + LaunchedEffect(key) { + resume() + } + return state + } +} + + +@Stable +private object CacheFirst : QueryCachingStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + private inline fun collectAsState( + key: UniqueId, + flow: StateFlow>, + crossinline resume: suspend () -> Unit + ): QueryState { + val state by flow.collectAsState() + LaunchedEffect(key) { + val currentValue = flow.value + if (currentValue.reply.isNone || currentValue.isInvalidated) { + resume() + } + } + return state + } +} + +@Stable +private object NetworkFirst : QueryCachingStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + private inline fun collectAsState( + key: UniqueId, + flow: StateFlow>, + crossinline resume: suspend () -> Unit + ): QueryState { + var resumed by rememberSaveable(key) { mutableStateOf(false) } + val initialValue = if (resumed) flow.value else QueryState.initial() + val state = produceState(initialValue, key) { + resume() + resumed = true + flow.collect { value = it } + } + return state.value + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index 2564dbb..c1446f1 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -4,9 +4,6 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.QueryClient @@ -17,7 +14,6 @@ import soil.query.QueryStatus import soil.query.core.isNone import soil.query.core.map import soil.query.invalidate -import soil.query.resume /** * Remember a [QueryObject] and subscribes to the query state of [key]. @@ -30,17 +26,12 @@ import soil.query.resume @Composable fun rememberQuery( key: QueryKey, + strategy: QueryCachingStrategy = QueryCachingStrategy, client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } - val state by query.state.collectAsState() - LaunchedEffect(query) { - query.resume() - } - return remember(query, state) { - state.toObject(query = query, select = { it }) - } + return strategy.collectAsState(query).toObject(query = query, select = { it }) } /** @@ -57,17 +48,12 @@ fun rememberQuery( fun rememberQuery( key: QueryKey, select: (T) -> U, + strategy: QueryCachingStrategy = QueryCachingStrategy, client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } - val state by query.state.collectAsState() - LaunchedEffect(query) { - query.resume() - } - return remember(query, state) { - state.toObject(query = query, select = select) - } + return strategy.collectAsState(query).toObject(query = query, select = select) } private fun QueryState.toObject( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index bc5467b..43011df 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -3,8 +3,11 @@ package soil.query +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor +import soil.query.core.awaitOrNull /** * A reference to an Query for [InfiniteQueryKey]. @@ -31,19 +34,25 @@ interface InfiniteQueryRef : Actor { * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. */ suspend fun InfiniteQueryRef.invalidate() { - send(InfiniteQueryCommands.Invalidate(key, state.value.revision)) + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() } /** * Resumes the Query. */ suspend fun InfiniteQueryRef.resume() { - send(InfiniteQueryCommands.Connect(key, state.value.revision)) + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() } /** * Fetches data for the [InfiniteQueryKey] using the value of [param]. */ suspend fun InfiniteQueryRef.loadMore(param: S) { - send(InfiniteQueryCommands.LoadMore(key, param)) + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.LoadMore(key, param, deferred::completeWith)) + deferred.awaitOrNull() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index ebb9cb9..8dc3ec7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -19,6 +19,13 @@ data class MutationState internal constructor( ) : MutationModel { companion object { + /** + * Creates a new [MutationState] with the [MutationStatus.Idle] status. + */ + fun initial(): MutationState { + return MutationState() + } + /** * Creates a new [MutationState] with the [MutationStatus.Success] status. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 464857b..47ef68b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -3,8 +3,11 @@ package soil.query +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor +import soil.query.core.awaitOrNull /** * A reference to an Query for [QueryKey]. @@ -30,12 +33,16 @@ interface QueryRef : Actor { * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. */ suspend fun QueryRef.invalidate() { - send(QueryCommands.Invalidate(key, state.value.revision)) + val deferred = CompletableDeferred() + send(QueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() } /** * Resumes the Query. */ suspend fun QueryRef.resume() { - send(QueryCommands.Connect(key, state.value.revision)) + val deferred = CompletableDeferred() + send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index 3b5c9b9..cbf7a27 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -36,6 +36,13 @@ data class QueryState internal constructor( companion object { + /** + * Creates a new [QueryState] with the [QueryStatus.Pending] status. + */ + fun initial(): QueryState { + return QueryState() + } + /** * Creates a new [QueryState] with the [QueryStatus.Success] status. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index cc0d62d..5b34b5b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -392,7 +392,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie return coroutineScope.launch { try { withTimeoutOrNull(query.options.prefetchWindowTime) { - query.prefetch() + query.resume() } } finally { scope.cancel() @@ -406,7 +406,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie return coroutineScope.launch { try { withTimeoutOrNull(query.options.prefetchWindowTime) { - query.prefetch() + query.resume() } } finally { scope.cancel() diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt index 6bcf293..7a79eb9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -3,11 +3,8 @@ package soil.query -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -38,19 +35,3 @@ internal class SwrInfiniteQuery( } } } - -/** - * Prefetches the Query. - */ -internal suspend fun InfiniteQueryRef.prefetch(): Boolean { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) - return try { - deferred.await() - true - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - false - } -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt index 034b730..bbe606a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt @@ -3,13 +3,10 @@ package soil.query -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException internal class SwrQuery( override val key: QueryKey, @@ -38,19 +35,3 @@ internal class SwrQuery( } } } - -/** - * Prefetches the Query. - */ -internal suspend fun QueryRef.prefetch(): Boolean { - val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) - return try { - deferred.await() - true - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - false - } -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt b/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt new file mode 100644 index 0000000..84f0656 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt @@ -0,0 +1,12 @@ +package soil.query.annotation + +@RequiresOptIn(message = "This API is experimental. It may be changed in the future without notice.") +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.CONSTRUCTOR +) +annotation class ExperimentalSoilQueryApi diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt similarity index 89% rename from soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt rename to soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt index 7992ba1..2c28011 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/FlowExt.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt @@ -3,6 +3,7 @@ package soil.query.core +import kotlinx.coroutines.Deferred import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.coroutineScope @@ -59,3 +60,14 @@ internal fun Flow.chunkedWithTimeout( } } } + +/** + * Returns null if an exception, including cancellation, occurs. + */ +internal suspend fun Deferred.awaitOrNull(): T? { + return try { + await() + } catch (e: Throwable) { + null + } +} From 14f9c21eacefa703ace64b16b205aeca9aa5c342 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 18 Aug 2024 06:25:32 +0000 Subject: [PATCH 096/155] Apply automatic changes --- .../kotlin/soil/query/compose/QueryCachingStrategy.kt | 3 +++ .../kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt index 8d6c653..f20b8f6 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.compose import androidx.compose.runtime.Composable diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt b/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt index 84f0656..4a33533 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/annotation/ExperimentalSoilQueryApi.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.annotation @RequiresOptIn(message = "This API is experimental. It may be changed in the future without notice.") From 15f721c117770260423514b306822591288e7062 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 18 Aug 2024 16:40:39 +0900 Subject: [PATCH 097/155] Add preview clients Provides the ability to preview specific queries and mutations for composable previews. --- .../compose/tooling/MutationPreviewClient.kt | 51 +++++++++++++ .../compose/tooling/QueryPreviewClient.kt | 74 +++++++++++++++++++ .../query/compose/tooling/SwrPreviewClient.kt | 35 +++++++++ 3 files changed, 160 insertions(+) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt new file mode 100644 index 0000000..7be9604 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -0,0 +1,51 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.tooling + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import soil.query.MutationClient +import soil.query.MutationCommand +import soil.query.MutationKey +import soil.query.MutationOptions +import soil.query.MutationRef +import soil.query.MutationState +import soil.query.core.UniqueId + +/** + * Usage: + * ```kotlin + * val client = MutationPreviewClient( + * previewData = mapOf( + * MyMutationId to MutationState.success("data"), + * .. + * ) + * ) + * ``` + */ +@Stable +class MutationPreviewClient( + private val previewData: Map>, + override val defaultMutationOptions: MutationOptions = MutationOptions +) : MutationClient { + + @Suppress("UNCHECKED_CAST") + override fun getMutation(key: MutationKey): MutationRef { + val state = previewData[key.id] as? MutationState ?: MutationState.initial() + val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions + return SnapshotMutation(key, options, MutableStateFlow(state)) + } + + private class SnapshotMutation( + override val key: MutationKey, + override val options: MutationOptions, + override val state: StateFlow> + ) : MutationRef { + override fun launchIn(scope: CoroutineScope): Job = Job() + override suspend fun send(command: MutationCommand) = Unit + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt new file mode 100644 index 0000000..04f39f9 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -0,0 +1,74 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.tooling + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import soil.query.InfiniteQueryKey +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryClient +import soil.query.QueryCommand +import soil.query.QueryKey +import soil.query.QueryOptions +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.core.UniqueId + +/** + * Usage: + * ```kotlin + * val client = QueryPreviewClient( + * previewData = mapOf( + * MyQueryId to QueryState.success("data"), + * .. + * ) + * ) + * ``` + */ +@Stable +class QueryPreviewClient( + private val previewData: Map>, + override val defaultQueryOptions: QueryOptions = QueryOptions +) : QueryClient { + + @Suppress("UNCHECKED_CAST") + override fun getQuery(key: QueryKey): QueryRef { + val state = previewData[key.id] as? QueryState ?: QueryState.initial() + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + return SnapshotQuery(key, options, MutableStateFlow(state)) + } + + @Suppress("UNCHECKED_CAST") + override fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef { + val state = previewData[key.id] as? QueryState> ?: QueryState.initial() + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + return SnapshotInfiniteQuery(key, options, MutableStateFlow(state)) + } + + override fun prefetchQuery(key: QueryKey): Job = Job() + + override fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job = Job() + + private class SnapshotQuery( + override val key: QueryKey, + override val options: QueryOptions, + override val state: StateFlow> + ) : QueryRef { + override fun launchIn(scope: CoroutineScope): Job = Job() + override suspend fun send(command: QueryCommand) = Unit + } + + private class SnapshotInfiniteQuery( + override val key: InfiniteQueryKey, + override val options: QueryOptions, + override val state: StateFlow>> + ) : InfiniteQueryRef { + override fun launchIn(scope: CoroutineScope): Job = Job() + override suspend fun send(command: QueryCommand>) = Unit + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt new file mode 100644 index 0000000..9ec770b --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt @@ -0,0 +1,35 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.tooling + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import soil.query.MutationClient +import soil.query.QueryClient +import soil.query.QueryEffect +import soil.query.SwrClient +import soil.query.core.ErrorRecord + +/** + * Provides the ability to preview specific queries and mutations for composable previews. + * + * ``` + * val client = SwrPreviewClient(..) + * SwrClientProvider(client = client) { + * // Composable previews + * } + * ``` + */ +@Stable +class SwrPreviewClient( + queryPreviewClient: QueryPreviewClient = QueryPreviewClient(emptyMap()), + mutationPreviewClient: MutationPreviewClient = MutationPreviewClient(emptyMap()), + override val errorRelay: Flow = flow { } +) : SwrClient, QueryClient by queryPreviewClient, MutationClient by mutationPreviewClient { + override fun perform(sideEffects: QueryEffect): Job = Job() + override fun onMount(id: String) = Unit + override fun onUnmount(id: String) = Unit +} From 1c7dee605f13a1f5a260a27bb0e495120cbf1708 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 8 Jun 2024 18:57:50 +0900 Subject: [PATCH 098/155] Bump kotlin to 2.0.20 --- .gitignore | 3 + build.gradle.kts | 19 + gradle.properties | 1 + gradle/libs.versions.toml | 14 +- internal/playground/build.gradle.kts | 12 +- .../soil/playground/Platform.android.kt | 5 - .../kotlin/soil/playground/Platform.kt | 8 - .../kotlin/soil/playground/form/FormData.kt | 7 +- .../soil/playground/query/data/PageParam.kt | 8 +- .../kotlin/soil/playground/Platform.skiko.kt | 3 - internal/testing/build.gradle.kts | 9 +- kotlin-js-store/yarn.lock | 566 +++++++++--------- sample/composeApp/build.gradle.kts | 18 +- .../kotlin/soil/kmp/screen/HelloFormScreen.kt | 7 +- soil-form/build.gradle.kts | 10 +- soil-query-compose-runtime/build.gradle.kts | 8 +- soil-query-compose/build.gradle.kts | 8 +- soil-query-core/build.gradle.kts | 9 +- soil-query-receivers/ktor/build.gradle.kts | 7 +- soil-query-test/build.gradle.kts | 7 +- soil-serialization-bundle/build.gradle.kts | 7 +- soil-space/build.gradle.kts | 10 +- 22 files changed, 342 insertions(+), 404 deletions(-) delete mode 100644 internal/playground/src/skikoMain/kotlin/soil/playground/Platform.skiko.kt diff --git a/.gitignore b/.gitignore index 33e4c75..410d6a2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ captures !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings + +# For K2 +.kotlin diff --git a/build.gradle.kts b/build.gradle.kts index 32981b8..269eda3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,12 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.kotlin.compose.compiler) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.maven.publish) apply false @@ -17,6 +22,20 @@ allprojects { javaVersion = provider { JavaVersion.VERSION_11 } } + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + tasks.withType>().configureEach { + compilerOptions { + // Note: Kotlin 2.0.20 ~ + // https://kotlinlang.org/docs/whatsnew2020.html#data-class-copy-function-to-have-the-same-visibility-as-constructor + freeCompilerArgs.add("-Xconsistent-data-class-copy-visibility") + } + } + apply(plugin = "com.diffplug.spotless") configure { format("format") { diff --git a/gradle.properties b/gradle.properties index f3d2d7e..b2b156c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,6 +11,7 @@ org.jetbrains.compose.experimental.wasm.enabled=true #MPP kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.enableCInteropCommonization=true +kotlin.mpp.stability.nowarn=true #Development development=true #Product diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab699b4..e743004 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] -# Kotlin Multiplatform <-> Android Gradle Plugin compatibility issue: -# Maximum tested Android Gradle Plugin version: 8.2 +# Kotlin Multiplatform Gradle plugin compatibility: +# - Gradle: 8.8 +# - Android Gradle plugin: 8.5.0 +# https://kotlinlang.org/docs/multiplatform-compatibility-guide.html#version-compatibility android-gradle = "8.2.2" androidx-activity = "1.8.2" androidx-annotation = "1.7.1" @@ -16,9 +18,9 @@ jbx-lifecycle = "2.8.0" jbx-navigation = "2.7.0-alpha07" jbx-savedstate = "1.2.0" junit = "4.13.2" -kotlin = "1.9.23" -kotlinx-coroutines = "1.8.0" -kotlinx-serialization = "1.6.3" +kotlin = "2.0.20" +kotlinx-coroutines = "1.8.1" +kotlinx-serialization = "1.7.0" ktor = "3.0.0-beta-2" maven-publish = "0.28.0" robolectric = "4.12.2" @@ -43,7 +45,6 @@ jbx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigatio jbx-savedstate = { module = "org.jetbrains.androidx.savedstate:savedstate", version.ref = "jbx-savedstate" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -63,6 +64,7 @@ android-application = { id = "com.android.application", version.ref = "android-g android-library = { id = "com.android.library", version.ref = "android-gradle" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } diff --git a/internal/playground/build.gradle.kts b/internal/playground/build.gradle.kts index dd99cff..d0f1d5a 100644 --- a/internal/playground/build.gradle.kts +++ b/internal/playground/build.gradle.kts @@ -1,12 +1,13 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + plugins { alias(libs.plugins.android.library) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) - id("kotlin-parcelize") } val buildTarget = the() @@ -16,11 +17,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } @@ -87,7 +83,7 @@ kotlin { } } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } diff --git a/internal/playground/src/androidMain/kotlin/soil/playground/Platform.android.kt b/internal/playground/src/androidMain/kotlin/soil/playground/Platform.android.kt index f61ade4..c288372 100644 --- a/internal/playground/src/androidMain/kotlin/soil/playground/Platform.android.kt +++ b/internal/playground/src/androidMain/kotlin/soil/playground/Platform.android.kt @@ -1,13 +1,8 @@ package soil.playground -import android.os.Parcelable import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp -import kotlinx.parcelize.Parcelize - -actual typealias CommonParcelable = Parcelable -actual typealias CommonParcelize = Parcelize actual fun createHttpClient(config: HttpClientConfig<*>.() -> Unit): HttpClient { return HttpClient(OkHttp) { diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/Platform.kt b/internal/playground/src/commonMain/kotlin/soil/playground/Platform.kt index 39755c8..23a756b 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/Platform.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/Platform.kt @@ -3,12 +3,4 @@ package soil.playground import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig -@OptIn(ExperimentalMultiplatform::class) -@OptionalExpectation -@Target(AnnotationTarget.CLASS) -@Retention(AnnotationRetention.BINARY) -expect annotation class CommonParcelize() - -expect interface CommonParcelable - expect fun createHttpClient(config: HttpClientConfig<*>.() -> Unit = {}): HttpClient diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/form/FormData.kt b/internal/playground/src/commonMain/kotlin/soil/playground/form/FormData.kt index b59b1eb..0a7d562 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/form/FormData.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/form/FormData.kt @@ -1,12 +1,11 @@ package soil.playground.form -import soil.playground.CommonParcelable -import soil.playground.CommonParcelize +import kotlinx.serialization.Serializable // The form input fields are based on the Live Demo used in React Hook Form. // You can reference it here: https://react-hook-form.com/ -@CommonParcelize +@Serializable data class FormData( val firstName: String = "", val lastName: String = "", @@ -14,7 +13,7 @@ data class FormData( val mobileNumber: String = "", val title: Title? = null, val developer: Boolean? = null -) : CommonParcelable +) enum class Title { Mr, diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/data/PageParam.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/data/PageParam.kt index e893f80..6e3199e 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/data/PageParam.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/data/PageParam.kt @@ -1,12 +1,6 @@ package soil.playground.query.data -import soil.playground.CommonParcelable -import soil.playground.CommonParcelize - - -// LazyColumn requires specifying an item of Parcelable type -@CommonParcelize data class PageParam( val offset: Int = 0, val limit: Int = 10, -) : CommonParcelable +) diff --git a/internal/playground/src/skikoMain/kotlin/soil/playground/Platform.skiko.kt b/internal/playground/src/skikoMain/kotlin/soil/playground/Platform.skiko.kt deleted file mode 100644 index 7623864..0000000 --- a/internal/playground/src/skikoMain/kotlin/soil/playground/Platform.skiko.kt +++ /dev/null @@ -1,3 +0,0 @@ -package soil.playground - -actual interface CommonParcelable diff --git a/internal/testing/build.gradle.kts b/internal/testing/build.gradle.kts index d198a67..96ca50c 100644 --- a/internal/testing/build.gradle.kts +++ b/internal/testing/build.gradle.kts @@ -1,5 +1,5 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) @@ -13,11 +13,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } @@ -64,7 +59,7 @@ kotlin { dependsOn(skikoMain) } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 3b9b5ab..45b5a26 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -125,7 +125,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.0": +"@types/estree@*", "@types/estree@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== @@ -221,7 +221,7 @@ dependencies: "@types/express" "*" -"@types/serve-static@*", "@types/serve-static@^1.13.10": +"@types/serve-static@*": version "1.15.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== @@ -230,6 +230,15 @@ "@types/mime" "*" "@types/node" "*" +"@types/serve-static@^1.13.10": + version "1.15.7" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.7.tgz#22174bbd74fb97fe303109738e9b5c2f3064f714" + integrity sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "*" + "@types/sockjs@^0.3.33": version "0.3.36" resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.36.tgz#ce322cf07bcc119d4cbf7f88954f3a3bd0f67535" @@ -237,17 +246,17 @@ dependencies: "@types/node" "*" -"@types/ws@^8.5.1": - version "8.5.10" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" - integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== +"@types/ws@^8.5.5": + version "8.5.12" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" + integrity sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ== dependencies: "@types/node" "*" -"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" - integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q== +"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" + integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== dependencies: "@webassemblyjs/helper-numbers" "1.11.6" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" @@ -262,10 +271,10 @@ resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== -"@webassemblyjs/helper-buffer@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" - integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== +"@webassemblyjs/helper-buffer@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" + integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== "@webassemblyjs/helper-numbers@1.11.6": version "1.11.6" @@ -281,15 +290,15 @@ resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== -"@webassemblyjs/helper-wasm-section@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" - integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g== +"@webassemblyjs/helper-wasm-section@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" + integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== dependencies: - "@webassemblyjs/ast" "1.11.6" - "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-gen" "1.12.1" "@webassemblyjs/ieee754@1.11.6": version "1.11.6" @@ -310,72 +319,72 @@ resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== -"@webassemblyjs/wasm-edit@^1.11.5": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" - integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw== +"@webassemblyjs/wasm-edit@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" + integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== dependencies: - "@webassemblyjs/ast" "1.11.6" - "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.11.6" - "@webassemblyjs/wasm-gen" "1.11.6" - "@webassemblyjs/wasm-opt" "1.11.6" - "@webassemblyjs/wasm-parser" "1.11.6" - "@webassemblyjs/wast-printer" "1.11.6" - -"@webassemblyjs/wasm-gen@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" - integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA== - dependencies: - "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.12.1" + "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/wasm-opt" "1.12.1" + "@webassemblyjs/wasm-parser" "1.12.1" + "@webassemblyjs/wast-printer" "1.12.1" + +"@webassemblyjs/wasm-gen@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" + integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== + dependencies: + "@webassemblyjs/ast" "1.12.1" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" "@webassemblyjs/ieee754" "1.11.6" "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wasm-opt@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" - integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g== +"@webassemblyjs/wasm-opt@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" + integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== dependencies: - "@webassemblyjs/ast" "1.11.6" - "@webassemblyjs/helper-buffer" "1.11.6" - "@webassemblyjs/wasm-gen" "1.11.6" - "@webassemblyjs/wasm-parser" "1.11.6" + "@webassemblyjs/ast" "1.12.1" + "@webassemblyjs/helper-buffer" "1.12.1" + "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/wasm-parser" "1.12.1" -"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" - integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ== +"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" + integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== dependencies: - "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/ast" "1.12.1" "@webassemblyjs/helper-api-error" "1.11.6" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" "@webassemblyjs/ieee754" "1.11.6" "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wast-printer@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" - integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A== +"@webassemblyjs/wast-printer@1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" + integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== dependencies: - "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/ast" "1.12.1" "@xtuc/long" "4.2.2" -"@webpack-cli/configtest@^2.1.0": +"@webpack-cli/configtest@^2.1.1": version "2.1.1" resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646" integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw== -"@webpack-cli/info@^2.0.1": +"@webpack-cli/info@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd" integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A== -"@webpack-cli/serve@^2.0.3": +"@webpack-cli/serve@^2.0.5": version "2.0.5" resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e" integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ== @@ -390,11 +399,6 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abab@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" - integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== - abort-controller@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -410,10 +414,10 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn-import-assertions@^1.7.6: - version "1.9.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" - integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== +acorn-import-attributes@^1.9.5: + version "1.9.5" + resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" + integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== acorn@^8.7.1, acorn@^8.8.2: version "8.11.3" @@ -459,10 +463,10 @@ ajv@^8.0.0, ajv@^8.9.0: require-from-string "^2.0.2" uri-js "^4.2.2" -ansi-colors@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== ansi-html-community@^0.0.8: version "0.0.8" @@ -567,20 +571,20 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -browser-stdout@1.3.1: +browser-stdout@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== -browserslist@^4.14.5: - version "4.23.0" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" - integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== +browserslist@^4.21.10: + version "4.23.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.3.tgz#debb029d3c93ebc97ffbc8d9cbb03403e227c800" + integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA== dependencies: - caniuse-lite "^1.0.30001587" - electron-to-chromium "^1.4.668" - node-releases "^2.0.14" - update-browserslist-db "^1.0.13" + caniuse-lite "^1.0.30001646" + electron-to-chromium "^1.5.4" + node-releases "^2.0.18" + update-browserslist-db "^1.1.0" buffer-from@^1.0.0: version "1.1.2" @@ -613,10 +617,10 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001587: - version "1.0.30001596" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz#da06b79c3d9c3d9958eb307aa832ac68ead79bee" - integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ== +caniuse-lite@^1.0.30001646: + version "1.0.30001651" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz#52de59529e8b02b1aedcaaf5c05d9e23c0c28138" + integrity sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg== chalk@^4.1.0: version "4.1.2" @@ -626,21 +630,6 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chokidar@3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chokidar@^3.5.1, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -812,13 +801,20 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4.3.4, debug@^4.1.0, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@^4.1.0, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" +debug@^4.3.5: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + decamelize@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" @@ -870,10 +866,10 @@ di@^0.0.1: resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== -diff@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== dns-packet@^5.2.2: version "5.6.1" @@ -897,10 +893,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -electron-to-chromium@^1.4.668: - version "1.4.699" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.699.tgz#dd53c939e13da64e94b341e563f0a3011b4ef0e9" - integrity sha512-I7q3BbQi6e4tJJN5CRcyvxhK0iJb34TV8eJQcgh+fR2fQ8miMgZcEInckCo1U9exDHbfz7DLDnFn8oqH/VcRKw== +electron-to-chromium@^1.5.4: + version "1.5.11" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.11.tgz#258077f1077a1c72f2925cd5b326c470a7f5adef" + integrity sha512-R1CccCDYqndR25CaXFd6hp/u9RaaMcftMkphmvuepXr5b1vfLkRml6aWVeBhXJ7rbevHkKEMJtz8XqPf7ffmew== emoji-regex@^8.0.0: version "8.0.0" @@ -933,10 +929,10 @@ engine.io@~6.5.2: engine.io-parser "~5.2.1" ws "~8.11.0" -enhanced-resolve@^5.13.0: - version "5.15.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.1.tgz#384391e025f099e67b4b00bfd7f0906a408214e1" - integrity sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg== +enhanced-resolve@^5.17.0: + version "5.17.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" + integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -968,7 +964,7 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5" integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w== -escalade@^3.1.1: +escalade@^3.1.1, escalade@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== @@ -978,7 +974,7 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== -escape-string-regexp@4.0.0: +escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== @@ -1140,14 +1136,6 @@ finalhandler@1.2.0: statuses "2.0.1" unpipe "~1.0.0" -find-up@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -1156,6 +1144,14 @@ find-up@^4.0.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + flat@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" @@ -1196,9 +1192,9 @@ fs-extra@^8.1.0: universalify "^0.1.0" fs-monkey@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788" - integrity sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew== + version "1.0.6" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.6.tgz#8ead082953e88d992cf3ff844faa907b26756da2" + integrity sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg== fs.realpath@^1.0.0: version "1.0.0" @@ -1248,29 +1244,28 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== +glob@^7.1.3, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.3, glob@^7.1.7: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.1.1" + minimatch "^5.0.1" once "^1.3.0" - path-is-absolute "^1.0.0" gopd@^1.0.1: version "1.0.1" @@ -1279,7 +1274,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -1318,7 +1313,7 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" -he@1.2.0: +he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -1445,9 +1440,9 @@ ipaddr.js@1.9.1: integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== ipaddr.js@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f" - integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ== + version "2.2.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" + integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== is-binary-path@~2.1.0: version "2.1.0" @@ -1553,7 +1548,7 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" -js-yaml@4.1.0: +js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== @@ -1603,19 +1598,19 @@ karma-sourcemap-loader@0.4.0: dependencies: graceful-fs "^4.2.10" -karma-webpack@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.0.tgz#2a2c7b80163fe7ffd1010f83f5507f95ef39f840" - integrity sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA== +karma-webpack@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.1.tgz#4eafd31bbe684a747a6e8f3e4ad373e53979ced4" + integrity sha512-oo38O+P3W2mSPCSUrQdySSPv1LvPpXP+f+bBimNomS5sW+1V4SuhCuW8TfJzV+rDv921w2fDSDw0xJbPe6U+kQ== dependencies: glob "^7.1.3" - minimatch "^3.0.4" + minimatch "^9.0.3" webpack-merge "^4.1.5" -karma@6.4.2: - version "6.4.2" - resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.2.tgz#a983f874cee6f35990c4b2dcc3d274653714de8e" - integrity sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ== +karma@6.4.3: + version "6.4.3" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.3.tgz#763e500f99597218bbb536de1a14acc4ceea7ce8" + integrity sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q== dependencies: "@colors/colors" "1.5.0" body-parser "^1.19.0" @@ -1636,7 +1631,7 @@ karma@6.4.2: qjobs "^1.2.0" range-parser "^1.2.1" rimraf "^3.0.2" - socket.io "^4.4.1" + socket.io "^4.7.2" source-map "^0.6.1" tmp "^0.2.1" ua-parser-js "^0.7.30" @@ -1648,9 +1643,9 @@ kind-of@^6.0.2: integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== launch-editor@^2.6.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.6.1.tgz#f259c9ef95cbc9425620bbbd14b468fcdb4ffe3c" - integrity sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw== + version "2.8.1" + resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.8.1.tgz#3bda72af213ec9b46b170e39661916ec66c2f463" + integrity sha512-elBx2l/tp9z99X5H/qev8uyDywVh0VXAwEbjk8kJhnc5grOFkGh7aW6q55me9xnYbss261XtnUrysZ+XvGbhQA== dependencies: picocolors "^1.0.0" shell-quote "^1.8.1" @@ -1679,7 +1674,7 @@ lodash@^4.17.15, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-symbols@4.1.0: +log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== @@ -1765,13 +1760,6 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== - dependencies: - brace-expansion "^2.0.1" - minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -1779,6 +1767,20 @@ minimatch@^3.0.4, minimatch@^3.1.1: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1, minimatch@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.3, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -1791,32 +1793,31 @@ mkdirp@^0.5.5: dependencies: minimist "^1.2.6" -mocha@10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8" - integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg== - dependencies: - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.5.3" - debug "4.3.4" - diff "5.0.0" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "7.2.0" - he "1.2.0" - js-yaml "4.1.0" - log-symbols "4.1.0" - minimatch "5.0.1" - ms "2.1.3" - nanoid "3.3.3" - serialize-javascript "6.0.0" - strip-json-comments "3.1.1" - supports-color "8.1.1" - workerpool "6.2.1" - yargs "16.2.0" - yargs-parser "20.2.4" - yargs-unparser "2.0.0" +mocha@10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" + integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" ms@2.0.0: version "2.0.0" @@ -1828,7 +1829,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@2.1.3, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -1841,11 +1842,6 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" -nanoid@3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" - integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== - negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -1868,10 +1864,10 @@ node-forge@^1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== -node-releases@^2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" - integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +node-releases@^2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" + integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -2018,6 +2014,11 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== +picocolors@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" + integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -2186,7 +2187,7 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -schema-utils@^3.1.1, schema-utils@^3.1.2: +schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== @@ -2237,14 +2238,7 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-javascript@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== - dependencies: - randombytes "^2.1.0" - -serialize-javascript@^6.0.1: +serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== @@ -2351,10 +2345,10 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" -socket.io@^4.4.1: - version "4.7.4" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.4.tgz#2401a2d7101e4bdc64da80b140d5d8b6a8c7738b" - integrity sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw== +socket.io@^4.7.2: + version "4.7.5" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8" + integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== dependencies: accepts "~1.3.4" base64id "~2.0.0" @@ -2378,12 +2372,11 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map-loader@4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2" - integrity sha512-oqXpzDIByKONVY8g1NUPOTQhe0UTU5bWUl32GSkqK2LjJj0HmwTMVKxcUip0RgAYhY1mqgOxjbQM48a0mmeNfA== +source-map-loader@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-5.0.0.tgz#f593a916e1cc54471cfc8851b905c8a845fc7e38" + integrity sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA== dependencies: - abab "^2.0.6" iconv-lite "^0.6.3" source-map-js "^1.0.2" @@ -2477,18 +2470,11 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== -strip-json-comments@3.1.1: +strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -supports-color@8.1.1, supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -2496,6 +2482,13 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^8.0.0, supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -2506,7 +2499,7 @@ tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== -terser-webpack-plugin@^5.3.7: +terser-webpack-plugin@^5.3.10: version "5.3.10" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== @@ -2562,10 +2555,10 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typescript@5.0.4: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== +typescript@5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" + integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== ua-parser-js@^0.7.30: version "0.7.37" @@ -2587,13 +2580,13 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -update-browserslist-db@^1.0.13: - version "1.0.13" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" - integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== +update-browserslist-db@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" + integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" + escalade "^3.1.2" + picocolors "^1.0.1" uri-js@^4.2.2: version "4.4.1" @@ -2627,10 +2620,10 @@ void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== -watchpack@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" - integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== +watchpack@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" + integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -2647,15 +2640,15 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== -webpack-cli@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.0.tgz#abc4b1f44b50250f2632d8b8b536cfe2f6257891" - integrity sha512-a7KRJnCxejFoDpYTOwzm5o21ZXMaNqtRlvS183XzGDUPRdVEzJNImcQokqYZ8BNTnk9DkKiuWxw75+DCCoZ26w== +webpack-cli@5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.4.tgz#c8e046ba7eaae4911d7e71e2b25b776fcc35759b" + integrity sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg== dependencies: "@discoveryjs/json-ext" "^0.5.0" - "@webpack-cli/configtest" "^2.1.0" - "@webpack-cli/info" "^2.0.1" - "@webpack-cli/serve" "^2.0.3" + "@webpack-cli/configtest" "^2.1.1" + "@webpack-cli/info" "^2.0.2" + "@webpack-cli/serve" "^2.0.5" colorette "^2.0.14" commander "^10.0.1" cross-spawn "^7.0.3" @@ -2666,10 +2659,10 @@ webpack-cli@5.1.0: rechoir "^0.8.0" webpack-merge "^5.7.3" -webpack-dev-middleware@^5.3.1: - version "5.3.3" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" - integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA== +webpack-dev-middleware@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517" + integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q== dependencies: colorette "^2.0.10" memfs "^3.4.3" @@ -2677,10 +2670,10 @@ webpack-dev-middleware@^5.3.1: range-parser "^1.2.1" schema-utils "^4.0.0" -webpack-dev-server@4.15.0: - version "4.15.0" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.0.tgz#87ba9006eca53c551607ea0d663f4ae88be7af21" - integrity sha512-HmNB5QeSl1KpulTBQ8UT4FPrByYyaLxpJoQ0+s7EvUrMc16m0ZS1sgb1XGqzmgCPk0c9y+aaXxn11tbLzuM7NQ== +webpack-dev-server@4.15.2: + version "4.15.2" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz#9e0c70a42a012560860adb186986da1248333173" + integrity sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g== dependencies: "@types/bonjour" "^3.5.9" "@types/connect-history-api-fallback" "^1.3.5" @@ -2688,7 +2681,7 @@ webpack-dev-server@4.15.0: "@types/serve-index" "^1.9.1" "@types/serve-static" "^1.13.10" "@types/sockjs" "^0.3.33" - "@types/ws" "^8.5.1" + "@types/ws" "^8.5.5" ansi-html-community "^0.0.8" bonjour-service "^1.0.11" chokidar "^3.5.3" @@ -2710,7 +2703,7 @@ webpack-dev-server@4.15.0: serve-index "^1.9.1" sockjs "^0.3.24" spdy "^4.0.2" - webpack-dev-middleware "^5.3.1" + webpack-dev-middleware "^5.3.4" ws "^8.13.0" webpack-merge@^4.1.5: @@ -2734,34 +2727,34 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.82.0: - version "5.82.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.82.0.tgz#3c0d074dec79401db026b4ba0fb23d6333f88e7d" - integrity sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg== +webpack@5.93.0: + version "5.93.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.93.0.tgz#2e89ec7035579bdfba9760d26c63ac5c3462a5e5" + integrity sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA== dependencies: "@types/eslint-scope" "^3.7.3" - "@types/estree" "^1.0.0" - "@webassemblyjs/ast" "^1.11.5" - "@webassemblyjs/wasm-edit" "^1.11.5" - "@webassemblyjs/wasm-parser" "^1.11.5" + "@types/estree" "^1.0.5" + "@webassemblyjs/ast" "^1.12.1" + "@webassemblyjs/wasm-edit" "^1.12.1" + "@webassemblyjs/wasm-parser" "^1.12.1" acorn "^8.7.1" - acorn-import-assertions "^1.7.6" - browserslist "^4.14.5" + acorn-import-attributes "^1.9.5" + browserslist "^4.21.10" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.13.0" + enhanced-resolve "^5.17.0" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" - graceful-fs "^4.2.9" + graceful-fs "^4.2.11" json-parse-even-better-errors "^2.3.1" loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.1.2" + schema-utils "^3.2.0" tapable "^2.1.1" - terser-webpack-plugin "^5.3.7" - watchpack "^2.4.0" + terser-webpack-plugin "^5.3.10" + watchpack "^2.4.1" webpack-sources "^3.2.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: @@ -2805,10 +2798,10 @@ wildcard@^2.0.0: resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== -workerpool@6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" - integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== wrap-ansi@^7.0.0: version "7.0.0" @@ -2830,9 +2823,9 @@ ws@8.5.0: integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== ws@^8.13.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" - integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== ws@~8.11.0: version "8.11.0" @@ -2844,17 +2837,12 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yargs-parser@20.2.4: - version "20.2.4" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - -yargs-parser@^20.2.2: +yargs-parser@^20.2.2, yargs-parser@^20.2.9: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== -yargs-unparser@2.0.0: +yargs-unparser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== @@ -2864,7 +2852,7 @@ yargs-unparser@2.0.0: flat "^5.0.2" is-plain-obj "^2.1.0" -yargs@16.2.0, yargs@^16.1.1: +yargs@^16.1.1, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 4267a84..4221889 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -1,14 +1,14 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) - id("kotlin-parcelize") } val buildTarget = the() @@ -33,13 +33,7 @@ kotlin { binaries.executable() } - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } - } + androidTarget() jvm("desktop") @@ -114,7 +108,7 @@ kotlin { implementation(libs.ktor.client.okhttp) } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } @@ -165,7 +159,3 @@ compose.desktop { } } } - -compose.experimental { - web.application {} -} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt index 807fb90..ca41fdf 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloFormScreen.kt @@ -19,12 +19,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.ExperimentalSerializationApi import soil.form.compose.Controller import soil.form.compose.FieldControl import soil.form.compose.Form import soil.form.compose.FormScope import soil.form.compose.rememberFieldRuleControl import soil.form.compose.rememberSubmissionRuleAutoControl +import soil.form.compose.serializationSaver import soil.form.rule.StringRuleBuilder import soil.form.rule.StringRuleTester import soil.form.rule.notBlank @@ -57,7 +59,7 @@ fun HelloFormScreen() { // The form input fields are based on the Live Demo used in React Hook Form. // You can reference it here: https://react-hook-form.com/ -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalSerializationApi::class) @Composable private fun HelloFormContent( onSubmitted: (FormData) -> Unit, @@ -72,7 +74,8 @@ private fun HelloFormContent( }, initialValue = FormData(), modifier = modifier, - key = formVersion + key = formVersion, + saver = serializationSaver() ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/soil-form/build.gradle.kts b/soil-form/build.gradle.kts index 0de66e2..658b61f 100644 --- a/soil-form/build.gradle.kts +++ b/soil-form/build.gradle.kts @@ -1,8 +1,9 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) @@ -15,11 +16,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } @@ -53,7 +49,7 @@ kotlin { dependsOn(skikoMain) } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } diff --git a/soil-query-compose-runtime/build.gradle.kts b/soil-query-compose-runtime/build.gradle.kts index f118101..ebacd60 100644 --- a/soil-query-compose-runtime/build.gradle.kts +++ b/soil-query-compose-runtime/build.gradle.kts @@ -1,8 +1,9 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) @@ -15,11 +16,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index cebba1c..dfb347a 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -1,9 +1,10 @@ import org.jetbrains.compose.ExperimentalComposeLibrary -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) @@ -16,11 +17,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index dd56279..fc234c0 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) @@ -14,11 +14,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } @@ -59,7 +54,7 @@ kotlin { dependsOn(skikoMain) } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } diff --git a/soil-query-receivers/ktor/build.gradle.kts b/soil-query-receivers/ktor/build.gradle.kts index 195a1ad..b63255d 100644 --- a/soil-query-receivers/ktor/build.gradle.kts +++ b/soil-query-receivers/ktor/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) @@ -14,11 +14,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } diff --git a/soil-query-test/build.gradle.kts b/soil-query-test/build.gradle.kts index 33d0d11..bf43fa7 100644 --- a/soil-query-test/build.gradle.kts +++ b/soil-query-test/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) @@ -14,11 +14,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } diff --git a/soil-serialization-bundle/build.gradle.kts b/soil-serialization-bundle/build.gradle.kts index 3ee869f..830720c 100644 --- a/soil-serialization-bundle/build.gradle.kts +++ b/soil-serialization-bundle/build.gradle.kts @@ -1,4 +1,4 @@ -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) @@ -15,11 +15,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index 9030dae..904e390 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -1,9 +1,10 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.android.library) alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.kotlin.compose.compiler) alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) @@ -16,11 +17,6 @@ kotlin { jvm() androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = buildTarget.javaVersion.get().toString() - } - } publishLibraryVariants("release") } @@ -70,7 +66,7 @@ kotlin { dependsOn(skikoMain) } - named("wasmJsMain") { + wasmJsMain { dependsOn(skikoMain) } } From e8c89e38f4d609bc684e7a4090c913ea7c2088e6 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 15:27:00 +0900 Subject: [PATCH 099/155] Bump up Gradle Wrapper to 8.8 --- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23..a441313 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4..b740cf1 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. From 6f45f1862c22177ab0cb83ce866cffbd9b0f87e4 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 15:32:05 +0900 Subject: [PATCH 100/155] Bump up AGP to 8.5.x --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e743004..7ee25ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] # Kotlin Multiplatform Gradle plugin compatibility: # - Gradle: 8.8 -# - Android Gradle plugin: 8.5.0 +# - Android Gradle plugin: 8.5 # https://kotlinlang.org/docs/multiplatform-compatibility-guide.html#version-compatibility -android-gradle = "8.2.2" +android-gradle = "8.5.2" androidx-activity = "1.8.2" androidx-annotation = "1.7.1" androidx-core = "1.12.0" From 887b6033994fb8998f030bb700221430d5258633 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 15:45:29 +0900 Subject: [PATCH 101/155] Fix a test --- Makefile | 4 ++++ soil-query-compose/build.gradle.kts | 8 +++++++- .../kotlin/soil/serialization/bundle/BundleTestData.kt | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b815590..ad7909d 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,10 @@ clean: build: @$(GRADLE_CMD) assemble +.PHONY: test +test: + @$(GRADLE_CMD) allTests + .PHONY: fmt fmt: @$(GRADLE_CMD) spotlessApply diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index dfb347a..a7a5f49 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -26,7 +26,13 @@ kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { - browser() + browser { + // TODO: We will consider using wasm tests when we update to 'org.jetbrains.compose.ui:ui:1.7.0' or later. + // - https://slack-chats.kotlinlang.org/t/22883390/wasmjs-unit-testing-what-is-the-status-of-unit-testing-on-wa + testTask { + enabled = false + } + } } sourceSets { diff --git a/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt index 8a0eded..8ff4547 100644 --- a/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt +++ b/soil-serialization-bundle/src/commonTest/kotlin/soil/serialization/bundle/BundleTestData.kt @@ -57,6 +57,7 @@ data class ObjectTestData( val age: Int ) +@Serializable enum class EnumTestData { Foo, Bar } From f1de3c9a48ff010fbeb9829a0292d82eebe86077 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 16:06:41 +0900 Subject: [PATCH 102/155] Refactor Command Type for Simplicity The following changes were made: - The Commands type is now an object that aggregates Command types. - Defined the interface for each Command type. (InfiniteQueryCommand) --- .../compose/tooling/QueryPreviewClient.kt | 3 ++- .../kotlin/soil/query/InfiniteQueryCommand.kt | 8 ++++++++ .../soil/query/InfiniteQueryCommands.kt | 20 +++++++------------ .../kotlin/soil/query/InfiniteQueryRef.kt | 2 +- .../kotlin/soil/query/MutationCommands.kt | 13 ++++-------- .../kotlin/soil/query/QueryCommands.kt | 15 +++++--------- .../kotlin/soil/query/SwrInfiniteQuery.kt | 2 +- 7 files changed, 28 insertions(+), 35 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index 04f39f9..26e149a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import soil.query.InfiniteQueryCommand import soil.query.InfiniteQueryKey import soil.query.InfiniteQueryRef import soil.query.QueryChunks @@ -69,6 +70,6 @@ class QueryPreviewClient( override val state: StateFlow>> ) : InfiniteQueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() - override suspend fun send(command: QueryCommand>) = Unit + override suspend fun send(command: InfiniteQueryCommand) = Unit } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index f4ce0ed..222c81a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -8,6 +8,14 @@ import soil.query.core.exponentialBackOff import soil.query.core.getOrElse import kotlin.coroutines.cancellation.CancellationException +/** + * Query command for [InfiniteQueryKey]. + * + * @param T Type of data to retrieve. + * @param S Type of parameter. + */ +interface InfiniteQueryCommand : QueryCommand> + /** * Fetches data for the [InfiniteQueryKey] using the value of [variable]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index f4367e2..7c4d543 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -8,13 +8,7 @@ import soil.query.core.getOrNull import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException -/** - * Query command for [InfiniteQueryKey]. - * - * @param T Type of data to retrieve. - * @param S Type of parameter. - */ -sealed class InfiniteQueryCommands : QueryCommand> { +object InfiniteQueryCommands { /** * Performs data fetching and validation based on the current data state. @@ -24,11 +18,11 @@ sealed class InfiniteQueryCommands : QueryCommand> { * @param key Instance of a class implementing [InfiniteQueryKey]. * @param revision The revision of the data to be fetched. */ - data class Connect( + class Connect( val key: InfiniteQueryKey, val revision: String? = null, val callback: QueryCallback>? = null - ) : InfiniteQueryCommands() { + ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { if (!ctx.shouldFetch(revision)) { ctx.options.vvv(key.id) { "skip fetch(shouldFetch=false)" } @@ -53,11 +47,11 @@ sealed class InfiniteQueryCommands : QueryCommand> { * @param key Instance of a class implementing [InfiniteQueryKey]. * @param revision The revision of the data to be invalidated. */ - data class Invalidate( + class Invalidate( val key: InfiniteQueryKey, val revision: String, val callback: QueryCallback>? = null - ) : InfiniteQueryCommands() { + ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { if (ctx.state.revision != revision) { ctx.options.vvv(key.id) { "skip fetch(revision is not matched)" } @@ -80,11 +74,11 @@ sealed class InfiniteQueryCommands : QueryCommand> { * @param key Instance of a class implementing [InfiniteQueryKey]. * @param param The parameter required for fetching data for [InfiniteQueryKey]. */ - data class LoadMore( + class LoadMore( val key: InfiniteQueryKey, val param: S, val callback: QueryCallback>? = null - ) : InfiniteQueryCommands() { + ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { val chunks = ctx.state.reply.getOrElse { emptyList() } if (param != key.loadMoreParam(chunks)) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 43011df..e32a7ef 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -24,7 +24,7 @@ interface InfiniteQueryRef : Actor { /** * Sends a [QueryCommand] to the Actor. */ - suspend fun send(command: QueryCommand>) + suspend fun send(command: InfiniteQueryCommand) } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index 26bb912..b3120d4 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -6,12 +6,7 @@ package soil.query import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException -/** - * Mutation commands are used to update the [mutation state][MutationState]. - * - * @param T Type of the return value from the mutation. - */ -sealed class MutationCommands : MutationCommand { +object MutationCommands { /** * Executes the [mutate][MutationKey.mutate] function of the specified [MutationKey]. @@ -22,12 +17,12 @@ sealed class MutationCommands : MutationCommand { * @param variable The variable to be mutated. * @param revision The revision of the mutation state. */ - data class Mutate( + class Mutate( val key: MutationKey, val variable: S, val revision: String, val callback: MutationCallback? = null - ) : MutationCommands() { + ) : MutationCommand { override suspend fun handle(ctx: MutationCommand.Context) { if (!ctx.shouldMutate(revision)) { @@ -43,7 +38,7 @@ sealed class MutationCommands : MutationCommand { /** * Resets the mutation state. */ - class Reset : MutationCommands() { + class Reset : MutationCommand { override suspend fun handle(ctx: MutationCommand.Context) { ctx.dispatch(MutationAction.Reset) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 7243988..38e2885 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -6,12 +6,7 @@ package soil.query import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException -/** - * Query command for [QueryKey]. - * - * @param T Type of data to retrieve. - */ -sealed class QueryCommands : QueryCommand { +object QueryCommands { /** * Performs data fetching and validation based on the current data state. @@ -21,11 +16,11 @@ sealed class QueryCommands : QueryCommand { * @param key Instance of a class implementing [QueryKey]. * @param revision The revision of the data to be fetched. */ - data class Connect( + class Connect( val key: QueryKey, val revision: String? = null, val callback: QueryCallback? = null - ) : QueryCommands() { + ) : QueryCommand { override suspend fun handle(ctx: QueryCommand.Context) { if (!ctx.shouldFetch(revision)) { @@ -46,11 +41,11 @@ sealed class QueryCommands : QueryCommand { * @param key Instance of a class implementing [QueryKey]. * @param revision The revision of the data to be invalidated. */ - data class Invalidate( + class Invalidate( val key: QueryKey, val revision: String, val callback: QueryCallback? = null - ) : QueryCommands() { + ) : QueryCommand { override suspend fun handle(ctx: QueryCommand.Context) { if (ctx.state.revision != revision) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt index 7a79eb9..772ebbe 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -24,7 +24,7 @@ internal class SwrInfiniteQuery( } } - override suspend fun send(command: QueryCommand>) { + override suspend fun send(command: InfiniteQueryCommand) { query.command.send(command) } From 231fb7c68818a13cee09b6688ebfbd78f2384f4c Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 17:44:10 +0900 Subject: [PATCH 103/155] Revert `XxxRef` from Extension Function to Interface Function Reverted the `XxxRef` implementation from an extension function back to an interface-based function due to unexpected behavior when combining `PreviewClient` for Compose with `QueryCachingStrategy.NetworkFirst`. By reverting to an interface-based function, PreviewClient can handle the `resume` function properly, ensuring expected behavior across all combinations. refs: #70 --- .../query/compose/InfiniteQueryComposable.kt | 2 - .../soil/query/compose/MutationComposable.kt | 3 -- .../query/compose/QueryCachingStrategy.kt | 1 - .../soil/query/compose/QueryComposable.kt | 1 - .../compose/tooling/QueryPreviewClient.kt | 5 ++ .../kotlin/soil/query/InfiniteQueryRef.kt | 54 +++++++++---------- .../kotlin/soil/query/MutationRef.kt | 50 ++++++++--------- .../commonMain/kotlin/soil/query/QueryRef.kt | 38 ++++++------- 8 files changed, 76 insertions(+), 78 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 80119e1..6904469 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -15,8 +15,6 @@ import soil.query.QueryStatus import soil.query.core.getOrThrow import soil.query.core.isNone import soil.query.core.map -import soil.query.invalidate -import soil.query.loadMore /** * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 640a18d..2fcc807 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -13,9 +13,6 @@ import soil.query.MutationKey import soil.query.MutationRef import soil.query.MutationState import soil.query.MutationStatus -import soil.query.mutate -import soil.query.mutateAsync -import soil.query.reset /** * Remember a [MutationObject] and subscribes to the mutation state of [key]. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt index f20b8f6..51bef6e 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt @@ -20,7 +20,6 @@ import soil.query.QueryState import soil.query.annotation.ExperimentalSoilQueryApi import soil.query.core.UniqueId import soil.query.core.isNone -import soil.query.resume /** * A mechanism to finely adjust the behavior of the query results on a component basis in Composable functions. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index c1446f1..1b72b3c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -13,7 +13,6 @@ import soil.query.QueryState import soil.query.QueryStatus import soil.query.core.isNone import soil.query.core.map -import soil.query.invalidate /** * Remember a [QueryObject] and subscribes to the query state of [key]. diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index 26e149a..7eb68f3 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -62,6 +62,8 @@ class QueryPreviewClient( ) : QueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() override suspend fun send(command: QueryCommand) = Unit + override suspend fun resume() = Unit + override suspend fun invalidate() = Unit } private class SnapshotInfiniteQuery( @@ -71,5 +73,8 @@ class QueryPreviewClient( ) : InfiniteQueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() override suspend fun send(command: InfiniteQueryCommand) = Unit + override suspend fun resume() = Unit + override suspend fun loadMore(param: S) = Unit + override suspend fun invalidate() = Unit } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index e32a7ef..06cbe87 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -25,34 +25,34 @@ interface InfiniteQueryRef : Actor { * Sends a [QueryCommand] to the Actor. */ suspend fun send(command: InfiniteQueryCommand) -} -/** - * Invalidates the Query. - * - * Calling this function will invalidate the retrieved data of the Query, - * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. - */ -suspend fun InfiniteQueryRef.invalidate() { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) - deferred.awaitOrNull() -} + /** + * Resumes the Query. + */ + suspend fun resume() { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() + } -/** - * Resumes the Query. - */ -suspend fun InfiniteQueryRef.resume() { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) - deferred.awaitOrNull() -} + /** + * Fetches data for the [InfiniteQueryKey] using the value of [param]. + */ + suspend fun loadMore(param: S) { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.LoadMore(key, param, deferred::completeWith)) + deferred.awaitOrNull() + } -/** - * Fetches data for the [InfiniteQueryKey] using the value of [param]. - */ -suspend fun InfiniteQueryRef.loadMore(param: S) { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.LoadMore(key, param, deferred::completeWith)) - deferred.awaitOrNull() + /** + * Invalidates the Query. + * + * Calling this function will invalidate the retrieved data of the Query, + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ + suspend fun invalidate() { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() + } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 65a71c3..31b59c4 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -24,32 +24,32 @@ interface MutationRef : Actor { * Sends a [MutationCommand] to the Actor. */ suspend fun send(command: MutationCommand) -} -/** - * Mutates the variable. - * - * @param variable The variable to be mutated. - * @return The result of the mutation. - */ -suspend fun MutationRef.mutate(variable: S): T { - val deferred = CompletableDeferred() - send(MutationCommands.Mutate(key, variable, state.value.revision, deferred::completeWith)) - return deferred.await() -} + /** + * Mutates the variable. + * + * @param variable The variable to be mutated. + * @return The result of the mutation. + */ + suspend fun mutate(variable: S): T { + val deferred = CompletableDeferred() + send(MutationCommands.Mutate(key, variable, state.value.revision, deferred::completeWith)) + return deferred.await() + } -/** - * Mutates the variable asynchronously. - * - * @param variable The variable to be mutated. - */ -suspend fun MutationRef.mutateAsync(variable: S) { - send(MutationCommands.Mutate(key, variable, state.value.revision)) -} + /** + * Mutates the variable asynchronously. + * + * @param variable The variable to be mutated. + */ + suspend fun mutateAsync(variable: S) { + send(MutationCommands.Mutate(key, variable, state.value.revision)) + } -/** - * Resets the mutation state. - */ -suspend fun MutationRef.reset() { - send(MutationCommands.Reset()) + /** + * Resets the mutation state. + */ + suspend fun reset() { + send(MutationCommands.Reset()) + } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 47ef68b..106ce4a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -24,25 +24,25 @@ interface QueryRef : Actor { * Sends a [QueryCommand] to the Actor. */ suspend fun send(command: QueryCommand) -} -/** - * Invalidates the Query. - * - * Calling this function will invalidate the retrieved data of the Query, - * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. - */ -suspend fun QueryRef.invalidate() { - val deferred = CompletableDeferred() - send(QueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) - deferred.awaitOrNull() -} + /** + * Resumes the Query. + */ + suspend fun resume() { + val deferred = CompletableDeferred() + send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() + } -/** - * Resumes the Query. - */ -suspend fun QueryRef.resume() { - val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) - deferred.awaitOrNull() + /** + * Invalidates the Query. + * + * Calling this function will invalidate the retrieved data of the Query, + * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. + */ + suspend fun invalidate() { + val deferred = CompletableDeferred() + send(QueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + deferred.awaitOrNull() + } } From 5a6c79f0f9a0849a1bd878053600f769c5849331 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 24 Aug 2024 18:12:53 +0900 Subject: [PATCH 104/155] Refactor `ErrorRecord` into a Simple Data Class The ErrorRecord class has been refactored into a simpler data class. Initially, ErrorRecord could only be determined within the ErrorRelay implementation. To accommodate different models, ErrorRecord was designed as an interface. In the new implementation, the decision point for ErrorRecord has been added to each Options object through the `shouldSuppressErrorRelay` method. This allows for a simplified ErrorRecord while preserving the same mechanism. Consequently, the implementation has been modified to utilize a simpler data type. refs: #51 --- .../kotlin/soil/query/MutationCommand.kt | 12 +++-- .../kotlin/soil/query/MutationError.kt | 39 ---------------- .../kotlin/soil/query/MutationOptions.kt | 42 ++++++++++++++--- .../kotlin/soil/query/QueryCommand.kt | 12 +++-- .../kotlin/soil/query/QueryError.kt | 39 ---------------- .../kotlin/soil/query/QueryOptions.kt | 45 ++++++++++++++++--- .../kotlin/soil/query/core/ErrorRecord.kt | 7 +-- .../kotlin/soil/query/MutationOptionsTest.kt | 10 ++++- .../kotlin/soil/query/QueryOptionsTest.kt | 10 ++++- 9 files changed, 115 insertions(+), 101 deletions(-) delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 0ab3e0f..4a897df 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -5,6 +5,7 @@ package soil.query import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext +import soil.query.core.ErrorRecord import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -40,6 +41,8 @@ interface MutationCommand { } } +internal typealias MutationErrorRelay = (ErrorRecord) -> Unit + /** * Determines whether a mutation operation is necessary based on the current state. * @@ -139,9 +142,12 @@ fun MutationCommand.Context.reportMutationError(error: Throwable, id: Uni if (options.onError == null && relay == null) { return } - val record = MutationError(error, id, state) - options.onError?.invoke(record) - relay?.invoke(record) + val record = ErrorRecord(error, id) + options.onError?.invoke(record, state) + val errorRelay = relay + if (errorRelay != null && options.shouldSuppressErrorRelay?.invoke(record, state) != true) { + errorRelay(record) + } } internal fun MutationCommand.Context.onRetryCallback( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt deleted file mode 100644 index bb544e9..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationError.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import soil.query.core.ErrorRecord -import soil.query.core.UniqueId - -/** - * Mutation error information that can be received via a back-channel. - */ -class MutationError @PublishedApi internal constructor( - override val exception: Throwable, - override val key: UniqueId, - - /** - * The mutation state that caused the error. - */ - val model: MutationModel<*> -) : ErrorRecord { - - override fun toString(): String { - return """ - MutationError( - message=${exception.message}, - key=$key, - model={ - replyUpdatedAt=${model.replyUpdatedAt}, - errorUpdatedAt=${model.errorUpdatedAt}, - status=${model.status}, - mutatedCount=${model.mutatedCount}, - submittedAt=${model.submittedAt}, - } - ) - """.trimIndent() - } -} - -internal typealias MutationErrorRelay = (MutationError) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index c9ec24b..23d2fd7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.core.ActorOptions +import soil.query.core.ErrorRecord import soil.query.core.LoggerFn import soil.query.core.LoggingOptions import soil.query.core.RetryOptions @@ -30,7 +31,12 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { /** * This callback function will be called if some mutation encounters an error. */ - val onError: ((MutationError) -> Unit)? + val onError: ((ErrorRecord, MutationModel<*>) -> Unit)? + + /** + * Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + */ + val shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? /** * Whether the query side effect should be synchronous. If true, side effect will be executed synchronously. @@ -40,7 +46,8 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { companion object Default : MutationOptions { override val isOneShot: Boolean = false override val isStrictMode: Boolean = false - override val onError: ((MutationError) -> Unit)? = null + override val onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = null + override val shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = null override val shouldExecuteEffectSynchronously: Boolean = false // ----- ActorOptions ----- // @@ -60,10 +67,29 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { } } +/** + * Creates a new [MutationOptions] with the specified settings. + * + * @param isOneShot Only allows mutate to execute once while active (until reset). + * @param isStrictMode Requires revision match as a precondition for executing mutate. + * @param onError This callback function will be called if some mutation encounters an error. + * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + * @param shouldExecuteEffectSynchronously Whether the query side effect should be synchronous. + * @param keepAliveTime The duration to keep the actor alive after the last message is processed. + * @param logger The logger function to use for logging. + * @param shouldRetry The predicate function to determine whether to retry on a given exception. + * @param retryCount The maximum number of retry attempts. + * @param retryInitialInterval The initial interval for exponential backoff. + * @param retryMaxInterval The maximum interval for exponential backoff. + * @param retryMultiplier The multiplier for exponential backoff. + * @param retryRandomizationFactor The randomization factor for exponential backoff. + * @param retryRandomizer The random number generator for exponential backoff. + */ fun MutationOptions( isOneShot: Boolean = MutationOptions.isOneShot, isStrictMode: Boolean = MutationOptions.isStrictMode, - onError: ((MutationError) -> Unit)? = MutationOptions.onError, + onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = MutationOptions.onError, + shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = MutationOptions.shouldSuppressErrorRelay, shouldExecuteEffectSynchronously: Boolean = MutationOptions.shouldExecuteEffectSynchronously, keepAliveTime: Duration = MutationOptions.keepAliveTime, logger: LoggerFn? = MutationOptions.logger, @@ -78,7 +104,8 @@ fun MutationOptions( return object : MutationOptions { override val isOneShot: Boolean = isOneShot override val isStrictMode: Boolean = isStrictMode - override val onError: ((MutationError) -> Unit)? = onError + override val onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = onError + override val shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = shouldSuppressErrorRelay override val shouldExecuteEffectSynchronously: Boolean = shouldExecuteEffectSynchronously override val keepAliveTime: Duration = keepAliveTime override val logger: LoggerFn? = logger @@ -92,10 +119,14 @@ fun MutationOptions( } } +/** + * Copies the current [MutationOptions] with the specified settings. + */ fun MutationOptions.copy( isOneShot: Boolean = this.isOneShot, isStrictMode: Boolean = this.isStrictMode, - onError: ((MutationError) -> Unit)? = this.onError, + onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = this.onError, + shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, shouldExecuteEffectSynchronously: Boolean = this.shouldExecuteEffectSynchronously, keepAliveTime: Duration = this.keepAliveTime, logger: LoggerFn? = this.logger, @@ -111,6 +142,7 @@ fun MutationOptions.copy( isOneShot = isOneShot, isStrictMode = isStrictMode, onError = onError, + shouldSuppressErrorRelay = shouldSuppressErrorRelay, shouldExecuteEffectSynchronously = shouldExecuteEffectSynchronously, keepAliveTime = keepAliveTime, logger = logger, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index fd6db80..36fb677 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.ErrorRecord import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -38,6 +39,8 @@ interface QueryCommand { } } +internal typealias QueryErrorRelay = (ErrorRecord) -> Unit + /** * Determines whether a fetch operation is necessary based on the current state. * @@ -144,9 +147,12 @@ fun QueryCommand.Context.reportQueryError(error: Throwable, id: UniqueId) if (options.onError == null && relay == null) { return } - val record = QueryError(error, id, state) - options.onError?.invoke(record) - relay?.invoke(record) + val record = ErrorRecord(error, id) + options.onError?.invoke(record, state) + val errorRelay = relay + if (errorRelay != null && options.shouldSuppressErrorRelay?.invoke(record, state) != true) { + errorRelay(record) + } } internal fun QueryCommand.Context.onRetryCallback( diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt deleted file mode 100644 index 76d241c..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryError.kt +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import soil.query.core.ErrorRecord -import soil.query.core.UniqueId - -/** - * Query error information that can be received via a back-channel. - */ -class QueryError @PublishedApi internal constructor( - override val exception: Throwable, - override val key: UniqueId, - - /** - * The query model that caused the error. - */ - val model: QueryModel<*> -) : ErrorRecord { - - override fun toString(): String { - return """ - QueryError( - message=${exception.message}, - key=$key, - model={ - replyUpdatedAt=${model.replyUpdatedAt}, - errorUpdatedAt=${model.errorUpdatedAt}, - staleAt=${model.staleAt}, - status=${model.status}, - isInvalidated=${model.isInvalidated} - } - ) - """.trimIndent() - } -} - -internal typealias QueryErrorRelay = (QueryError) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index a6ce4c8..5849c46 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.core.ActorOptions +import soil.query.core.ErrorRecord import soil.query.core.LoggerFn import soil.query.core.LoggingOptions import soil.query.core.RetryOptions @@ -63,7 +64,12 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { /** * This callback function will be called if some query encounters an error. */ - val onError: ((QueryError) -> Unit)? + val onError: ((ErrorRecord, QueryModel<*>) -> Unit)? + + /** + * Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + */ + val shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)? companion object Default : QueryOptions { override val staleTime: Duration = Duration.ZERO @@ -72,7 +78,8 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { override val pauseDurationAfter: ((Throwable) -> Duration?)? = null override val revalidateOnReconnect: Boolean = true override val revalidateOnFocus: Boolean = true - override val onError: ((QueryError) -> Unit)? = null + override val onError: ((ErrorRecord, QueryModel<*>) -> Unit)? = null + override val shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)? = null // ----- ActorOptions ----- // override val keepAliveTime: Duration = 5.seconds @@ -93,6 +100,27 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { } } +/** + * Creates a new [QueryOptions] with the specified settings. + * + * @param staleTime The duration after which the returned value of the fetch function block is considered stale. + * @param gcTime The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. + * @param prefetchWindowTime Maximum window time on prefetch processing. + * @param pauseDurationAfter Determines whether query processing needs to be paused based on error. + * @param revalidateOnReconnect Automatically revalidate active [Query] when the network reconnects. + * @param revalidateOnFocus Automatically revalidate active [Query] when the window is refocused. + * @param onError This callback function will be called if some query encounters an error. + * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + * @param keepAliveTime The duration to keep the actor alive after the last command is executed. + * @param logger The logger function. + * @param shouldRetry Determines whether to retry the command when an error occurs. + * @param retryCount The number of times to retry the command. + * @param retryInitialInterval The initial interval for exponential backoff. + * @param retryMaxInterval The maximum interval for exponential backoff. + * @param retryMultiplier The multiplier for exponential backoff. + * @param retryRandomizationFactor The randomization factor for exponential backoff. + * @param retryRandomizer The randomizer for exponential backoff. + */ fun QueryOptions( staleTime: Duration = QueryOptions.staleTime, gcTime: Duration = QueryOptions.gcTime, @@ -100,7 +128,8 @@ fun QueryOptions( pauseDurationAfter: ((Throwable) -> Duration?)? = QueryOptions.pauseDurationAfter, revalidateOnReconnect: Boolean = QueryOptions.revalidateOnReconnect, revalidateOnFocus: Boolean = QueryOptions.revalidateOnFocus, - onError: ((QueryError) -> Unit)? = QueryOptions.onError, + onError: ((ErrorRecord, QueryModel<*>) -> Unit)? = QueryOptions.onError, + shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)? = QueryOptions.shouldSuppressErrorRelay, keepAliveTime: Duration = QueryOptions.keepAliveTime, logger: LoggerFn? = QueryOptions.logger, shouldRetry: (Throwable) -> Boolean = QueryOptions.shouldRetry, @@ -118,7 +147,8 @@ fun QueryOptions( override val pauseDurationAfter: ((Throwable) -> Duration?)? = pauseDurationAfter override val revalidateOnReconnect: Boolean = revalidateOnReconnect override val revalidateOnFocus: Boolean = revalidateOnFocus - override val onError: ((QueryError) -> Unit)? = onError + override val onError: ((ErrorRecord, QueryModel<*>) -> Unit)? = onError + override val shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)? = shouldSuppressErrorRelay override val keepAliveTime: Duration = keepAliveTime override val logger: LoggerFn? = logger override val shouldRetry: (Throwable) -> Boolean = shouldRetry @@ -131,6 +161,9 @@ fun QueryOptions( } } +/** + * Copies the current [QueryOptions] with the specified settings. + */ fun QueryOptions.copy( staleTime: Duration = this.staleTime, gcTime: Duration = this.gcTime, @@ -138,7 +171,8 @@ fun QueryOptions.copy( pauseDurationAfter: ((Throwable) -> Duration?)? = this.pauseDurationAfter, revalidateOnReconnect: Boolean = this.revalidateOnReconnect, revalidateOnFocus: Boolean = this.revalidateOnFocus, - onError: ((QueryError) -> Unit)? = this.onError, + onError: ((ErrorRecord, QueryModel<*>) -> Unit)? = this.onError, + shouldSuppressErrorRelay: ((ErrorRecord, QueryModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, keepAliveTime: Duration = this.keepAliveTime, logger: LoggerFn? = this.logger, shouldRetry: (Throwable) -> Boolean = this.shouldRetry, @@ -157,6 +191,7 @@ fun QueryOptions.copy( revalidateOnReconnect = revalidateOnReconnect, revalidateOnFocus = revalidateOnFocus, onError = onError, + shouldSuppressErrorRelay = shouldSuppressErrorRelay, keepAliveTime = keepAliveTime, logger = logger, shouldRetry = shouldRetry, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt index f028a87..450e030 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt @@ -7,11 +7,12 @@ package soil.query.core * Error information that can be received via a back-channel * such as [ErrorRelay] or `onError` options for Query/Mutation. */ -interface ErrorRecord { +data class ErrorRecord internal constructor( + /** * The details of the error. */ - val exception: Throwable + val exception: Throwable, /** * Key information that caused the error. @@ -19,4 +20,4 @@ interface ErrorRecord { * NOTE: Defining an ID with a custom interface, such as metadata, can be helpful when receiving error information. */ val key: UniqueId -} +) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt index 126457f..2ddf1e3 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -18,6 +18,7 @@ class MutationOptionsTest : UnitTest() { assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(MutationOptions.Default.logger, actual.logger) @@ -35,7 +36,8 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions( isOneShot = true, isStrictMode = true, - onError = { }, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, @@ -50,6 +52,7 @@ class MutationOptionsTest : UnitTest() { assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(MutationOptions.Default.logger, actual.logger) @@ -68,6 +71,7 @@ class MutationOptionsTest : UnitTest() { assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertEquals(MutationOptions.Default.onError, actual.onError) + assertEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(MutationOptions.Default.logger, actual.logger) @@ -85,7 +89,8 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions.Default.copy( isOneShot = true, isStrictMode = true, - onError = { }, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, shouldExecuteEffectSynchronously = true, keepAliveTime = 4000.seconds, logger = { _ -> }, @@ -100,6 +105,7 @@ class MutationOptionsTest : UnitTest() { assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) assertNotEquals(MutationOptions.Default.onError, actual.onError) + assertNotEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) assertNotEquals(MutationOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(MutationOptions.Default.logger, actual.logger) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt index 639eff9..69b7121 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt @@ -22,6 +22,7 @@ class QueryOptionsTest : UnitTest() { assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) assertEquals(QueryOptions.Default.onError, actual.onError) + assertEquals(QueryOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(QueryOptions.Default.logger, actual.logger) assertEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) @@ -42,7 +43,8 @@ class QueryOptionsTest : UnitTest() { pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, - onError = { }, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, @@ -60,6 +62,7 @@ class QueryOptionsTest : UnitTest() { assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) assertNotEquals(QueryOptions.Default.onError, actual.onError) + assertNotEquals(QueryOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(QueryOptions.Default.logger, actual.logger) assertNotEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) @@ -81,6 +84,7 @@ class QueryOptionsTest : UnitTest() { assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) assertEquals(QueryOptions.Default.onError, actual.onError) + assertEquals(QueryOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) assertEquals(QueryOptions.Default.logger, actual.logger) assertEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) @@ -101,7 +105,8 @@ class QueryOptionsTest : UnitTest() { pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, - onError = { }, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, logger = { _ -> }, shouldRetry = { _ -> true }, @@ -119,6 +124,7 @@ class QueryOptionsTest : UnitTest() { assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) assertNotEquals(QueryOptions.Default.onError, actual.onError) + assertNotEquals(QueryOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(QueryOptions.Default.keepAliveTime, actual.keepAliveTime) assertNotEquals(QueryOptions.Default.logger, actual.logger) assertNotEquals(QueryOptions.Default.shouldRetry, actual.shouldRetry) From da7395a997542dada258e6019ac1541ef879f133 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 25 Aug 2024 08:45:58 +0900 Subject: [PATCH 105/155] Use a companion object to `QueryId` and `InfiniteQueryId` With the enhancement of preview and test implementations, more APIs now accept `UniqueId` types as arguments. Associating the ID definitions with a companion object helps leverage these features more effectively. refs: #63, #70 --- .../query/key/albums/GetAlbumPhotosKey.kt | 12 ++++++------ .../playground/query/key/albums/GetAlbumsKey.kt | 12 ++++++------ .../playground/query/key/posts/CreatePostKey.kt | 3 ++- .../playground/query/key/posts/DeletePostKey.kt | 6 ++++-- .../query/key/posts/GetPostCommentsKey.kt | 12 ++++++------ .../playground/query/key/posts/GetPostKey.kt | 14 +++++++------- .../playground/query/key/posts/GetPostsKey.kt | 16 ++++++++-------- .../playground/query/key/posts/UpdatePostKey.kt | 6 ++++-- .../query/key/users/GetUserAlbumsKey.kt | 12 ++++++------ .../playground/query/key/users/GetUserKey.kt | 14 +++++++------- .../query/key/users/GetUserPostsKey.kt | 12 ++++++------ .../query/key/users/GetUserTodosKey.kt | 12 ++++++------ .../playground/query/key/users/GetUsersKey.kt | 12 ++++++------ .../kotlin/soil/query/InfiniteQueryKey.kt | 2 ++ .../src/commonMain/kotlin/soil/query/QueryKey.kt | 2 ++ 15 files changed, 78 insertions(+), 69 deletions(-) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt index 2235fca..316796b 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumPhotosKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetAlbumPhotosKey(albumId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(albumId), + id = InfiniteQueryId.forGetAlbumPhotos(albumId), fetch = { param -> get("https://jsonplaceholder.typicode.com/albums/$albumId/photos") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetAlbumPhotosKey(albumId: Int) : InfiniteQueryKey by b ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id(albumId: Int) : InfiniteQueryId( - namespace = "albums/$albumId/photos/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetAlbumPhotos(albumId: Int) = InfiniteQueryId( + namespace = "albums/$albumId/photos/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt index 07f291c..faf3a49 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/albums/GetAlbumsKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetAlbumsKey : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(), + id = InfiniteQueryId.forGetAlbums(), fetch = { param -> get("https://jsonplaceholder.typicode.com/albums") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetAlbumsKey : InfiniteQueryKey by buildKtorInfiniteQue ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id : InfiniteQueryId( - namespace = "albums/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetAlbums() = InfiniteQueryId( + namespace = "albums/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt index e65399b..362442e 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/CreatePostKey.kt @@ -4,6 +4,7 @@ import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import soil.playground.query.data.Post +import soil.query.InfiniteQueryId import soil.query.MutationKey import soil.query.QueryEffect import soil.query.receivers.ktor.buildKtorMutationKey @@ -17,7 +18,7 @@ class CreatePostKey : MutationKey by buildKtorMutationKey( } ) { override fun onQueryUpdate(variable: PostForm, data: Post): QueryEffect = { - invalidateQueriesBy(GetPostsKey.Id()) + invalidateQueriesBy(InfiniteQueryId.forGetPosts()) } } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt index 4fe84c2..cbdd788 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/DeletePostKey.kt @@ -1,8 +1,10 @@ package soil.playground.query.key.posts import io.ktor.client.request.delete +import soil.query.InfiniteQueryId import soil.query.MutationKey import soil.query.QueryEffect +import soil.query.QueryId import soil.query.receivers.ktor.buildKtorMutationKey class DeletePostKey : MutationKey by buildKtorMutationKey( @@ -12,7 +14,7 @@ class DeletePostKey : MutationKey by buildKtorMutationKey( } ) { override fun onQueryUpdate(variable: Int, data: Unit): QueryEffect = { - removeQueriesBy(GetPostKey.Id(variable)) - invalidateQueriesBy(GetPostsKey.Id()) + removeQueriesBy(QueryId.forGetPost(variable)) + invalidateQueriesBy(InfiniteQueryId.forGetPosts()) } } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt index 083c862..340d9c5 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostCommentsKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetPostCommentsKey(private val postId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(postId), + id = InfiniteQueryId.forGetPostComments(postId), fetch = { param -> get("https://jsonplaceholder.typicode.com/posts/$postId/comments") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetPostCommentsKey(private val postId: Int) : InfiniteQueryKey( - namespace = "posts/$postId/comments/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetPostComments(postId: Int) = InfiniteQueryId( + namespace = "posts/$postId/comments/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt index 0730bb7..8b561f4 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostKey.kt @@ -3,6 +3,7 @@ package soil.playground.query.key.posts import io.ktor.client.call.body import io.ktor.client.request.get import soil.playground.query.data.Post +import soil.query.InfiniteQueryId import soil.query.QueryId import soil.query.QueryInitialData import soil.query.QueryKey @@ -10,19 +11,18 @@ import soil.query.chunkedData import soil.query.receivers.ktor.buildKtorQueryKey class GetPostKey(private val postId: Int) : QueryKey by buildKtorQueryKey( - id = Id(postId), + id = QueryId.forGetPost(postId), fetch = { get("https://jsonplaceholder.typicode.com/posts/$postId").body() } ) { - override fun onInitialData(): QueryInitialData = { - getInfiniteQueryData(GetPostsKey.Id())?.let { + getInfiniteQueryData(InfiniteQueryId.forGetPosts())?.let { it.chunkedData.firstOrNull { post -> post.id == postId } } } - - class Id(postId: Int) : QueryId( - namespace = "posts/$postId" - ) } + +fun QueryId.Companion.forGetPost(postId: Int) = QueryId( + namespace = "posts/$postId" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt index 00ba1c9..c1f004a 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/GetPostsKey.kt @@ -12,8 +12,8 @@ import soil.query.receivers.ktor.buildKtorInfiniteQueryKey // NOTE: userId // Filtering resources // ref. https://jsonplaceholder.typicode.com/guide/ -class GetPostsKey(userId: Int? = null) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(userId), +class GetPostsKey(val userId: Int? = null) : InfiniteQueryKey by buildKtorInfiniteQueryKey( + id = InfiniteQueryId.forGetPosts(userId), fetch = { param -> get("https://jsonplaceholder.typicode.com/posts") { parameter("_start", param.offset) @@ -29,9 +29,9 @@ class GetPostsKey(userId: Int? = null) : InfiniteQueryKey by b ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id(userId: Int? = null) : InfiniteQueryId( - namespace = "posts/*", - "userId" to userId - ) -} +) + +fun InfiniteQueryId.Companion.forGetPosts(userId: Int? = null) = InfiniteQueryId( + namespace = "posts/*", + "userId" to userId +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt index 55f232c..2a7698d 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/posts/UpdatePostKey.kt @@ -4,8 +4,10 @@ import io.ktor.client.call.body import io.ktor.client.request.put import io.ktor.client.request.setBody import soil.playground.query.data.Post +import soil.query.InfiniteQueryId import soil.query.MutationKey import soil.query.QueryEffect +import soil.query.QueryId import soil.query.modifyData import soil.query.receivers.ktor.buildKtorMutationKey @@ -18,7 +20,7 @@ class UpdatePostKey : MutationKey by buildKtorMutationKey( } ) { override fun onQueryUpdate(variable: Post, data: Post): QueryEffect = { - updateQueryData(GetPostKey.Id(data.id)) { data } - updateInfiniteQueryData(GetPostsKey.Id()) { modifyData({ it.id == data.id }) { data } } + updateQueryData(QueryId.forGetPost(data.id)) { data } + updateInfiniteQueryData(InfiniteQueryId.forGetPosts()) { modifyData({ it.id == data.id }) { data } } } } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt index 1a11898..1b426e7 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserAlbumsKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetUserAlbumsKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(userId), + id = InfiniteQueryId.forGetUserAlbums(userId), fetch = { param -> get("https://jsonplaceholder.typicode.com/users/$userId/albums") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetUserAlbumsKey(userId: Int) : InfiniteQueryKey by bui ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id(userId: Int) : InfiniteQueryId( - namespace = "users/$userId/albums/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetUserAlbums(userId: Int) = InfiniteQueryId( + namespace = "users/$userId/albums/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt index 6e3f596..3996609 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserKey.kt @@ -3,6 +3,7 @@ package soil.playground.query.key.users import io.ktor.client.call.body import io.ktor.client.request.get import soil.playground.query.data.User +import soil.query.InfiniteQueryId import soil.query.QueryId import soil.query.QueryInitialData import soil.query.QueryKey @@ -10,19 +11,18 @@ import soil.query.chunkedData import soil.query.receivers.ktor.buildKtorQueryKey class GetUserKey(private val userId: Int) : QueryKey by buildKtorQueryKey( - id = Id(userId), + id = QueryId.forGetUser(userId), fetch = { get("https://jsonplaceholder.typicode.com/users/$userId").body() } ) { - override fun onInitialData(): QueryInitialData = { - getInfiniteQueryData(GetUsersKey.Id())?.let { + getInfiniteQueryData(InfiniteQueryId.forGetUsers())?.let { it.chunkedData.firstOrNull { user -> user.id == userId } } } - - class Id(userId: Int) : QueryId( - namespace = "users/$userId" - ) } + +fun QueryId.Companion.forGetUser(userId: Int) = QueryId( + namespace = "users/$userId" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt index 34ccbec..b89ac08 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserPostsKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetUserPostsKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(userId), + id = InfiniteQueryId.forGetUserPosts(userId), fetch = { param -> get("https://jsonplaceholder.typicode.com/users/$userId/posts") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetUserPostsKey(userId: Int) : InfiniteQueryKey by build ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id(userId: Int) : InfiniteQueryId( - namespace = "users/$userId/posts/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetUserPosts(userId: Int) = InfiniteQueryId( + namespace = "users/$userId/posts/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt index 8760397..1a2f8e1 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUserTodosKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetUserTodosKey(userId: Int) : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(userId), + id = InfiniteQueryId.forGetUserTodos(userId), fetch = { param -> get("https://jsonplaceholder.typicode.com/users/$userId/todos") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetUserTodosKey(userId: Int) : InfiniteQueryKey by build ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id(userId: Int) : InfiniteQueryId( - namespace = "users/$userId/todos/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetUserTodos(userId: Int) = InfiniteQueryId( + namespace = "users/$userId/todos/*" +) diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt index 9df94fb..d83c83e 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/users/GetUsersKey.kt @@ -10,7 +10,7 @@ import soil.query.InfiniteQueryKey import soil.query.receivers.ktor.buildKtorInfiniteQueryKey class GetUsersKey : InfiniteQueryKey by buildKtorInfiniteQueryKey( - id = Id(), + id = InfiniteQueryId.forGetUsers(), fetch = { param -> get("https://jsonplaceholder.typicode.com/users") { parameter("_start", param.offset) @@ -23,8 +23,8 @@ class GetUsersKey : InfiniteQueryKey by buildKtorInfiniteQuery ?.takeIf { it.data.isNotEmpty() } ?.run { param.copy(offset = param.offset + param.limit) } } -) { - class Id : InfiniteQueryId( - namespace = "users/*" - ) -} +) + +fun InfiniteQueryId.Companion.forGetUsers() = InfiniteQueryId( + namespace = "users/*" +) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index 8cc9a29..ccd9424 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -82,6 +82,8 @@ open class InfiniteQueryId( override fun toString(): String { return "InfiniteQueryId(namespace='$namespace', tags=${tags.contentToString()})" } + + companion object } internal fun InfiniteQueryKey.hasMore(chunks: QueryChunks): Boolean { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index 4c0c1cc..d168ce5 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -100,6 +100,8 @@ open class QueryId( override fun toString(): String { return "QueryId(namespace='$namespace', tags=${tags.contentToString()})" } + + companion object } /** From bd6cca12c3fbd4b3fedd1de74dc083a6b61c4346 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 25 Aug 2024 09:19:35 +0900 Subject: [PATCH 106/155] Replace DerivedState with StateFlow The `derivedStateOf` defined in the Composable functions of the query-compose-runtime module has been replaced with `StateFlow`, which offers the same functionality. The StateFlow does not emit a value if it remains unchanged, ensuring that the behavior remains unaltered. - ErrorBoundary - Suspense --- .../query/compose/runtime/ErrorBoundary.kt | 23 +++++++++++------- .../soil/query/compose/runtime/Suspense.kt | 24 ++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt index fdeac3a..6aa5bed 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/ErrorBoundary.kt @@ -9,12 +9,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * Wrap an ErrorBoundary around other [Catch] composable functions to catch errors and render a fallback UI. @@ -71,7 +72,7 @@ fun ErrorBoundary( state: ErrorBoundaryState = remember { ErrorBoundaryState() }, content: @Composable () -> Unit ) { - val currentError by remember(state) { derivedStateOf { state.error } } + val currentError by state.error.collectAsState() val onErrorCallback by rememberUpdatedState(newValue = onError) val onResetCallback by rememberUpdatedState(newValue = onReset) Box(modifier) { @@ -115,13 +116,11 @@ class ErrorBoundaryContext( */ @Stable class ErrorBoundaryState : CatchThrowHost { - private val hostMap = mutableStateMapOf() + private val hostMap = mutableMapOf() - /** - * Returns the caught error. - */ - val error: Throwable? - get() = hostMap.values.firstOrNull() + // FIXME: This can be fixed by enabling K2 mode + private val _error = MutableStateFlow(null) + val error: StateFlow = _error override val keys: Set get() = hostMap.keys @@ -131,9 +130,15 @@ class ErrorBoundaryState : CatchThrowHost { override fun set(key: Any, error: Throwable) { hostMap[key] = error + onStateChanged() } override fun remove(key: Any) { hostMap.remove(key) + onStateChanged() + } + + private fun onStateChanged() { + _error.value = hostMap.values.firstOrNull() } } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt index 4322d84..b384fa9 100644 --- a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Suspense.kt @@ -9,14 +9,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -55,9 +56,7 @@ fun Suspense( contentThreshold: Duration = 3.seconds, content: @Composable () -> Unit ) { - val isAwaited by remember(state) { - derivedStateOf { state.isAwaited() } - } + val isAwaited by state.isAwaited.collectAsState() var isFirstTime by remember { mutableStateOf(true) } Box(modifier = modifier) { CompositionLocalProvider(LocalAwaitHost provides state) { @@ -80,7 +79,11 @@ fun Suspense( */ @Stable class SuspenseState : AwaitHost { - private val hostMap = mutableStateMapOf() + private val hostMap = mutableMapOf() + + // FIXME: This can be fixed by enabling K2 mode + private val _isAwaited = MutableStateFlow(false) + val isAwaited: StateFlow = _isAwaited override val keys: Set get() = hostMap.keys @@ -90,16 +93,15 @@ class SuspenseState : AwaitHost { override fun set(key: Any, isAwaited: Boolean) { hostMap[key] = isAwaited + onStateChanged() } override fun remove(key: Any) { hostMap.remove(key) + onStateChanged() } - /** - * Returns `true` if any of the [Await] is awaited. - */ - fun isAwaited(): Boolean { - return hostMap.any { it.value } + private fun onStateChanged() { + _isAwaited.value = hostMap.any { it.value } } } From 73dc43eb3022aef51208fdbdfd346aa9b5972f3d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 25 Aug 2024 10:16:17 +0900 Subject: [PATCH 107/155] Use compose-compiler options It can now be enabled in the build options. ``` ./gradlew -PcomposeCompilerMetrics=true -PcomposeCompilerReports=true .. ``` --- build.gradle.kts | 3 +++ buildSrc/src/main/kotlin/BuildTargetExtension.kt | 4 ++++ gradle.properties | 2 ++ soil-form/build.gradle.kts | 9 +++++++++ soil-query-compose-runtime/build.gradle.kts | 9 +++++++++ soil-query-compose/build.gradle.kts | 9 +++++++++ soil-space/build.gradle.kts | 9 +++++++++ 7 files changed, 45 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 269eda3..bf694b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,9 @@ allprojects { androidMinSdk = providers.gradleProperty("androidMinSdk").map { it.toInt() } androidTargetSdk = providers.gradleProperty("androidTargetSdk").map { it.toInt() } javaVersion = provider { JavaVersion.VERSION_11 } + composeCompilerDestination = layout.buildDirectory.dir("compose-compiler") + composeCompilerMetrics = providers.gradleProperty("composeCompilerMetrics").map { it.toBoolean() } + composeCompilerReports = providers.gradleProperty("composeCompilerReports").map { it.toBoolean() } } tasks.withType().configureEach { diff --git a/buildSrc/src/main/kotlin/BuildTargetExtension.kt b/buildSrc/src/main/kotlin/BuildTargetExtension.kt index e9d0c63..82778f7 100644 --- a/buildSrc/src/main/kotlin/BuildTargetExtension.kt +++ b/buildSrc/src/main/kotlin/BuildTargetExtension.kt @@ -1,5 +1,6 @@ import org.gradle.api.JavaVersion import org.gradle.api.Project +import org.gradle.api.file.Directory import org.gradle.api.provider.Provider open class BuildTargetExtension { @@ -7,6 +8,9 @@ open class BuildTargetExtension { lateinit var androidMinSdk: Provider lateinit var androidTargetSdk: Provider lateinit var javaVersion: Provider + lateinit var composeCompilerDestination: Provider + lateinit var composeCompilerMetrics: Provider + lateinit var composeCompilerReports: Provider } fun Project.buildTarget(block: BuildTargetExtension.() -> Unit) { diff --git a/gradle.properties b/gradle.properties index b2b156c..54f8b28 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,8 @@ version=1.0.0-alpha04 androidCompileSdk=34 androidTargetSdk=34 androidMinSdk=23 +composeCompilerMetrics=false +composeCompilerReports=false #Maven Publish - https://vanniktech.github.io/gradle-maven-publish-plugin/central/#configuring-maven-central SONATYPE_HOST=CENTRAL_PORTAL RELEASE_SIGNING_ENABLED=true diff --git a/soil-form/build.gradle.kts b/soil-form/build.gradle.kts index 658b61f..a9eb39a 100644 --- a/soil-form/build.gradle.kts +++ b/soil-form/build.gradle.kts @@ -84,3 +84,12 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +composeCompiler { + if (buildTarget.composeCompilerMetrics.getOrElse(false)) { + metricsDestination = buildTarget.composeCompilerDestination + } + if (buildTarget.composeCompilerReports.getOrElse(false)) { + reportsDestination = buildTarget.composeCompilerDestination + } +} diff --git a/soil-query-compose-runtime/build.gradle.kts b/soil-query-compose-runtime/build.gradle.kts index ebacd60..55d5724 100644 --- a/soil-query-compose-runtime/build.gradle.kts +++ b/soil-query-compose-runtime/build.gradle.kts @@ -68,3 +68,12 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +composeCompiler { + if (buildTarget.composeCompilerMetrics.getOrElse(false)) { + metricsDestination = buildTarget.composeCompilerDestination + } + if (buildTarget.composeCompilerReports.getOrElse(false)) { + reportsDestination = buildTarget.composeCompilerDestination + } +} diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index a7a5f49..3b4c9f5 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -99,3 +99,12 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +composeCompiler { + if (buildTarget.composeCompilerMetrics.getOrElse(false)) { + metricsDestination = buildTarget.composeCompilerDestination + } + if (buildTarget.composeCompilerReports.getOrElse(false)) { + reportsDestination = buildTarget.composeCompilerDestination + } +} diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index 904e390..0fa60e6 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -101,3 +101,12 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +composeCompiler { + if (buildTarget.composeCompilerMetrics.getOrElse(false)) { + metricsDestination = buildTarget.composeCompilerDestination + } + if (buildTarget.composeCompilerReports.getOrElse(false)) { + reportsDestination = buildTarget.composeCompilerDestination + } +} From 9443ede0d8ad1e41070925587254263a4237ea14 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Thu, 22 Aug 2024 13:37:16 +0900 Subject: [PATCH 108/155] Add kover plugin --- Makefile | 4 +++ build.gradle.kts | 27 +++++++++++++++++++++ gradle/libs.versions.toml | 2 ++ soil-form/build.gradle.kts | 9 +++++++ soil-query-compose-runtime/build.gradle.kts | 9 +++++++ soil-query-compose/build.gradle.kts | 9 +++++++ soil-query-core/build.gradle.kts | 9 +++++++ soil-query-receivers/ktor/build.gradle.kts | 9 +++++++ soil-query-test/build.gradle.kts | 9 +++++++ soil-serialization-bundle/build.gradle.kts | 10 ++++++++ soil-space/build.gradle.kts | 9 +++++++ 11 files changed, 106 insertions(+) diff --git a/Makefile b/Makefile index ad7909d..1ce9a3d 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,10 @@ test: fmt: @$(GRADLE_CMD) spotlessApply +.PHONY: kover +kover: + @$(GRADLE_CMD) koverHtmlReport + .PHONY: dokka dokka: @$(GRADLE_CMD) dokkaHtmlMultiModule diff --git a/build.gradle.kts b/build.gradle.kts index bf694b9..47d2b3c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.maven.publish) apply false alias(libs.plugins.dokka) + alias(libs.plugins.kover) alias(libs.plugins.spotless) } @@ -68,3 +69,29 @@ allprojects { } } } + +kover { + currentProject { + createVariant("soil") { + + } + } + reports { + filters { + excludes { + androidGeneratedClasses() + } + } + } +} + +dependencies { + kover(projects.soilQueryCore) + kover(projects.soilQueryCompose) + kover(projects.soilQueryComposeRuntime) + kover(projects.soilQueryReceivers.ktor) + kover(projects.soilQueryTest) + kover(projects.soilSerializationBundle) + kover(projects.soilForm) + kover(projects.soilSpace) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7ee25ee..bf42953 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ junit = "4.13.2" kotlin = "2.0.20" kotlinx-coroutines = "1.8.1" kotlinx-serialization = "1.7.0" +kover = "0.8.3" ktor = "3.0.0-beta-2" maven-publish = "0.28.0" robolectric = "4.12.2" @@ -67,5 +68,6 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publish" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/soil-form/build.gradle.kts b/soil-form/build.gradle.kts index a9eb39a..6efa806 100644 --- a/soil-form/build.gradle.kts +++ b/soil-form/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -93,3 +94,11 @@ composeCompiler { reportsDestination = buildTarget.composeCompilerDestination } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-query-compose-runtime/build.gradle.kts b/soil-query-compose-runtime/build.gradle.kts index 55d5724..87f2e14 100644 --- a/soil-query-compose-runtime/build.gradle.kts +++ b/soil-query-compose-runtime/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -77,3 +78,11 @@ composeCompiler { reportsDestination = buildTarget.composeCompilerDestination } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index 3b4c9f5..1bbc44c 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -108,3 +109,11 @@ composeCompiler { reportsDestination = buildTarget.composeCompilerDestination } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index fc234c0..e52c41c 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -93,3 +94,11 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-query-receivers/ktor/build.gradle.kts b/soil-query-receivers/ktor/build.gradle.kts index b63255d..eebe310 100644 --- a/soil-query-receivers/ktor/build.gradle.kts +++ b/soil-query-receivers/ktor/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -64,3 +65,11 @@ android { unitTests.isIncludeAndroidResources = true } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-query-test/build.gradle.kts b/soil-query-test/build.gradle.kts index bf43fa7..6e99fd6 100644 --- a/soil-query-test/build.gradle.kts +++ b/soil-query-test/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -70,3 +71,11 @@ android { unitTests.isIncludeAndroidResources = true } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} diff --git a/soil-serialization-bundle/build.gradle.kts b/soil-serialization-bundle/build.gradle.kts index 830720c..885ad43 100644 --- a/soil-serialization-bundle/build.gradle.kts +++ b/soil-serialization-bundle/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -72,3 +73,12 @@ android { debugImplementation(libs.compose.ui.tooling) } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} + diff --git a/soil-space/build.gradle.kts b/soil-space/build.gradle.kts index 0fa60e6..82c3b57 100644 --- a/soil-space/build.gradle.kts +++ b/soil-space/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.maven.publish) alias(libs.plugins.dokka) + alias(libs.plugins.kover) } val buildTarget = the() @@ -110,3 +111,11 @@ composeCompiler { reportsDestination = buildTarget.composeCompilerDestination } } + +kover { + currentProject { + createVariant("soil") { + add("debug") + } + } +} From 7607a715ec02b86b8fec99c91c8b6047e625ecc2 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 25 Aug 2024 01:56:30 +0000 Subject: [PATCH 109/155] Apply automatic changes --- soil-serialization-bundle/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/soil-serialization-bundle/build.gradle.kts b/soil-serialization-bundle/build.gradle.kts index 885ad43..5cd1f25 100644 --- a/soil-serialization-bundle/build.gradle.kts +++ b/soil-serialization-bundle/build.gradle.kts @@ -81,4 +81,3 @@ kover { } } } - From 464b6222c74de3c590e73045f9dfc119c01aa919 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 25 Aug 2024 17:00:33 +0900 Subject: [PATCH 110/155] Implement a Configuration Class for Compose In #69, we implemented QueryCachingStrategy. To avoid the undesired practice of continually adding more arguments with future feature additions, we have introduced a separate configuration class specifically for Composable functions. --- .../query/compose/RememberGetPostQuery.kt | 8 ++- .../query/compose/RememberGetPostsQuery.kt | 8 ++- .../compose/RememberGetUserPostsQuery.kt | 8 ++- .../query/compose/RememberGetUserQuery.kt | 8 ++- .../query/compose/InfiniteQueryComposable.kt | 14 +++-- .../soil/query/compose/InfiniteQueryConfig.kt | 45 ++++++++++++++ .../soil/query/compose/MutationComposable.kt | 4 +- .../soil/query/compose/MutationConfig.kt | 40 +++++++++++++ .../soil/query/compose/QueryComposable.kt | 14 +++-- .../kotlin/soil/query/compose/QueryConfig.kt | 45 ++++++++++++++ .../compose/tooling/MutationPreviewClient.kt | 11 ++-- .../compose/tooling/QueryPreviewClient.kt | 31 ++++++---- .../kotlin/soil/query/InfiniteQueryCommand.kt | 15 +++-- .../soil/query/InfiniteQueryCommands.kt | 20 +++++-- .../kotlin/soil/query/InfiniteQueryRef.kt | 9 +-- .../kotlin/soil/query/MutationClient.kt | 5 +- .../kotlin/soil/query/MutationCommand.kt | 14 +++-- .../kotlin/soil/query/MutationCommands.kt | 6 +- .../kotlin/soil/query/MutationRef.kt | 7 ++- .../kotlin/soil/query/QueryClient.kt | 21 +++++-- .../kotlin/soil/query/QueryCommand.kt | 12 ++-- .../kotlin/soil/query/QueryCommands.kt | 11 +++- .../commonMain/kotlin/soil/query/QueryRef.kt | 7 ++- .../commonMain/kotlin/soil/query/SwrCache.kt | 59 +++++++++++++------ .../kotlin/soil/query/SwrInfiniteQuery.kt | 3 +- .../kotlin/soil/query/SwrMutation.kt | 3 +- .../commonMain/kotlin/soil/query/SwrQuery.kt | 3 +- .../kotlin/soil/query/core/ErrorRecord.kt | 11 ++-- .../kotlin/soil/query/core/Marker.kt | 16 +++++ .../kotlin/soil/query/test/TestSwrClient.kt | 28 ++++++--- .../soil/query/test/TestSwrClientTest.kt | 5 +- 31 files changed, 383 insertions(+), 108 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/core/Marker.kt diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostQuery.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostQuery.kt index c23d7a6..ae00748 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostQuery.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostQuery.kt @@ -4,13 +4,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import soil.playground.query.data.Post import soil.playground.query.key.posts.GetPostKey +import soil.query.compose.QueryConfig import soil.query.compose.QueryObject import soil.query.compose.rememberQuery typealias GetPostQueryObject = QueryObject @Composable -fun rememberGetPostQuery(postId: Int): GetPostQueryObject { +fun rememberGetPostQuery( + postId: Int, + builderBlock: QueryConfig.Builder.() -> Unit = {} +): GetPostQueryObject { val key = remember(postId) { GetPostKey(postId) } - return rememberQuery(key) + return rememberQuery(key, QueryConfig(builderBlock)) } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostsQuery.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostsQuery.kt index 0776340..5325792 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostsQuery.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetPostsQuery.kt @@ -6,13 +6,17 @@ import soil.playground.query.data.PageParam import soil.playground.query.data.Posts import soil.playground.query.key.posts.GetPostsKey import soil.query.chunkedData +import soil.query.compose.InfiniteQueryConfig import soil.query.compose.InfiniteQueryObject import soil.query.compose.rememberInfiniteQuery typealias GetPostsQueryObject = InfiniteQueryObject @Composable -fun rememberGetPostsQuery(userId: Int? = null): GetPostsQueryObject { +fun rememberGetPostsQuery( + userId: Int? = null, + builderBlock: InfiniteQueryConfig.Builder.() -> Unit = {} +): GetPostsQueryObject { val key = remember(userId) { GetPostsKey(userId) } - return rememberInfiniteQuery(key, select = { it.chunkedData }) + return rememberInfiniteQuery(key, select = { it.chunkedData }, config = InfiniteQueryConfig(builderBlock)) } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserPostsQuery.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserPostsQuery.kt index 5de243a..996a19c 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserPostsQuery.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserPostsQuery.kt @@ -6,13 +6,17 @@ import soil.playground.query.data.PageParam import soil.playground.query.data.Posts import soil.playground.query.key.users.GetUserPostsKey import soil.query.chunkedData +import soil.query.compose.InfiniteQueryConfig import soil.query.compose.InfiniteQueryObject import soil.query.compose.rememberInfiniteQuery typealias GetUserPostsQueryObject = InfiniteQueryObject @Composable -fun rememberGetUserPostsQuery(userId: Int): GetUserPostsQueryObject { +fun rememberGetUserPostsQuery( + userId: Int, + builderBlock: InfiniteQueryConfig.Builder.() -> Unit = {} +): GetUserPostsQueryObject { val key = remember(userId) { GetUserPostsKey(userId) } - return rememberInfiniteQuery(key = key, select = { it.chunkedData }) + return rememberInfiniteQuery(key = key, select = { it.chunkedData }, config = InfiniteQueryConfig(builderBlock)) } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserQuery.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserQuery.kt index ba4789b..e3b1fa1 100644 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserQuery.kt +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberGetUserQuery.kt @@ -4,13 +4,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import soil.playground.query.data.User import soil.playground.query.key.users.GetUserKey +import soil.query.compose.QueryConfig import soil.query.compose.QueryObject import soil.query.compose.rememberQuery typealias GetUserQueryObject = QueryObject @Composable -fun rememberGetUserQuery(userId: Int): GetUserQueryObject { +fun rememberGetUserQuery( + userId: Int, + builderBlock: QueryConfig.Builder.() -> Unit = {} +): GetUserQueryObject { val key = remember(userId) { GetUserKey(userId) } - return rememberQuery(key) + return rememberQuery(key, config = QueryConfig(builderBlock)) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 6904469..45bb352 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -22,18 +22,19 @@ import soil.query.core.map * @param T Type of data to retrieve. * @param S Type of parameter. * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. + * @param config The configuration for the query. By default, it uses the [InfiniteQueryConfig.Default]. * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [InfiniteQueryObject] each the query state changed. */ @Composable fun rememberInfiniteQuery( key: InfiniteQueryKey, - strategy: QueryCachingStrategy = QueryCachingStrategy, + config: InfiniteQueryConfig = InfiniteQueryConfig.Default, client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() - val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } - return strategy.collectAsState(query).toInfiniteObject(query = query, select = { it }) + val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(query).toInfiniteObject(query = query, select = { it }) } /** @@ -43,6 +44,7 @@ fun rememberInfiniteQuery( * @param S Type of parameter. * @param key The [InfiniteQueryKey] for managing [query][soil.query.Query] associated with [id][soil.query.InfiniteQueryId]. * @param select A function to select data from [QueryChunks]. + * @param config The configuration for the query. By default, it uses the [InfiniteQueryConfig.Default]. * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [InfiniteQueryObject] with selected data each the query state changed. */ @@ -50,12 +52,12 @@ fun rememberInfiniteQuery( fun rememberInfiniteQuery( key: InfiniteQueryKey, select: (chunks: QueryChunks) -> U, - strategy: QueryCachingStrategy = QueryCachingStrategy, + config: InfiniteQueryConfig = InfiniteQueryConfig.Default, client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getInfiniteQuery(key).also { it.launchIn(scope) } } - return strategy.collectAsState(query).toInfiniteObject(query = query, select = select) + val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(query).toInfiniteObject(query = query, select = select) } private fun QueryState>.toInfiniteObject( diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt new file mode 100644 index 0000000..0033851 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt @@ -0,0 +1,45 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Immutable +import soil.query.core.Marker + +/** + * Configuration for the infinite query. + * + * @property strategy The strategy for caching query data. + * @property marker The marker with additional information based on the caller of a query. + */ +@Immutable +data class InfiniteQueryConfig internal constructor( + val strategy: QueryCachingStrategy, + val marker: Marker +) { + + @Suppress("MemberVisibilityCanBePrivate") + class Builder { + var strategy: QueryCachingStrategy = Default.strategy + val marker: Marker = Default.marker + + fun build() = InfiniteQueryConfig( + strategy = strategy, + marker = marker + ) + } + + companion object { + val Default = InfiniteQueryConfig( + strategy = QueryCachingStrategy.Default, + marker = Marker.None + ) + } +} + +/** + * Creates a [InfiniteQueryConfig] with the provided [initializer]. + */ +fun InfiniteQueryConfig(initializer: InfiniteQueryConfig.Builder.() -> Unit): InfiniteQueryConfig { + return InfiniteQueryConfig.Builder().apply(initializer).build() +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 2fcc807..9350917 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -20,16 +20,18 @@ import soil.query.MutationStatus * @param T Type of the return value from the mutation. * @param S Type of the variable to be mutated. * @param key The [MutationKey] for managing [mutation][soil.query.Mutation] associated with [id][soil.query.MutationId]. + * @param config The configuration for the mutation. By default, it uses the [MutationConfig.Default]. * @param client The [MutationClient] to resolve [key]. By default, it uses the [LocalMutationClient]. * @return A [MutationObject] each the mutation state changed. */ @Composable fun rememberMutation( key: MutationKey, + config: MutationConfig = MutationConfig.Default, client: MutationClient = LocalMutationClient.current ): MutationObject { val scope = rememberCoroutineScope() - val mutation = remember(key) { client.getMutation(key).also { it.launchIn(scope) } } + val mutation = remember(key) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } val state by mutation.state.collectAsState() return state.toObject(mutation = mutation) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt new file mode 100644 index 0000000..be0040e --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt @@ -0,0 +1,40 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Immutable +import soil.query.core.Marker + +/** + * Configuration for the mutation. + * + * @property marker The marker with additional information based on the caller of a mutation. + */ +@Immutable +data class MutationConfig internal constructor( + val marker: Marker +) { + + @Suppress("MemberVisibilityCanBePrivate") + class Builder { + val marker: Marker = Default.marker + + fun build() = MutationConfig( + marker = marker + ) + } + + companion object { + val Default = MutationConfig( + marker = Marker.None + ) + } +} + +/** + * Creates a [MutationConfig] with the provided [initializer]. + */ +fun MutationConfig(initializer: MutationConfig.Builder.() -> Unit): MutationConfig { + return MutationConfig.Builder().apply(initializer).build() +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index 1b72b3c..f3d6c26 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -19,18 +19,19 @@ import soil.query.core.map * * @param T Type of data to retrieve. * @param key The [QueryKey] for managing [query][soil.query.Query]. + * @param config The configuration for the query. By default, it uses the [QueryConfig.Default]. * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [QueryObject] each the query state changed. */ @Composable fun rememberQuery( key: QueryKey, - strategy: QueryCachingStrategy = QueryCachingStrategy, + config: QueryConfig = QueryConfig.Default, client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } - return strategy.collectAsState(query).toObject(query = query, select = { it }) + val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(query).toObject(query = query, select = { it }) } /** @@ -40,6 +41,7 @@ fun rememberQuery( * @param U Type of selected data. * @param key The [QueryKey] for managing [query][soil.query.Query]. * @param select A function to select data from [T]. + * @param config The configuration for the query. By default, it uses the [QueryConfig.Default]. * @param client The [QueryClient] to resolve [key]. By default, it uses the [LocalQueryClient]. * @return A [QueryObject] with selected data each the query state changed. */ @@ -47,12 +49,12 @@ fun rememberQuery( fun rememberQuery( key: QueryKey, select: (T) -> U, - strategy: QueryCachingStrategy = QueryCachingStrategy, + config: QueryConfig = QueryConfig.Default, client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getQuery(key).also { it.launchIn(scope) } } - return strategy.collectAsState(query).toObject(query = query, select = select) + val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(query).toObject(query = query, select = select) } private fun QueryState.toObject( diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt new file mode 100644 index 0000000..4d3ea39 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt @@ -0,0 +1,45 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Immutable +import soil.query.core.Marker + +/** + * Configuration for the query. + * + * @property strategy The strategy for caching query data. + * @property marker The marker with additional information based on the caller of a query. + */ +@Immutable +data class QueryConfig internal constructor( + val strategy: QueryCachingStrategy, + val marker: Marker +) { + + @Suppress("MemberVisibilityCanBePrivate") + class Builder { + var strategy: QueryCachingStrategy = Default.strategy + var marker: Marker = Default.marker + + fun build() = QueryConfig( + strategy = strategy, + marker = marker + ) + } + + companion object { + val Default = QueryConfig( + strategy = QueryCachingStrategy.Default, + marker = Marker.None + ) + } +} + +/** + * Creates a [QueryConfig] with the provided [initializer]. + */ +fun QueryConfig(initializer: QueryConfig.Builder.() -> Unit): QueryConfig { + return QueryConfig.Builder().apply(initializer).build() +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt index 7be9604..f0b7e29 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -14,6 +14,7 @@ import soil.query.MutationKey import soil.query.MutationOptions import soil.query.MutationRef import soil.query.MutationState +import soil.query.core.Marker import soil.query.core.UniqueId /** @@ -34,15 +35,17 @@ class MutationPreviewClient( ) : MutationClient { @Suppress("UNCHECKED_CAST") - override fun getMutation(key: MutationKey): MutationRef { + override fun getMutation( + key: MutationKey, + marker: Marker + ): MutationRef { val state = previewData[key.id] as? MutationState ?: MutationState.initial() - val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions - return SnapshotMutation(key, options, MutableStateFlow(state)) + return SnapshotMutation(key, marker, MutableStateFlow(state)) } private class SnapshotMutation( override val key: MutationKey, - override val options: MutationOptions, + override val marker: Marker, override val state: StateFlow> ) : MutationRef { override fun launchIn(scope: CoroutineScope): Job = Job() diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index 7eb68f3..80e386b 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -18,6 +18,7 @@ import soil.query.QueryKey import soil.query.QueryOptions import soil.query.QueryRef import soil.query.QueryState +import soil.query.core.Marker import soil.query.core.UniqueId /** @@ -38,26 +39,36 @@ class QueryPreviewClient( ) : QueryClient { @Suppress("UNCHECKED_CAST") - override fun getQuery(key: QueryKey): QueryRef { + override fun getQuery( + key: QueryKey, + marker: Marker + ): QueryRef { val state = previewData[key.id] as? QueryState ?: QueryState.initial() - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions - return SnapshotQuery(key, options, MutableStateFlow(state)) + return SnapshotQuery(key, marker, MutableStateFlow(state)) } @Suppress("UNCHECKED_CAST") - override fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef { + override fun getInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker + ): InfiniteQueryRef { val state = previewData[key.id] as? QueryState> ?: QueryState.initial() - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions - return SnapshotInfiniteQuery(key, options, MutableStateFlow(state)) + return SnapshotInfiniteQuery(key, marker, MutableStateFlow(state)) } - override fun prefetchQuery(key: QueryKey): Job = Job() + override fun prefetchQuery( + key: QueryKey, + marker: Marker + ): Job = Job() - override fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job = Job() + override fun prefetchInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker + ): Job = Job() private class SnapshotQuery( override val key: QueryKey, - override val options: QueryOptions, + override val marker: Marker, override val state: StateFlow> ) : QueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() @@ -68,7 +79,7 @@ class QueryPreviewClient( private class SnapshotInfiniteQuery( override val key: InfiniteQueryKey, - override val options: QueryOptions, + override val marker: Marker, override val state: StateFlow>> ) : InfiniteQueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index 222c81a..f1c6bf7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.Marker import soil.query.core.RetryFn import soil.query.core.exponentialBackOff import soil.query.core.getOrElse @@ -83,11 +84,14 @@ suspend fun QueryCommand.Context>.revalidate( * @param S Type of parameter. * @param key Instance of a class implementing [InfiniteQueryKey]. * @param variable Value of the parameter required for fetching data for [InfiniteQueryKey]. + * @param marker The marker with additional information based on the caller of a query. + * @param callback Callback to handle the result. */ suspend inline fun QueryCommand.Context>.dispatchFetchChunksResult( key: InfiniteQueryKey, variable: S, - noinline callback: QueryCallback>? = null + marker: Marker, + noinline callback: QueryCallback>? ) { fetch(key, variable) .map { QueryChunk(it, variable) } @@ -95,7 +99,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { reportQueryError(it, key.id) } + .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } } @@ -107,16 +111,19 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC * @param S Type of parameter. * @param key Instance of a class implementing [InfiniteQueryKey]. * @param chunks Data to revalidate. + * @param marker The marker with additional information based on the caller of a query. + * @param callback Callback to handle the result. */ suspend inline fun QueryCommand.Context>.dispatchRevalidateChunksResult( key: InfiniteQueryKey, chunks: QueryChunks, - noinline callback: QueryCallback>? = null + marker: Marker, + noinline callback: QueryCallback>? ) { revalidate(key, chunks) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { reportQueryError(it, key.id) } + .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index 7c4d543..97f6616 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.Marker import soil.query.core.getOrElse import soil.query.core.getOrNull import soil.query.core.vvv @@ -17,10 +18,13 @@ object InfiniteQueryCommands { * * @param key Instance of a class implementing [InfiniteQueryKey]. * @param revision The revision of the data to be fetched. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ class Connect( val key: InfiniteQueryKey, val revision: String? = null, + val marker: Marker = Marker.None, val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { @@ -32,9 +36,9 @@ object InfiniteQueryCommands { ctx.dispatch(QueryAction.Fetching()) val chunks = ctx.state.reply.getOrNull() if (chunks.isNullOrEmpty()) { - ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) + ctx.dispatchFetchChunksResult(key, key.initialParam(), marker, callback) } else { - ctx.dispatchRevalidateChunksResult(key, chunks, callback) + ctx.dispatchRevalidateChunksResult(key, chunks, marker, callback) } } } @@ -46,10 +50,13 @@ object InfiniteQueryCommands { * * @param key Instance of a class implementing [InfiniteQueryKey]. * @param revision The revision of the data to be invalidated. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ class Invalidate( val key: InfiniteQueryKey, val revision: String, + val marker: Marker = Marker.None, val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { @@ -61,9 +68,9 @@ object InfiniteQueryCommands { ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) val chunks = ctx.state.reply.getOrNull() if (chunks.isNullOrEmpty()) { - ctx.dispatchFetchChunksResult(key, key.initialParam(), callback) + ctx.dispatchFetchChunksResult(key, key.initialParam(), marker, callback) } else { - ctx.dispatchRevalidateChunksResult(key, chunks, callback) + ctx.dispatchRevalidateChunksResult(key, chunks, marker, callback) } } } @@ -73,10 +80,13 @@ object InfiniteQueryCommands { * * @param key Instance of a class implementing [InfiniteQueryKey]. * @param param The parameter required for fetching data for [InfiniteQueryKey]. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ class LoadMore( val key: InfiniteQueryKey, val param: S, + val marker: Marker = Marker.None, val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { @@ -88,7 +98,7 @@ object InfiniteQueryCommands { } ctx.dispatch(QueryAction.Fetching()) - ctx.dispatchFetchChunksResult(key, param, callback) + ctx.dispatchFetchChunksResult(key, param, marker, callback) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 06cbe87..db05842 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor +import soil.query.core.Marker import soil.query.core.awaitOrNull /** @@ -18,7 +19,7 @@ import soil.query.core.awaitOrNull interface InfiniteQueryRef : Actor { val key: InfiniteQueryKey - val options: QueryOptions + val marker: Marker val state: StateFlow>> /** @@ -31,7 +32,7 @@ interface InfiniteQueryRef : Actor { */ suspend fun resume() { val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + send(InfiniteQueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) deferred.awaitOrNull() } @@ -40,7 +41,7 @@ interface InfiniteQueryRef : Actor { */ suspend fun loadMore(param: S) { val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.LoadMore(key, param, deferred::completeWith)) + send(InfiniteQueryCommands.LoadMore(key, param, marker, deferred::completeWith)) deferred.awaitOrNull() } @@ -52,7 +53,7 @@ interface InfiniteQueryRef : Actor { */ suspend fun invalidate() { val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + send(InfiniteQueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) deferred.awaitOrNull() } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt index 555f160..82b32af 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt @@ -3,6 +3,8 @@ package soil.query +import soil.query.core.Marker + /** * A Mutation client, which allows you to make mutations actor and handle [MutationKey]. */ @@ -17,7 +19,8 @@ interface MutationClient { * Gets the [MutationRef] by the specified [MutationKey]. */ fun getMutation( - key: MutationKey + key: MutationKey, + marker: Marker = Marker.None ): MutationRef } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 4a897df..6e4f6a7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -6,6 +6,7 @@ package soil.query import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import soil.query.core.ErrorRecord +import soil.query.core.Marker import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -86,10 +87,13 @@ suspend fun MutationCommand.Context.mutate( * * @param key Instance of a class implementing [MutationKey]. * @param variable The variable to be mutated. + * @param marker The marker with additional information based on the caller of a mutation. + * @param callback The callback to receive the result of the mutation. */ suspend inline fun MutationCommand.Context.dispatchMutateResult( key: MutationKey, variable: S, + marker: Marker, noinline callback: MutationCallback? ) { mutate(key, variable) @@ -103,10 +107,8 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( } } .onFailure(::dispatchMutateFailure) - .onFailure { reportMutationError(it, key.id) } - .also { - callback?.invoke(it) - } + .onFailure { reportMutationError(it, key.id, marker) } + .also { callback?.invoke(it) } } /** @@ -138,11 +140,11 @@ fun MutationCommand.Context.dispatchMutateFailure(error: Throwable) { } -fun MutationCommand.Context.reportMutationError(error: Throwable, id: UniqueId) { +fun MutationCommand.Context.reportMutationError(error: Throwable, id: UniqueId, marker: Marker) { if (options.onError == null && relay == null) { return } - val record = ErrorRecord(error, id) + val record = ErrorRecord(error, id, marker) options.onError?.invoke(record, state) val errorRelay = relay if (errorRelay != null && options.shouldSuppressErrorRelay?.invoke(record, state) != true) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index b3120d4..d8b002b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.Marker import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException @@ -16,11 +17,14 @@ object MutationCommands { * @param key Instance of a class implementing [MutationKey]. * @param variable The variable to be mutated. * @param revision The revision of the mutation state. + * @param marker The marker with additional information based on the caller of a mutation. + * @param callback The callback to receive the result of the mutation. */ class Mutate( val key: MutationKey, val variable: S, val revision: String, + val marker: Marker = Marker.None, val callback: MutationCallback? = null ) : MutationCommand { @@ -31,7 +35,7 @@ object MutationCommands { return } ctx.dispatch(MutationAction.Mutating) - ctx.dispatchMutateResult(key, variable, callback) + ctx.dispatchMutateResult(key, variable, marker, callback) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 31b59c4..1686b76 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor +import soil.query.core.Marker /** * A reference to a Mutation for [MutationKey]. @@ -17,7 +18,7 @@ import soil.query.core.Actor interface MutationRef : Actor { val key: MutationKey - val options: MutationOptions + val marker: Marker val state: StateFlow> /** @@ -33,7 +34,7 @@ interface MutationRef : Actor { */ suspend fun mutate(variable: S): T { val deferred = CompletableDeferred() - send(MutationCommands.Mutate(key, variable, state.value.revision, deferred::completeWith)) + send(MutationCommands.Mutate(key, variable, state.value.revision, marker, deferred::completeWith)) return deferred.await() } @@ -43,7 +44,7 @@ interface MutationRef : Actor { * @param variable The variable to be mutated. */ suspend fun mutateAsync(variable: S) { - send(MutationCommands.Mutate(key, variable, state.value.revision)) + send(MutationCommands.Mutate(key, variable, state.value.revision, marker)) } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index e6f4b54..b1b8ad9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -4,6 +4,7 @@ package soil.query import kotlinx.coroutines.Job +import soil.query.core.Marker import soil.query.core.UniqueId /** @@ -19,12 +20,18 @@ interface QueryClient { /** * Gets the [QueryRef] by the specified [QueryKey]. */ - fun getQuery(key: QueryKey): QueryRef + fun getQuery( + key: QueryKey, + marker: Marker = Marker.None + ): QueryRef /** * Gets the [InfiniteQueryRef] by the specified [InfiniteQueryKey]. */ - fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef + fun getInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker = Marker.None + ): InfiniteQueryRef /** * Prefetches the query by the specified [QueryKey]. @@ -33,7 +40,10 @@ interface QueryClient { * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. */ - fun prefetchQuery(key: QueryKey): Job + fun prefetchQuery( + key: QueryKey, + marker: Marker = Marker.None + ): Job /** * Prefetches the infinite query by the specified [InfiniteQueryKey]. @@ -42,7 +52,10 @@ interface QueryClient { * Prefetch is executed within a [kotlinx.coroutines.CoroutineScope] associated with the instance of [QueryClient]. * After data retrieval, subscription is automatically unsubscribed, hence the caching period depends on [QueryOptions]. */ - fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job + fun prefetchInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker = Marker.None + ): Job } /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index 36fb677..996fe38 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -4,6 +4,7 @@ package soil.query import soil.query.core.ErrorRecord +import soil.query.core.Marker import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -100,16 +101,19 @@ suspend fun QueryCommand.Context.fetch( * Dispatches the fetch result. * * @param key Instance of a class implementing [QueryKey]. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ suspend inline fun QueryCommand.Context.dispatchFetchResult( key: QueryKey, - noinline callback: QueryCallback? = null + marker: Marker, + noinline callback: QueryCallback? ) { fetch(key) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } .onSuccess(::dispatchFetchSuccess) .onFailure(::dispatchFetchFailure) - .onFailure { reportQueryError(it, key.id) } + .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } } @@ -143,11 +147,11 @@ fun QueryCommand.Context.dispatchFetchFailure(error: Throwable) { dispatch(action) } -fun QueryCommand.Context.reportQueryError(error: Throwable, id: UniqueId) { +fun QueryCommand.Context.reportQueryError(error: Throwable, id: UniqueId, marker: Marker) { if (options.onError == null && relay == null) { return } - val record = ErrorRecord(error, id) + val record = ErrorRecord(error, id, marker) options.onError?.invoke(record, state) val errorRelay = relay if (errorRelay != null && options.shouldSuppressErrorRelay?.invoke(record, state) != true) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 38e2885..4b08aa8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -3,6 +3,7 @@ package soil.query +import soil.query.core.Marker import soil.query.core.vvv import kotlin.coroutines.cancellation.CancellationException @@ -15,10 +16,13 @@ object QueryCommands { * * @param key Instance of a class implementing [QueryKey]. * @param revision The revision of the data to be fetched. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ class Connect( val key: QueryKey, val revision: String? = null, + val marker: Marker = Marker.None, val callback: QueryCallback? = null ) : QueryCommand { @@ -29,7 +33,7 @@ object QueryCommands { return } ctx.dispatch(QueryAction.Fetching()) - ctx.dispatchFetchResult(key, callback) + ctx.dispatchFetchResult(key, marker, callback) } } @@ -40,10 +44,13 @@ object QueryCommands { * * @param key Instance of a class implementing [QueryKey]. * @param revision The revision of the data to be invalidated. + * @param marker The marker with additional information based on the caller of a query. + * @param callback The callback to receive the result of the query. */ class Invalidate( val key: QueryKey, val revision: String, + val marker: Marker = Marker.None, val callback: QueryCallback? = null ) : QueryCommand { @@ -54,7 +61,7 @@ object QueryCommands { return } ctx.dispatch(QueryAction.Fetching(isInvalidated = true)) - ctx.dispatchFetchResult(key, callback) + ctx.dispatchFetchResult(key, marker, callback) } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 106ce4a..089142c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor +import soil.query.core.Marker import soil.query.core.awaitOrNull /** @@ -17,7 +18,7 @@ import soil.query.core.awaitOrNull interface QueryRef : Actor { val key: QueryKey - val options: QueryOptions + val marker: Marker val state: StateFlow> /** @@ -30,7 +31,7 @@ interface QueryRef : Actor { */ suspend fun resume() { val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, state.value.revision, deferred::completeWith)) + send(QueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) deferred.awaitOrNull() } @@ -42,7 +43,7 @@ interface QueryRef : Actor { */ suspend fun invalidate() { val deferred = CompletableDeferred() - send(QueryCommands.Invalidate(key, state.value.revision, deferred::completeWith)) + send(QueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) deferred.awaitOrNull() } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 5b34b5b..d09561f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -29,6 +29,7 @@ import soil.query.core.ActorBlockRunner import soil.query.core.ActorSequenceNumber import soil.query.core.BatchScheduler import soil.query.core.ErrorRecord +import soil.query.core.Marker import soil.query.core.MemoryPressure import soil.query.core.MemoryPressureLevel import soil.query.core.NetworkConnectivity @@ -185,21 +186,21 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie @Suppress("UNCHECKED_CAST") override fun getMutation( - key: MutationKey + key: MutationKey, + marker: Marker ): MutationRef { val id = key.id - val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions var mutation = mutationStore[id] as? ManagedMutation if (mutation == null) { mutation = newMutation( id = id, - options = options, + options = key.configureOptions(defaultMutationOptions), initialValue = MutationState() ).also { mutationStore[id] = it } } return SwrMutation( key = key, - options = options, + marker = marker, mutation = mutation ) } @@ -260,20 +261,22 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } @Suppress("UNCHECKED_CAST") - override fun getQuery(key: QueryKey): QueryRef { + override fun getQuery( + key: QueryKey, + marker: Marker + ): QueryRef { val id = key.id - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions var query = queryStore[id] as? ManagedQuery if (query == null) { query = newQuery( id = id, - options = options, + options = key.configureOptions(defaultQueryOptions), initialValue = queryCache[key.id] as? QueryState ?: newQueryState(key) ).also { queryStore[id] = it } } return SwrQuery( key = key, - options = options, + marker = marker, query = query ) } @@ -355,21 +358,21 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie @Suppress("UNCHECKED_CAST") override fun getInfiniteQuery( - key: InfiniteQueryKey + key: InfiniteQueryKey, + marker: Marker ): InfiniteQueryRef { val id = key.id - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions var query = queryStore[id] as? ManagedQuery> if (query == null) { query = newInfiniteQuery( id = id, - options = options, + options = key.configureOptions(defaultQueryOptions), initialValue = queryCache[id] as? QueryState> ?: QueryState() ).also { queryStore[id] = it } } return SwrInfiniteQuery( key = key, - options = options, + marker = marker, query = query ) } @@ -386,12 +389,16 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) } - override fun prefetchQuery(key: QueryKey): Job { + override fun prefetchQuery( + key: QueryKey, + marker: Marker + ): Job { val scope = CoroutineScope(policy.mainDispatcher) - val query = getQuery(key).also { it.launchIn(scope) } + val query = getQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - withTimeoutOrNull(query.options.prefetchWindowTime) { + val options = key.configureOptions(defaultQueryOptions) + withTimeoutOrNull(options.prefetchWindowTime) { query.resume() } } finally { @@ -400,12 +407,16 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } - override fun prefetchInfiniteQuery(key: InfiniteQueryKey): Job { + override fun prefetchInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker + ): Job { val scope = CoroutineScope(policy.mainDispatcher) - val query = getInfiniteQuery(key).also { it.launchIn(scope) } + val query = getInfiniteQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - withTimeoutOrNull(query.options.prefetchWindowTime) { + val options = key.configureOptions(defaultQueryOptions) + withTimeoutOrNull(options.prefetchWindowTime) { query.resume() } } finally { @@ -662,6 +673,18 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } +private fun MutationKey.configureOptions(defaultOptions: MutationOptions): MutationOptions { + return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions +} + +private fun QueryKey.configureOptions(defaultOptions: QueryOptions): QueryOptions { + return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions +} + +private fun InfiniteQueryKey.configureOptions(defaultOptions: QueryOptions): QueryOptions { + return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions +} + /** * [CoroutineScope] with limited concurrency for [SwrCache]. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt index 772ebbe..6bce405 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -7,10 +7,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import soil.query.core.Marker internal class SwrInfiniteQuery( override val key: InfiniteQueryKey, - override val options: QueryOptions, + override val marker: Marker, private val query: Query> ) : InfiniteQueryRef { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt index 3f6d565..61c19e0 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt @@ -6,10 +6,11 @@ package soil.query import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow +import soil.query.core.Marker internal class SwrMutation( override val key: MutationKey, - override val options: MutationOptions, + override val marker: Marker, private val mutation: Mutation ) : MutationRef { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt index bbe606a..73424b1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt @@ -7,10 +7,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import soil.query.core.Marker internal class SwrQuery( override val key: QueryKey, - override val options: QueryOptions, + override val marker: Marker, private val query: Query ) : QueryRef { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt index 450e030..27368de 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/ErrorRecord.kt @@ -15,9 +15,12 @@ data class ErrorRecord internal constructor( val exception: Throwable, /** - * Key information that caused the error. - * - * NOTE: Defining an ID with a custom interface, such as metadata, can be helpful when receiving error information. + * The unique identifier of the key that caused the error. */ - val key: UniqueId + val keyId: UniqueId, + + /** + * The marker that was set when the error occurred. + */ + val marker: Marker ) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Marker.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Marker.kt new file mode 100644 index 0000000..d301466 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Marker.kt @@ -0,0 +1,16 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +/** + * An interface for providing additional information based on the caller of a query or mutation. + * + * **Note:** + * You can include different information in the [ErrorRecord] depending on where the key is used. + * This is useful when you want to differentiate error messages based on the specific use case, + * even if the same query or mutation is used in multiple places. + */ +interface Marker { + companion object None : Marker +} diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt index 0e49c38..5c70173 100644 --- a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt @@ -13,6 +13,7 @@ import soil.query.QueryId import soil.query.QueryKey import soil.query.QueryRef import soil.query.SwrClient +import soil.query.core.Marker /** * This extended interface of the [SwrClient] provides the capability to mock specific queries and mutations for the purpose of testing. @@ -71,32 +72,41 @@ internal class TestSwrClientImpl( } @Suppress("UNCHECKED_CAST") - override fun getMutation(key: MutationKey): MutationRef { + override fun getMutation( + key: MutationKey, + marker: Marker + ): MutationRef { val mock = mockMutations[key.id] as? FakeMutationMutate return if (mock != null) { - target.getMutation(FakeMutationKey(key, mock)) + target.getMutation(FakeMutationKey(key, mock), marker) } else { - target.getMutation(key) + target.getMutation(key, marker) } } @Suppress("UNCHECKED_CAST") - override fun getQuery(key: QueryKey): QueryRef { + override fun getQuery( + key: QueryKey, + marker: Marker + ): QueryRef { val mock = mockQueries[key.id] as? FakeQueryFetch return if (mock != null) { - target.getQuery(FakeQueryKey(key, mock)) + target.getQuery(FakeQueryKey(key, mock), marker) } else { - target.getQuery(key) + target.getQuery(key, marker) } } @Suppress("UNCHECKED_CAST") - override fun getInfiniteQuery(key: InfiniteQueryKey): InfiniteQueryRef { + override fun getInfiniteQuery( + key: InfiniteQueryKey, + marker: Marker + ): InfiniteQueryRef { val mock = mockInfiniteQueries[key.id] as? FakeInfiniteQueryFetch return if (mock != null) { - target.getInfiniteQuery(FakeInfiniteQueryKey(key, mock)) + target.getInfiniteQuery(FakeInfiniteQueryKey(key, mock), marker) } else { - target.getInfiniteQuery(key) + target.getInfiniteQuery(key, marker) } } } diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt index fbcbc44..5a250a3 100644 --- a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt @@ -24,6 +24,7 @@ import soil.query.SwrCachePolicy import soil.query.buildInfiniteQueryKey import soil.query.buildMutationKey import soil.query.buildQueryKey +import soil.query.core.Marker import soil.query.core.getOrThrow import soil.query.mutate import soil.testing.UnitTest @@ -128,12 +129,12 @@ private class ExampleInfiniteQueryKey : InfiniteQueryKey by buildIn private suspend fun QueryRef.test(): T { val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, callback = deferred::completeWith)) + send(QueryCommands.Connect(key, marker = Marker.None, callback = deferred::completeWith)) return deferred.await() } private suspend fun InfiniteQueryRef.test(): QueryChunks { val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, callback = deferred::completeWith)) + send(InfiniteQueryCommands.Connect(key, marker = Marker.None, callback = deferred::completeWith)) return deferred.await() } From dbbdacebf85752fdd3b7fb05658af7176e8e709b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Tue, 27 Aug 2024 08:29:00 +0900 Subject: [PATCH 111/155] Add a test for Actor --- .../kotlin/soil/query/core/ActorTest.kt | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/ActorTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/ActorTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/ActorTest.kt new file mode 100644 index 0000000..2c3ef87 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/ActorTest.kt @@ -0,0 +1,187 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ActorTest : UnitTest() { + + @Test + fun testLaunchIn_one() = runTest { + val uiDispatcher = UnconfinedTestDispatcher(testScheduler) + + val blockHandler = TestBlockHandler() + val timeoutHandler = TestTimeoutHandler() + val actor = ActorBlockRunner( + scope = backgroundScope, + options = TestActorOptions(), + onTimeout = timeoutHandler::onTimeout, + block = blockHandler::handle + ) + + assertEquals(0, actor.seq) + assertEquals(0, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + val scope1 = CoroutineScope(uiDispatcher) + val job1 = actor.launchIn(scope1) + yield() + + assertEquals(1, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + job1.cancel() + // Wait for the actor to be canceled. (keepAliveTime = 5.seconds) + advanceTimeBy(6.seconds) + + assertEquals(1, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(1, timeoutHandler.seq) + } + + @Test + fun testLaunchIn_many() = runTest { + val uiDispatcher = UnconfinedTestDispatcher(testScheduler) + + val blockHandler = TestBlockHandler() + val timeoutHandler = TestTimeoutHandler() + val actor = ActorBlockRunner( + scope = backgroundScope, + options = TestActorOptions(), + onTimeout = timeoutHandler::onTimeout, + block = blockHandler::handle + ) + + assertEquals(0, actor.seq) + assertEquals(0, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + val scope1 = CoroutineScope(uiDispatcher) + val job1 = actor.launchIn(scope1) + yield() + + assertEquals(1, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + val scope2 = CoroutineScope(uiDispatcher) + val job2 = actor.launchIn(scope2) + yield() + + assertEquals(2, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + job1.cancel() + yield() + + assertEquals(2, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + job2.cancel() + + // Wait for the actor to be canceled. (keepAliveTime = 5.seconds) + advanceTimeBy(6.seconds) + assertEquals(2, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(2, timeoutHandler.seq) + } + + @Test + fun testLaunchIn_actorCanceled() = runTest { + val uiDispatcher = UnconfinedTestDispatcher(testScheduler) + + val actorScope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val blockHandler = TestBlockHandler() + val timeoutHandler = TestTimeoutHandler() + val actor = ActorBlockRunner( + scope = actorScope, + options = TestActorOptions(), + onTimeout = timeoutHandler::onTimeout, + block = blockHandler::handle + ) + + assertEquals(0, actor.seq) + assertEquals(0, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + val scope1 = CoroutineScope(uiDispatcher) + val job1 = actor.launchIn(scope1) + yield() + + assertEquals(1, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(-1, timeoutHandler.seq) + + job1.cancel() + + // Wait for the actor to be canceled. (keepAliveTime = 5.seconds) + advanceTimeBy(6.seconds) + assertEquals(1, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(1, timeoutHandler.seq) + + actorScope.cancel() + advanceUntilIdle() + + val scope2 = CoroutineScope(uiDispatcher) + val job2 = actor.launchIn(scope2) + yield() + + assertEquals(2, actor.seq) + // Unchanged (already canceled) + assertEquals(1, blockHandler.count) + assertEquals(1, timeoutHandler.seq) + + job2.cancel() + + advanceTimeBy(6.seconds) + assertEquals(2, actor.seq) + assertEquals(1, blockHandler.count) + assertEquals(1, timeoutHandler.seq) + } + + private class TestBlockHandler { + + val dummyFlow = MutableStateFlow(0) + + var count: Int = 0 + private set + + suspend fun handle() { + count++ + dummyFlow.collect { } + } + } + + private class TestTimeoutHandler { + var seq: ActorSequenceNumber = -1 + private set + + fun onTimeout(seq: ActorSequenceNumber) { + this.seq = seq + } + } + + private class TestActorOptions( + override val keepAliveTime: Duration = 5.seconds + ) : ActorOptions +} From caf161840456b7ac58613d2e3bd4abecf9541e78 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Tue, 27 Aug 2024 08:30:02 +0900 Subject: [PATCH 112/155] Remove unnecessary example's test --- .../commonTest/kotlin/soil/query/ExampleTest.kt | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt deleted file mode 100644 index fc25021..0000000 --- a/soil-query-core/src/commonTest/kotlin/soil/query/ExampleTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import soil.testing.UnitTest -import kotlin.test.Test -import kotlin.test.assertTrue - -class ExampleTest : UnitTest() { - - @Test - fun sample() { - assertTrue(true) - } -} From b03fa6733b080e1ceed1d1aee2fabfecde13fd94 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Tue, 27 Aug 2024 09:38:30 +0900 Subject: [PATCH 113/155] Add a test for BatchScheduler --- .../soil/query/core/BatchSchedulerTest.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt new file mode 100644 index 0000000..dfda73d --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt @@ -0,0 +1,73 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class BatchSchedulerTest : UnitTest() { + + @Test + fun testDefault_chunkSizeTrigger() = runTest { + val scheduler = BatchScheduler.default( + dispatcher = StandardTestDispatcher(testScheduler), + interval = 3.seconds, + chunkSize = 3 + ) + val scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)) + scheduler.start(scope) + val task = TestBatchTask() + + scheduler.post(task) + scheduler.post(task) + + advanceTimeBy(500.milliseconds) + assertEquals(0, task.executedCount) + + scheduler.post(task) + + advanceTimeBy(500.milliseconds) + assertEquals(3, task.executedCount) + } + + @Test + fun testDefault_intervalTrigger() = runTest { + val scheduler = BatchScheduler.default( + dispatcher = StandardTestDispatcher(testScheduler), + interval = 3.seconds, + chunkSize = 3 + ) + val scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)) + scheduler.start(scope) + val task = TestBatchTask() + + scheduler.post(task) + scheduler.post(task) + + advanceTimeBy(1.seconds) + assertEquals(0, task.executedCount) + + advanceTimeBy(3.seconds) + assertEquals(2, task.executedCount) + } + + private class TestBatchTask : BatchTask { + var executedCount: Int = 0 + private set + + override fun invoke() { + executedCount++ + } + } +} From 0a5d87cb2ac387a53f36401ca705778fe27496b7 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 08:10:17 +0900 Subject: [PATCH 114/155] Add turbine library to the test dependencies of the `query.core' Introduced the turbine library for use in Flow tests. --- gradle/libs.versions.toml | 2 ++ soil-query-core/build.gradle.kts | 1 + 2 files changed, 3 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf42953..80260d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ ktor = "3.0.0-beta-2" maven-publish = "0.28.0" robolectric = "4.12.2" spotless = "6.25.0" +turbine = "1.1.0" [libraries] @@ -58,6 +59,7 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [plugins] diff --git a/soil-query-core/build.gradle.kts b/soil-query-core/build.gradle.kts index e52c41c..ee53989 100644 --- a/soil-query-core/build.gradle.kts +++ b/soil-query-core/build.gradle.kts @@ -35,6 +35,7 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) implementation(projects.internal.testing) } From 645324f7eb0b2e75e342eb150f9c47357abb2843 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 08:17:21 +0900 Subject: [PATCH 115/155] Add a test for ErrorRelayTest --- .../kotlin/soil/query/core/ErrorRelayTest.kt | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/ErrorRelayTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/ErrorRelayTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/ErrorRelayTest.kt new file mode 100644 index 0000000..6dd0397 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/ErrorRelayTest.kt @@ -0,0 +1,156 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import soil.query.QueryId +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ErrorRelayTest : UnitTest() { + + @Test + fun testAnycast_one() = runTest { + val relay = ErrorRelay.newAnycast( + scope = backgroundScope + ) + + val err1 = ErrorRecord( + exception = RuntimeException("Error 1"), + keyId = QueryId(namespace = "test"), + marker = Marker.None + ) + + relay.send(err1) + yield() + + relay.receiveAsFlow().test { + assertEquals(err1, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun testAnycast_many() = runTest { + val relay = ErrorRelay.newAnycast( + scope = backgroundScope + ) + + val err1 = ErrorRecord( + exception = RuntimeException("Error 1"), + keyId = QueryId(namespace = "test"), + marker = Marker.None + ) + + relay.send(err1) + yield() + + val flow1 = relay.receiveAsFlow() + val flow2 = relay.receiveAsFlow() + merge(flow1, flow2).test { + assertEquals(err1, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun testAnycast_dropOldest() = runTest { + val relay = ErrorRelay.newAnycast( + scope = backgroundScope + ) + + val err1 = ErrorRecord( + exception = RuntimeException("Error 1"), + keyId = QueryId(namespace = "test"), + marker = Marker.None + ) + + relay.send(err1) + runCurrent() + + val err2 = ErrorRecord( + exception = RuntimeException("Error 2"), + keyId = QueryId(namespace = "test"), + marker = Marker.None + ) + + relay.send(err2) + runCurrent() + + relay.receiveAsFlow().test { + assertEquals(err2, awaitItem()) + ensureAllEventsConsumed() + } + } + + @Test + fun testAnycastPolicy_shouldSuppressError() = runTest { + val relay = ErrorRelay.newAnycast( + scope = backgroundScope, + policy = TestErrorRelayPolicy( + shouldSuppressError = { it.marker == SuppressMarker } + ) + ) + + val err1 = ErrorRecord( + exception = RuntimeException("Error 1"), + keyId = QueryId(namespace = "test"), + marker = SuppressMarker + ) + + relay.send(err1) + runCurrent() + + relay.receiveAsFlow().test { + ensureAllEventsConsumed() + } + } + + @Test + fun testAnycastPolicy_areErrorsEqual() = runTest { + val relay = ErrorRelay.newAnycast( + scope = backgroundScope, + policy = TestErrorRelayPolicy( + areErrorsEqual = { a, b -> a.keyId == b.keyId } + ) + ) + + val err1 = ErrorRecord( + exception = RuntimeException("Error 1"), + keyId = QueryId(namespace = "test"), + marker = SuppressMarker + ) + + relay.send(err1) + runCurrent() + + val err2 = ErrorRecord( + exception = RuntimeException("Error 2"), + keyId = QueryId(namespace = "test"), + marker = Marker.None + ) + + relay.send(err2) + runCurrent() + + relay.receiveAsFlow().test { + assertEquals(err1, awaitItem()) + ensureAllEventsConsumed() + } + } + + class TestErrorRelayPolicy( + override val shouldSuppressError: (ErrorRecord) -> Boolean = ErrorRelayPolicy.None.shouldSuppressError, + override val areErrorsEqual: (ErrorRecord, ErrorRecord) -> Boolean = ErrorRelayPolicy.None.areErrorsEqual + ) : ErrorRelayPolicy + + object SuppressMarker : Marker +} From 1139f9367c83fa63c88eeef70130d11a595cd797 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 08:28:41 +0900 Subject: [PATCH 116/155] Add a test for PriorityQueueTest --- .../soil/query/core/PriorityQueueTest.kt | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/PriorityQueueTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/PriorityQueueTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/PriorityQueueTest.kt new file mode 100644 index 0000000..6ca6252 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/PriorityQueueTest.kt @@ -0,0 +1,94 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PriorityQueueTest : UnitTest() { + + @Test + fun testSorting() { + val queue = PriorityQueue(10) + queue.push(3) + queue.push(1) + queue.push(2) + queue.push(5) + queue.push(4) + assertEquals(1, queue.pop()) + assertEquals(2, queue.pop()) + assertEquals(3, queue.pop()) + assertEquals(4, queue.pop()) + assertEquals(5, queue.pop()) + assertNull(queue.pop()) + } + + @Test + fun testRemove() { + val queue = PriorityQueue(10) + queue.push(3) + queue.push(1) + queue.push(2) + queue.push(5) + queue.push(4) + assertTrue(queue.remove(3)) + assertFalse(queue.remove(3)) + assertEquals(1, queue.pop()) + assertEquals(2, queue.pop()) + assertEquals(4, queue.pop()) + assertEquals(5, queue.pop()) + assertNull(queue.pop()) + } + + @Test + fun testPeek() { + val queue = PriorityQueue(10) + queue.push(3) + queue.push(1) + queue.push(2) + queue.push(5) + queue.push(4) + assertEquals(1, queue.peek()) + assertEquals(1, queue.peek()) + assertEquals(1, queue.pop()) + assertEquals(2, queue.peek()) + assertEquals(2, queue.pop()) + assertEquals(3, queue.peek()) + assertEquals(3, queue.pop()) + assertEquals(4, queue.peek()) + assertEquals(4, queue.pop()) + assertEquals(5, queue.peek()) + assertEquals(5, queue.pop()) + assertNull(queue.peek()) + assertNull(queue.pop()) + } + + @Test + fun testEmpty() { + val queue = PriorityQueue(10) + assertTrue(queue.isEmpty()) + assertFalse(queue.isNotEmpty()) + assertNull(queue.peek()) + assertNull(queue.pop()) + } + + @Test + fun testClean() { + val queue = PriorityQueue(10) + queue.push(3) + queue.push(1) + queue.push(2) + queue.push(5) + queue.push(4) + queue.clear() + assertTrue(queue.isEmpty()) + assertFalse(queue.isNotEmpty()) + assertNull(queue.peek()) + assertNull(queue.pop()) + } +} From f9cfabe314aaa9cccc9e34a351b3c216bb9331bd Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 08:29:08 +0900 Subject: [PATCH 117/155] Suppress inline warning about `Reply` class --- soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt index 862dc33..f018b86 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt @@ -13,6 +13,7 @@ sealed interface Reply { data class Some internal constructor(val value: T) : Reply companion object { + @Suppress("NOTHING_TO_INLINE") internal inline operator fun invoke(value: T): Reply = Some(value) fun none(): Reply = None From 908d898680b4cf3e3333a30bdf4be9bf258a5bc3 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 08:45:14 +0900 Subject: [PATCH 118/155] Add a test for ReplyTest --- .../kotlin/soil/query/core/ReplyTest.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/ReplyTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/ReplyTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/ReplyTest.kt new file mode 100644 index 0000000..1546f32 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/ReplyTest.kt @@ -0,0 +1,59 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReplyTest : UnitTest() { + + @Test + fun testNone() { + val reply = Reply.none() + assertTrue(reply.isNone) + assertFailsWith { reply.getOrThrow() } + assertNull(reply.getOrNull()) + assertEquals(0, reply.getOrElse { 0 }) + assertEquals(0, reply.map { it + 1 }.getOrElse { 0 }) + } + + @Test + fun testSome() { + val reply = Reply.some(1) + assertTrue(!reply.isNone) + assertEquals(1, reply.getOrThrow()) + assertEquals(1, reply.getOrNull()) + assertEquals(1, reply.getOrElse { 0 }) + assertEquals(2, reply.map { it + 1 }.getOrThrow()) + } + + @Test + fun testCompanion_combinePair() { + val reply1 = Reply.some(1) + val reply2 = Reply.some(2) + val reply3 = Reply.none() + + assertEquals(3, Reply.combine(reply1, reply2) { a, b -> a + b }.getOrThrow()) + assertTrue(Reply.combine(reply1, reply3) { a, b -> a + b }.isNone) + assertTrue(Reply.combine(reply2, reply3) { a, b -> a + b }.isNone) + assertTrue(Reply.combine(reply3, reply3) { a, b -> a + b }.isNone) + } + + @Test + fun testCompanion_combineTriple() { + val reply1 = Reply.some(1) + val reply2 = Reply.some(2) + val reply3 = Reply.some(3) + val reply4 = Reply.none() + + assertEquals(6, Reply.combine(reply1, reply2, reply3) { a, b, c -> a + b + c }.getOrThrow()) + assertTrue(Reply.combine(reply1, reply2, reply4) { a, b, c -> a + b + c }.isNone) + assertTrue(Reply.combine(reply2, reply3, reply4) { a, b, c -> a + b + c }.isNone) + assertTrue(Reply.combine(reply4, reply4, reply4) { a, b, c -> a + b + c }.isNone) + } +} From 7d30c03b2023b52a6ab18705334d3b47af15cf77 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 09:48:41 +0900 Subject: [PATCH 119/155] Fix a bug that was missing the capacity limit check However, the only time evict is called directly is to free up memory, so it didn't have much of an impact. --- .../src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt index ec83040..428f8e0 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/TimeBasedCache.kt @@ -95,7 +95,7 @@ class TimeBasedCache internal constructor( cache.remove(item.key) } - if (cache.size == size) { + if (cache.size == size && cache.size >= capacity) { val item = queue.pop()!! cache.remove(item.key) } From 4be0e4f62d75f185d7d4c5052dbf3cdb33c9248d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Wed, 28 Aug 2024 09:49:02 +0900 Subject: [PATCH 120/155] Add a test for TimeBasedCache --- .../soil/query/core/TimeBasedCacheTest.kt | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/TimeBasedCacheTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/TimeBasedCacheTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/TimeBasedCacheTest.kt new file mode 100644 index 0000000..c06aff1 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/TimeBasedCacheTest.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class TimeBasedCacheTest : UnitTest() { + + @Test + fun testCache() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 10, + time = clock::epoch + ) + cache.set("key1", "value1", ttl = 10.seconds) + cache.set("key2", "value2", ttl = 30.seconds) + + assertEquals(2, cache.size) + assertEquals(setOf("key1", "key2"), cache.keys) + assertEquals("value1", cache["key1"]) + assertEquals("value2", cache["key2"]) + + clock.advance(20.seconds) + assertEquals(2, cache.size) + assertEquals(setOf("key1", "key2"), cache.keys) + assertNull(cache["key1"]) + assertEquals("value2", cache["key2"]) + + clock.advance(20.seconds) + assertEquals(2, cache.size) + assertEquals(setOf("key1", "key2"), cache.keys) + assertNull(cache["key1"]) + assertNull(cache["key2"]) + + cache.evict() + assertEquals(0, cache.size) + assertEquals(emptySet(), cache.keys) + } + + @Test + fun testSet_containsKey() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 10, + time = clock::epoch + ) + cache.set("key1", "value1", ttl = 10.seconds) + cache.set("key1", "value2", ttl = 20.seconds) + + assertEquals("value2", cache["key1"]) + + clock.advance(15.seconds) + assertEquals("value2", cache["key1"]) + } + + @Test + fun testSet_capacityOver() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 3, + time = clock::epoch + ) + cache.set("key1", "value1", ttl = 10.seconds) + cache.set("key2", "value2", ttl = 20.seconds) + cache.set("key3", "value3", ttl = 5.seconds) + cache.set("key4", "value4", ttl = 15.seconds) + + assertEquals(3, cache.size) + assertEquals("value1", cache["key1"]) + assertEquals("value2", cache["key2"]) + assertNull(cache["key3"]) + assertEquals("value4", cache["key4"]) + } + + @Test + fun testSwap() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 10, + time = clock::epoch + ) + cache.set("key1", "value1", ttl = 10.seconds) + cache.swap("key1") { "value2" } + + assertEquals("value2", cache["key1"]) + } + + @Test + fun testEvict_expired() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 5, + time = clock::epoch + ) + + cache.evict() + + cache.set("key1", "value1", ttl = 10.seconds) + cache.evict() + assertEquals(1, cache.size) + assertEquals("value1", cache["key1"]) + + clock.advance(11.seconds) + cache.evict() + assertEquals(0, cache.size) + assertNull(cache["key1"]) + } + + @Test + fun testDelete() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 5, + time = clock::epoch + ) + + cache.set("key1", "value1", ttl = 10.seconds) + cache.delete("key1") + assertEquals(0, cache.size) + assertNull(cache["key1"]) + } + + @Test + fun testClear() { + val clock = TestClock() + val cache = TimeBasedCache( + capacity = 5, + time = clock::epoch + ) + + cache.set("key1", "value1", ttl = 10.seconds) + cache.set("key2", "value2", ttl = 20.seconds) + cache.clear() + assertEquals(0, cache.size) + assertNull(cache["key1"]) + assertNull(cache["key2"]) + } + + class TestClock { + private var time: Long = TEST_CLOCK_EPOCH + + fun advance(duration: Duration) { + time += duration.inWholeSeconds + } + + fun epoch(): Long = time + } + + companion object { + // Epoch time: 2024-08-01T00:00:00Z + private const val TEST_CLOCK_EPOCH: Long = 1722470400 + } + +} From 999af016b19f84ce2a56cdfbeb9bae3d5b8156e7 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Thu, 29 Aug 2024 07:32:15 +0900 Subject: [PATCH 121/155] Add a test for Retry --- .../kotlin/soil/query/core/RetryTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/core/RetryTest.kt diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/RetryTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/RetryTest.kt new file mode 100644 index 0000000..7d920c6 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/RetryTest.kt @@ -0,0 +1,71 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.core + +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import soil.testing.UnitTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class RetryTest : UnitTest() { + + @Test + fun testExponentialBackOff() = runTest { + val options = TestRetryOptions(retryCount = 3) + val retry = options.exponentialBackOff() + val runner = TestBlockRunner(1) + val result = retry.withRetry { runner.run() } + assertEquals(2, result) + } + + @Test + fun testExponentialBackOff_retryOver() = runTest { + val options = TestRetryOptions(retryCount = 2) + val retry = options.exponentialBackOff() + val runner = TestBlockRunner(3) + assertFails { + retry.withRetry { runner.run() } + } + } + + @Test + fun testExponentialBackOff_retryNone() = runTest { + val options = TestRetryOptions(retryCount = 1) + val retry = options.exponentialBackOff() + val runner = TestBlockRunner(0) + val result = retry.withRetry { runner.run() } + assertEquals(1, result) + } + + class TestBlockRunner( + private val errorSimulationCount: Int + ) { + private var count = 0 + suspend fun run(): Int { + delay(1000) + count++ + return if (count <= errorSimulationCount) { + throw Exception("Test") + } else { + count + } + } + } + + class TestRetryOptions( + override val shouldRetry: (Throwable) -> Boolean = { true }, + override val retryCount: Int, + override val retryInitialInterval: Duration = 500.milliseconds, + override val retryMaxInterval: Duration = 30.seconds, + override val retryMultiplier: Double = 1.5, + override val retryRandomizationFactor: Double = 0.5, + override val retryRandomizer: Random = Random + ) : RetryOptions +} From 971104a275ccc726faa909f903c6ba1716570200 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 30 Aug 2024 08:06:06 +0900 Subject: [PATCH 122/155] Add a builder class to simplify Preview --- .../compose/tooling/MutationPreviewClient.kt | 31 ++++++++++++---- .../compose/tooling/QueryPreviewClient.kt | 36 +++++++++++++++---- .../query/compose/tooling/SwrPreviewClient.kt | 6 ++-- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt index f0b7e29..ffb571d 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import soil.query.MutationClient import soil.query.MutationCommand +import soil.query.MutationId import soil.query.MutationKey import soil.query.MutationOptions import soil.query.MutationRef @@ -20,12 +21,10 @@ import soil.query.core.UniqueId /** * Usage: * ```kotlin - * val client = MutationPreviewClient( - * previewData = mapOf( - * MyMutationId to MutationState.success("data"), - * .. - * ) - * ) + * val mutationClient = MutationPreviewClient { + * on(MyMutationId1) { MutationState.success("data") } + * on(MyMutationId2) { .. } + * } * ``` */ @Stable @@ -51,4 +50,24 @@ class MutationPreviewClient( override fun launchIn(scope: CoroutineScope): Job = Job() override suspend fun send(command: MutationCommand) = Unit } + + /** + * Builder for [MutationPreviewClient]. + */ + class Builder { + private val previewData = mutableMapOf>() + + fun on(id: MutationId, state: MutationState) { + previewData[id] = state + } + + fun build(): MutationPreviewClient = MutationPreviewClient(previewData) + } +} + +/** + * Creates a [MutationPreviewClient] with the provided [initializer]. + */ +fun MutationPreviewClient(initializer: MutationPreviewClient.Builder.() -> Unit): MutationPreviewClient { + return MutationPreviewClient.Builder().apply(initializer).build() } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index 80e386b..7ddf18d 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -9,11 +9,13 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import soil.query.InfiniteQueryCommand +import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey import soil.query.InfiniteQueryRef import soil.query.QueryChunks import soil.query.QueryClient import soil.query.QueryCommand +import soil.query.QueryId import soil.query.QueryKey import soil.query.QueryOptions import soil.query.QueryRef @@ -24,12 +26,10 @@ import soil.query.core.UniqueId /** * Usage: * ```kotlin - * val client = QueryPreviewClient( - * previewData = mapOf( - * MyQueryId to QueryState.success("data"), - * .. - * ) - * ) + * val queryClient = QueryPreviewClient { + * on(MyQueryId1) { QueryState.success("data") } + * on(MyQueryId2) { .. } + * } * ``` */ @Stable @@ -88,4 +88,28 @@ class QueryPreviewClient( override suspend fun loadMore(param: S) = Unit override suspend fun invalidate() = Unit } + + /** + * Builder for [QueryPreviewClient]. + */ + class Builder { + private val previewData = mutableMapOf>() + + fun on(id: QueryId, snapshot: () -> QueryState) { + previewData[id] = snapshot() + } + + fun on(id: InfiniteQueryId, snapshot: () -> QueryState>) { + previewData[id] = snapshot() + } + + fun build() = QueryPreviewClient(previewData) + } +} + +/** + * Create a [QueryPreviewClient] instance with the provided [initializer]. + */ +fun QueryPreviewClient(initializer: QueryPreviewClient.Builder.() -> Unit): QueryPreviewClient { + return QueryPreviewClient.Builder().apply(initializer).build() } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt index 9ec770b..6d79fcf 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt @@ -25,10 +25,10 @@ import soil.query.core.ErrorRecord */ @Stable class SwrPreviewClient( - queryPreviewClient: QueryPreviewClient = QueryPreviewClient(emptyMap()), - mutationPreviewClient: MutationPreviewClient = MutationPreviewClient(emptyMap()), + queryPreview: QueryPreviewClient = QueryPreviewClient(emptyMap()), + mutationPreview: MutationPreviewClient = MutationPreviewClient(emptyMap()), override val errorRelay: Flow = flow { } -) : SwrClient, QueryClient by queryPreviewClient, MutationClient by mutationPreviewClient { +) : SwrClient, QueryClient by queryPreview, MutationClient by mutationPreview { override fun perform(sideEffects: QueryEffect): Job = Job() override fun onMount(id: String) = Unit override fun onUnmount(id: String) = Unit From bf39694cd6a8d368d3a5bbcaf0e9e562b93a6c6a Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 30 Aug 2024 08:07:52 +0900 Subject: [PATCH 123/155] Add a factory function that can represent QueryState's refreshError. --- .../kotlin/soil/query/QueryState.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index cbf7a27..a1c3fbf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -79,5 +79,31 @@ data class QueryState internal constructor( status = QueryStatus.Failure ) } + + /** + * Creates a new [QueryState] with the [QueryStatus.Failure] status. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. Default is the current epoch. + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt]. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch(), + data: T, + dataUpdatedAt: Long = epoch(), + dataStaleAt: Long = dataUpdatedAt + ): QueryState { + return QueryState( + error = error, + errorUpdatedAt = errorUpdatedAt, + status = QueryStatus.Failure, + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, + staleAt = dataStaleAt + ) + } } } From 31d23e9dd0967b64d89704af0a4f6a303cfdb60f Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 30 Aug 2024 08:10:33 +0900 Subject: [PATCH 124/155] Add a test for QueryComposable --- .../soil/query/compose/QueryComposableTest.kt | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt new file mode 100644 index 0000000..6ec57f8 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -0,0 +1,186 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryState +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildQueryKey +import soil.query.compose.tooling.QueryPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.Marker +import soil.query.core.Reply +import soil.query.test.test +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class QueryComposableTest : UnitTest() { + + @Test + fun testRememberQuery() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key, config = QueryConfig { + strategy = QueryCachingStrategy.Default + marker = Marker.None + }) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Soil!") + } + + @Test + fun testRememberQuery_select() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { "Hello, Soil!" } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key = key, select = { it.uppercase() }) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("HELLO, SOIL!") + } + + @Test + fun testRememberQuery_throwError() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { throw RuntimeException("Failed to do something :(") } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + if (query.error != null) { + Text("error", modifier = Modifier.testTag("query")) + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("error") + } + + @Test + fun testRememberQuery_loadingPreview() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.initial() } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberQuery(key = key)) { + is QueryLoadingObject -> Text("Loading", modifier = Modifier.testTag("query")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Loading") + } + + @Test + fun testRememberQuery_successPreview() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.success("Hello, Query!") } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberQuery(key = key)) { + is QuerySuccessObject -> Text(query.data, modifier = Modifier.testTag("query")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Hello, Query!") + } + + @Test + fun testRememberQuery_loadingErrorPreview() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.failure(RuntimeException("Error")) } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberQuery(key = key)) { + is QueryLoadingErrorObject -> Text(query.error.message ?: "", modifier = Modifier.testTag("query")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Error") + } + + @Test + fun testRememberQuery_refreshErrorPreview() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.failure(RuntimeException("Refresh Error"), data = "Hello, Query!") } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberQuery(key = key)) { + is QueryRefreshErrorObject -> Text(query.data, modifier = Modifier.testTag("query")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Hello, Query!") + } + + private class TestQueryKey : QueryKey by buildQueryKey( + id = Id, + fetch = { "Hello, Soil!" } + ) { + object Id : QueryId("test/query") + } +} From 7d09099b994acbeecab6a8c5afb9b3c5334518b2 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 30 Aug 2024 09:14:12 +0900 Subject: [PATCH 125/155] Add a initializer block to simplify Testing --- .../src/commonMain/kotlin/soil/query/test/TestSwrClient.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt index 5c70173..afb46f3 100644 --- a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt @@ -49,7 +49,9 @@ interface TestSwrClient : SwrClient { /** * Switches [SwrClient] to a test interface. */ -fun SwrClient.test(): TestSwrClient = TestSwrClientImpl(this) +fun SwrClient.test(initializer: TestSwrClient.() -> Unit = {}): TestSwrClient { + return TestSwrClientImpl(this).apply(initializer) +} internal class TestSwrClientImpl( private val target: SwrClient From 84d100ccb0bb3b206cb691c56411a1e10e286870 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Fri, 30 Aug 2024 09:15:45 +0900 Subject: [PATCH 126/155] Remove unnecessary example's test --- .../kotlin/soil/query/compose/ExampleTest.kt | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt deleted file mode 100644 index 67f780d..0000000 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/ExampleTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query.compose - -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest -import soil.testing.UnitTest -import kotlin.test.Test - -@OptIn(ExperimentalTestApi::class) -class ExampleTest: UnitTest() { - - @Test - fun myTest() = runComposeUiTest { - setContent { - var text by remember { mutableStateOf("Hello") } - Text( - text = text, - modifier = Modifier.testTag("text") - ) - Button( - onClick = { text = "Compose" }, - modifier = Modifier.testTag("button") - ) { - Text("Click me") - } - } - - onNodeWithTag("text").assertTextEquals("Hello") - onNodeWithTag("button").performClick() - onNodeWithTag("text").assertTextEquals("Compose") - } -} From feb347490e643a7036c17ee44ffafdac84506797 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 13:33:36 +0900 Subject: [PATCH 127/155] Add a test for InfiniteQueryComposable --- .../soil/query/compose/InfiniteQueryConfig.kt | 2 +- .../compose/InfiniteQueryComposableTest.kt | 271 ++++++++++++++++++ 2 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt index 0033851..2949da9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt @@ -21,7 +21,7 @@ data class InfiniteQueryConfig internal constructor( @Suppress("MemberVisibilityCanBePrivate") class Builder { var strategy: QueryCachingStrategy = Default.strategy - val marker: Marker = Default.marker + var marker: Marker = Default.marker fun build() = InfiniteQueryConfig( strategy = strategy, diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt new file mode 100644 index 0000000..bdd7088 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -0,0 +1,271 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.launch +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.QueryChunk +import soil.query.QueryState +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildInfiniteQueryKey +import soil.query.chunkedData +import soil.query.compose.tooling.QueryPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.Marker +import soil.query.core.Reply +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class InfiniteQueryComposableTest : UnitTest() { + + @Test + fun testRememberInfiniteQuery() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val query = rememberInfiniteQuery(key, config = InfiniteQueryConfig { + strategy = QueryCachingStrategy + marker = Marker.None + }) + when (val reply = query.reply) { + is Reply.Some -> { + Column { + reply.value.forEach { chunk -> + Text( + "Size: ${chunk.data.size} - Page: ${chunk.param.page}", + modifier = Modifier.testTag("query") + ) + } + Text("HasNext: ${query.loadMoreParam != null}", modifier = Modifier.testTag("loadMore")) + } + } + + is Reply.None -> Unit + } + } + } + + waitUntilAtLeastOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Size: 10 - Page: 0") + onNodeWithTag("loadMore").assertTextEquals("HasNext: true") + } + + @Test + fun testRememberInfiniteQuery_select() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val query = rememberInfiniteQuery(key, select = { it.chunkedData }) + when (val reply = query.reply) { + is Reply.Some -> { + Column { + reply.value.forEach { data -> + Text(data, modifier = Modifier.testTag("query")) + } + } + } + + is Reply.None -> Unit + } + } + } + + waitUntilAtLeastOneExists(hasTestTag("query")) + onAllNodes(hasTestTag("query")).assertCountEquals(10) + } + + @Test + fun testRememberInfiniteQuery_loadMore() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val query = rememberInfiniteQuery(key) + when (val reply = query.reply) { + is Reply.Some -> { + Column { + reply.value.forEachIndexed { index, chunk -> + Text( + "Size: ${chunk.data.size} - Page: ${chunk.param.page}", + modifier = Modifier.testTag("query${index}") + ) + } + val scope = rememberCoroutineScope() + val handleClick = query.loadMoreParam?.let { + { scope.launch { query.loadMore(it) } } + } + Button( + onClick = { handleClick?.invoke() }, + enabled = handleClick != null, + modifier = Modifier.testTag("loadMore") + ) { + Text("Load More") + } + } + } + + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query0")) + onNodeWithTag("query0").assertTextEquals("Size: 10 - Page: 0") + onNodeWithTag("loadMore").performClick() + waitUntilExactlyOneExists(hasTestTag("query1")) + onNodeWithTag("query1").assertTextEquals("Size: 10 - Page: 1") + } + + + @Test + fun testRememberInfiniteQuery_loadingPreview() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.initial() } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberInfiniteQuery(key = key)) { + is InfiniteQueryLoadingObject -> Text("Loading", modifier = Modifier.testTag("query")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Loading") + } + + @Test + fun testRememberInfiniteQuery_successPreview() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { + QueryState.success(buildList { + add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) + add(QueryChunk((10 until 20).map { "Item $it" }, PageParam(1, 10))) + add(QueryChunk((20 until 30).map { "Item $it" }, PageParam(2, 10))) + }) + } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberInfiniteQuery(key = key, select = { it.chunkedData })) { + is InfiniteQuerySuccessObject -> { + Column { + query.data.forEach { data -> + Text(data, modifier = Modifier.testTag("query")) + } + } + } + + else -> Unit + } + } + } + + waitForIdle() + onAllNodes(hasTestTag("query")).assertCountEquals(30) + } + + @Test + fun testRememberInfiniteQuery_loadingErrorPreview() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { QueryState.failure(RuntimeException("Error")) } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberInfiniteQuery(key = key)) { + is InfiniteQueryLoadingErrorObject -> Text( + query.error.message ?: "", + modifier = Modifier.testTag("query") + ) + + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("Error") + } + + @Test + fun testRememberInfiniteQuery_refreshErrorPreview() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + queryPreview = QueryPreviewClient { + on(key.id) { + QueryState.failure(RuntimeException("Refresh Error"), data = buildList { + add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) + }) + } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberInfiniteQuery(key = key)) { + is InfiniteQueryRefreshErrorObject -> Text( + "ChunkSize: ${query.data.size}", + modifier = Modifier.testTag("query") + ) + + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("query").assertTextEquals("ChunkSize: 1") + } + + private class TestInfiniteQueryKey : InfiniteQueryKey, PageParam> by buildInfiniteQueryKey( + id = Id, + fetch = { param -> + val startPosition = param.page * param.size + (startPosition.., PageParam>("test/infinite-query") + } + + private data class PageParam( + val page: Int, + val size: Int + ) +} From d9c98680e441cdc44c1874dafd4b60316ce6bf82 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 14:06:50 +0900 Subject: [PATCH 128/155] Tweak a MutationPreviewClient The implementation was out of sync with QueryPreviewClient, so I fixed it. --- .../soil/query/compose/tooling/MutationPreviewClient.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt index ffb571d..cc451c9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -57,8 +57,8 @@ class MutationPreviewClient( class Builder { private val previewData = mutableMapOf>() - fun on(id: MutationId, state: MutationState) { - previewData[id] = state + fun on(id: MutationId, snapshot: () -> MutationState) { + previewData[id] = snapshot() } fun build(): MutationPreviewClient = MutationPreviewClient(previewData) From 27bdf3a8258503cb8ee6ae389ae8fde951d67230 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 14:09:03 +0900 Subject: [PATCH 129/155] Add a factory function to represent pending for testing purposes. --- .../src/commonMain/kotlin/soil/query/MutationState.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index 8dc3ec7..3dd1462 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -26,6 +26,15 @@ data class MutationState internal constructor( return MutationState() } + /** + * Creates a new [MutationState] with the [MutationStatus.Pending] status. + */ + fun pending(): MutationState { + return MutationState( + status = MutationStatus.Pending + ) + } + /** * Creates a new [MutationState] with the [MutationStatus.Success] status. * From ad947a4561001175bb5f7717b9ca2980e8a793f9 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 14:09:41 +0900 Subject: [PATCH 130/155] Add a test for MutationComposable --- .../soil/query/compose/MutationConfig.kt | 2 +- .../query/compose/MutationComposableTest.kt | 238 ++++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt index be0040e..2263198 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt @@ -18,7 +18,7 @@ data class MutationConfig internal constructor( @Suppress("MemberVisibilityCanBePrivate") class Builder { - val marker: Marker = Default.marker + var marker: Marker = Default.marker fun build() = MutationConfig( marker = marker diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt new file mode 100644 index 0000000..957cc7c --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -0,0 +1,238 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.launch +import soil.query.MutationKey +import soil.query.MutationState +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildMutationKey +import soil.query.compose.tooling.MutationPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.Marker +import soil.query.core.Reply +import soil.query.test.test +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class MutationComposableTest : UnitTest() { + + @Test + fun testRememberMutation() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val mutation = rememberMutation(key, config = MutationConfig { + marker = Marker.None + }) + when (val reply = mutation.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("result")) + is Reply.None -> Unit + } + val scope = rememberCoroutineScope() + Button( + onClick = { + scope.launch { + mutation.mutate(TestForm("Soil", 1)) + } + }, + modifier = Modifier.testTag("mutation") + ) { + Text("Mutate") + } + } + } + + // TODO: I don't know why but it's broken. + // Related issue: https://github.com/JetBrains/compose-multiplatform-core/blob/46232e6533a71625f7599c206594fca5e2e28e09/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/Assertions.skikoMain.kt#L26 + // onNodeWithTag("result").assertIsNotDisplayed() + onNodeWithTag("mutation").performClick() + waitUntilExactlyOneExists(hasTestTag("result")) + onNodeWithTag("result").assertTextEquals("Soil - 1") + } + + @Test + fun testRememberMutation_throwError() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { throw RuntimeException("Failed to do something :(") } + } + setContent { + SwrClientProvider(client) { + val mutation = rememberMutation(key) + when (val reply = mutation.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("result")) + is Reply.None -> Unit + } + if (mutation.error != null) { + Text("error", modifier = Modifier.testTag("result")) + } + val scope = rememberCoroutineScope() + Button( + onClick = { + scope.launch { + try { + mutation.mutate(TestForm("Soil", 1)) + } catch (e: RuntimeException) { + // unused + } + } + }, + modifier = Modifier.testTag("mutation") + ) { + Text("Mutate") + } + } + } + + onNodeWithTag("mutation").performClick() + waitUntilExactlyOneExists(hasTestTag("result")) + onNodeWithTag("result").assertTextEquals("error") + } + + @Test + fun testRememberMutation_throwErrorAsync() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { throw RuntimeException("Failed to do something :(") } + } + setContent { + SwrClientProvider(client) { + val mutation = rememberMutation(key) + when (val reply = mutation.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("result")) + is Reply.None -> Unit + } + if (mutation.error != null) { + Text("error", modifier = Modifier.testTag("result")) + } + val scope = rememberCoroutineScope() + Button( + onClick = { + scope.launch { + mutation.mutateAsync(TestForm("Soil", 1)) + } + }, + modifier = Modifier.testTag("mutation") + ) { + Text("Mutate") + } + } + } + + onNodeWithTag("mutation").performClick() + waitUntilExactlyOneExists(hasTestTag("result")) + onNodeWithTag("result").assertTextEquals("error") + } + + @Test + fun testRememberMutation_idlePreview() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutationPreview = MutationPreviewClient { + on(key.id) { MutationState.initial() } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberMutation(key = key)) { + is MutationIdleObject -> Text("Idle", modifier = Modifier.testTag("mutation")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("mutation").assertTextEquals("Idle") + } + + @Test + fun testRememberMutation_loadingPreview() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutationPreview = MutationPreviewClient { + on(key.id) { MutationState.pending() } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberMutation(key = key)) { + is MutationLoadingObject -> Text("Loading", modifier = Modifier.testTag("mutation")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("mutation").assertTextEquals("Loading") + } + + @Test + fun testRememberMutation_successPreview() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutationPreview = MutationPreviewClient { + on(key.id) { MutationState.success("Hello, Mutation!") } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberMutation(key = key)) { + is MutationSuccessObject -> Text(query.data, modifier = Modifier.testTag("mutation")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("mutation").assertTextEquals("Hello, Mutation!") + } + + @Test + fun testRememberQuery_errorPreview() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutationPreview = MutationPreviewClient { + on(key.id) { MutationState.failure(RuntimeException("Error")) } + } + ) + setContent { + SwrClientProvider(client) { + when (val query = rememberMutation(key = key)) { + is MutationErrorObject -> Text(query.error.message ?: "", modifier = Modifier.testTag("mutation")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("mutation").assertTextEquals("Error") + } + + private class TestMutationKey : MutationKey by buildMutationKey( + mutate = { form -> + "${form.name} - ${form.age}" + } + ) + + private data class TestForm( + val name: String, + val age: Int + ) +} From f53c5083a84fa3cc8dc9fc5ecc54b00caf380b43 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 15:33:34 +0900 Subject: [PATCH 131/155] Add a query testing library to `query-compose` modules dependency --- soil-query-compose-runtime/build.gradle.kts | 36 ++++++++++++++++++++- soil-query-compose/build.gradle.kts | 1 + 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/soil-query-compose-runtime/build.gradle.kts b/soil-query-compose-runtime/build.gradle.kts index 87f2e14..30f59a6 100644 --- a/soil-query-compose-runtime/build.gradle.kts +++ b/soil-query-compose-runtime/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { @@ -26,7 +27,13 @@ kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { - browser() + browser { + // TODO: We will consider using wasm tests when we update to 'org.jetbrains.compose.ui:ui:1.7.0' or later. + // - https://slack-chats.kotlinlang.org/t/22883390/wasmjs-unit-testing-what-is-the-status-of-unit-testing-on-wa + testTask { + enabled = false + } + } } sourceSets { @@ -37,6 +44,28 @@ kotlin { implementation(compose.foundation) implementation(compose.ui) } + + commonTest.dependencies { + implementation(libs.kotlin.test) + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(compose.runtime) + implementation(compose.ui) + implementation(compose.material) + implementation(projects.internal.testing) + api(projects.soilQueryTest) + } + + val androidUnitTest by getting { + dependencies { + implementation(libs.compose.ui.test.junit4.android) + implementation(libs.compose.ui.test.manifest) + } + } + + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + } } } @@ -50,6 +79,7 @@ android { defaultConfig { minSdk = buildTarget.androidMinSdk.get() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } packaging { resources { @@ -65,6 +95,10 @@ android { sourceCompatibility = buildTarget.javaVersion.get() targetCompatibility = buildTarget.javaVersion.get() } + @Suppress("UnstableApiUsage") + testOptions { + unitTests.isIncludeAndroidResources = true + } dependencies { debugImplementation(libs.compose.ui.tooling) } diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index 1bbc44c..d7d7988 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { implementation(compose.ui) implementation(compose.material) implementation(projects.internal.testing) + api(projects.soilQueryTest) } val androidUnitTest by getting { From 1c7655f580cc25ac92f9527bf460ed413f2c9adf Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 15:35:32 +0900 Subject: [PATCH 132/155] Add a test for Await --- .../soil/query/compose/runtime/AwaitTest.kt | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt new file mode 100644 index 0000000..bf7c35c --- /dev/null +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt @@ -0,0 +1,245 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.runtime + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilDoesNotExist +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildInfiniteQueryKey +import soil.query.buildQueryKey +import soil.query.compose.SwrClientProvider +import soil.query.compose.rememberInfiniteQuery +import soil.query.compose.rememberQuery +import soil.query.test.test +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.time.Duration + +@OptIn(ExperimentalTestApi::class) +class AwaitTest : UnitTest() { + + @Test + fun testAwait() = runComposeUiTest { + val deferred = CompletableDeferred() + val key = TestQueryKey("foo") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { deferred.await() } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key) + Await(query) { data -> + Text(data, modifier = Modifier.testTag("await")) + } + } + } + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred.complete("Hello, Soil!") + waitUntilExactlyOneExists(hasTestTag("await")) + onNodeWithTag("await").assertTextEquals("Hello, Soil!") + } + + @Test + fun testAwait_pair() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + val key1 = TestQueryKey("foo") + val key2 = TestQueryKey("bar") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key1.id) { deferred1.await() } + mock(key2.id) { deferred2.await() } + } + setContent { + SwrClientProvider(client) { + val query1 = rememberQuery(key1) + val query2 = rememberQuery(key2) + Await(query1, query2) { data1, data2 -> + Text(data1 + data2, modifier = Modifier.testTag("await")) + } + } + } + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred1.complete("Hello, Soil!") + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred2.complete("Hello, Compose!") + waitUntilExactlyOneExists(hasTestTag("await")) + onNodeWithTag("await").assertTextEquals("Hello, Soil!Hello, Compose!") + } + + @Test + fun testAwait_triple() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + val deferred3 = CompletableDeferred() + val key1 = TestQueryKey("foo") + val key2 = TestQueryKey("bar") + val key3 = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key1.id) { deferred1.await() } + mock(key2.id) { deferred2.await() } + mock(key3.id) { deferred3.await() } + } + setContent { + SwrClientProvider(client) { + val query1 = rememberQuery(key1) + val query2 = rememberQuery(key2) + val query3 = rememberInfiniteQuery(key3, select = { it.first().data }) + Await(query1, query2, query3) { data1, data2, data3 -> + Text(data1 + data2 + data3, modifier = Modifier.testTag("await")) + } + } + } + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred1.complete("Hello, Soil!") + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred2.complete("Hello, Compose!") + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + + deferred3.complete(3) + waitUntilExactlyOneExists(hasTestTag("await")) + onNodeWithTag("await").assertTextEquals("Hello, Soil!Hello, Compose!3") + } + + @Test + fun testAwait_withSuspense() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + val key1 = TestQueryKey("foo") + val key2 = TestQueryKey("bar") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key1.id) { deferred1.await() } + mock(key2.id) { deferred2.await() } + } + setContent { + SwrClientProvider(client) { + val query1 = rememberQuery(key1) + val query2 = rememberQuery(key2) + Suspense( + fallback = { Text("Loading...", modifier = Modifier.testTag("fallback")) } + ) { + Await(query1, query2) { data1, data2 -> + Text(data1 + data2, modifier = Modifier.testTag("await")) + } + } + } + } + waitForIdle() + waitUntilExactlyOneExists(hasTestTag("fallback")) + onNodeWithTag("fallback").assertTextEquals("Loading...") + onNodeWithTag("await").assertDoesNotExist() + + deferred1.complete("Hello, Soil!") + waitForIdle() + onNodeWithTag("fallback").assertTextEquals("Loading...") + onNodeWithTag("await").assertDoesNotExist() + + deferred2.complete("Hello, Compose!") + waitUntilExactlyOneExists(hasTestTag("await")) + onNodeWithTag("await").assertTextEquals("Hello, Soil!Hello, Compose!") + onNodeWithTag("fallback").assertDoesNotExist() + } + + @Test + fun testAwait_withSuspense_refresh() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + var isFirst = true + val key = TestQueryKey("foo") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { + if (isFirst) { + isFirst = false + deferred1.await() + } else { + deferred2.await() + } + } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key) + Suspense( + fallback = { Text("Loading...", modifier = Modifier.testTag("fallback")) }, + contentThreshold = Duration.ZERO + ) { + Await(query) { data -> + Text(data, modifier = Modifier.testTag("await")) + } + } + val scope = rememberCoroutineScope() + Button( + onClick = { scope.launch { query.refresh() } }, + modifier = Modifier.testTag("refresh") + ) { + Text("Refresh") + } + } + } + waitForIdle() + waitUntilExactlyOneExists(hasTestTag("fallback")) + onNodeWithTag("fallback").assertTextEquals("Loading...") + onNodeWithTag("await").assertDoesNotExist() + + deferred1.complete("Hello, Soil!") + waitForIdle() + waitUntilExactlyOneExists(hasTestTag("await")) + onNodeWithTag("await").assertTextEquals("Hello, Soil!") + onNodeWithTag("fallback").assertDoesNotExist() + + onNodeWithTag("refresh").performClick() + waitForIdle() + waitUntilExactlyOneExists(hasTestTag("fallback")) + onNodeWithTag("fallback").assertTextEquals("Loading...") + onNodeWithTag("await").assertTextEquals("Hello, Soil!") + + deferred2.complete("Hello, Compose!") + waitUntilDoesNotExist(hasTestTag("fallback")) + onNodeWithTag("await").assertTextEquals("Hello, Compose!") + } + + private class TestQueryKey(val variant: String) : QueryKey by buildQueryKey( + id = Id(variant), + fetch = { "Hello, Soil!" } + ) { + class Id(variant: String) : QueryId("test/query", "variant" to variant) + } + + private class TestInfiniteQueryKey : InfiniteQueryKey by buildInfiniteQueryKey( + id = Id, + fetch = { it }, + initialParam = { 0 }, + loadMoreParam = { it.last().param + 1 } + ) { + object Id : InfiniteQueryId("test/infinite-query") + } +} From b19502d4e345d78ecdd6bd1f50980254b36b0d94 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 16:12:44 +0900 Subject: [PATCH 133/155] Add a test for Suspense --- .../query/compose/runtime/SuspenseTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/SuspenseTest.kt diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/SuspenseTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/SuspenseTest.kt new file mode 100644 index 0000000..6e71897 --- /dev/null +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/SuspenseTest.kt @@ -0,0 +1,56 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.runtime + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class SuspenseTest : UnitTest() { + + @Test + fun testSuspense() = runComposeUiTest { + setContent { + var state by remember { mutableStateOf>(Loadable.Pending) } + Suspense( + fallback = { Text("Loading...", modifier = Modifier.testTag("fallback")) } + ) { + Await(state) { + Text(it, modifier = Modifier.testTag("content")) + } + } + Button( + onClick = { + state = Loadable.Fulfilled("Hello, Soil!") + }, + modifier = Modifier.testTag("load") + ) { + Text("Load") + } + } + + waitUntilExactlyOneExists(hasTestTag("fallback")) + onNodeWithTag("fallback").assertTextEquals("Loading...") + + onNodeWithTag("load").performClick() + waitUntilExactlyOneExists(hasTestTag("content")) + onNodeWithTag("content").assertTextEquals("Hello, Soil!") + onNodeWithTag("fallback").assertDoesNotExist() + } +} From 7b5e7801df6015cb1a6aba4174db65a15d716310 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 16:50:44 +0900 Subject: [PATCH 134/155] Add a test for Catch --- .../soil/query/compose/runtime/CatchTest.kt | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt new file mode 100644 index 0000000..0858d20 --- /dev/null +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt @@ -0,0 +1,130 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.runtime + +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildQueryKey +import soil.query.compose.SwrClientProvider +import soil.query.compose.rememberQuery +import soil.query.test.test +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class CatchTest : UnitTest() { + + @Test + fun testCatch() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + var isFirst = true + val key = TestQueryKey("foo") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { + if (isFirst) { + isFirst = false + deferred1.await() + } else { + deferred2.await() + } + } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key) + Catch(query) { + Text("Error", modifier = Modifier.testTag("catch")) + } + val scope = rememberCoroutineScope() + Button( + onClick = { scope.launch { query.refresh() } }, + modifier = Modifier.testTag("refresh") + ) { + Text("Refresh") + } + } + } + waitForIdle() + onNodeWithTag("catch").assertDoesNotExist() + + deferred1.completeExceptionally(RuntimeException("Error")) + waitUntilExactlyOneExists(hasTestTag("catch")) + onNodeWithTag("catch").assertTextEquals("Error") + + onNodeWithTag("refresh").performClick() + deferred2.complete("Hello, Soil!") + waitForIdle() + onNodeWithTag("catch").assertDoesNotExist() + } + + @Test + fun testCatch_withErrorBoundary() = runComposeUiTest { + val deferred1 = CompletableDeferred() + val deferred2 = CompletableDeferred() + var isFirst = true + val key = TestQueryKey("foo") + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + mock(key.id) { + if (isFirst) { + isFirst = false + deferred1.await() + } else { + deferred2.await() + } + } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key) + ErrorBoundary( + fallback = { Text("Error", modifier = Modifier.testTag("fallback")) } + ) { + Catch(query) + } + val scope = rememberCoroutineScope() + Button( + onClick = { scope.launch { query.refresh() } }, + modifier = Modifier.testTag("refresh") + ) { + Text("Refresh") + } + } + } + waitForIdle() + onNodeWithTag("fallback").assertDoesNotExist() + + deferred1.completeExceptionally(RuntimeException("Error")) + waitUntilExactlyOneExists(hasTestTag("fallback")) + onNodeWithTag("fallback").assertTextEquals("Error") + + onNodeWithTag("refresh").performClick() + deferred2.complete("Hello, Soil!") + waitForIdle() + onNodeWithTag("fallback").assertDoesNotExist() + } + + private class TestQueryKey(val variant: String) : QueryKey by buildQueryKey( + id = Id(variant), + fetch = { "Hello, Soil!" } + ) { + class Id(variant: String) : QueryId("test/query", "variant" to variant) + } +} From f6dbe432e3493e5ff952e6bd90b1539d7552818e Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 18:34:12 +0900 Subject: [PATCH 135/155] Enabling behavior customization through the strategy layer. We've adjusted the strategy API implemented in #69 so that it can be used for both queries and mutations as needed. refs: #69 --- .../soil/query/compose/CachingStrategy.kt | 108 +++++++++++++ .../soil/query/compose/InfiniteQueryConfig.kt | 7 +- .../query/compose/InfiniteQueryStrategy.kt | 40 +++++ .../soil/query/compose/MutationComposable.kt | 5 +- .../soil/query/compose/MutationConfig.kt | 5 +- .../soil/query/compose/MutationStrategy.kt | 30 ++++ .../query/compose/QueryCachingStrategy.kt | 153 ------------------ .../kotlin/soil/query/compose/QueryConfig.kt | 7 +- .../soil/query/compose/QueryStrategy.kt | 38 +++++ .../compose/InfiniteQueryComposableTest.kt | 2 +- .../query/compose/MutationComposableTest.kt | 1 + .../soil/query/compose/QueryComposableTest.kt | 2 +- 12 files changed, 230 insertions(+), 168 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt delete mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt new file mode 100644 index 0000000..bb61533 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt @@ -0,0 +1,108 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.flow.StateFlow +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.UniqueId +import soil.query.core.isNone + +/** + * A mechanism to finely adjust the behavior of the query results on a component basis in Composable functions. + * + * In addition to the default behavior provided by Stale-While-Revalidate, two experimental strategies are now available: + * + * 1. Cache-First: + * This strategy avoids requesting data re-fetch as long as valid cached data is available. + * It prioritizes using the cached data over network requests. + * + * 2. Network-First: + * This strategy maintains the initial loading state until data is re-fetched, regardless of the presence of valid cached data. + * This ensures that the most up-to-date data is always displayed. + * + * + * Background: + * During in-app development, there are scenarios where returning cached data first can lead to issues. + * For example, if the externally updated data state is not accurately reflected on the screen, inconsistencies can occur. + * This is particularly problematic in processes that automatically redirect to other screens based on the data state. + * + * On the other hand, there are situations where data re-fetching should be suppressed to minimize data traffic. + * In such cases, setting a long staleTime in QueryOptions is not sufficient, as specific conditions for reducing data traffic may persist. + */ +@Suppress("unused") +sealed interface CachingStrategy { + + @ExperimentalSoilQueryApi + @Stable + data object CacheFirst : CachingStrategy, QueryStrategy, InfiniteQueryStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + private inline fun collectAsState( + key: UniqueId, + flow: StateFlow>, + crossinline resume: suspend () -> Unit + ): QueryState { + val state by flow.collectAsState() + LaunchedEffect(key) { + val currentValue = flow.value + if (currentValue.reply.isNone || currentValue.isInvalidated) { + resume() + } + } + return state + } + } + + @ExperimentalSoilQueryApi + @Stable + data object NetworkFirst : CachingStrategy, QueryStrategy, InfiniteQueryStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + return collectAsState(query.key.id, query.state, query::resume) + } + + @Composable + private inline fun collectAsState( + key: UniqueId, + flow: StateFlow>, + crossinline resume: suspend () -> Unit + ): QueryState { + var resumed by rememberSaveable(key) { mutableStateOf(false) } + val initialValue = if (resumed) flow.value else QueryState.initial() + val state = produceState(initialValue, key) { + resume() + resumed = true + flow.collect { value = it } + } + return state.value + } + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt index 2949da9..8cf8ac9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt @@ -14,13 +14,12 @@ import soil.query.core.Marker */ @Immutable data class InfiniteQueryConfig internal constructor( - val strategy: QueryCachingStrategy, + val strategy: InfiniteQueryStrategy, val marker: Marker ) { - @Suppress("MemberVisibilityCanBePrivate") class Builder { - var strategy: QueryCachingStrategy = Default.strategy + var strategy: InfiniteQueryStrategy = Default.strategy var marker: Marker = Default.marker fun build() = InfiniteQueryConfig( @@ -31,7 +30,7 @@ data class InfiniteQueryConfig internal constructor( companion object { val Default = InfiniteQueryConfig( - strategy = QueryCachingStrategy.Default, + strategy = InfiniteQueryStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt new file mode 100644 index 0000000..143fc59 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt @@ -0,0 +1,40 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryState + +/** + * A mechanism to finely adjust the behavior of the infinite-query on a component basis in Composable functions. + * + * If you want to customize, please create a class implementing [InfiniteQueryStrategy]. + * For example, this is useful when you want to switch your implementation to `collectAsStateWithLifecycle`. + * + * @see CachingStrategy + */ +@Stable +interface InfiniteQueryStrategy { + + @Composable + fun collectAsState(query: InfiniteQueryRef): QueryState> + + companion object Default : InfiniteQueryStrategy { + + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + val state by query.state.collectAsState() + LaunchedEffect(query.key.id) { + query.resume() + } + return state + } + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 9350917..3d83d08 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -4,8 +4,6 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.MutationClient @@ -32,8 +30,7 @@ fun rememberMutation( ): MutationObject { val scope = rememberCoroutineScope() val mutation = remember(key) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } - val state by mutation.state.collectAsState() - return state.toObject(mutation = mutation) + return config.strategy.collectAsState(mutation).toObject(mutation = mutation) } private fun MutationState.toObject( diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt index 2263198..3ce11d5 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt @@ -13,20 +13,23 @@ import soil.query.core.Marker */ @Immutable data class MutationConfig internal constructor( + val strategy: MutationStrategy, val marker: Marker ) { - @Suppress("MemberVisibilityCanBePrivate") class Builder { + var strategy: MutationStrategy = Default.strategy var marker: Marker = Default.marker fun build() = MutationConfig( + strategy = strategy, marker = marker ) } companion object { val Default = MutationConfig( + strategy = MutationStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt new file mode 100644 index 0000000..f5ed874 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt @@ -0,0 +1,30 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import soil.query.MutationRef +import soil.query.MutationState + +/** + * A mechanism to finely adjust the behavior of the mutation on a component basis in Composable functions. + * + * If you want to customize, please create a class implementing [MutationStrategy]. + * For example, this is useful when you want to switch your implementation to `collectAsStateWithLifecycle`. + */ +@Stable +interface MutationStrategy { + + @Composable + fun collectAsState(mutation: MutationRef): MutationState + + companion object Default : MutationStrategy { + @Composable + override fun collectAsState(mutation: MutationRef): MutationState { + return mutation.state.collectAsState().value + } + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt deleted file mode 100644 index 51bef6e..0000000 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryCachingStrategy.kt +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import kotlinx.coroutines.flow.StateFlow -import soil.query.InfiniteQueryRef -import soil.query.QueryChunks -import soil.query.QueryRef -import soil.query.QueryState -import soil.query.annotation.ExperimentalSoilQueryApi -import soil.query.core.UniqueId -import soil.query.core.isNone - -/** - * A mechanism to finely adjust the behavior of the query results on a component basis in Composable functions. - * - * In addition to the default behavior provided by Stale-While-Revalidate, two experimental strategies are now available: - * - * 1. Cache-First: - * This strategy avoids requesting data re-fetch as long as valid cached data is available. - * It prioritizes using the cached data over network requests. - * - * 2. Network-First: - * This strategy maintains the initial loading state until data is re-fetched, regardless of the presence of valid cached data. - * This ensures that the most up-to-date data is always displayed. - * - * If you want to customize further, please create a class implementing [QueryCachingStrategy]. - * However, as this is an experimental API, the interface may change significantly in future versions. - * - * In future updates, we plan to provide additional options for more granular control over the behavior at the component level. - * - * Background: - * During in-app development, there are scenarios where returning cached data first can lead to issues. - * For example, if the externally updated data state is not accurately reflected on the screen, inconsistencies can occur. - * This is particularly problematic in processes that automatically redirect to other screens based on the data state. - * - * On the other hand, there are situations where data re-fetching should be suppressed to minimize data traffic. - * In such cases, setting a long staleTime in QueryOptions is not sufficient, as specific conditions for reducing data traffic may persist. - */ -@Stable -interface QueryCachingStrategy { - @Composable - fun collectAsState(query: QueryRef): QueryState - - @Composable - fun collectAsState(query: InfiniteQueryRef): QueryState> - - companion object Default : QueryCachingStrategy by StaleWhileRevalidate { - - @Suppress("FunctionName") - @ExperimentalSoilQueryApi - fun CacheFirst(): QueryCachingStrategy = CacheFirst - - @Suppress("FunctionName") - @ExperimentalSoilQueryApi - fun NetworkFirst(): QueryCachingStrategy = NetworkFirst - } -} - -@Stable -private object StaleWhileRevalidate : QueryCachingStrategy { - @Composable - override fun collectAsState(query: QueryRef): QueryState { - return collectAsState(key = query.key.id, flow = query.state, resume = query::resume) - } - - @Composable - override fun collectAsState(query: InfiniteQueryRef): QueryState> { - return collectAsState(key = query.key.id, flow = query.state, resume = query::resume) - } - - @Composable - private inline fun collectAsState( - key: UniqueId, - flow: StateFlow>, - crossinline resume: suspend () -> Unit - ): QueryState { - val state by flow.collectAsState() - LaunchedEffect(key) { - resume() - } - return state - } -} - - -@Stable -private object CacheFirst : QueryCachingStrategy { - @Composable - override fun collectAsState(query: QueryRef): QueryState { - return collectAsState(query.key.id, query.state, query::resume) - } - - @Composable - override fun collectAsState(query: InfiniteQueryRef): QueryState> { - return collectAsState(query.key.id, query.state, query::resume) - } - - @Composable - private inline fun collectAsState( - key: UniqueId, - flow: StateFlow>, - crossinline resume: suspend () -> Unit - ): QueryState { - val state by flow.collectAsState() - LaunchedEffect(key) { - val currentValue = flow.value - if (currentValue.reply.isNone || currentValue.isInvalidated) { - resume() - } - } - return state - } -} - -@Stable -private object NetworkFirst : QueryCachingStrategy { - @Composable - override fun collectAsState(query: QueryRef): QueryState { - return collectAsState(query.key.id, query.state, query::resume) - } - - @Composable - override fun collectAsState(query: InfiniteQueryRef): QueryState> { - return collectAsState(query.key.id, query.state, query::resume) - } - - @Composable - private inline fun collectAsState( - key: UniqueId, - flow: StateFlow>, - crossinline resume: suspend () -> Unit - ): QueryState { - var resumed by rememberSaveable(key) { mutableStateOf(false) } - val initialValue = if (resumed) flow.value else QueryState.initial() - val state = produceState(initialValue, key) { - resume() - resumed = true - flow.collect { value = it } - } - return state.value - } -} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt index 4d3ea39..0693688 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt @@ -14,13 +14,12 @@ import soil.query.core.Marker */ @Immutable data class QueryConfig internal constructor( - val strategy: QueryCachingStrategy, + val strategy: QueryStrategy, val marker: Marker ) { - @Suppress("MemberVisibilityCanBePrivate") class Builder { - var strategy: QueryCachingStrategy = Default.strategy + var strategy: QueryStrategy = Default.strategy var marker: Marker = Default.marker fun build() = QueryConfig( @@ -31,7 +30,7 @@ data class QueryConfig internal constructor( companion object { val Default = QueryConfig( - strategy = QueryCachingStrategy.Default, + strategy = QueryStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt new file mode 100644 index 0000000..8ee80cd --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt @@ -0,0 +1,38 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import soil.query.QueryRef +import soil.query.QueryState + +/** + * A mechanism to finely adjust the behavior of the query on a component basis in Composable functions. + * + * If you want to customize, please create a class implementing [QueryStrategy]. + * For example, this is useful when you want to switch your implementation to `collectAsStateWithLifecycle`. + * + * @see CachingStrategy + */ +@Stable +interface QueryStrategy { + + @Composable + fun collectAsState(query: QueryRef): QueryState + + companion object Default : QueryStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + val state by query.state.collectAsState() + LaunchedEffect(query.key.id) { + query.resume() + } + return state + } + } +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index bdd7088..dd3dd8d 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -44,7 +44,7 @@ class InfiniteQueryComposableTest : UnitTest() { setContent { SwrClientProvider(client) { val query = rememberInfiniteQuery(key, config = InfiniteQueryConfig { - strategy = QueryCachingStrategy + strategy = InfiniteQueryStrategy.Default marker = Marker.None }) when (val reply = query.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt index 957cc7c..a6b5422 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -39,6 +39,7 @@ class MutationComposableTest : UnitTest() { setContent { SwrClientProvider(client) { val mutation = rememberMutation(key, config = MutationConfig { + strategy = MutationStrategy.Default marker = Marker.None }) when (val reply = mutation.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index 6ec57f8..1698f37 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -36,7 +36,7 @@ class QueryComposableTest : UnitTest() { setContent { SwrClientProvider(client) { val query = rememberQuery(key, config = QueryConfig { - strategy = QueryCachingStrategy.Default + strategy = QueryStrategy.Default marker = Marker.None }) when (val reply = query.reply) { From 5f86c8f7683dd356d33a0075a2a41f68d2a6bad1 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 21:05:16 +0900 Subject: [PATCH 136/155] Create an instance of BatchScheduler via a factory function By making it a factory function, it is possible to defer creation until it is needed. refs: #66 --- .../commonMain/kotlin/soil/query/SwrCache.kt | 8 ++--- .../kotlin/soil/query/SwrCachePolicy.kt | 3 +- .../kotlin/soil/query/core/BatchScheduler.kt | 33 +++++++++++-------- .../soil/query/core/BatchSchedulerTest.kt | 8 ++--- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index d09561f..88e31d6 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -75,18 +75,16 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private val queryReceiver = policy.queryReceiver private val queryStore: MutableMap> = mutableMapOf() private val queryCache: QueryCache = policy.queryCache - private val batchScheduler: BatchScheduler = policy.batchScheduler private val coroutineScope: CoroutineScope = CoroutineScope( context = newCoroutineContext(policy.coroutineScope) ) + private val batchScheduler: BatchScheduler by lazy { + policy.batchSchedulerFactory.create(coroutineScope) + } private var mountedIds: Set = emptySet() private var mountedScope: CoroutineScope? = null - init { - batchScheduler.start(coroutineScope) - } - /** * Releases data in memory based on the specified [level]. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt index 8d19f1f..d78418e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import soil.query.core.BatchScheduler +import soil.query.core.BatchSchedulerFactory import soil.query.core.ErrorRelay import soil.query.core.MemoryPressure import soil.query.core.NetworkConnectivity @@ -69,7 +70,7 @@ class SwrCachePolicy( * This is used for internal processes such as moving inactive query caches. * Please avoid changing this unless you need to substitute it for testing purposes. */ - val batchScheduler: BatchScheduler = BatchScheduler.default(mainDispatcher), + val batchSchedulerFactory: BatchSchedulerFactory = BatchSchedulerFactory.default(mainDispatcher), /** * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt index 25a26f1..adf918a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/BatchScheduler.kt @@ -6,7 +6,6 @@ package soil.query.core import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -20,19 +19,24 @@ import kotlin.time.Duration.Companion.milliseconds interface BatchScheduler { /** - * Start the scheduler. + * Post a task to the scheduler. */ - fun start(scope: CoroutineScope): Job + suspend fun post(task: BatchTask) +} + +/** + * Factory for creating [BatchScheduler] instances. + */ +fun interface BatchSchedulerFactory { /** - * Post a task to the scheduler. + * Create a new [BatchScheduler] instance. */ - suspend fun post(task: BatchTask) + fun create(scope: CoroutineScope): BatchScheduler companion object { - /** - * Create a new [BatchScheduler] with built-in scheduler implementation. + * Create a new [BatchScheduler] factory with built-in scheduler implementation. * * @param dispatcher Coroutine dispatcher for the main thread. * @param interval Interval for batching tasks. @@ -42,8 +46,8 @@ interface BatchScheduler { dispatcher: CoroutineDispatcher = Dispatchers.Main, interval: Duration = 500.milliseconds, chunkSize: Int = 10 - ): BatchScheduler { - return DefaultBatchScheduler(dispatcher, interval, chunkSize) + ): BatchSchedulerFactory = BatchSchedulerFactory { scope -> + DefaultBatchScheduler(scope, dispatcher, interval, chunkSize) } } } @@ -51,15 +55,16 @@ interface BatchScheduler { typealias BatchTask = () -> Unit internal class DefaultBatchScheduler( - private val dispatcher: CoroutineDispatcher, - private val interval: Duration, - private val chunkSize: Int + scope: CoroutineScope, + dispatcher: CoroutineDispatcher, + interval: Duration, + chunkSize: Int ) : BatchScheduler { private val batchFlow: MutableSharedFlow = MutableSharedFlow() - override fun start(scope: CoroutineScope): Job { - return batchFlow + init { + batchFlow .chunkedWithTimeout(size = chunkSize, duration = interval) .onEach { tasks -> withContext(dispatcher) { diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt index dfda73d..2ec264f 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/core/BatchSchedulerTest.kt @@ -20,13 +20,13 @@ class BatchSchedulerTest : UnitTest() { @Test fun testDefault_chunkSizeTrigger() = runTest { - val scheduler = BatchScheduler.default( + val factory = BatchSchedulerFactory.default( dispatcher = StandardTestDispatcher(testScheduler), interval = 3.seconds, chunkSize = 3 ) val scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)) - scheduler.start(scope) + val scheduler = factory.create(scope) val task = TestBatchTask() scheduler.post(task) @@ -43,13 +43,13 @@ class BatchSchedulerTest : UnitTest() { @Test fun testDefault_intervalTrigger() = runTest { - val scheduler = BatchScheduler.default( + val factory = BatchSchedulerFactory.default( dispatcher = StandardTestDispatcher(testScheduler), interval = 3.seconds, chunkSize = 3 ) val scope = CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)) - scheduler.start(scope) + val scheduler = factory.create(scope) val task = TestBatchTask() scheduler.post(task) From 3f5ccb81bca0dea66e823ec9dedd48b54b21fe1b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 21:14:36 +0900 Subject: [PATCH 137/155] Fix a flaky test --- .../commonTest/kotlin/soil/query/compose/QueryComposableTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index 1698f37..e5c8d78 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -66,7 +66,7 @@ class QueryComposableTest : UnitTest() { } } - waitForIdle() + waitUntilExactlyOneExists(hasTestTag("query")) onNodeWithTag("query").assertTextEquals("HELLO, SOIL!") } From 06094345398726de104d05a40853e08a87af8def Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 31 Aug 2024 21:29:00 +0900 Subject: [PATCH 138/155] Rename `mock(xx)` to `on(xx)` Aligning PreviewClient implementation and naming reduces cognitive load. refs: #63, #70 --- .../soil/query/compose/runtime/AwaitTest.kt | 18 +++++++++--------- .../soil/query/compose/runtime/CatchTest.kt | 4 ++-- .../query/compose/MutationComposableTest.kt | 4 ++-- .../soil/query/compose/QueryComposableTest.kt | 4 ++-- .../kotlin/soil/query/test/TestSwrClient.kt | 17 +++++++++-------- .../soil/query/test/TestSwrClientTest.kt | 19 ++++++------------- 6 files changed, 30 insertions(+), 36 deletions(-) diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt index bf7c35c..6abcae3 100644 --- a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt @@ -42,7 +42,7 @@ class AwaitTest : UnitTest() { val deferred = CompletableDeferred() val key = TestQueryKey("foo") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { deferred.await() } + on(key.id) { deferred.await() } } setContent { SwrClientProvider(client) { @@ -67,8 +67,8 @@ class AwaitTest : UnitTest() { val key1 = TestQueryKey("foo") val key2 = TestQueryKey("bar") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key1.id) { deferred1.await() } - mock(key2.id) { deferred2.await() } + on(key1.id) { deferred1.await() } + on(key2.id) { deferred2.await() } } setContent { SwrClientProvider(client) { @@ -100,9 +100,9 @@ class AwaitTest : UnitTest() { val key2 = TestQueryKey("bar") val key3 = TestInfiniteQueryKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key1.id) { deferred1.await() } - mock(key2.id) { deferred2.await() } - mock(key3.id) { deferred3.await() } + on(key1.id) { deferred1.await() } + on(key2.id) { deferred2.await() } + on(key3.id) { deferred3.await() } } setContent { SwrClientProvider(client) { @@ -137,8 +137,8 @@ class AwaitTest : UnitTest() { val key1 = TestQueryKey("foo") val key2 = TestQueryKey("bar") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key1.id) { deferred1.await() } - mock(key2.id) { deferred2.await() } + on(key1.id) { deferred1.await() } + on(key2.id) { deferred2.await() } } setContent { SwrClientProvider(client) { @@ -176,7 +176,7 @@ class AwaitTest : UnitTest() { var isFirst = true val key = TestQueryKey("foo") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { + on(key.id) { if (isFirst) { isFirst = false deferred1.await() diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt index 0858d20..13fc29a 100644 --- a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/CatchTest.kt @@ -38,7 +38,7 @@ class CatchTest : UnitTest() { var isFirst = true val key = TestQueryKey("foo") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { + on(key.id) { if (isFirst) { isFirst = false deferred1.await() @@ -82,7 +82,7 @@ class CatchTest : UnitTest() { var isFirst = true val key = TestQueryKey("foo") val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { + on(key.id) { if (isFirst) { isFirst = false deferred1.await() diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt index a6b5422..896b54b 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -72,7 +72,7 @@ class MutationComposableTest : UnitTest() { fun testRememberMutation_throwError() = runComposeUiTest { val key = TestMutationKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { throw RuntimeException("Failed to do something :(") } + on(key.id) { throw RuntimeException("Failed to do something :(") } } setContent { SwrClientProvider(client) { @@ -111,7 +111,7 @@ class MutationComposableTest : UnitTest() { fun testRememberMutation_throwErrorAsync() = runComposeUiTest { val key = TestMutationKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { throw RuntimeException("Failed to do something :(") } + on(key.id) { throw RuntimeException("Failed to do something :(") } } setContent { SwrClientProvider(client) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index e5c8d78..95d2e6a 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -54,7 +54,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_select() = runComposeUiTest { val key = TestQueryKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { "Hello, Soil!" } + on(key.id) { "Hello, Soil!" } } setContent { SwrClientProvider(client) { @@ -74,7 +74,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_throwError() = runComposeUiTest { val key = TestQueryKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - mock(key.id) { throw RuntimeException("Failed to do something :(") } + on(key.id) { throw RuntimeException("Failed to do something :(") } } setContent { SwrClientProvider(client) { diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt index afb46f3..98031c4 100644 --- a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClient.kt @@ -22,8 +22,9 @@ import soil.query.core.Marker * * ```kotlin * val client = SwrCache(..) - * val testClient = client.test() - * testClient.mock(MyQueryId) { "returned fake data" } + * val testClient = client.test { + * on(MyQueryId) { "returned fake data" } + * } * * testClient.doSomething() * ``` @@ -33,17 +34,17 @@ interface TestSwrClient : SwrClient { /** * Mocks the mutation process corresponding to [MutationId]. */ - fun mock(id: MutationId, mutate: FakeMutationMutate) + fun on(id: MutationId, mutate: FakeMutationMutate) /** * Mocks the query process corresponding to [QueryId]. */ - fun mock(id: QueryId, fetch: FakeQueryFetch) + fun on(id: QueryId, fetch: FakeQueryFetch) /** * Mocks the query process corresponding to [InfiniteQueryId]. */ - fun mock(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) + fun on(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) } /** @@ -61,15 +62,15 @@ internal class TestSwrClientImpl( private val mockQueries = mutableMapOf, FakeQueryFetch<*>>() private val mockInfiniteQueries = mutableMapOf, FakeInfiniteQueryFetch<*, *>>() - override fun mock(id: MutationId, mutate: FakeMutationMutate) { + override fun on(id: MutationId, mutate: FakeMutationMutate) { mockMutations[id] = mutate } - override fun mock(id: QueryId, fetch: FakeQueryFetch) { + override fun on(id: QueryId, fetch: FakeQueryFetch) { mockQueries[id] = fetch } - override fun mock(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) { + override fun on(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) { mockInfiniteQueries[id] = fetch } diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt index 5a250a3..6994c75 100644 --- a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt @@ -26,7 +26,6 @@ import soil.query.buildMutationKey import soil.query.buildQueryKey import soil.query.core.Marker import soil.query.core.getOrThrow -import soil.query.mutate import soil.testing.UnitTest import kotlin.test.Test import kotlin.test.assertEquals @@ -42,10 +41,8 @@ class TestSwrClientTest : UnitTest() { mainDispatcher = UnconfinedTestDispatcher(testScheduler) ) ) - val testClient = client.test().apply { - mock(ExampleMutationKey.Id) { - "Hello, World!" - } + val testClient = client.test { + on(ExampleMutationKey.Id) { "Hello, World!" } } val key = ExampleMutationKey() val mutation = testClient.getMutation(key).also { it.launchIn(backgroundScope) } @@ -61,10 +58,8 @@ class TestSwrClientTest : UnitTest() { mainDispatcher = UnconfinedTestDispatcher(testScheduler) ) ) - val testClient = client.test().apply { - mock(ExampleQueryKey.Id) { - "Hello, World!" - } + val testClient = client.test { + on(ExampleQueryKey.Id) { "Hello, World!" } } val key = ExampleQueryKey() val query = testClient.getQuery(key).also { it.launchIn(backgroundScope) } @@ -80,10 +75,8 @@ class TestSwrClientTest : UnitTest() { mainDispatcher = UnconfinedTestDispatcher(testScheduler) ) ) - val testClient = client.test().apply { - mock(ExampleInfiniteQueryKey.Id) { - "Hello, World!" - } + val testClient = client.test { + on(ExampleInfiniteQueryKey.Id) { "Hello, World!" } } val key = ExampleInfiniteQueryKey() val query = testClient.getInfiniteQuery(key).also { it.launchIn(backgroundScope) } From df9825074751a73eeee3a28b682c9d4c979eed7b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 1 Sep 2024 08:23:06 +0900 Subject: [PATCH 139/155] Using XxxOptions via the XxxRef interface With the implementation of the Strategy Interface (#84), we believe there will be cases in the future where players will want to refer to the options, so we have made the change so that they can be referenced. refs: #84 --- .../compose/tooling/MutationPreviewClient.kt | 4 +- .../compose/tooling/QueryPreviewClient.kt | 8 +++- .../kotlin/soil/query/InfiniteQueryRef.kt | 16 ++++++++ .../commonMain/kotlin/soil/query/Mutation.kt | 5 +++ .../kotlin/soil/query/MutationRef.kt | 16 ++++++++ .../src/commonMain/kotlin/soil/query/Query.kt | 7 +++- .../commonMain/kotlin/soil/query/QueryRef.kt | 16 ++++++++ .../commonMain/kotlin/soil/query/SwrCache.kt | 38 +++++++++---------- .../kotlin/soil/query/SwrInfiniteQuery.kt | 3 ++ .../kotlin/soil/query/SwrMutation.kt | 3 ++ .../commonMain/kotlin/soil/query/SwrQuery.kt | 3 ++ 11 files changed, 95 insertions(+), 24 deletions(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt index cc451c9..18eeab7 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -39,11 +39,13 @@ class MutationPreviewClient( marker: Marker ): MutationRef { val state = previewData[key.id] as? MutationState ?: MutationState.initial() - return SnapshotMutation(key, marker, MutableStateFlow(state)) + val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions + return SnapshotMutation(key, options, marker, MutableStateFlow(state)) } private class SnapshotMutation( override val key: MutationKey, + override val options: MutationOptions, override val marker: Marker, override val state: StateFlow> ) : MutationRef { diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index 7ddf18d..a261bdc 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -44,7 +44,8 @@ class QueryPreviewClient( marker: Marker ): QueryRef { val state = previewData[key.id] as? QueryState ?: QueryState.initial() - return SnapshotQuery(key, marker, MutableStateFlow(state)) + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + return SnapshotQuery(key, options, marker, MutableStateFlow(state)) } @Suppress("UNCHECKED_CAST") @@ -53,7 +54,8 @@ class QueryPreviewClient( marker: Marker ): InfiniteQueryRef { val state = previewData[key.id] as? QueryState> ?: QueryState.initial() - return SnapshotInfiniteQuery(key, marker, MutableStateFlow(state)) + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + return SnapshotInfiniteQuery(key, options, marker, MutableStateFlow(state)) } override fun prefetchQuery( @@ -68,6 +70,7 @@ class QueryPreviewClient( private class SnapshotQuery( override val key: QueryKey, + override val options: QueryOptions, override val marker: Marker, override val state: StateFlow> ) : QueryRef { @@ -79,6 +82,7 @@ class QueryPreviewClient( private class SnapshotInfiniteQuery( override val key: InfiniteQueryKey, + override val options: QueryOptions, override val marker: Marker, override val state: StateFlow>> ) : InfiniteQueryRef { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index db05842..fd7220a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -18,8 +18,24 @@ import soil.query.core.awaitOrNull */ interface InfiniteQueryRef : Actor { + /** + * The [InfiniteQueryKey] for the Query. + */ val key: InfiniteQueryKey + + /** + * The QueryOptions configured for the query. + */ + val options: QueryOptions + + /** + * The Marker specified in [QueryClient.getInfiniteQuery]. + */ val marker: Marker + + /** + * [State Flow][StateFlow] to receive the current state of the query. + */ val state: StateFlow>> /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index d668ea1..8054a22 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -14,6 +14,11 @@ import soil.query.core.Actor */ internal interface Mutation : Actor { + /** + * The MutationOptions configured for the mutation. + */ + val options: MutationOptions + /** * [State Flow][StateFlow] to receive the current state of the mutation. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 1686b76..f92ccd2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -17,8 +17,24 @@ import soil.query.core.Marker */ interface MutationRef : Actor { + /** + * The [MutationKey] for the Mutation. + */ val key: MutationKey + + /** + * The MutationOptions configured for the mutation. + */ + val options: MutationOptions + + /** + * The Marker specified in [MutationClient.getMutation]. + */ val marker: Marker + + /** + * [State Flow][StateFlow] to receive the current state of the mutation. + */ val state: StateFlow> /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index f38c474..46db5fb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -13,7 +13,12 @@ import soil.query.core.Actor * * @param T Type of the return value from the query. */ -internal interface Query: Actor { +internal interface Query : Actor { + + /** + * The QueryOptions configured for the query. + */ + val options: QueryOptions /** * [Shared Flow][SharedFlow] to receive query [events][QueryEvent]. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 089142c..a09d2ed 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -17,8 +17,24 @@ import soil.query.core.awaitOrNull */ interface QueryRef : Actor { + /** + * The [QueryKey] for the Query. + */ val key: QueryKey + + /** + * The QueryOptions configured for the query. + */ + val options: QueryOptions + + /** + * The Marker specified in [QueryClient.getQuery]. + */ val marker: Marker + + /** + * [State Flow][StateFlow] to receive the current state of the query. + */ val state: StateFlow> /** diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 88e31d6..1d7cc8c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -240,12 +240,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } return ManagedMutation( + scope = scope, id = id, options = options, - scope = scope, - actor = actor, state = state, - command = command + command = command, + actor = actor ) } @@ -318,14 +318,14 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } } return ManagedQuery( + scope = scope, id = id, options = options, - scope = scope, - dispatch = dispatch, - actor = actor, event = event, state = state, - command = command + command = command, + actor = actor, + dispatch = dispatch ) } @@ -395,8 +395,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie val query = getQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - val options = key.configureOptions(defaultQueryOptions) - withTimeoutOrNull(options.prefetchWindowTime) { + withTimeoutOrNull(query.options.prefetchWindowTime) { query.resume() } } finally { @@ -413,8 +412,7 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie val query = getInfiniteQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - val options = key.configureOptions(defaultQueryOptions) - withTimeoutOrNull(options.prefetchWindowTime) { + withTimeoutOrNull(query.options.prefetchWindowTime) { query.resume() } } finally { @@ -595,12 +593,12 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie } internal class ManagedMutation( - val id: UniqueId, - val options: MutationOptions, val scope: CoroutineScope, - internal val actor: ActorBlockRunner, + val id: UniqueId, + override val options: MutationOptions, override val state: StateFlow>, - override val command: SendChannel> + override val command: SendChannel>, + internal val actor: ActorBlockRunner, ) : Mutation { override fun launchIn(scope: CoroutineScope): Job { @@ -623,14 +621,14 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) : MutationCommand.Context internal class ManagedQuery( - val id: UniqueId, - val options: QueryOptions, val scope: CoroutineScope, - val dispatch: QueryDispatch, - internal val actor: ActorBlockRunner, + val id: UniqueId, + override val options: QueryOptions, override val event: MutableSharedFlow, override val state: StateFlow>, - override val command: SendChannel> + override val command: SendChannel>, + internal val actor: ActorBlockRunner, + private val dispatch: QueryDispatch ) : Query { override fun launchIn(scope: CoroutineScope): Job { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt index 6bce405..14b4661 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt @@ -15,6 +15,9 @@ internal class SwrInfiniteQuery( private val query: Query> ) : InfiniteQueryRef { + override val options: QueryOptions + get() = query.options + override val state: StateFlow>> get() = query.state diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt index 61c19e0..4fb53d9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt @@ -14,6 +14,9 @@ internal class SwrMutation( private val mutation: Mutation ) : MutationRef { + override val options: MutationOptions + get() = mutation.options + override val state: StateFlow> get() = mutation.state diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt index 73424b1..747f587 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt @@ -15,6 +15,9 @@ internal class SwrQuery( private val query: Query ) : QueryRef { + override val options: QueryOptions + get() = query.options + override val state: StateFlow> get() = query.state From 41f4f82b4e467a7493c68ceb85e72bf1fc5bda2b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Thu, 29 Aug 2024 12:05:24 +0900 Subject: [PATCH 140/155] Experimental support for subscription API Implemented an API using Flow to subscribe to real-time data sources. The API is designed to receive real-time data unidirectionally, similar to GraphQL Subscriptions. (Bidirectional support will be considered at a later time.) Use cases for this API include: - Receiving continuously provided sensor data from platform APIs, etc. - Monitoring changes in databases (such as Room or Firebase Realtime Database) and retrieving the updated data Since the Subscription API is not directly related to the existing SWR (Stale While Revalidate) approach, it has been implemented separately as an experimental feature. To use this API, you will need to use `SwrClientPlus` instead of the `SwrClient`. --- gradle/libs.versions.toml | 1 + .../compose/RememberExampleSubscription.kt | 15 ++ .../query/key/ExampleSubscriptionKey.kt | 21 ++ .../kotlin/soil/kmp/SoilApplication.kt | 14 +- .../kotlin/soil/kmp/SwrClientFactory.kt | 4 +- .../soil/kmp/screen/HelloQueryDetailScreen.kt | 5 + .../composeApp/src/desktopMain/kotlin/main.kt | 11 +- .../src/iosMain/kotlin/MainViewController.kt | 11 +- .../composeApp/src/wasmJsMain/kotlin/main.kt | 12 +- soil-query-compose/build.gradle.kts | 3 + .../query/compose/InfiniteQueryStrategy.kt | 23 +- .../soil/query/compose/MutationStrategy.kt | 18 +- .../soil/query/compose/QueryStrategy.kt | 24 ++- .../query/compose/SubscriptionComposable.kt | 112 ++++++++++ .../soil/query/compose/SubscriptionConfig.kt | 44 ++++ .../soil/query/compose/SubscriptionObject.kt | 130 +++++++++++ .../query/compose/SubscriptionStrategy.kt | 66 ++++++ .../soil/query/compose/SwrClientProvider.kt | 38 ++++ .../tooling/SubscriptionPreviewClient.kt | 80 +++++++ .../query/compose/tooling/SwrPreviewClient.kt | 11 +- .../compose/InfiniteQueryComposableTest.kt | 8 +- .../query/compose/MutationComposableTest.kt | 8 +- .../soil/query/compose/QueryComposableTest.kt | 12 +- .../compose/SubscriptionComposableTest.kt | 201 ++++++++++++++++++ .../soil/query/InfiniteQueryCommands.kt | 24 +-- .../kotlin/soil/query/InfiniteQueryRef.kt | 50 +++++ .../commonMain/kotlin/soil/query/Mutation.kt | 2 +- .../kotlin/soil/query/MutationCommands.kt | 10 +- .../kotlin/soil/query/MutationRef.kt | 39 ++++ .../src/commonMain/kotlin/soil/query/Query.kt | 4 +- .../kotlin/soil/query/QueryCacheBuilder.kt | 2 + .../kotlin/soil/query/QueryCommand.kt | 7 + .../kotlin/soil/query/QueryCommands.kt | 16 +- .../commonMain/kotlin/soil/query/QueryRef.kt | 49 +++++ .../kotlin/soil/query/Subscription.kt | 37 ++++ .../kotlin/soil/query/SubscriptionAction.kt | 94 ++++++++ .../soil/query/SubscriptionCacheBuilder.kt | 69 ++++++ .../kotlin/soil/query/SubscriptionClient.kt | 30 +++ .../kotlin/soil/query/SubscriptionCommand.kt | 104 +++++++++ .../kotlin/soil/query/SubscriptionCommands.kt | 71 +++++++ .../kotlin/soil/query/SubscriptionKey.kt | 109 ++++++++++ .../kotlin/soil/query/SubscriptionModel.kt | 77 +++++++ .../kotlin/soil/query/SubscriptionOptions.kt | 152 +++++++++++++ .../kotlin/soil/query/SubscriptionReceiver.kt | 23 ++ .../kotlin/soil/query/SubscriptionRef.kt | 118 ++++++++++ .../kotlin/soil/query/SubscriptionState.kt | 76 +++++++ .../commonMain/kotlin/soil/query/SwrCache.kt | 74 ++----- .../kotlin/soil/query/SwrCachePlus.kt | 197 +++++++++++++++++ .../kotlin/soil/query/SwrCachePlusPolicy.kt | 127 +++++++++++ .../kotlin/soil/query/SwrCachePolicy.kt | 113 ++++++++-- .../kotlin/soil/query/SwrCacheScope.kt | 23 ++ .../commonMain/kotlin/soil/query/SwrClient.kt | 6 + .../kotlin/soil/query/SwrClientPlus.kt | 9 + .../kotlin/soil/query/SwrInfiniteQuery.kt | 41 ---- .../kotlin/soil/query/SwrMutation.kt | 30 --- .../commonMain/kotlin/soil/query/SwrQuery.kt | 41 ---- .../kotlin/soil/query/core/CoroutineExt.kt | 69 ++++++ .../kotlin/soil/query/core/Retry.kt | 2 +- .../kotlin/soil/query/core/RetryOptions.kt | 30 +-- .../soil/query/SubscriptionOptionsTest.kt | 112 ++++++++++ .../soil/query/receivers/ktor/KtorReceiver.kt | 3 +- .../soil/query/receivers/ktor/builders.kt | 24 +++ .../soil/query/test/FakeSubscriptionKey.kt | 20 ++ .../soil/query/test/TestSwrClientPlus.kt | 96 +++++++++ .../soil/query/test/TestSwrClientPlusTest.kt | 57 +++++ 65 files changed, 2816 insertions(+), 293 deletions(-) create mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberExampleSubscription.kt create mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/key/ExampleSubscriptionKey.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCacheBuilder.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommands.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionModel.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionReceiver.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlusPolicy.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrCacheScope.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrClientPlus.kt delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt delete mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/FakeSubscriptionKey.kt create mode 100644 soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClientPlus.kt create mode 100644 soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80260d1..93036cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", ve compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } jbx-core-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version.ref = "jbx-core-bundle" } +jbx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jbx-lifecycle" } jbx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jbx-lifecycle" } jbx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "jbx-lifecycle" } jbx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "jbx-navigation" } diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberExampleSubscription.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberExampleSubscription.kt new file mode 100644 index 0000000..177165c --- /dev/null +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/RememberExampleSubscription.kt @@ -0,0 +1,15 @@ +package soil.playground.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import soil.playground.query.key.ExampleSubscriptionKey +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.compose.SubscriptionObject +import soil.query.compose.rememberSubscription + +@OptIn(ExperimentalSoilQueryApi::class) +@Composable +fun rememberExampleSubscription(): SubscriptionObject { + val key = remember { ExampleSubscriptionKey() } + return rememberSubscription(key) +} diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/key/ExampleSubscriptionKey.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/ExampleSubscriptionKey.kt new file mode 100644 index 0000000..92c02c7 --- /dev/null +++ b/internal/playground/src/commonMain/kotlin/soil/playground/query/key/ExampleSubscriptionKey.kt @@ -0,0 +1,21 @@ +package soil.playground.query.key + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.buildSubscriptionKey + +class ExampleSubscriptionKey : SubscriptionKey by buildSubscriptionKey( + id = SubscriptionId("test/id"), + subscribe = { + flow { + delay(1000) + emit("Hello, World!") + delay(1000) + emit("Hello, Compose!") + delay(1000) + emit("Hello, Soil!") + } + } +) diff --git a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt index 4bd22a2..4335e7b 100644 --- a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt +++ b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SoilApplication.kt @@ -8,17 +8,19 @@ import soil.playground.createHttpClient import soil.query.AndroidMemoryPressure import soil.query.AndroidNetworkConnectivity import soil.query.AndroidWindowVisibility -import soil.query.SwrCache -import soil.query.SwrCachePolicy import soil.query.SwrCacheScope -import soil.query.SwrClient +import soil.query.SwrCachePlus +import soil.query.SwrCachePlusPolicy +import soil.query.SwrClientPlus +import soil.query.annotation.ExperimentalSoilQueryApi import soil.query.receivers.ktor.KtorReceiver class SoilApplication : Application(), SwrClientFactory { - override val queryClient: SwrClient by lazy { - SwrCache( - policy = SwrCachePolicy( + @OptIn(ExperimentalSoilQueryApi::class) + override val queryClient: SwrClientPlus by lazy { + SwrCachePlus( + policy = SwrCachePlusPolicy( coroutineScope = SwrCacheScope(), memoryPressure = AndroidMemoryPressure(this), networkConnectivity = AndroidNetworkConnectivity(this), diff --git a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SwrClientFactory.kt b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SwrClientFactory.kt index 9d586c8..8dc09f4 100644 --- a/sample/composeApp/src/androidMain/kotlin/soil/kmp/SwrClientFactory.kt +++ b/sample/composeApp/src/androidMain/kotlin/soil/kmp/SwrClientFactory.kt @@ -1,7 +1,7 @@ package soil.kmp -import soil.query.SwrClient +import soil.query.SwrClientPlus interface SwrClientFactory { - val queryClient: SwrClient + val queryClient: SwrClientPlus } diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt index 385c39a..19ef8d7 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryDetailScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -13,6 +14,7 @@ import soil.playground.query.compose.ContentLoading import soil.playground.query.compose.ContentUnavailable import soil.playground.query.compose.PostDetailItem import soil.playground.query.compose.PostUserDetailItem +import soil.playground.query.compose.rememberExampleSubscription import soil.playground.query.compose.rememberGetPostQuery import soil.playground.query.compose.rememberGetUserPostsQuery import soil.playground.query.compose.rememberGetUserQuery @@ -24,6 +26,7 @@ import soil.query.compose.runtime.Await import soil.query.compose.runtime.Catch import soil.query.compose.runtime.ErrorBoundary import soil.query.compose.runtime.Suspense +import soil.query.core.getOrElse @Composable fun HelloQueryDetailScreen(postId: Int) { @@ -64,11 +67,13 @@ private fun PostDetailContent( postId: Int, modifier: Modifier = Modifier ) { + val foo = rememberExampleSubscription() PostDetailContainer(postId) { post -> Column( modifier = modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(24.dp) ) { + Text(text = foo.reply.getOrElse { "" }) PostDetailItem(post, modifier = Modifier.fillMaxWidth()) PostUserDetailContainer(userId = post.userId) { user, posts -> PostUserDetailItem(user = user, posts = posts) diff --git a/sample/composeApp/src/desktopMain/kotlin/main.kt b/sample/composeApp/src/desktopMain/kotlin/main.kt index f0f1180..4cf8f07 100644 --- a/sample/composeApp/src/desktopMain/kotlin/main.kt +++ b/sample/composeApp/src/desktopMain/kotlin/main.kt @@ -4,9 +4,10 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.query.SwrCache -import soil.query.SwrCachePolicy import soil.query.SwrCacheScope +import soil.query.SwrCachePlus +import soil.query.SwrCachePlusPolicy +import soil.query.annotation.ExperimentalSoilQueryApi import soil.query.compose.SwrClientProvider import soil.query.receivers.ktor.KtorReceiver @@ -20,14 +21,16 @@ private val ktorReceiver: KtorReceiver = KtorReceiver(client = createHttpClient } }) -private val swrClient = SwrCache( - policy = SwrCachePolicy( +@OptIn(ExperimentalSoilQueryApi::class) +private val swrClient = SwrCachePlus( + policy = SwrCachePlusPolicy( coroutineScope = SwrCacheScope(), mutationReceiver = ktorReceiver, queryReceiver = ktorReceiver ) ) +@OptIn(ExperimentalSoilQueryApi::class) fun main() = application { SwrClientProvider(client = swrClient) { Window(onCloseRequest = ::exitApplication, title = "soil") { diff --git a/sample/composeApp/src/iosMain/kotlin/MainViewController.kt b/sample/composeApp/src/iosMain/kotlin/MainViewController.kt index eafee18..3ac0320 100644 --- a/sample/composeApp/src/iosMain/kotlin/MainViewController.kt +++ b/sample/composeApp/src/iosMain/kotlin/MainViewController.kt @@ -5,9 +5,10 @@ import kotlinx.serialization.json.Json import soil.playground.createHttpClient import soil.query.IosMemoryPressure import soil.query.IosWindowVisibility -import soil.query.SwrCache -import soil.query.SwrCachePolicy import soil.query.SwrCacheScope +import soil.query.SwrCachePlus +import soil.query.SwrCachePlusPolicy +import soil.query.annotation.ExperimentalSoilQueryApi import soil.query.compose.SwrClientProvider import soil.query.receivers.ktor.KtorReceiver @@ -21,8 +22,9 @@ private val ktorReceiver: KtorReceiver = KtorReceiver(client = createHttpClient } }) -private val swrClient = SwrCache( - policy = SwrCachePolicy( +@OptIn(ExperimentalSoilQueryApi::class) +private val swrClient = SwrCachePlus( + policy = SwrCachePlusPolicy( coroutineScope = SwrCacheScope(), memoryPressure = IosMemoryPressure(), windowVisibility = IosWindowVisibility(), @@ -31,6 +33,7 @@ private val swrClient = SwrCache( ) ) +@OptIn(ExperimentalSoilQueryApi::class) fun MainViewController() = ComposeUIViewController { SwrClientProvider(client = swrClient) { App() diff --git a/sample/composeApp/src/wasmJsMain/kotlin/main.kt b/sample/composeApp/src/wasmJsMain/kotlin/main.kt index b45deec..74a2d7b 100644 --- a/sample/composeApp/src/wasmJsMain/kotlin/main.kt +++ b/sample/composeApp/src/wasmJsMain/kotlin/main.kt @@ -4,11 +4,12 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import soil.playground.createHttpClient -import soil.query.SwrCache -import soil.query.SwrCachePolicy import soil.query.SwrCacheScope +import soil.query.SwrCachePlus +import soil.query.SwrCachePlusPolicy import soil.query.WasmJsNetworkConnectivity import soil.query.WasmJsWindowVisibility +import soil.query.annotation.ExperimentalSoilQueryApi import soil.query.compose.SwrClientProvider import soil.query.receivers.ktor.KtorReceiver @@ -22,8 +23,9 @@ private val ktorReceiver = KtorReceiver(client = createHttpClient { } }) -private val swrClient = SwrCache( - policy = SwrCachePolicy( +@OptIn(ExperimentalSoilQueryApi::class) +private val swrClient = SwrCachePlus( + policy = SwrCachePlusPolicy( coroutineScope = SwrCacheScope(), networkConnectivity = WasmJsNetworkConnectivity(), windowVisibility = WasmJsWindowVisibility(), @@ -32,7 +34,7 @@ private val swrClient = SwrCache( ) ) -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalSoilQueryApi::class) fun main() { CanvasBasedWindow(canvasElementId = "ComposeTarget") { SwrClientProvider(client = swrClient) { diff --git a/soil-query-compose/build.gradle.kts b/soil-query-compose/build.gradle.kts index d7d7988..d8b769c 100644 --- a/soil-query-compose/build.gradle.kts +++ b/soil-query-compose/build.gradle.kts @@ -41,6 +41,9 @@ kotlin { api(projects.soilQueryCore) implementation(compose.runtime) implementation(compose.runtimeSaveable) + // TODO: CompositionLocal LocalLifecycleOwner not present in Android, it works only with Compose UI 1.7.0-alpha05 or above. + // Therefore, we will postpone adding this code until a future release. + // implementation(libs.jbx.lifecycle.runtime.compose) } commonTest.dependencies { diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt index 143fc59..bad64d0 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt @@ -26,15 +26,22 @@ interface InfiniteQueryStrategy { @Composable fun collectAsState(query: InfiniteQueryRef): QueryState> - companion object Default : InfiniteQueryStrategy { + companion object +} - @Composable - override fun collectAsState(query: InfiniteQueryRef): QueryState> { - val state by query.state.collectAsState() - LaunchedEffect(query.key.id) { - query.resume() - } - return state +/** + * The default built-in strategy for Infinite Query built into the library. + */ +val InfiniteQueryStrategy.Companion.Default: InfiniteQueryStrategy + get() = InfiniteQueryStrategyDefault + +private object InfiniteQueryStrategyDefault : InfiniteQueryStrategy { + @Composable + override fun collectAsState(query: InfiniteQueryRef): QueryState> { + val state by query.state.collectAsState() + LaunchedEffect(query.key.id) { + query.resume() } + return state } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt index f5ed874..ed49ef8 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt @@ -21,10 +21,18 @@ interface MutationStrategy { @Composable fun collectAsState(mutation: MutationRef): MutationState - companion object Default : MutationStrategy { - @Composable - override fun collectAsState(mutation: MutationRef): MutationState { - return mutation.state.collectAsState().value - } + companion object +} + +/** + * The default built-in strategy for Mutation built into the library. + */ +val MutationStrategy.Companion.Default: MutationStrategy + get() = MutationStrategyDefault + +private object MutationStrategyDefault : MutationStrategy { + @Composable + override fun collectAsState(mutation: MutationRef): MutationState { + return mutation.state.collectAsState().value } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt index 8ee80cd..48da03e 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt @@ -25,14 +25,22 @@ interface QueryStrategy { @Composable fun collectAsState(query: QueryRef): QueryState - companion object Default : QueryStrategy { - @Composable - override fun collectAsState(query: QueryRef): QueryState { - val state by query.state.collectAsState() - LaunchedEffect(query.key.id) { - query.resume() - } - return state + companion object +} + +/** + * The default built-in strategy for Query built into the library. + */ +val QueryStrategy.Companion.Default: QueryStrategy + get() = QueryStrategyDefault + +private object QueryStrategyDefault : QueryStrategy { + @Composable + override fun collectAsState(query: QueryRef): QueryState { + val state by query.state.collectAsState() + LaunchedEffect(query.key.id) { + query.resume() } + return state } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt new file mode 100644 index 0000000..1f88b28 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt @@ -0,0 +1,112 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import soil.query.SubscriberStatus +import soil.query.SubscriptionClient +import soil.query.SubscriptionKey +import soil.query.SubscriptionRef +import soil.query.SubscriptionState +import soil.query.SubscriptionStatus +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.map + +/** + * Remember a [SubscriptionObject] and subscribes to the subscription state of [key]. + * + * @param T Type of data to receive. + * @param key The [SubscriptionKey] for managing [subscription][soil.query.Subscription]. + * @param config The configuration for the subscription. By default, it uses the [SubscriptionConfig.Default]. + * @param client The [SubscriptionClient] to resolve [key]. By default, it uses the [LocalSubscriptionClient]. + * @return A [SubscriptionObject] each the subscription state changed. + */ +@ExperimentalSoilQueryApi +@Composable +fun rememberSubscription( + key: SubscriptionKey, + config: SubscriptionConfig = SubscriptionConfig.Default, + client: SubscriptionClient = LocalSubscriptionClient.current +): SubscriptionObject { + val scope = rememberCoroutineScope() + val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = { it }) +} + +/** + * Remember a [SubscriptionObject] and subscribes to the subscription state of [key]. + * + * @param T Type of data to receive. + * @param U Type of selected data. + * @param key The [SubscriptionKey] for managing [subscription][soil.query.Subscription]. + * @param select A function to select data from [T]. + * @param config The configuration for the subscription. By default, it uses the [SubscriptionConfig.Default]. + * @param client The [SubscriptionClient] to resolve [key]. By default, it uses the [LocalSubscriptionClient]. + * @return A [SubscriptionObject] with selected data each the subscription state changed. + */ +@ExperimentalSoilQueryApi +@Composable +fun rememberSubscription( + key: SubscriptionKey, + select: (T) -> U, + config: SubscriptionConfig = SubscriptionConfig.Default, + client: SubscriptionClient = LocalSubscriptionClient.current +): SubscriptionObject { + val scope = rememberCoroutineScope() + val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + return config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = select) +} + +private fun SubscriptionState.toObject( + subscription: SubscriptionRef, + select: (T) -> U +): SubscriptionObject { + return when (status) { + SubscriptionStatus.Pending -> if (subscriberStatus == SubscriberStatus.NoSubscribers) { + SubscriptionIdleObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } else { + SubscriptionLoadingObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } + + SubscriptionStatus.Success -> SubscriptionSuccessObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscriberStatus = subscriberStatus, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + + SubscriptionStatus.Failure -> SubscriptionErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + subscriberStatus = subscriberStatus, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt new file mode 100644 index 0000000..c7d7375 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt @@ -0,0 +1,44 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Immutable +import soil.query.core.Marker + +/** + * Configuration for the subscription. + * + * @property strategy The strategy for caching subscription data. + * @property marker The marker with additional information based on the caller of a subscription. + */ +@Immutable +data class SubscriptionConfig internal constructor( + val strategy: SubscriptionStrategy, + val marker: Marker +) { + + class Builder { + var strategy: SubscriptionStrategy = SubscriptionStrategy.Default + var marker: Marker = Default.marker + + fun build() = SubscriptionConfig( + strategy = strategy, + marker = marker + ) + } + + companion object { + val Default = SubscriptionConfig( + strategy = SubscriptionStrategy.Default, + marker = Marker.None + ) + } +} + +/** + * Creates a [SubscriptionConfig] with the provided [initializer]. + */ +fun SubscriptionConfig(initializer: SubscriptionConfig.Builder.() -> Unit): SubscriptionConfig { + return SubscriptionConfig.Builder().apply(initializer).build() +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt new file mode 100644 index 0000000..01623dc --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt @@ -0,0 +1,130 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import soil.query.SubscriberStatus +import soil.query.SubscriptionModel +import soil.query.SubscriptionStatus +import soil.query.core.Reply +import soil.query.core.getOrNull +import soil.query.core.getOrThrow + +/** + * A SubscriptionObject represents [SubscriptionModel]s interface for receiving data. + * + * @param T Type of data to receive. + */ +@Stable +sealed interface SubscriptionObject : SubscriptionModel { + + /** + * The received value from the data source. + */ + val data: T? + + /** + * Starts the subscription if not already subscribed. + */ + val subscribe: suspend () -> Unit + + /** + * Cancels the subscription if currently subscribed. + */ + val unsubscribe: () -> Unit + + /** + * Resets the subscribed data and re-executes the subscription process. + */ + val reset: suspend () -> Unit +} + +/** + * A SubscriptionIdleObject represents the initial idle state of the [SubscriptionObject]. + * + * This means that no data has been received and there are no subscribers. + * This is the initial state when subscription processing has not been performed automatically. + * + * @param T Type of data to receive. + */ +@Immutable +data class SubscriptionIdleObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, + override val error: Throwable?, + override val errorUpdatedAt: Long, + override val subscribe: suspend () -> Unit, + override val unsubscribe: () -> Unit, + override val reset: suspend () -> Unit +) : SubscriptionObject { + override val status: SubscriptionStatus = SubscriptionStatus.Pending + override val subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + override val data: T? get() = reply.getOrNull() +} + +/** + * A SubscriptionLoadingObject represents the initial loading state of the [SubscriptionObject]. + * + * This means that no data is being received and there is at least one more subscriber. + * + * @param T Type of data to receive. + */ +@Immutable +data class SubscriptionLoadingObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, + override val error: Throwable?, + override val errorUpdatedAt: Long, + override val subscribe: suspend () -> Unit, + override val unsubscribe: () -> Unit, + override val reset: suspend () -> Unit +) : SubscriptionObject { + override val status: SubscriptionStatus = SubscriptionStatus.Pending + override val subscriberStatus: SubscriberStatus = SubscriberStatus.Active + override val data: T? get() = reply.getOrNull() +} + +/** + * A SubscriptionErrorObject represents the error state of the [SubscriptionObject]. + * + * This means that an error occurred during the subscription process and the subscription is being stopped, + * and you must call [reset] to restart the subscription. + * + * @param T Type of data to receive. + */ +@Immutable +data class SubscriptionErrorObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, + override val error: Throwable, + override val errorUpdatedAt: Long, + override val subscriberStatus: SubscriberStatus, + override val subscribe: suspend () -> Unit, + override val unsubscribe: () -> Unit, + override val reset: suspend () -> Unit +) : SubscriptionObject { + override val status: SubscriptionStatus = SubscriptionStatus.Failure + override val data: T? get() = reply.getOrNull() +} + +/** + * A SubscriptionSuccessObject represents the successful state of the [SubscriptionObject]. + * + * @param T Type of data to receive. + */ +@Immutable +data class SubscriptionSuccessObject internal constructor( + override val reply: Reply, + override val replyUpdatedAt: Long, + override val error: Throwable?, + override val errorUpdatedAt: Long, + override val subscriberStatus: SubscriberStatus, + override val subscribe: suspend () -> Unit, + override val unsubscribe: () -> Unit, + override val reset: suspend () -> Unit +) : SubscriptionObject { + override val status: SubscriptionStatus = SubscriptionStatus.Success + override val data: T get() = reply.getOrThrow() +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt new file mode 100644 index 0000000..d2ee495 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt @@ -0,0 +1,66 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import soil.query.SubscriptionRef +import soil.query.SubscriptionState + +/** + * A mechanism to finely adjust the behavior of the subscription on a component basis in Composable functions. + * + * If you want to customize, please create a class implementing [SubscriptionStrategy]. + * For example, this is useful when you want to switch your implementation to `collectAsStateWithLifecycle`. + */ +@Stable +interface SubscriptionStrategy { + + @Composable + fun collectAsState(subscription: SubscriptionRef): SubscriptionState + + companion object +} + +/** + * The default built-in strategy for Subscription built into the library. + */ +val SubscriptionStrategy.Companion.Default: SubscriptionStrategy + get() = SubscriptionStrategyDefault + +private object SubscriptionStrategyDefault : SubscriptionStrategy { + @Composable + override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { + val state by subscription.state.collectAsState() + LaunchedEffect(subscription.key.id) { + if (subscription.options.subscribeOnMount) { + subscription.resume() + } + } + return state + } +} + +// FIXME: CompositionLocal LocalLifecycleOwner not present +// Android, it works only with Compose UI 1.7.0-alpha05 or above. +// Therefore, we will postpone adding this code until a future release. +//val SubscriptionStrategy.Companion.Lifecycle: SubscriptionStrategy +// get() = SubscriptionStrategyLifecycle +// +//private object SubscriptionStrategyLifecycle : SubscriptionStrategy { +// @Composable +// override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { +// val state by subscription.state.collectAsStateWithLifecycle() +// LifecycleStartEffect(subscription.key.id) { +// if (subscription.options.subscribeOnMount) { +// lifecycleScope.launch { subscription.resume() } +// } +// onStopOrDispose { subscription.cancel() } +// } +// return state +// } +//} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt index 0b5852c..35974eb 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SwrClientProvider.kt @@ -9,7 +9,9 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.staticCompositionLocalOf import soil.query.MutationClient import soil.query.QueryClient +import soil.query.SubscriptionClient import soil.query.SwrClient +import soil.query.SwrClientPlus import soil.query.core.uuid /** @@ -39,6 +41,35 @@ fun SwrClientProvider( } } +/** + * Provides a [SwrClientPlus] to the [content] over [LocalSwrClient] + * + * @param client Applying to [LocalSwrClient]. + * @param content The content under the [CompositionLocalProvider]. + */ +@Composable +fun SwrClientProvider( + client: SwrClientPlus, + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalSwrClient provides client, + LocalQueryClient provides client, + LocalMutationClient provides client, + LocalSubscriptionClient provides client + ) { + content() + } + DisposableEffect(client) { + val id = uuid() + client.onMount(id) + onDispose { + client.onUnmount(id) + } + } +} + + /** * CompositionLocal for [SwrClient]. */ @@ -59,3 +90,10 @@ val LocalQueryClient = staticCompositionLocalOf { val LocalMutationClient = staticCompositionLocalOf { error("CompositionLocal 'MutationClient' not present") } + +/** + * CompositionLocal for [SubscriptionClient]. + */ +val LocalSubscriptionClient = staticCompositionLocalOf { + error("CompositionLocal 'SubscriptionClient' not present") +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt new file mode 100644 index 0000000..1ca6b8f --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt @@ -0,0 +1,80 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.tooling + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import soil.query.SubscriptionClient +import soil.query.SubscriptionCommand +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionOptions +import soil.query.SubscriptionRef +import soil.query.SubscriptionState +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.Marker +import soil.query.core.UniqueId + +/** + * Usage: + * ```kotlin + * val subscriptionClient = SubscriptionPreviewClient { + * on(MySubscriptionId1) { SubscriptionState.success("data") } + * on(MySubscriptionId2) { .. } + * } + * ``` + */ +@Stable +class SubscriptionPreviewClient( + private val previewData: Map>, + override val defaultSubscriptionOptions: SubscriptionOptions = SubscriptionOptions() +) : SubscriptionClient { + + @ExperimentalSoilQueryApi + @Suppress("UNCHECKED_CAST") + override fun getSubscription( + key: SubscriptionKey, + marker: Marker + ): SubscriptionRef { + val state = previewData[key.id] as? SubscriptionState ?: SubscriptionState.initial() + val options = key.onConfigureOptions()?.invoke(defaultSubscriptionOptions) ?: defaultSubscriptionOptions + return SnapshotSubscription(key, options, marker, MutableStateFlow(state)) + } + + private class SnapshotSubscription( + override val key: SubscriptionKey, + override val options: SubscriptionOptions, + override val marker: Marker, + override val state: StateFlow> + ) : SubscriptionRef { + override fun launchIn(scope: CoroutineScope): Job = Job() + override suspend fun send(command: SubscriptionCommand) = Unit + override suspend fun resume() = Unit + override fun cancel() = Unit + } + + /** + * Builder for [SubscriptionPreviewClient]. + */ + class Builder { + private val previewData = mutableMapOf>() + + fun on(id: SubscriptionId, snapshot: () -> SubscriptionState) { + previewData[id] = snapshot() + } + + fun build() = SubscriptionPreviewClient(previewData) + } +} + +/** + * Create a [SubscriptionPreviewClient] instance with the provided [initializer]. + */ +fun SubscriptionPreviewClient(initializer: SubscriptionPreviewClient.Builder.() -> Unit): SubscriptionPreviewClient { + return SubscriptionPreviewClient.Builder().apply(initializer).build() +} + diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt index 6d79fcf..e5d579b 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SwrPreviewClient.kt @@ -10,8 +10,11 @@ import kotlinx.coroutines.flow.flow import soil.query.MutationClient import soil.query.QueryClient import soil.query.QueryEffect +import soil.query.SubscriptionClient import soil.query.SwrClient +import soil.query.SwrClientPlus import soil.query.core.ErrorRecord +import soil.query.core.MemoryPressureLevel /** * Provides the ability to preview specific queries and mutations for composable previews. @@ -25,10 +28,12 @@ import soil.query.core.ErrorRecord */ @Stable class SwrPreviewClient( - queryPreview: QueryPreviewClient = QueryPreviewClient(emptyMap()), - mutationPreview: MutationPreviewClient = MutationPreviewClient(emptyMap()), + query: QueryPreviewClient = QueryPreviewClient(emptyMap()), + mutation: MutationPreviewClient = MutationPreviewClient(emptyMap()), + subscription: SubscriptionPreviewClient = SubscriptionPreviewClient(emptyMap()), override val errorRelay: Flow = flow { } -) : SwrClient, QueryClient by queryPreview, MutationClient by mutationPreview { +) : SwrClient, SwrClientPlus, QueryClient by query, MutationClient by mutation, SubscriptionClient by subscription { + override fun gc(level: MemoryPressureLevel) = Unit override fun perform(sideEffects: QueryEffect): Job = Job() override fun onMount(id: String) = Unit override fun onUnmount(id: String) = Unit diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index dd3dd8d..5515c88 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -142,7 +142,7 @@ class InfiniteQueryComposableTest : UnitTest() { fun testRememberInfiniteQuery_loadingPreview() = runComposeUiTest { val key = TestInfiniteQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.initial() } } ) @@ -163,7 +163,7 @@ class InfiniteQueryComposableTest : UnitTest() { fun testRememberInfiniteQuery_successPreview() = runComposeUiTest { val key = TestInfiniteQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.success(buildList { add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) @@ -197,7 +197,7 @@ class InfiniteQueryComposableTest : UnitTest() { fun testRememberInfiniteQuery_loadingErrorPreview() = runComposeUiTest { val key = TestInfiniteQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.failure(RuntimeException("Error")) } } ) @@ -222,7 +222,7 @@ class InfiniteQueryComposableTest : UnitTest() { fun testRememberInfiniteQuery_refreshErrorPreview() = runComposeUiTest { val key = TestInfiniteQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.failure(RuntimeException("Refresh Error"), data = buildList { add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt index 896b54b..9cfd475 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -146,7 +146,7 @@ class MutationComposableTest : UnitTest() { fun testRememberMutation_idlePreview() = runComposeUiTest { val key = TestMutationKey() val client = SwrPreviewClient( - mutationPreview = MutationPreviewClient { + mutation = MutationPreviewClient { on(key.id) { MutationState.initial() } } ) @@ -167,7 +167,7 @@ class MutationComposableTest : UnitTest() { fun testRememberMutation_loadingPreview() = runComposeUiTest { val key = TestMutationKey() val client = SwrPreviewClient( - mutationPreview = MutationPreviewClient { + mutation = MutationPreviewClient { on(key.id) { MutationState.pending() } } ) @@ -188,7 +188,7 @@ class MutationComposableTest : UnitTest() { fun testRememberMutation_successPreview() = runComposeUiTest { val key = TestMutationKey() val client = SwrPreviewClient( - mutationPreview = MutationPreviewClient { + mutation = MutationPreviewClient { on(key.id) { MutationState.success("Hello, Mutation!") } } ) @@ -209,7 +209,7 @@ class MutationComposableTest : UnitTest() { fun testRememberQuery_errorPreview() = runComposeUiTest { val key = TestMutationKey() val client = SwrPreviewClient( - mutationPreview = MutationPreviewClient { + mutation = MutationPreviewClient { on(key.id) { MutationState.failure(RuntimeException("Error")) } } ) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index 95d2e6a..85682cb 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -54,7 +54,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_select() = runComposeUiTest { val key = TestQueryKey() val client = SwrCache(coroutineScope = SwrCacheScope()).test { - on(key.id) { "Hello, Soil!" } + on(key.id) { "Hello, Compose!" } } setContent { SwrClientProvider(client) { @@ -67,7 +67,7 @@ class QueryComposableTest : UnitTest() { } waitUntilExactlyOneExists(hasTestTag("query")) - onNodeWithTag("query").assertTextEquals("HELLO, SOIL!") + onNodeWithTag("query").assertTextEquals("HELLO, COMPOSE!") } @Test @@ -97,7 +97,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_loadingPreview() = runComposeUiTest { val key = TestQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.initial() } } ) @@ -118,7 +118,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_successPreview() = runComposeUiTest { val key = TestQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.success("Hello, Query!") } } ) @@ -139,7 +139,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_loadingErrorPreview() = runComposeUiTest { val key = TestQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.failure(RuntimeException("Error")) } } ) @@ -160,7 +160,7 @@ class QueryComposableTest : UnitTest() { fun testRememberQuery_refreshErrorPreview() = runComposeUiTest { val key = TestQueryKey() val client = SwrPreviewClient( - queryPreview = QueryPreviewClient { + query = QueryPreviewClient { on(key.id) { QueryState.failure(RuntimeException("Refresh Error"), data = "Hello, Query!") } } ) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt new file mode 100644 index 0000000..3ec79d0 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt @@ -0,0 +1,201 @@ +package soil.query.compose + +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import soil.query.SubscriberStatus +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionState +import soil.query.SwrCachePlus +import soil.query.SwrCacheScope +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.buildSubscriptionKey +import soil.query.compose.tooling.SubscriptionPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.Marker +import soil.query.core.Reply +import soil.query.test.testPlus +import soil.testing.UnitTest +import kotlin.test.Test + +@OptIn(ExperimentalSoilQueryApi::class, ExperimentalTestApi::class) +class SubscriptionComposableTest : UnitTest() { + + @Test + fun testRememberSubscription() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + val subscription = rememberSubscription(key, config = SubscriptionConfig { + strategy = SubscriptionStrategy.Default + marker = Marker.None + }) + when (val reply = subscription.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("subscription")) + onNodeWithTag("subscription").assertTextEquals("Hello, Soil!") + } + + @Test + fun testRememberSubscription_select() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()).testPlus { + on(key.id) { MutableStateFlow("Hello, Compose!") } + } + setContent { + SwrClientProvider(client) { + val subscription = rememberSubscription(key = key, select = { it.uppercase() }) + when (val reply = subscription.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("subscription")) + onNodeWithTag("subscription").assertTextEquals("HELLO, COMPOSE!") + } + + @Test + fun testRememberSubscription_throwError() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()).testPlus { + on(key.id) { flow { throw RuntimeException("Failed to do something :(") } } + } + setContent { + SwrClientProvider(client) { + val subscription = rememberSubscription(key) + when (val reply = subscription.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + if (subscription.error != null) { + Text("error", modifier = Modifier.testTag("subscription")) + } + } + } + + waitUntilExactlyOneExists(hasTestTag("subscription")) + onNodeWithTag("subscription").assertTextEquals("error") + } + + @Test + fun testRememberSubscription_idlePreview() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { SubscriptionState.initial(subscriberStatus = SubscriberStatus.NoSubscribers) } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberSubscription(key)) { + is SubscriptionIdleObject -> Text("idle", modifier = Modifier.testTag("subscription")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertTextEquals("idle") + } + + @Test + fun testRememberSubscription_loadingPreview() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { SubscriptionState.initial(subscriberStatus = SubscriberStatus.Active) } + } + ) + setContent { + SwrClientProvider(client) { + when (rememberSubscription(key)) { + is SubscriptionLoadingObject -> Text("loading", modifier = Modifier.testTag("subscription")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertTextEquals("loading") + } + + @Test + fun testRememberSubscription_successPreview() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { + SubscriptionState.success( + data = "Hello, Subscription!", + subscriberStatus = SubscriberStatus.Active + ) + } + } + ) + setContent { + SwrClientProvider(client) { + when (val subscription = rememberSubscription(key)) { + is SubscriptionSuccessObject -> Text(subscription.data, modifier = Modifier.testTag("subscription")) + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertTextEquals("Hello, Subscription!") + } + + @Test + fun testRememberSubscription_errorPreview() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { + SubscriptionState.failure( + error = RuntimeException("Error"), + subscriberStatus = SubscriberStatus.Active + ) + } + } + ) + setContent { + SwrClientProvider(client) { + when (val subscription = rememberSubscription(key)) { + is SubscriptionErrorObject -> Text( + subscription.error.message ?: "", + modifier = Modifier.testTag("subscription") + ) + + else -> Unit + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertTextEquals("Error") + } + + private class TestSubscriptionKey : SubscriptionKey by buildSubscriptionKey( + id = Id, + subscribe = { MutableStateFlow("Hello, Soil!") } + ) { + object Id : SubscriptionId("test/subscription") + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt index 97f6616..5d15057 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommands.kt @@ -22,10 +22,10 @@ object InfiniteQueryCommands { * @param callback The callback to receive the result of the query. */ class Connect( - val key: InfiniteQueryKey, - val revision: String? = null, - val marker: Marker = Marker.None, - val callback: QueryCallback>? = null + private val key: InfiniteQueryKey, + private val revision: String? = null, + private val marker: Marker = Marker.None, + private val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { if (!ctx.shouldFetch(revision)) { @@ -54,10 +54,10 @@ object InfiniteQueryCommands { * @param callback The callback to receive the result of the query. */ class Invalidate( - val key: InfiniteQueryKey, - val revision: String, - val marker: Marker = Marker.None, - val callback: QueryCallback>? = null + private val key: InfiniteQueryKey, + private val revision: String, + private val marker: Marker = Marker.None, + private val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { if (ctx.state.revision != revision) { @@ -84,10 +84,10 @@ object InfiniteQueryCommands { * @param callback The callback to receive the result of the query. */ class LoadMore( - val key: InfiniteQueryKey, - val param: S, - val marker: Marker = Marker.None, - val callback: QueryCallback>? = null + private val key: InfiniteQueryKey, + private val param: S, + private val marker: Marker = Marker.None, + private val callback: QueryCallback>? = null ) : InfiniteQueryCommand { override suspend fun handle(ctx: QueryCommand.Context>) { val chunks = ctx.state.reply.getOrElse { emptyList() } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index fd7220a..244c7a5 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -4,12 +4,16 @@ package soil.query import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import soil.query.core.Actor import soil.query.core.Marker import soil.query.core.awaitOrNull + /** * A reference to an Query for [InfiniteQueryKey]. * @@ -73,3 +77,49 @@ interface InfiniteQueryRef : Actor { deferred.awaitOrNull() } } + +/** + * Creates a new [InfiniteQueryRef] instance. + * + * @param key The [InfiniteQueryKey] for the Query. + * @param marker The Marker specified in [QueryClient.getInfiniteQuery]. + * @param query The Query to create a reference. + */ +fun InfiniteQueryRef( + key: InfiniteQueryKey, + marker: Marker, + query: Query> +): InfiniteQueryRef { + return InfiniteQueryRefImpl(key, marker, query) +} + +private class InfiniteQueryRefImpl( + override val key: InfiniteQueryKey, + override val marker: Marker, + private val query: Query> +) : InfiniteQueryRef { + + override val options: QueryOptions + get() = query.options + + override val state: StateFlow>> + get() = query.state + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.launchIn(this) + query.event.collect(::handleEvent) + } + } + + override suspend fun send(command: InfiniteQueryCommand) { + query.command.send(command) + } + + private suspend fun handleEvent(e: QueryEvent) { + when (e) { + QueryEvent.Invalidate -> invalidate() + QueryEvent.Resume -> resume() + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index 8054a22..b2e53e0 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -12,7 +12,7 @@ import soil.query.core.Actor * * @param T Type of the return value from the mutation. */ -internal interface Mutation : Actor { +interface Mutation : Actor { /** * The MutationOptions configured for the mutation. diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt index d8b002b..f340b71 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommands.kt @@ -21,11 +21,11 @@ object MutationCommands { * @param callback The callback to receive the result of the mutation. */ class Mutate( - val key: MutationKey, - val variable: S, - val revision: String, - val marker: Marker = Marker.None, - val callback: MutationCallback? = null + private val key: MutationKey, + private val variable: S, + private val revision: String, + private val marker: Marker = Marker.None, + private val callback: MutationCallback? = null ) : MutationCommand { override suspend fun handle(ctx: MutationCommand.Context) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index f92ccd2..8dc60fd 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -4,11 +4,14 @@ package soil.query import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow import soil.query.core.Actor import soil.query.core.Marker + /** * A reference to a Mutation for [MutationKey]. * @@ -70,3 +73,39 @@ interface MutationRef : Actor { send(MutationCommands.Reset()) } } + +/** + * Creates a new [MutationRef] instance. + * + * @param key The [MutationKey] for the Mutation. + * @param marker The Marker specified in [MutationClient.getMutation]. + * @param mutation The Mutation to create a reference. + */ +fun MutationRef( + key: MutationKey, + marker: Marker, + mutation: Mutation +): MutationRef { + return MutationRefImpl(key, marker, mutation) +} + +private class MutationRefImpl( + override val key: MutationKey, + override val marker: Marker, + private val mutation: Mutation +) : MutationRef { + + override val options: MutationOptions + get() = mutation.options + + override val state: StateFlow> + get() = mutation.state + + override fun launchIn(scope: CoroutineScope): Job { + return mutation.launchIn(scope) + } + + override suspend fun send(command: MutationCommand) { + mutation.command.send(command) + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 46db5fb..80c54bc 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -13,7 +13,7 @@ import soil.query.core.Actor * * @param T Type of the return value from the query. */ -internal interface Query : Actor { +interface Query : Actor { /** * The QueryOptions configured for the query. @@ -39,7 +39,7 @@ internal interface Query : Actor { /** * Events occurring in the query. */ -internal enum class QueryEvent { +enum class QueryEvent { Invalidate, Resume } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt index 4a6895a..0b1b3db 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCacheBuilder.kt @@ -29,6 +29,7 @@ interface QueryCacheBuilder { * @param data Data to store. * @param dataUpdatedAt Timestamp when the data was updated. Default is the current epoch time. * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt] + * @param ttl Time to live for the data. Default is [Duration.INFINITE] */ fun put( id: QueryId, @@ -45,6 +46,7 @@ interface QueryCacheBuilder { * @param data Data to store. * @param dataUpdatedAt Timestamp when the data was updated. Default is the current epoch time. * @param dataStaleAt The timestamp after which data is considered stale. Default is the same as [dataUpdatedAt] + * @param ttl Time to live for the data. Default is [Duration.INFINITE] */ fun put( id: InfiniteQueryId, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index 996fe38..9f43d4e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -147,6 +147,13 @@ fun QueryCommand.Context.dispatchFetchFailure(error: Throwable) { dispatch(action) } +/** + * Reports the query error. + * + * @param error The query error. + * @param id The unique identifier of the query. + * @param marker The marker for the query. + */ fun QueryCommand.Context.reportQueryError(error: Throwable, id: UniqueId, marker: Marker) { if (options.onError == null && relay == null) { return diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt index 4b08aa8..f4d96bf 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommands.kt @@ -20,10 +20,10 @@ object QueryCommands { * @param callback The callback to receive the result of the query. */ class Connect( - val key: QueryKey, - val revision: String? = null, - val marker: Marker = Marker.None, - val callback: QueryCallback? = null + private val key: QueryKey, + private val revision: String? = null, + private val marker: Marker = Marker.None, + private val callback: QueryCallback? = null ) : QueryCommand { override suspend fun handle(ctx: QueryCommand.Context) { @@ -48,10 +48,10 @@ object QueryCommands { * @param callback The callback to receive the result of the query. */ class Invalidate( - val key: QueryKey, - val revision: String, - val marker: Marker = Marker.None, - val callback: QueryCallback? = null + private val key: QueryKey, + private val revision: String, + private val marker: Marker = Marker.None, + private val callback: QueryCallback? = null ) : QueryCommand { override suspend fun handle(ctx: QueryCommand.Context) { diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index a09d2ed..47516eb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -4,8 +4,11 @@ package soil.query import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.completeWith import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import soil.query.core.Actor import soil.query.core.Marker import soil.query.core.awaitOrNull @@ -63,3 +66,49 @@ interface QueryRef : Actor { deferred.awaitOrNull() } } + +/** + * Creates a new [QueryRef] instance. + * + * @param key The [QueryKey] for the Query. + * @param marker The Marker specified in [QueryClient.getQuery]. + * @param query The Query to create a reference. + */ +fun QueryRef( + key: QueryKey, + marker: Marker, + query: Query +): QueryRef { + return QueryRefImpl(key, marker, query) +} + +private class QueryRefImpl( + override val key: QueryKey, + override val marker: Marker, + private val query: Query +) : QueryRef { + + override val options: QueryOptions + get() = query.options + + override val state: StateFlow> + get() = query.state + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.launchIn(this) + query.event.collect(::handleEvent) + } + } + + override suspend fun send(command: QueryCommand) { + query.command.send(command) + } + + private suspend fun handleEvent(e: QueryEvent) { + when (e) { + QueryEvent.Invalidate -> invalidate() + QueryEvent.Resume -> resume() + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt new file mode 100644 index 0000000..274d18a --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt @@ -0,0 +1,37 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import soil.query.core.Actor + +/** + * Subscription as the base interface for an [SubscriptionClient] implementations. + * + * @param T Type of the receive value from the subscription. + */ +interface Subscription : Actor { + + /** + * The SubscriptionOptions configured for the subscription. + */ + val options: SubscriptionOptions + + /** + * [Shared Flow][SharedFlow] to receive subscription result. + */ + val source: SharedFlow> + + /** + * [State Flow][StateFlow] to receive the current state of the subscription. + */ + val state: StateFlow> + + /** + * [Send Channel][SendChannel] to manipulate the state of the subscription. + */ + val command: SendChannel> +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt new file mode 100644 index 0000000..9ef6504 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt @@ -0,0 +1,94 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply + +/** + * Subscription actions are used to update the [subscription state][SubscriptionState]. + * + * @param T Type of the receive value from the subscription. + */ +sealed interface SubscriptionAction { + + /** + * Resets the subscription state. + */ + data object Reset : SubscriptionAction + + /** + * Indicates that the subscription is successful. + * + * @param data The data to be updated. + * @param dataUpdatedAt The timestamp when the data was updated. + */ + data class ReceiveSuccess( + val data: T, + val dataUpdatedAt: Long + ) : SubscriptionAction + + /** + * Indicates that the subscription has failed. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. + */ + data class ReceiveFailure( + val error: Throwable, + val errorUpdatedAt: Long + ) : SubscriptionAction + + /** + * Updates the subscriber status. + * + * @param subscriberStatus The subscriber status to be updated. + */ + data class UpdateSubscriberStatus( + val subscriberStatus: SubscriberStatus + ) : SubscriptionAction +} + +typealias SubscriptionReducer = (SubscriptionState, SubscriptionAction) -> SubscriptionState +typealias SubscriptionDispatch = (SubscriptionAction) -> Unit + +/** + * Creates a [SubscriptionReducer] function. + */ +fun createSubscriptionReducer(): SubscriptionReducer = { state, action -> + when (action) { + is SubscriptionAction.Reset -> { + state.copy( + reply = Reply.none(), + replyUpdatedAt = 0, + error = null, + errorUpdatedAt = 0, + status = SubscriptionStatus.Pending + ) + } + + is SubscriptionAction.ReceiveSuccess -> { + state.copy( + status = SubscriptionStatus.Success, + reply = Reply(action.data), + replyUpdatedAt = action.dataUpdatedAt, + error = null, + errorUpdatedAt = action.dataUpdatedAt + ) + } + + is SubscriptionAction.ReceiveFailure -> { + state.copy( + status = SubscriptionStatus.Failure, + error = action.error, + errorUpdatedAt = action.errorUpdatedAt + ) + } + + is SubscriptionAction.UpdateSubscriberStatus -> { + state.copy( + subscriberStatus = action.subscriberStatus + ) + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCacheBuilder.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCacheBuilder.kt new file mode 100644 index 0000000..e7003d5 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCacheBuilder.kt @@ -0,0 +1,69 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.TimeBasedCache +import soil.query.core.UniqueId +import soil.query.core.epoch +import kotlin.time.Duration + +typealias SubscriptionCache = TimeBasedCache> + +/** + * Creates a new subscription cache. + */ +fun SubscriptionCache(capacity: Int = 20): SubscriptionCache { + return TimeBasedCache(capacity) +} + +/** + * Builder for creating a subscription cache. + */ +interface SubscriptionCacheBuilder { + + /** + * Puts the subscription data into the cache. + * + * @param id Unique identifier for the subscription. + * @param data Data to store. + * @param dataUpdatedAt Timestamp when the data was updated. Default is the current epoch time. + * @param ttl Time to live for the data. Default is [Duration.INFINITE] + */ + fun put( + id: SubscriptionId, + data: T, + dataUpdatedAt: Long = epoch(), + ttl: Duration = Duration.INFINITE + ) +} + +/** + * Creates a new subscription cache with the specified [capacity] and applies the [block] to the builder. + * + * ```kotlin + * val cache = SubscriptionCacheBuilder { + * put(UserSubscriptionKey.Id(userId), user) + * .. + * } + * ``` + */ +@Suppress("FunctionName") +fun SubscriptionCacheBuilder(capacity: Int = 20, block: SubscriptionCacheBuilder.() -> Unit): SubscriptionCache { + return DefaultSubscriptionCacheBuilder(capacity).apply(block).build() +} + +internal class DefaultSubscriptionCacheBuilder(capacity: Int) : SubscriptionCacheBuilder { + private val cache = SubscriptionCache(capacity) + + override fun put( + id: SubscriptionId, + data: T, + dataUpdatedAt: Long, + ttl: Duration + ) = cache.set(id, SubscriptionState.success(data, dataUpdatedAt), ttl) + + fun build(): SubscriptionCache { + return cache + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt new file mode 100644 index 0000000..1f17ec3 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt @@ -0,0 +1,30 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.Marker + +/** + * A Subscription client, which allows you to make subscriptions actor and handle [SubscriptionKey]. + */ +interface SubscriptionClient { + + /** + * The default subscription options. + */ + val defaultSubscriptionOptions: SubscriptionOptions + + /** + * Gets the [SubscriptionRef] by the specified [SubscriptionKey]. + */ + @ExperimentalSoilQueryApi + fun getSubscription( + key: SubscriptionKey, + marker: Marker = Marker.None + ): SubscriptionRef +} + +typealias SubscriptionRecoverData = (error: Throwable) -> T +typealias SubscriptionOptionsOverride = (SubscriptionOptions) -> SubscriptionOptions diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt new file mode 100644 index 0000000..6e14807 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt @@ -0,0 +1,104 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.ErrorRecord +import soil.query.core.Marker +import soil.query.core.UniqueId +import soil.query.core.epoch + +/** + * Subscription command to handle subscription. + * + * @param T Type of the receive value from the subscription. + */ +interface SubscriptionCommand { + + /** + * Handles the subscription. + */ + suspend fun handle(ctx: Context) + + /** + * Context for subscription command. + * + * @param T Type of the receive value from the subscription. + */ + interface Context { + val options: SubscriptionOptions + val state: SubscriptionModel + val dispatch: SubscriptionDispatch + val restart: SubscriptionRestart + val relay: SubscriptionErrorRelay? + } +} + +internal typealias SubscriptionErrorRelay = (ErrorRecord) -> Unit +internal typealias SubscriptionRestart = () -> Unit + +/** + * Dispatches the result of the subscription. + * + * @param key The subscription key. + * @param result The result of the subscription. + * @param marker The marker for the subscription. + */ +fun SubscriptionCommand.Context.dispatchResult( + key: SubscriptionKey, + result: Result, + marker: Marker +) { + result + .run { key.onRecoverData()?.let(::recoverCatching) ?: this } + .onSuccess(::dispatchReceiveSuccess) + .onFailure(::dispatchReceiveFailure) + .onFailure { reportSubscriptionError(it, key.id, marker) } +} + +/** + * Dispatches the success result of the subscription. + * + * @param data The data of the subscription. + */ +fun SubscriptionCommand.Context.dispatchReceiveSuccess(data: T) { + val currentAt = epoch() + val action = SubscriptionAction.ReceiveSuccess( + data = data, + dataUpdatedAt = currentAt + ) + dispatch(action) +} + +/** + * Dispatches the failure result of the subscription. + * + * @param error The error of the subscription. + */ +fun SubscriptionCommand.Context.dispatchReceiveFailure(error: Throwable) { + val currentAt = epoch() + val action = SubscriptionAction.ReceiveFailure( + error = error, + errorUpdatedAt = currentAt + ) + dispatch(action) +} + +/** + * Reports the subscription error. + * + * @param error The error of the subscription. + * @param id The unique identifier of the subscription. + * @param marker The marker for the subscription. + */ +fun SubscriptionCommand.Context.reportSubscriptionError(error: Throwable, id: UniqueId, marker: Marker) { + if (options.onError == null && relay == null) { + return + } + val record = ErrorRecord(error, id, marker) + options.onError?.invoke(record, state) + val errorRelay = relay + if (errorRelay != null && options.shouldSuppressErrorRelay?.invoke(record, state) != true) { + errorRelay(record) + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommands.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommands.kt new file mode 100644 index 0000000..544cdae --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommands.kt @@ -0,0 +1,71 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Marker +import soil.query.core.vvv + +object SubscriptionCommands { + + /** + * Handles the result received from the subscription source. + * + * @param key Instance of a class implementing [SubscriptionKey]. + * @param result The received result. + * @param revision The revision of the subscription state. + * @param marker The marker with additional information based on the caller of a subscription. + */ + class Receive( + private val key: SubscriptionKey, + private val result: Result, + private val revision: String, + private val marker: Marker = Marker.None, + ) : SubscriptionCommand { + + override suspend fun handle(ctx: SubscriptionCommand.Context) { + if (ctx.state.revision != revision) { + ctx.options.vvv(key.id) { "skip receive(revision is not matched)" } + return + } + ctx.dispatchResult(key, result, marker) + } + } + + /** + * Resets the state and re-executes the subscription process. + * + * @param key Instance of a class implementing [SubscriptionKey]. + * @param revision The revision of the subscription state. + */ + class Reset( + private val key: SubscriptionKey, + private val revision: String + ) : SubscriptionCommand { + override suspend fun handle(ctx: SubscriptionCommand.Context) { + if (ctx.state.revision != revision) { + ctx.options.vvv(key.id) { "skip receive(revision is not matched)" } + return + } + ctx.dispatch(SubscriptionAction.Reset) + ctx.restart() + } + } + + /** + * Handles changes in the number of subscribers. + * + * @param subscribers The number of subscribers. + */ + class Count( + private val subscribers: Int + ) : SubscriptionCommand { + override suspend fun handle(ctx: SubscriptionCommand.Context) { + val currentStatus = if (subscribers > 0) SubscriberStatus.Active else SubscriberStatus.NoSubscribers + if (ctx.state.subscriberStatus == currentStatus) { + return + } + ctx.dispatch(SubscriptionAction.UpdateSubscriberStatus(currentStatus)) + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt new file mode 100644 index 0000000..f97c0f3 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt @@ -0,0 +1,109 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.flow.Flow +import soil.query.core.SurrogateKey +import soil.query.core.UniqueId +import soil.query.core.uuid + +/** + * [SubscriptionKey] for managing [Subscription] associated with [id]. + * + * @param T Type of data to receive. + */ +interface SubscriptionKey { + + /** + * A unique identifier used for managing [SubscriptionKey]. + */ + val id: SubscriptionId + + /** + * Function to subscribe to the data as [Flow]. + * + * @receiver SubscriptionReceiver You can use a custom SubscriptionReceiver within the subscribe function. + */ + val subscribe: SubscriptionReceiver.() -> Flow + + /** + * Function to configure the [SubscriptionOptions]. + * + * If unspecified, the default value of [SubscriptionOptions] is used. + * + * ```kotlin + * override fun onConfigureOptions(): SubscriptionOptionsOverride = { options -> + * options.copy(gcTime = Duration.ZERO) + * } + * ``` + */ + fun onConfigureOptions(): SubscriptionOptionsOverride? = null + + /** + * Function to recover data from the error. + * + * You can recover data from the error instead of the error state. + */ + fun onRecoverData(): SubscriptionRecoverData? = null +} + +/** + * Unique identifier for [SubscriptionKey]. + */ +@Suppress("unused") +open class SubscriptionId( + override val namespace: String, + override vararg val tags: SurrogateKey +) : UniqueId { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SubscriptionId<*>) return false + if (namespace != other.namespace) return false + return tags.contentEquals(other.tags) + } + + override fun hashCode(): Int { + var result = namespace.hashCode() + result = 31 * result + tags.contentHashCode() + return result + } + + override fun toString(): String { + return "SubscriptionId(namespace='$namespace', tags=${tags.contentToString()})" + } + + companion object { + fun auto( + namespace: String = "auto/${uuid()}", + vararg tags: SurrogateKey + ): SubscriptionId { + return SubscriptionId(namespace, *tags) + } + } +} + +/** + * Function for building implementations of [SubscriptionKey] using [Kotlin Delegation](https://kotlinlang.org/docs/delegation.html). + * + * **Note:** By implementing through delegation, you can reduce the impact of future changes to [SubscriptionKey] interface extensions. + * + * Usage: + * + * ```kotlin + * class UserSubscriptionKey(private val userId: String) : SubscriptionKey by buildSubscriptionKey( + * id = Id(userId), + * subscribe = { ... } + * ) + * ``` + */ +fun buildSubscriptionKey( + id: SubscriptionId = SubscriptionId.auto(), + subscribe: SubscriptionReceiver.() -> Flow +): SubscriptionKey { + return object : SubscriptionKey { + override val id: SubscriptionId = id + override val subscribe: SubscriptionReceiver.() -> Flow = subscribe + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionModel.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionModel.kt new file mode 100644 index 0000000..3d492b8 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionModel.kt @@ -0,0 +1,77 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.DataModel + +/** + * Data model for the state handled by [SubscriptionKey]. + * + * All data models related to subscriptions, implement this interface. + * + * @param T Type of data to receive. + */ +interface SubscriptionModel : DataModel { + + /** + * The received status of the subscription. + */ + val status: SubscriptionStatus + + /** + * The subscriber status of the subscription. + */ + val subscriberStatus: SubscriberStatus + + /** + * The revision of the currently snapshot. + */ + val revision: String get() = "d-$replyUpdatedAt/e-$errorUpdatedAt" + + /** + * Returns `true` if the query is pending, `false` otherwise. + */ + val isPending: Boolean get() = status == SubscriptionStatus.Pending + + /** + * Returns `true` if the query is successful, `false` otherwise. + */ + val isSuccess: Boolean get() = status == SubscriptionStatus.Success + + /** + * Returns `true` if the query is a failure, `false` otherwise. + */ + val isFailure: Boolean get() = status == SubscriptionStatus.Failure + + /** + * Returns `true` if the subscription has subscribers, `false` otherwise. + */ + val hasSubscribers: Boolean get() = subscriberStatus == SubscriberStatus.Active + + /** + * Returns true if the [SubscriptionModel] is awaited. + * + * @see DataModel.isAwaited + */ + override fun isAwaited(): Boolean { + return isPending && hasSubscribers + } +} + +/** + * The received status of the subscription. + */ +enum class SubscriptionStatus { + Pending, + Success, + Failure +} + +/** + * The subscriber status of the subscription. + */ +enum class SubscriberStatus { + NoSubscribers, + Active, +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt new file mode 100644 index 0000000..c79c597 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt @@ -0,0 +1,152 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.ActorOptions +import soil.query.core.ErrorRecord +import soil.query.core.LoggerFn +import soil.query.core.LoggingOptions +import soil.query.core.RetryOptions +import soil.query.core.Retryable +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * [SubscriptionOptions] providing settings related to the internal behavior of an [Subscription]. + */ +interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { + + /** + * The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. + */ + val gcTime: Duration + + /** + * Determines whether to subscribe automatically to the subscription when mounted. + */ + val subscribeOnMount: Boolean + + /** + * This callback function will be called if some mutation encounters an error. + */ + val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? + + /** + * Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + */ + val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? + + + companion object Default : SubscriptionOptions { + override val gcTime: Duration = 5.minutes + override val subscribeOnMount: Boolean = true + override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = null + override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = null + + // ----- ActorOptions ----- // + override val keepAliveTime: Duration = 5.seconds + + // ----- LoggingOptions ----- // + override val logger: LoggerFn? = null + + // ----- RetryOptions ----- // + override val shouldRetry: (Throwable) -> Boolean = { e -> + e is Retryable && e.canRetry + } + override val retryCount: Int = 3 + override val retryInitialInterval: Duration = 500.milliseconds + override val retryMaxInterval: Duration = 30.seconds + override val retryMultiplier: Double = 1.5 + override val retryRandomizationFactor: Double = 0.5 + override val retryRandomizer: Random = Random + } +} + +/** + * Creates a new [SubscriptionOptions] with the specified settings. + * + * @param gcTime The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. + * @param subscribeOnMount Determines whether to subscribe automatically to the subscription when mounted. + * @param onError This callback function will be called if some subscription encounters an error. + * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. + * @param keepAliveTime The duration to keep the actor alive after the last command is executed. + * @param logger The logger function. + * @param shouldRetry Determines whether to retry the command when an error occurs. + * @param retryCount The number of times to retry the command. + * @param retryInitialInterval The initial interval for exponential backoff. + * @param retryMaxInterval The maximum interval for exponential backoff. + * @param retryMultiplier The multiplier for exponential backoff. + * @param retryRandomizationFactor The randomization factor for exponential backoff. + * @param retryRandomizer The randomizer for exponential backoff. + */ +fun SubscriptionOptions( + gcTime: Duration = SubscriptionOptions.gcTime, + subscribeOnMount: Boolean = SubscriptionOptions.subscribeOnMount, + onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = SubscriptionOptions.onError, + shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = SubscriptionOptions.shouldSuppressErrorRelay, + keepAliveTime: Duration = SubscriptionOptions.keepAliveTime, + logger: LoggerFn? = SubscriptionOptions.logger, + shouldRetry: (Throwable) -> Boolean = SubscriptionOptions.shouldRetry, + retryCount: Int = SubscriptionOptions.retryCount, + retryInitialInterval: Duration = SubscriptionOptions.retryInitialInterval, + retryMaxInterval: Duration = SubscriptionOptions.retryMaxInterval, + retryMultiplier: Double = SubscriptionOptions.retryMultiplier, + retryRandomizationFactor: Double = SubscriptionOptions.retryRandomizationFactor, + retryRandomizer: Random = SubscriptionOptions.retryRandomizer +): SubscriptionOptions { + return object : SubscriptionOptions { + override val gcTime: Duration = gcTime + override val subscribeOnMount: Boolean = subscribeOnMount + override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = onError + override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = + shouldSuppressErrorRelay + override val keepAliveTime: Duration = keepAliveTime + override val logger: LoggerFn? = logger + override val shouldRetry: (Throwable) -> Boolean = shouldRetry + override val retryCount: Int = retryCount + override val retryInitialInterval: Duration = retryInitialInterval + override val retryMaxInterval: Duration = retryMaxInterval + override val retryMultiplier: Double = retryMultiplier + override val retryRandomizationFactor: Double = retryRandomizationFactor + override val retryRandomizer: Random = retryRandomizer + } +} + +/** + * Copies the current [SubscriptionOptions] with the specified settings. + */ +fun SubscriptionOptions.copy( + gcTime: Duration = this.gcTime, + subscribeOnMount: Boolean = this.subscribeOnMount, + onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = this.onError, + shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, + keepAliveTime: Duration = this.keepAliveTime, + logger: LoggerFn? = this.logger, + shouldRetry: (Throwable) -> Boolean = this.shouldRetry, + retryCount: Int = this.retryCount, + retryInitialInterval: Duration = this.retryInitialInterval, + retryMaxInterval: Duration = this.retryMaxInterval, + retryMultiplier: Double = this.retryMultiplier, + retryRandomizationFactor: Double = this.retryRandomizationFactor, + retryRandomizer: Random = this.retryRandomizer +): SubscriptionOptions { + return SubscriptionOptions( + gcTime = gcTime, + subscribeOnMount = subscribeOnMount, + onError = onError, + shouldSuppressErrorRelay = shouldSuppressErrorRelay, + keepAliveTime = keepAliveTime, + logger = logger, + shouldRetry = shouldRetry, + retryCount = retryCount, + retryInitialInterval = retryInitialInterval, + retryMaxInterval = retryMaxInterval, + retryMultiplier = retryMultiplier, + retryRandomizationFactor = retryRandomizationFactor, + retryRandomizer = retryRandomizer + ) +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionReceiver.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionReceiver.kt new file mode 100644 index 0000000..589d9c0 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionReceiver.kt @@ -0,0 +1,23 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +/** + * Extension receiver for referencing external instances needed when receiving subscription. + * + * Usage: + * + * ```kotlin + * class SubscriptionReceiver( + * val client: SubscriptionClient + * ) : SubscriptionReceiver + * ``` + */ +interface SubscriptionReceiver { + + /** + * Default implementation for [SubscriptionReceiver]. + */ + companion object : SubscriptionReceiver +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt new file mode 100644 index 0000000..ffbf20c --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt @@ -0,0 +1,118 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.core.Actor +import soil.query.core.Marker + +/** + * A reference to an Subscription for [SubscriptionKey]. + * + * @param T Type of data to receive. + */ +interface SubscriptionRef : Actor { + + /** + * The [SubscriptionKey] for the Subscription. + */ + val key: SubscriptionKey + + /** + * The SubscriptionOptions configured for the subscription. + */ + val options: SubscriptionOptions + + /** + * The Marker specified in [SubscriptionClient.getSubscription]. + */ + val marker: Marker + + /** + * [State Flow][StateFlow] to receive the current state of the subscription. + */ + val state: StateFlow> + + /** + * Sends a [SubscriptionCommand] to the Actor. + */ + suspend fun send(command: SubscriptionCommand) + + /** + * Resumes the Subscription. + */ + suspend fun resume() + + /** + * Cancels the Subscription. + */ + fun cancel() + + /** + * Resets the Subscription. + */ + suspend fun reset() { + send(SubscriptionCommands.Reset(key, state.value.revision)) + } +} + +/** + * Creates a new [SubscriptionRef] instance. + * + * @param key The [SubscriptionKey] for the Subscription. + * @param marker The Marker specified in [SubscriptionClient.getSubscription]. + * @param subscription The Subscription to create a reference. + */ +fun SubscriptionRef( + key: SubscriptionKey, + marker: Marker, + subscription: Subscription +): SubscriptionRef { + return SubscriptionRefImpl(key, marker, subscription) +} + +private class SubscriptionRefImpl( + override val key: SubscriptionKey, + override val marker: Marker, + private val subscription: Subscription +) : SubscriptionRef { + + private var job: Job? = null + + override val options: SubscriptionOptions + get() = subscription.options + + override val state: StateFlow> + get() = subscription.state + + override fun launchIn(scope: CoroutineScope): Job { + return subscription.launchIn(scope) + } + + override suspend fun send(command: SubscriptionCommand) { + subscription.command.send(command) + } + + override suspend fun resume() { + if (job?.isActive == true) return + coroutineScope { + job = launch { + subscription.source.collect(::receive) + } + } + } + + override fun cancel() { + job?.cancel() + job = null + } + + private suspend fun receive(result: Result) { + send(SubscriptionCommands.Receive(key, result, state.value.revision, marker)) + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt new file mode 100644 index 0000000..ac707a3 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt @@ -0,0 +1,76 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply +import soil.query.core.epoch + +/** + * State for managing the execution result of [Subscription]. + */ +data class SubscriptionState internal constructor( + override val reply: Reply = Reply.None, + override val replyUpdatedAt: Long = 0, + override val error: Throwable? = null, + override val errorUpdatedAt: Long = 0, + override val status: SubscriptionStatus = SubscriptionStatus.Pending, + override val subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers +) : SubscriptionModel { + + companion object { + + /** + * Creates a new [SubscriptionState] with the [SubscriptionStatus.Pending] status. + * + * @param subscriberStatus The status of the subscriber. + */ + fun initial( + subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + return SubscriptionState( + subscriberStatus = subscriberStatus + ) + } + + /** + * Creates a new [SubscriptionState] with the [SubscriptionStatus.Success] status. + * + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param subscriberStatus The status of the subscriber. + */ + fun success( + data: T, + dataUpdatedAt: Long = epoch(), + subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + return SubscriptionState( + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, + status = SubscriptionStatus.Success, + subscriberStatus = subscriberStatus + ) + } + + /** + * Creates a new [SubscriptionState] with the [SubscriptionStatus.Failure] status. + * + * @param error The error to be stored in the state. + * @param errorUpdatedAt The timestamp when the error was updated. Default is the current epoch. + * @param subscriberStatus The status of the subscriber. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch(), + subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + return SubscriptionState( + error = error, + errorUpdatedAt = errorUpdatedAt, + status = SubscriptionStatus.Failure, + subscriberStatus = subscriberStatus + ) + } + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index 1d7cc8c..bfc41fb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -3,12 +3,8 @@ package soil.query -import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -57,7 +53,7 @@ import kotlin.coroutines.CoroutineContext * On the other hand, [Query] in the Inactive state gradually disappears from memory when one of the following conditions is met: * - Exceeds the maximum retention count of [TimeBasedCache] * - Past the [QueryOptions.gcTime] period since saved in [TimeBasedCache] - * - [evictCache] or [clearCache] is executed for unnecessary memory release + * - [gc] is executed for unnecessary memory release * * [Mutation] is managed similarly to Active state [Query], but it is not explicitly deleted like [removeQueries]. * Typically, since the result of [Mutation] execution is not reused, it does not cache after going inactive. @@ -65,7 +61,7 @@ import kotlin.coroutines.CoroutineContext * @param policy The policy for the [SwrCache]. * @constructor Creates a new [SwrCache] instance. */ -class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClient { +open class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClient { @Suppress("unused") constructor(coroutineScope: CoroutineScope) : this(SwrCachePolicy(coroutineScope)) @@ -78,31 +74,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie private val coroutineScope: CoroutineScope = CoroutineScope( context = newCoroutineContext(policy.coroutineScope) ) - private val batchScheduler: BatchScheduler by lazy { - policy.batchSchedulerFactory.create(coroutineScope) - } + private val batchScheduler: BatchScheduler = policy.batchSchedulerFactory.create(coroutineScope) private var mountedIds: Set = emptySet() private var mountedScope: CoroutineScope? = null - /** - * Releases data in memory based on the specified [level]. - */ - @Suppress("MemberVisibilityCanBePrivate") - fun gc(level: MemoryPressureLevel = MemoryPressureLevel.Low) { - when (level) { - MemoryPressureLevel.Low -> evictCache() - MemoryPressureLevel.High -> clearCache() - } - } - - private fun evictCache() { - queryCache.evict() - } - - private fun clearCache() { - queryCache.clear() - } // ----- SwrClient ----- // @@ -112,6 +88,13 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie override val errorRelay: Flow get() = policy.errorRelay?.receiveAsFlow() ?: error("policy.errorRelay is not configured :(") + override fun gc(level: MemoryPressureLevel) { + when (level) { + MemoryPressureLevel.Low -> queryCache.evict() + MemoryPressureLevel.High -> queryCache.clear() + } + } + override fun perform(sideEffects: QueryEffect): Job { return coroutineScope.launch { with(this@SwrCache) { sideEffects() } @@ -192,11 +175,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie if (mutation == null) { mutation = newMutation( id = id, - options = key.configureOptions(defaultMutationOptions), + options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions, initialValue = MutationState() ).also { mutationStore[id] = it } } - return SwrMutation( + return MutationRef( key = key, marker = marker, mutation = mutation @@ -268,11 +251,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie if (query == null) { query = newQuery( id = id, - options = key.configureOptions(defaultQueryOptions), + options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions, initialValue = queryCache[key.id] as? QueryState ?: newQueryState(key) ).also { queryStore[id] = it } } - return SwrQuery( + return QueryRef( key = key, marker = marker, query = query @@ -364,11 +347,11 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie if (query == null) { query = newInfiniteQuery( id = id, - options = key.configureOptions(defaultQueryOptions), + options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions, initialValue = queryCache[id] as? QueryState> ?: QueryState() ).also { queryStore[id] = it } } - return SwrInfiniteQuery( + return InfiniteQueryRef( key = key, marker = marker, query = query @@ -663,31 +646,8 @@ class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutableClie ) : QueryCommand.Context companion object { - private fun newCoroutineContext(parent: CoroutineScope): CoroutineContext { + internal fun newCoroutineContext(parent: CoroutineScope): CoroutineContext { return parent.coroutineContext + Job(parent.coroutineContext[Job]) } } } - -private fun MutationKey.configureOptions(defaultOptions: MutationOptions): MutationOptions { - return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions -} - -private fun QueryKey.configureOptions(defaultOptions: QueryOptions): QueryOptions { - return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions -} - -private fun InfiniteQueryKey.configureOptions(defaultOptions: QueryOptions): QueryOptions { - return onConfigureOptions()?.invoke(defaultOptions) ?: defaultOptions -} - -/** - * [CoroutineScope] with limited concurrency for [SwrCache]. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class SwrCacheScope(parent: Job? = null) : CoroutineScope { - override val coroutineContext: CoroutineContext = - SupervisorJob(parent) + - Dispatchers.Default.limitedParallelism(1) + - CoroutineName("SwrCache") -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt new file mode 100644 index 0000000..dbedd72 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt @@ -0,0 +1,197 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.ActorBlockRunner +import soil.query.core.ActorSequenceNumber +import soil.query.core.BatchScheduler +import soil.query.core.Marker +import soil.query.core.MemoryPressureLevel +import soil.query.core.UniqueId +import soil.query.core.WhileSubscribedAlt +import soil.query.core.epoch +import soil.query.core.retryWithExponentialBackoff +import soil.query.core.toResultFlow +import soil.query.core.vvv + +/** + * An enhanced version of [SwrCache] that integrates [SwrClientPlus] into SwrCache. + */ +@ExperimentalSoilQueryApi +class SwrCachePlus(private val policy: SwrCachePlusPolicy) : SwrCache(policy), SwrClientPlus { + + @Suppress("unused") + constructor(coroutineScope: CoroutineScope) : this(SwrCachePlusPolicy(coroutineScope)) + + private val subscriptionReceiver = policy.subscriptionReceiver + private val subscriptionStore: MutableMap> = mutableMapOf() + private val subscriptionCache = policy.subscriptionCache + + private val coroutineScope: CoroutineScope = CoroutineScope( + context = newCoroutineContext(policy.coroutineScope) + ) + private val batchScheduler: BatchScheduler = policy.batchSchedulerFactory.create(coroutineScope) + + override val defaultSubscriptionOptions: SubscriptionOptions = policy.subscriptionOptions + + override fun gc(level: MemoryPressureLevel) { + super.gc(level) + when (level) { + MemoryPressureLevel.Low -> subscriptionCache.evict() + MemoryPressureLevel.High -> subscriptionCache.clear() + } + } + + @Suppress("UNCHECKED_CAST") + override fun getSubscription( + key: SubscriptionKey, + marker: Marker + ): SubscriptionRef { + val id = key.id + var subscription = subscriptionStore[id] as? ManagedSubscription + if (subscription == null) { + subscription = newSubscription( + id = id, + options = key.onConfigureOptions()?.invoke(defaultSubscriptionOptions) ?: defaultSubscriptionOptions, + initialValue = subscriptionCache[key.id] as? SubscriptionState ?: SubscriptionState(), + subscribe = key.subscribe + ).also { subscriptionStore[id] = it } + } + return SubscriptionRef( + key = key, + marker = marker, + subscription = subscription + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun newSubscription( + id: UniqueId, + options: SubscriptionOptions, + initialValue: SubscriptionState, + subscribe: SubscriptionReceiver.() -> Flow + ): ManagedSubscription { + val scope = CoroutineScope(newCoroutineContext(coroutineScope)) + val state = MutableStateFlow(initialValue) + val reducer = createSubscriptionReducer() + val dispatch: SubscriptionDispatch = { action -> + options.vvv(id) { "dispatching $action" } + state.value = reducer(state.value, action) + } + val refresh = MutableStateFlow(epoch()) + val restart: SubscriptionRestart = { + scope.launch { refresh.emit(epoch()) } + } + val relay: SubscriptionErrorRelay? = policy.errorRelay?.let { it::send } + val command = Channel>() + val actor = ActorBlockRunner( + scope = scope, + options = options, + onTimeout = { seq -> + scope.launch { batchScheduler.post { closeSubscription(id, seq) } } + } + ) { + for (c in command) { + options.vvv(id) { "next command $c" } + c.handle( + ctx = ManagedSubscriptionContext( + options = options, + state = state.value, + dispatch = dispatch, + restart = restart, + relay = relay + ) + ) + } + } + val subscribeFlow = subscriptionReceiver.subscribe() + val source = refresh + .flatMapLatest { subscribeFlow } + .retryWithExponentialBackoff(options) { err, count, nextBackOff -> + options.vvv(id) { "retry(count=$count next=$nextBackOff error=${err.message})" } + } + .toResultFlow() + .shareIn( + scope = scope, + started = SharingStarted.WhileSubscribedAlt( + stopTimeout = options.keepAliveTime, + onSubscriptionCount = { subscribers -> + options.vvv(id) { "subscription count: $subscribers" } + scope.launch { command.send(SubscriptionCommands.Count(subscribers)) } + } + ) + ) + return ManagedSubscription( + scope = scope, + id = id, + options = options, + source = source, + state = state, + command = command, + actor = actor + ) + } + + @Suppress("UNCHECKED_CAST") + private fun closeSubscription(id: UniqueId, seq: ActorSequenceNumber) { + val subscription = subscriptionStore[id] as? ManagedSubscription ?: return + if (subscription.actor.seq == seq) { + subscriptionStore.remove(id) + subscription.close() + saveToCache(subscription) + } + } + + private fun saveToCache(subscription: ManagedSubscription) { + val lastValue = subscription.state.value + val ttl = subscription.options.gcTime + if (lastValue.isSuccess && ttl.isPositive()) { + subscriptionCache.set(subscription.id, lastValue, ttl) + subscription.options.vvv(subscription.id) { "cached(ttl=$ttl)" } + } + } + + internal class ManagedSubscription( + val scope: CoroutineScope, + val id: UniqueId, + override val options: SubscriptionOptions, + override val source: SharedFlow>, + override val state: StateFlow>, + override val command: SendChannel>, + internal val actor: ActorBlockRunner + ) : Subscription { + + override fun launchIn(scope: CoroutineScope): Job { + return actor.launchIn(scope) + } + + fun close() { + scope.cancel() + command.close() + } + } + + internal class ManagedSubscriptionContext( + override val options: SubscriptionOptions, + override val state: SubscriptionState, + override val dispatch: SubscriptionDispatch, + override val restart: SubscriptionRestart, + override val relay: SubscriptionErrorRelay? + ) : SubscriptionCommand.Context +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlusPolicy.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlusPolicy.kt new file mode 100644 index 0000000..edac0db --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlusPolicy.kt @@ -0,0 +1,127 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.BatchSchedulerFactory +import soil.query.core.ErrorRelay +import soil.query.core.MemoryPressure +import soil.query.core.NetworkConnectivity +import soil.query.core.WindowVisibility +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Policy for the [SwrCachePlus]. + */ +@ExperimentalSoilQueryApi +interface SwrCachePlusPolicy : SwrCachePolicy { + + /** + * Default [SubscriptionOptions] applied to [Subscription]. + */ + val subscriptionOptions: SubscriptionOptions + + /** + * Extension receiver for referencing external instances needed when executing [subscribe][SubscriptionKey.subscribe]. + */ + val subscriptionReceiver: SubscriptionReceiver + + /** + * Management of cached data for inactive [Subscription] instances. + */ + val subscriptionCache: SubscriptionCache +} + +/** + * Creates a new instance of [SwrCachePlusPolicy]. + * + * @param coroutineScope [CoroutineScope] for coroutines executed on the [SwrCachePlus]. + * @param mainDispatcher [CoroutineDispatcher] for the main thread. + * @param mutationOptions Default [MutationOptions] applied to [Mutation]. + * @param mutationReceiver Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. + * @param queryOptions Default [QueryOptions] applied to [Query]. + * @param queryReceiver Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. + * @param queryCache Management of cached data for inactive [Query] instances. + * @param subscriptionOptions Default [SubscriptionOptions] applied to [Subscription]. + * @param subscriptionReceiver Extension receiver for referencing external instances needed when executing [subscribe][SubscriptionKey.subscribe]. + * @param subscriptionCache Management of cached data for inactive [Subscription] instances. + * @param batchSchedulerFactory Factory for creating a [soil.query.core.BatchScheduler]. + * @param errorRelay Relay for error handling. + * @param memoryPressure Management of memory pressure. + * @param networkConnectivity Management of network connectivity. + * @param networkResumeAfterDelay Duration after which the network resumes. + * @param networkResumeQueriesFilter Filter for resuming queries after a network error. + * @param windowVisibility Management of window visibility. + * @param windowResumeQueriesFilter Filter for resuming queries after a window focus. + */ +@ExperimentalSoilQueryApi +fun SwrCachePlusPolicy( + coroutineScope: CoroutineScope, + mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + mutationOptions: MutationOptions = MutationOptions, + mutationReceiver: MutationReceiver = MutationReceiver, + queryOptions: QueryOptions = QueryOptions, + queryReceiver: QueryReceiver = QueryReceiver, + queryCache: QueryCache = QueryCache(), + subscriptionOptions: SubscriptionOptions = SubscriptionOptions, + subscriptionReceiver: SubscriptionReceiver = SubscriptionReceiver, + subscriptionCache: SubscriptionCache = SubscriptionCache(), + batchSchedulerFactory: BatchSchedulerFactory = BatchSchedulerFactory.default(mainDispatcher), + errorRelay: ErrorRelay? = null, + memoryPressure: MemoryPressure = MemoryPressure, + networkConnectivity: NetworkConnectivity = NetworkConnectivity, + networkResumeAfterDelay: Duration = 2.seconds, + networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + predicate = { it.isFailure } + ), + windowVisibility: WindowVisibility = WindowVisibility, + windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + predicate = { it.isStaled() } + ) +): SwrCachePlusPolicy = SwrCachePlusPolicyImpl( + coroutineScope = coroutineScope, + mainDispatcher = mainDispatcher, + mutationOptions = mutationOptions, + mutationReceiver = mutationReceiver, + queryOptions = queryOptions, + queryReceiver = queryReceiver, + queryCache = queryCache, + subscriptionOptions = subscriptionOptions, + subscriptionReceiver = subscriptionReceiver, + subscriptionCache = subscriptionCache, + batchSchedulerFactory = batchSchedulerFactory, + errorRelay = errorRelay, + memoryPressure = memoryPressure, + networkConnectivity = networkConnectivity, + networkResumeAfterDelay = networkResumeAfterDelay, + networkResumeQueriesFilter = networkResumeQueriesFilter, + windowVisibility = windowVisibility, + windowResumeQueriesFilter = windowResumeQueriesFilter +) + +@ExperimentalSoilQueryApi +internal class SwrCachePlusPolicyImpl( + override val coroutineScope: CoroutineScope, + override val mainDispatcher: CoroutineDispatcher, + override val mutationOptions: MutationOptions, + override val mutationReceiver: MutationReceiver, + override val queryOptions: QueryOptions, + override val queryReceiver: QueryReceiver, + override val queryCache: QueryCache, + override val subscriptionOptions: SubscriptionOptions, + override val subscriptionReceiver: SubscriptionReceiver, + override val subscriptionCache: SubscriptionCache, + override val batchSchedulerFactory: BatchSchedulerFactory, + override val errorRelay: ErrorRelay?, + override val memoryPressure: MemoryPressure, + override val networkConnectivity: NetworkConnectivity, + override val networkResumeAfterDelay: Duration, + override val networkResumeQueriesFilter: ResumeQueriesFilter, + override val windowVisibility: WindowVisibility, + override val windowResumeQueriesFilter: ResumeQueriesFilter +) : SwrCachePlusPolicy diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt index d78418e..7fcef95 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePolicy.kt @@ -6,7 +6,6 @@ package soil.query import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import soil.query.core.BatchScheduler import soil.query.core.BatchSchedulerFactory import soil.query.core.ErrorRelay import soil.query.core.MemoryPressure @@ -15,10 +14,7 @@ import soil.query.core.WindowVisibility import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -/** - * Policy for the [SwrCache]. - */ -class SwrCachePolicy( +interface SwrCachePolicy { /** * [CoroutineScope] for coroutines executed on the [SwrCache]. @@ -27,7 +23,7 @@ class SwrCachePolicy( * The [SwrCache] internals are not thread-safe. * Always use a scoped implementation such as [SwrCacheScope] or [kotlinx.coroutines.MainScope] with limited concurrency. */ - val coroutineScope: CoroutineScope, + val coroutineScope: CoroutineScope /** * [CoroutineDispatcher] for the main thread. @@ -36,56 +32,56 @@ class SwrCachePolicy( * Some processes are safely synchronized with the caller using the main thread. * When unit testing, please replace it with a test Dispatcher. */ - val mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + val mainDispatcher: CoroutineDispatcher /** * Default [MutationOptions] applied to [Mutation]. */ - val mutationOptions: MutationOptions = MutationOptions, + val mutationOptions: MutationOptions /** * Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. */ - val mutationReceiver: MutationReceiver = MutationReceiver, + val mutationReceiver: MutationReceiver /** * Default [QueryOptions] applied to [Query]. */ - val queryOptions: QueryOptions = QueryOptions, + val queryOptions: QueryOptions /** * Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. */ - val queryReceiver: QueryReceiver = QueryReceiver, + val queryReceiver: QueryReceiver /** * Management of cached data for inactive [Query] instances. */ - val queryCache: QueryCache = QueryCache(), + val queryCache: QueryCache /** - * Scheduler for batching tasks. + * Factory for creating a [soil.query.core.BatchScheduler]. * * **Note:** * This is used for internal processes such as moving inactive query caches. * Please avoid changing this unless you need to substitute it for testing purposes. */ - val batchSchedulerFactory: BatchSchedulerFactory = BatchSchedulerFactory.default(mainDispatcher), + val batchSchedulerFactory: BatchSchedulerFactory /** * Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. */ - val errorRelay: ErrorRelay? = null, + val errorRelay: ErrorRelay? /** * Receiving events of memory pressure. */ - val memoryPressure: MemoryPressure = MemoryPressure, + val memoryPressure: MemoryPressure /** * Receiving events of network connectivity. */ - val networkConnectivity: NetworkConnectivity = NetworkConnectivity, + val networkConnectivity: NetworkConnectivity /** * The delay time to resume queries after network connectivity is reconnected. @@ -93,7 +89,7 @@ class SwrCachePolicy( * **Note:** * This setting is only effective when [networkConnectivity] is available. */ - val networkResumeAfterDelay: Duration = 2.seconds, + val networkResumeAfterDelay: Duration /** * The specified filter to resume queries after network connectivity is reconnected. @@ -101,14 +97,12 @@ class SwrCachePolicy( * **Note:** * This setting is only effective when [networkConnectivity] is available. */ - val networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( - predicate = { it.isFailure } - ), + val networkResumeQueriesFilter: ResumeQueriesFilter /** * Receiving events of window visibility. */ - val windowVisibility: WindowVisibility = WindowVisibility, + val windowVisibility: WindowVisibility /** * The specified filter to resume queries after window visibility is refocused. @@ -116,7 +110,80 @@ class SwrCachePolicy( * **Note:** * This setting is only effective when [windowVisibility] is available. */ - val windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + val windowResumeQueriesFilter: ResumeQueriesFilter +} + +/** + * Create a new [SwrCachePolicy] instance. + * + * @param coroutineScope [CoroutineScope] for coroutines executed on the [SwrCache]. + * @param mainDispatcher [CoroutineDispatcher] for the main thread. + * @param mutationOptions Default [MutationOptions] applied to [Mutation]. + * @param mutationReceiver Extension receiver for referencing external instances needed when executing [mutate][MutationKey.mutate]. + * @param queryOptions Default [QueryOptions] applied to [Query]. + * @param queryReceiver Extension receiver for referencing external instances needed when executing [fetch][QueryKey.fetch]. + * @param queryCache Management of cached data for inactive [Query] instances. + * @param batchSchedulerFactory Factory for creating a [soil.query.core.BatchScheduler]. + * @param errorRelay Specify the mechanism of [ErrorRelay] when using [SwrClient.errorRelay]. + * @param memoryPressure Receiving events of memory pressure. + * @param networkConnectivity Receiving events of network connectivity. + * @param networkResumeAfterDelay The delay time to resume queries after network connectivity is reconnected. + * @param networkResumeQueriesFilter The specified filter to resume queries after network connectivity is reconnected. + * @param windowVisibility Receiving events of window visibility. + * @param windowResumeQueriesFilter The specified filter to resume queries after window visibility is refocused. + */ +fun SwrCachePolicy( + coroutineScope: CoroutineScope, + mainDispatcher: CoroutineDispatcher = Dispatchers.Main, + mutationOptions: MutationOptions = MutationOptions, + mutationReceiver: MutationReceiver = MutationReceiver, + queryOptions: QueryOptions = QueryOptions, + queryReceiver: QueryReceiver = QueryReceiver, + queryCache: QueryCache = QueryCache(), + batchSchedulerFactory: BatchSchedulerFactory = BatchSchedulerFactory.default(mainDispatcher), + errorRelay: ErrorRelay? = null, + memoryPressure: MemoryPressure = MemoryPressure, + networkConnectivity: NetworkConnectivity = NetworkConnectivity, + networkResumeAfterDelay: Duration = 2.seconds, + networkResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( + predicate = { it.isFailure } + ), + windowVisibility: WindowVisibility = WindowVisibility, + windowResumeQueriesFilter: ResumeQueriesFilter = ResumeQueriesFilter( predicate = { it.isStaled() } ) +): SwrCachePolicy = SwrCachePolicyImpl( + coroutineScope = coroutineScope, + mainDispatcher = mainDispatcher, + mutationOptions = mutationOptions, + mutationReceiver = mutationReceiver, + queryOptions = queryOptions, + queryReceiver = queryReceiver, + queryCache = queryCache, + batchSchedulerFactory = batchSchedulerFactory, + errorRelay = errorRelay, + memoryPressure = memoryPressure, + networkConnectivity = networkConnectivity, + networkResumeAfterDelay = networkResumeAfterDelay, + networkResumeQueriesFilter = networkResumeQueriesFilter, + windowVisibility = windowVisibility, + windowResumeQueriesFilter = windowResumeQueriesFilter ) + +internal class SwrCachePolicyImpl( + override val coroutineScope: CoroutineScope, + override val mainDispatcher: CoroutineDispatcher, + override val mutationOptions: MutationOptions, + override val mutationReceiver: MutationReceiver, + override val queryOptions: QueryOptions, + override val queryReceiver: QueryReceiver, + override val queryCache: QueryCache, + override val batchSchedulerFactory: BatchSchedulerFactory, + override val errorRelay: ErrorRelay?, + override val memoryPressure: MemoryPressure, + override val networkConnectivity: NetworkConnectivity, + override val networkResumeAfterDelay: Duration, + override val networkResumeQueriesFilter: ResumeQueriesFilter, + override val windowVisibility: WindowVisibility, + override val windowResumeQueriesFilter: ResumeQueriesFilter +) : SwrCachePolicy diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCacheScope.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCacheScope.kt new file mode 100644 index 0000000..d7b6bb6 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCacheScope.kt @@ -0,0 +1,23 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +/** + * [CoroutineScope] with limited concurrency for [SwrCache] and [SwrCachePlus]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SwrCacheScope(parent: Job? = null) : CoroutineScope { + override val coroutineContext: CoroutineContext = + SupervisorJob(parent) + + Dispatchers.Default.limitedParallelism(1) + + CoroutineName("SwrCache") +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt index 003e453..af8bdf7 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClient.kt @@ -6,6 +6,7 @@ package soil.query import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import soil.query.core.ErrorRecord +import soil.query.core.MemoryPressureLevel /** * An all-in-one [SwrClient] integrating [MutationClient] and [QueryClient] for library users. @@ -22,6 +23,11 @@ interface SwrClient : MutationClient, QueryClient { */ val errorRelay: Flow + /** + * Releases data in memory based on the specified [level]. + */ + fun gc(level: MemoryPressureLevel = MemoryPressureLevel.Low) + /** * Executes side effects for queries. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrClientPlus.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClientPlus.kt new file mode 100644 index 0000000..7f5fb0e --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrClientPlus.kt @@ -0,0 +1,9 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +/** + * An enhanced version of [SwrClient] that integrates [SubscriptionClient] into SwrClient. + */ +interface SwrClientPlus : SwrClient, SubscriptionClient diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt deleted file mode 100644 index 14b4661..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrInfiniteQuery.kt +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import soil.query.core.Marker - -internal class SwrInfiniteQuery( - override val key: InfiniteQueryKey, - override val marker: Marker, - private val query: Query> -) : InfiniteQueryRef { - - override val options: QueryOptions - get() = query.options - - override val state: StateFlow>> - get() = query.state - - override fun launchIn(scope: CoroutineScope): Job { - return scope.launch { - query.launchIn(this) - query.event.collect(::handleEvent) - } - } - - override suspend fun send(command: InfiniteQueryCommand) { - query.command.send(command) - } - - private suspend fun handleEvent(e: QueryEvent) { - when (e) { - QueryEvent.Invalidate -> invalidate() - QueryEvent.Resume -> resume() - } - } -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt deleted file mode 100644 index 4fb53d9..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrMutation.kt +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow -import soil.query.core.Marker - -internal class SwrMutation( - override val key: MutationKey, - override val marker: Marker, - private val mutation: Mutation -) : MutationRef { - - override val options: MutationOptions - get() = mutation.options - - override val state: StateFlow> - get() = mutation.state - - override fun launchIn(scope: CoroutineScope): Job { - return mutation.launchIn(scope) - } - - override suspend fun send(command: MutationCommand) { - mutation.command.send(command) - } -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt deleted file mode 100644 index 747f587..0000000 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrQuery.kt +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2024 Soil Contributors -// SPDX-License-Identifier: Apache-2.0 - -package soil.query - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import soil.query.core.Marker - -internal class SwrQuery( - override val key: QueryKey, - override val marker: Marker, - private val query: Query -) : QueryRef { - - override val options: QueryOptions - get() = query.options - - override val state: StateFlow> - get() = query.state - - override fun launchIn(scope: CoroutineScope): Job { - return scope.launch { - query.launchIn(this) - query.event.collect(::handleEvent) - } - } - - override suspend fun send(command: QueryCommand) { - query.command.send(command) - } - - private suspend fun handleEvent(e: QueryEvent) { - when (e) { - QueryEvent.Invalidate -> invalidate() - QueryEvent.Resume -> resume() - } - } -} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt index 2c28011..11cb999 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/CoroutineExt.kt @@ -4,14 +4,26 @@ package soil.query.core import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingCommand +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.selects.select import kotlin.time.Duration @@ -61,6 +73,29 @@ internal fun Flow.chunkedWithTimeout( } } +internal fun Flow.retryWithExponentialBackoff( + retryOptions: RetryOptions, + onRetry: RetryCallback? = null +): Flow { + return retryWhen { cause, attempt -> + if (attempt >= retryOptions.retryCount || !retryOptions.shouldRetry(cause)) { + return@retryWhen false + } + + val nextBackoff = retryOptions.calculateBackoffInterval(attempt.toInt()) + onRetry?.invoke(cause, attempt.toInt(), nextBackoff) + + delay(nextBackoff) + return@retryWhen true + } +} + +internal fun Flow.toResultFlow(): Flow> { + return this + .map { value -> Result.success(value) } + .catch { e -> emit(Result.failure(e)) } +} + /** * Returns null if an exception, including cancellation, occurs. */ @@ -71,3 +106,37 @@ internal suspend fun Deferred.awaitOrNull(): T? { null } } + +@Suppress("FunctionName") +internal fun SharingStarted.Companion.WhileSubscribedAlt( + stopTimeout: Duration, + onSubscriptionCount: (Int) -> Unit +): SharingStarted = StartedWhileSubscribedAlt(stopTimeout, onSubscriptionCount) + +@OptIn(ExperimentalCoroutinesApi::class) +private class StartedWhileSubscribedAlt( + private val stopTimeout: Duration, + private val onSubscriptionCount: (Int) -> Unit +) : SharingStarted { + + init { + require(stopTimeout >= Duration.ZERO) { "stopTimeout cannot be negative" } + } + + override fun command(subscriptionCount: StateFlow): Flow = subscriptionCount + .onEach { onSubscriptionCount(it) } + .transformLatest { count -> + if (count > 0) { + emit(SharingCommand.START) + } else { + delay(stopTimeout) + emit(SharingCommand.STOP) + } + } + .dropWhile { it != SharingCommand.START } + .distinctUntilChanged() + + override fun toString(): String { + return "StartedWhileSubscribedAlt(stopTimeout=$stopTimeout)" + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt index 7938f9e..a37f9bb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Retry.kt @@ -36,4 +36,4 @@ interface Retryable { /** * Callback function to notify the execution of retry logic. */ -typealias RetryCallback = (err: Throwable, count: Int, nextBackOff: Duration) -> Unit +typealias RetryCallback = (err: Throwable, attempt: Int, nextBackOff: Duration) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt index 5471b81..482d343 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/RetryOptions.kt @@ -5,6 +5,7 @@ package soil.query.core import kotlinx.coroutines.delay import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.pow import kotlin.random.Random import kotlin.time.Duration @@ -55,9 +56,8 @@ interface RetryOptions { fun RetryOptions.exponentialBackOff( onRetry: RetryCallback? = null ) = RetryFn { block -> - var nextBackOff = retryInitialInterval - repeat(retryCount) { count -> - try { + repeat(retryCount) { attempt -> + val cause = try { return@RetryFn block() } catch (e: CancellationException) { throw e @@ -65,16 +65,22 @@ fun RetryOptions.exponentialBackOff( if (!shouldRetry(t)) { throw t } - onRetry?.invoke(t, count, nextBackOff) + t } - val randomizedInterval = nextBackOff.times( - retryRandomizer.nextDouble( - 1 - retryRandomizationFactor, - 1 + retryRandomizationFactor - ) - ) - delay(randomizedInterval) - nextBackOff = nextBackOff.times(retryMultiplier).coerceAtMost(retryMaxInterval) + + val nextBackOff = calculateBackoffInterval(attempt) + onRetry?.invoke(cause, attempt, nextBackOff) + + delay(nextBackOff) } block() } + +fun RetryOptions.calculateBackoffInterval(attempt: Int): Duration { + val exponentialBackoff = retryInitialInterval * retryMultiplier.pow(attempt.toDouble()) + val randomizedBackoff = exponentialBackoff * retryRandomizer.nextDouble( + 1.0 - retryRandomizationFactor, + 1.0 + retryRandomizationFactor + ) + return randomizedBackoff.coerceAtMost(retryMaxInterval) +} diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt new file mode 100644 index 0000000..5498189 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt @@ -0,0 +1,112 @@ +package soil.query + +import soil.testing.UnitTest +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.time.Duration.Companion.seconds + +class SubscriptionOptionsTest : UnitTest() { + + @Test + fun factory_default() { + val actual = SubscriptionOptions() + assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) + assertEquals(SubscriptionOptions.Default.onError, actual.onError) + assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) + assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(SubscriptionOptions.Default.logger, actual.logger) + assertEquals(SubscriptionOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(SubscriptionOptions.Default.retryCount, actual.retryCount) + assertEquals(SubscriptionOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(SubscriptionOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(SubscriptionOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(SubscriptionOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(SubscriptionOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun factory_factory_specifyingArguments() { + val actual = SubscriptionOptions( + gcTime = 1000.seconds, + subscribeOnMount = false, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertNotEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) + assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) + assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) + assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(SubscriptionOptions.Default.logger, actual.logger) + assertNotEquals(SubscriptionOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(SubscriptionOptions.Default.retryCount, actual.retryCount) + assertNotEquals(SubscriptionOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(SubscriptionOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(SubscriptionOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(SubscriptionOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(SubscriptionOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_default() { + val actual = SubscriptionOptions.copy() + assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) + assertEquals(SubscriptionOptions.Default.onError, actual.onError) + assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) + assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) + assertEquals(SubscriptionOptions.Default.logger, actual.logger) + assertEquals(SubscriptionOptions.Default.shouldRetry, actual.shouldRetry) + assertEquals(SubscriptionOptions.Default.retryCount, actual.retryCount) + assertEquals(SubscriptionOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertEquals(SubscriptionOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertEquals(SubscriptionOptions.Default.retryMultiplier, actual.retryMultiplier) + assertEquals(SubscriptionOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertEquals(SubscriptionOptions.Default.retryRandomizer, actual.retryRandomizer) + } + + @Test + fun copy_override() { + val actual = SubscriptionOptions.copy( + gcTime = 1000.seconds, + subscribeOnMount = false, + onError = { _, _ -> }, + shouldSuppressErrorRelay = { _, _ -> true }, + keepAliveTime = 4000.seconds, + logger = { _ -> }, + shouldRetry = { _ -> true }, + retryCount = 999, + retryInitialInterval = 5000.seconds, + retryMaxInterval = 6000.seconds, + retryMultiplier = 0.1, + retryRandomizationFactor = 0.01, + retryRandomizer = Random(999) + ) + + assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertNotEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) + assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) + assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) + assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) + assertNotEquals(SubscriptionOptions.Default.logger, actual.logger) + assertNotEquals(SubscriptionOptions.Default.shouldRetry, actual.shouldRetry) + assertNotEquals(SubscriptionOptions.Default.retryCount, actual.retryCount) + assertNotEquals(SubscriptionOptions.Default.retryInitialInterval, actual.retryInitialInterval) + assertNotEquals(SubscriptionOptions.Default.retryMaxInterval, actual.retryMaxInterval) + assertNotEquals(SubscriptionOptions.Default.retryMultiplier, actual.retryMultiplier) + assertNotEquals(SubscriptionOptions.Default.retryRandomizationFactor, actual.retryRandomizationFactor) + assertNotEquals(SubscriptionOptions.Default.retryRandomizer, actual.retryRandomizer) + } +} diff --git a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt index 1e14475..dac19c9 100644 --- a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt +++ b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/KtorReceiver.kt @@ -6,6 +6,7 @@ package soil.query.receivers.ktor import io.ktor.client.HttpClient import soil.query.MutationReceiver import soil.query.QueryReceiver +import soil.query.SubscriptionReceiver /** * A receiver that uses Ktor to send queries and mutations. @@ -52,7 +53,7 @@ import soil.query.QueryReceiver * @see buildKtorInfiniteQueryKey * @see buildKtorMutationKey */ -interface KtorReceiver : QueryReceiver, MutationReceiver { +interface KtorReceiver : QueryReceiver, MutationReceiver, SubscriptionReceiver { val ktorClient: HttpClient } diff --git a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt index 7558ac9..8fcadf4 100644 --- a/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt +++ b/soil-query-receivers/ktor/src/commonMain/kotlin/soil/query/receivers/ktor/builders.kt @@ -4,6 +4,7 @@ package soil.query.receivers.ktor import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.Flow import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey import soil.query.MutationId @@ -11,9 +12,12 @@ import soil.query.MutationKey import soil.query.QueryChunks import soil.query.QueryId import soil.query.QueryKey +import soil.query.SubscriptionId +import soil.query.SubscriptionKey import soil.query.buildInfiniteQueryKey import soil.query.buildMutationKey import soil.query.buildQueryKey +import soil.query.buildSubscriptionKey /** * A delegation function to build a [MutationKey] for Ktor. @@ -110,3 +114,23 @@ inline fun buildKtorInfiniteQueryKey( initialParam = initialParam, loadMoreParam = loadMoreParam ) + +/** + * A delegation function to build a [SubscriptionKey] for Ktor. + * + * **Note:** + * [KtorReceiver] is required to use the builder functions designed for [KtorReceiver]. + * + * @param id The identifier of the subscription key. + * @param subscribe The subscription function for receiving data, such as from a server. + */ +inline fun buildKtorSubscriptionKey( + id: SubscriptionId = SubscriptionId.auto(), + crossinline subscribe: HttpClient.() -> Flow +): SubscriptionKey = buildSubscriptionKey( + id = id, + subscribe = { + check(this is KtorReceiver) { "KtorReceiver isn't available. Did you forget to set it up?" } + with(ktorClient) { subscribe() } + } +) diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeSubscriptionKey.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeSubscriptionKey.kt new file mode 100644 index 0000000..7f149ca --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/FakeSubscriptionKey.kt @@ -0,0 +1,20 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import kotlinx.coroutines.flow.Flow +import soil.query.SubscriptionKey +import soil.query.SubscriptionReceiver + +/** + * Creates a fake subscription key that returns the result of the given [mock] function. + */ +class FakeSubscriptionKey( + private val target: SubscriptionKey, + private val mock: FakeSubscriptionSubscribe +) : SubscriptionKey by target { + override val subscribe: SubscriptionReceiver.() -> Flow = { mock() } +} + +typealias FakeSubscriptionSubscribe = () -> Flow diff --git a/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClientPlus.kt b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClientPlus.kt new file mode 100644 index 0000000..0b1671c --- /dev/null +++ b/soil-query-test/src/commonMain/kotlin/soil/query/test/TestSwrClientPlus.kt @@ -0,0 +1,96 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.test + +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.InfiniteQueryRef +import soil.query.MutationId +import soil.query.MutationKey +import soil.query.MutationRef +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryRef +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionRef +import soil.query.SwrClientPlus +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.core.Marker + +/** + * This extended interface of the [SwrClientPlus] provides the capability to mock specific queries, mutations, and subscriptions for the purpose of testing. + * By registering certain keys as mocks, you can control the behavior of these specific keys while the rest of the keys function normally. + * This allows for more targeted and precise testing of your application. + * + * ```kotlin + * val client = SwrCachePlus(..) + * val testClient = client.testPlus { + * on(MySubscriptionId) { MutableStateFlow("returned fake data") } + * } + * + * testClient.doSomething() + * ``` + */ +interface TestSwrClientPlus : TestSwrClient, SwrClientPlus { + + /** + * Mocks the subscription process corresponding to [SubscriptionId]. + */ + fun on(id: SubscriptionId, subscribe: FakeSubscriptionSubscribe) +} + +/** + * Switches [SwrClientPlus] to a test interface. + */ +fun SwrClientPlus.testPlus(initializer: TestSwrClientPlus.() -> Unit = {}): TestSwrClientPlus { + return TestSwrClientPlusImpl(this).apply(initializer) +} + +internal class TestSwrClientPlusImpl( + private val target: SwrClientPlus +) : TestSwrClientPlus, SwrClientPlus by target { + + private val testSwrClient = TestSwrClientImpl(target) + private val mockSubscriptions = mutableMapOf, FakeSubscriptionSubscribe<*>>() + + override fun on(id: MutationId, mutate: FakeMutationMutate) { + testSwrClient.on(id, mutate) + } + + override fun on(id: QueryId, fetch: FakeQueryFetch) { + testSwrClient.on(id, fetch) + } + + override fun on(id: InfiniteQueryId, fetch: FakeInfiniteQueryFetch) { + testSwrClient.on(id, fetch) + } + + override fun on(id: SubscriptionId, subscribe: FakeSubscriptionSubscribe) { + mockSubscriptions[id] = subscribe + } + + override fun getMutation(key: MutationKey, marker: Marker): MutationRef { + return testSwrClient.getMutation(key, marker) + } + + override fun getQuery(key: QueryKey, marker: Marker): QueryRef { + return testSwrClient.getQuery(key, marker) + } + + override fun getInfiniteQuery(key: InfiniteQueryKey, marker: Marker): InfiniteQueryRef { + return testSwrClient.getInfiniteQuery(key, marker) + } + + @ExperimentalSoilQueryApi + @Suppress("UNCHECKED_CAST") + override fun getSubscription(key: SubscriptionKey, marker: Marker): SubscriptionRef { + val mock = mockSubscriptions[key.id] as? FakeSubscriptionSubscribe + return if (mock != null) { + target.getSubscription(FakeSubscriptionKey(key, mock), marker) + } else { + target.getSubscription(key, marker) + } + } +} diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt new file mode 100644 index 0000000..62c5c31 --- /dev/null +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt @@ -0,0 +1,57 @@ +package soil.query.test + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionOptions +import soil.query.SwrCachePlus +import soil.query.SwrCachePlusPolicy +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.buildSubscriptionKey +import soil.query.core.getOrThrow +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalSoilQueryApi::class, ExperimentalCoroutinesApi::class) +class TestSwrClientPlusTest : UnitTest() { + + @Test + fun testSubscription() = runTest { + val client = SwrCachePlus( + policy = SwrCachePlusPolicy( + coroutineScope = backgroundScope, + mainDispatcher = UnconfinedTestDispatcher(testScheduler), + subscriptionOptions = SubscriptionOptions( + logger = { println(it) } + ) + ) + ) + val testClient = client.testPlus { + on(ExampleSubscriptionKey.Id) { MutableStateFlow("Hello, World!") } + } + val key = ExampleSubscriptionKey() + val subscription = testClient.getSubscription(key).also { it.launchIn(backgroundScope) } + launch { subscription.resume() } + launch { subscription.state.filter { it.isSuccess }.first() } + runCurrent() + assertEquals("Hello, World!", subscription.state.value.reply.getOrThrow()) + subscription.cancel() + } +} + +private class ExampleSubscriptionKey : SubscriptionKey by buildSubscriptionKey( + id = Id, + subscribe = { error("Not implemented") } +) { + object Id : SubscriptionId( + namespace = "subscription/example" + ) +} From 838a401b6aa5d12fb76819b5650230703e1e8319 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 7 Sep 2024 06:33:00 +0000 Subject: [PATCH 141/155] Apply automatic changes --- .../soil/query/compose/tooling/SubscriptionPreviewClient.kt | 1 - .../kotlin/soil/query/compose/SubscriptionComposableTest.kt | 3 +++ .../commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt | 3 +++ .../commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt | 3 +++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt index 1ca6b8f..8973343 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt @@ -77,4 +77,3 @@ class SubscriptionPreviewClient( fun SubscriptionPreviewClient(initializer: SubscriptionPreviewClient.Builder.() -> Unit): SubscriptionPreviewClient { return SubscriptionPreviewClient.Builder().apply(initializer).build() } - diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt index 3ec79d0..ccff34e 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.compose import androidx.compose.material.Text diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt index 5498189..fe12cf5 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query import soil.testing.UnitTest diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt index 62c5c31..86dd228 100644 --- a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientPlusTest.kt @@ -1,3 +1,6 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + package soil.query.test import kotlinx.coroutines.ExperimentalCoroutinesApi From 55db2a7100758cc681cb68909b77b7518abd5b1c Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 7 Sep 2024 18:36:23 +0900 Subject: [PATCH 142/155] Implements a ObjectMapper class for Compose In #80, we implemented the Configuration class. We introduced a separate mapper class for Composable functions so that you can customize the object conversion process. --- .../query/compose/InfiniteQueryComposable.kt | 73 +------ .../soil/query/compose/InfiniteQueryConfig.kt | 4 + .../soil/query/compose/InfiniteQueryObject.kt | 8 +- .../compose/InfiniteQueryObjectMapper.kt | 99 ++++++++++ .../query/compose/InfiniteQueryStrategy.kt | 4 +- .../soil/query/compose/MutationComposable.kt | 55 +----- .../soil/query/compose/MutationConfig.kt | 4 + .../soil/query/compose/MutationObject.kt | 8 +- .../query/compose/MutationObjectMapper.kt | 82 ++++++++ .../soil/query/compose/MutationStrategy.kt | 4 +- .../soil/query/compose/QueryComposable.kt | 64 +------ .../kotlin/soil/query/compose/QueryConfig.kt | 4 + .../kotlin/soil/query/compose/QueryObject.kt | 8 +- .../soil/query/compose/QueryObjectMapper.kt | 89 +++++++++ .../soil/query/compose/QueryStrategy.kt | 4 +- .../query/compose/SubscriptionComposable.kt | 62 +----- .../soil/query/compose/SubscriptionConfig.kt | 4 + .../soil/query/compose/SubscriptionObject.kt | 8 +- .../query/compose/SubscriptionObjectMapper.kt | 87 +++++++++ .../query/compose/SubscriptionStrategy.kt | 8 +- .../compose/InfiniteQueryComposableTest.kt | 1 + .../compose/InfiniteQueryObjectMapperTest.kt | 180 ++++++++++++++++++ .../query/compose/MutationComposableTest.kt | 1 + .../query/compose/MutationObjectMapperTest.kt | 177 +++++++++++++++++ .../soil/query/compose/QueryComposableTest.kt | 1 + .../query/compose/QueryObjectMapperTest.kt | 151 +++++++++++++++ .../compose/SubscriptionComposableTest.kt | 1 + .../compose/SubscriptionObjectMapperTest.kt | 179 +++++++++++++++++ .../kotlin/soil/query/MutationState.kt | 26 +++ .../kotlin/soil/query/SubscriptionState.kt | 26 +++ 30 files changed, 1159 insertions(+), 263 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObjectMapper.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObjectMapper.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObjectMapper.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationObjectMapperTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryObjectMapperTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionObjectMapperTest.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 45bb352..919cdd7 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -7,14 +7,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.InfiniteQueryKey -import soil.query.InfiniteQueryRef import soil.query.QueryChunks import soil.query.QueryClient -import soil.query.QueryState -import soil.query.QueryStatus -import soil.query.core.getOrThrow -import soil.query.core.isNone -import soil.query.core.map /** * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. @@ -34,7 +28,9 @@ fun rememberInfiniteQuery( ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(query).toInfiniteObject(query = query, select = { it }) + return with(config.mapper) { + config.strategy.collectAsState(query).toObject(query = query, select = { it }) + } } /** @@ -57,66 +53,7 @@ fun rememberInfiniteQuery( ): InfiniteQueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(query).toInfiniteObject(query = query, select = select) -} - -private fun QueryState>.toInfiniteObject( - query: InfiniteQueryRef, - select: (chunks: QueryChunks) -> U -): InfiniteQueryObject { - return when (status) { - QueryStatus.Pending -> InfiniteQueryLoadingObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate, - loadMore = query::loadMore, - loadMoreParam = null - ) - - QueryStatus.Success -> InfiniteQuerySuccessObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate, - loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) - ) - - QueryStatus.Failure -> if (reply.isNone) { - InfiniteQueryLoadingErrorObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate, - loadMore = query::loadMore, - loadMoreParam = null - ) - } else { - InfiniteQueryRefreshErrorObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate, - loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) - ) - } + return with(config.mapper) { + config.strategy.collectAsState(query).toObject(query = query, select = select) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt index 8cf8ac9..9873b80 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt @@ -15,15 +15,18 @@ import soil.query.core.Marker @Immutable data class InfiniteQueryConfig internal constructor( val strategy: InfiniteQueryStrategy, + val mapper: InfiniteQueryObjectMapper, val marker: Marker ) { class Builder { var strategy: InfiniteQueryStrategy = Default.strategy + var mapper: InfiniteQueryObjectMapper = Default.mapper var marker: Marker = Default.marker fun build() = InfiniteQueryConfig( strategy = strategy, + mapper = mapper, marker = marker ) } @@ -31,6 +34,7 @@ data class InfiniteQueryConfig internal constructor( companion object { val Default = InfiniteQueryConfig( strategy = InfiniteQueryStrategy.Default, + mapper = InfiniteQueryObjectMapper.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt index d5abcb3..570eb99 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObject.kt @@ -50,7 +50,7 @@ sealed interface InfiniteQueryObject : QueryModel { * @constructor Creates a [InfiniteQueryLoadingObject]. */ @Immutable -data class InfiniteQueryLoadingObject internal constructor( +data class InfiniteQueryLoadingObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -74,7 +74,7 @@ data class InfiniteQueryLoadingObject internal constructor( * @constructor Creates a [InfiniteQueryLoadingErrorObject]. */ @Immutable -data class InfiniteQueryLoadingErrorObject internal constructor( +data class InfiniteQueryLoadingErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, @@ -98,7 +98,7 @@ data class InfiniteQueryLoadingErrorObject internal constructor( * @constructor Creates a [InfiniteQuerySuccessObject]. */ @Immutable -data class InfiniteQuerySuccessObject internal constructor( +data class InfiniteQuerySuccessObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -124,7 +124,7 @@ data class InfiniteQuerySuccessObject internal constructor( * @constructor Creates a [InfiniteQueryRefreshErrorObject]. */ @Immutable -data class InfiniteQueryRefreshErrorObject internal constructor( +data class InfiniteQueryRefreshErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt new file mode 100644 index 0000000..bd75e78 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt @@ -0,0 +1,99 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryState +import soil.query.QueryStatus +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.query.core.map + +/** + * A mapper that converts [QueryState] to [InfiniteQueryObject]. + */ +interface InfiniteQueryObjectMapper { + + /** + * Converts the given [QueryState] to [InfiniteQueryObject]. + * + * @param query The query reference. + * @param select A function that selects the object from the chunks. + * @return The converted object. + */ + fun QueryState>.toObject( + query: InfiniteQueryRef, + select: (chunks: QueryChunks) -> U + ): InfiniteQueryObject + + companion object +} + +/** + * The default [InfiniteQueryObjectMapper]. + */ +val InfiniteQueryObjectMapper.Companion.Default: InfiniteQueryObjectMapper + get() = DefaultInfiniteQueryObjectMapper + +private object DefaultInfiniteQueryObjectMapper : InfiniteQueryObjectMapper { + override fun QueryState>.toObject( + query: InfiniteQueryRef, + select: (chunks: QueryChunks) -> U + ): InfiniteQueryObject = when (status) { + QueryStatus.Pending -> InfiniteQueryLoadingObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate, + loadMore = query::loadMore, + loadMoreParam = null + ) + + QueryStatus.Success -> InfiniteQuerySuccessObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate, + loadMore = query::loadMore, + loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) + ) + + QueryStatus.Failure -> if (reply.isNone) { + InfiniteQueryLoadingErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate, + loadMore = query::loadMore, + loadMoreParam = null + ) + } else { + InfiniteQueryRefreshErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate, + loadMore = query::loadMore, + loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) + ) + } + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt index bad64d0..822616e 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt @@ -33,9 +33,9 @@ interface InfiniteQueryStrategy { * The default built-in strategy for Infinite Query built into the library. */ val InfiniteQueryStrategy.Companion.Default: InfiniteQueryStrategy - get() = InfiniteQueryStrategyDefault + get() = DefaultInfiniteQueryStrategy -private object InfiniteQueryStrategyDefault : InfiniteQueryStrategy { +private object DefaultInfiniteQueryStrategy : InfiniteQueryStrategy { @Composable override fun collectAsState(query: InfiniteQueryRef): QueryState> { val state by query.state.collectAsState() diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index 3d83d08..b452f6d 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -8,9 +8,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.MutationClient import soil.query.MutationKey -import soil.query.MutationRef -import soil.query.MutationState -import soil.query.MutationStatus /** * Remember a [MutationObject] and subscribes to the mutation state of [key]. @@ -30,55 +27,7 @@ fun rememberMutation( ): MutationObject { val scope = rememberCoroutineScope() val mutation = remember(key) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(mutation).toObject(mutation = mutation) -} - -private fun MutationState.toObject( - mutation: MutationRef, -): MutationObject { - return when (status) { - MutationStatus.Idle -> MutationIdleObject( - reply = reply, - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - mutatedCount = mutatedCount, - mutate = mutation::mutate, - mutateAsync = mutation::mutateAsync, - reset = mutation::reset - ) - - MutationStatus.Pending -> MutationLoadingObject( - reply = reply, - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - mutatedCount = mutatedCount, - mutate = mutation::mutate, - mutateAsync = mutation::mutateAsync, - reset = mutation::reset - ) - - MutationStatus.Success -> MutationSuccessObject( - reply = reply, - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - mutatedCount = mutatedCount, - mutate = mutation::mutate, - mutateAsync = mutation::mutateAsync, - reset = mutation::reset - ) - - MutationStatus.Failure -> MutationErrorObject( - reply = reply, - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - errorUpdatedAt = errorUpdatedAt, - mutatedCount = mutatedCount, - mutate = mutation::mutate, - mutateAsync = mutation::mutateAsync, - reset = mutation::reset - ) + return with(config.mapper) { + config.strategy.collectAsState(mutation).toObject(mutation = mutation) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt index 3ce11d5..f350b36 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt @@ -14,15 +14,18 @@ import soil.query.core.Marker @Immutable data class MutationConfig internal constructor( val strategy: MutationStrategy, + val mapper: MutationObjectMapper, val marker: Marker ) { class Builder { var strategy: MutationStrategy = Default.strategy + var mapper: MutationObjectMapper = Default.mapper var marker: Marker = Default.marker fun build() = MutationConfig( strategy = strategy, + mapper = mapper, marker = marker ) } @@ -30,6 +33,7 @@ data class MutationConfig internal constructor( companion object { val Default = MutationConfig( strategy = MutationStrategy.Default, + mapper = MutationObjectMapper.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt index 2e73bcf..b3f679c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObject.kt @@ -48,7 +48,7 @@ sealed interface MutationObject : MutationModel { * @param S Type of the variable to be mutated. */ @Immutable -data class MutationIdleObject internal constructor( +data class MutationIdleObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -69,7 +69,7 @@ data class MutationIdleObject internal constructor( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationLoadingObject internal constructor( +data class MutationLoadingObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -90,7 +90,7 @@ data class MutationLoadingObject internal constructor( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationErrorObject internal constructor( +data class MutationErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, @@ -111,7 +111,7 @@ data class MutationErrorObject internal constructor( * @param S Type of the variable to be mutated. */ @Immutable -data class MutationSuccessObject internal constructor( +data class MutationSuccessObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObjectMapper.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObjectMapper.kt new file mode 100644 index 0000000..834003a --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationObjectMapper.kt @@ -0,0 +1,82 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.MutationRef +import soil.query.MutationState +import soil.query.MutationStatus + +/** + * A mapper that converts [MutationState] to [MutationObject]. + */ +interface MutationObjectMapper { + + /** + * Converts the given [MutationState] to [MutationObject]. + * + * @param mutation The mutation reference. + * @return The converted object. + */ + fun MutationState.toObject( + mutation: MutationRef, + ): MutationObject + + companion object +} + +/** + * The default [MutationObjectMapper]. + */ +val MutationObjectMapper.Companion.Default: MutationObjectMapper + get() = DefaultMutationObjectMapper + +private object DefaultMutationObjectMapper : MutationObjectMapper { + override fun MutationState.toObject( + mutation: MutationRef + ): MutationObject = when (status) { + MutationStatus.Idle -> MutationIdleObject( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + mutatedCount = mutatedCount, + mutate = mutation::mutate, + mutateAsync = mutation::mutateAsync, + reset = mutation::reset + ) + + MutationStatus.Pending -> MutationLoadingObject( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + mutatedCount = mutatedCount, + mutate = mutation::mutate, + mutateAsync = mutation::mutateAsync, + reset = mutation::reset + ) + + MutationStatus.Success -> MutationSuccessObject( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + mutatedCount = mutatedCount, + mutate = mutation::mutate, + mutateAsync = mutation::mutateAsync, + reset = mutation::reset + ) + + MutationStatus.Failure -> MutationErrorObject( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + mutatedCount = mutatedCount, + mutate = mutation::mutate, + mutateAsync = mutation::mutateAsync, + reset = mutation::reset + ) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt index ed49ef8..8647179 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationStrategy.kt @@ -28,9 +28,9 @@ interface MutationStrategy { * The default built-in strategy for Mutation built into the library. */ val MutationStrategy.Companion.Default: MutationStrategy - get() = MutationStrategyDefault + get() = DefaultMutationStrategy -private object MutationStrategyDefault : MutationStrategy { +private object DefaultMutationStrategy : MutationStrategy { @Composable override fun collectAsState(mutation: MutationRef): MutationState { return mutation.state.collectAsState().value diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index f3d6c26..cba2210 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -8,11 +8,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.QueryClient import soil.query.QueryKey -import soil.query.QueryRef -import soil.query.QueryState -import soil.query.QueryStatus -import soil.query.core.isNone -import soil.query.core.map /** * Remember a [QueryObject] and subscribes to the query state of [key]. @@ -31,7 +26,9 @@ fun rememberQuery( ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(query).toObject(query = query, select = { it }) + return with(config.mapper) { + config.strategy.collectAsState(query).toObject(query = query, select = { it }) + } } /** @@ -54,58 +51,7 @@ fun rememberQuery( ): QueryObject { val scope = rememberCoroutineScope() val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(query).toObject(query = query, select = select) -} - -private fun QueryState.toObject( - query: QueryRef, - select: (T) -> U -): QueryObject { - return when (status) { - QueryStatus.Pending -> QueryLoadingObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate - ) - - QueryStatus.Success -> QuerySuccessObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate - ) - - QueryStatus.Failure -> if (reply.isNone) { - QueryLoadingErrorObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - errorUpdatedAt = errorUpdatedAt, - staleAt = staleAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate - ) - } else { - QueryRefreshErrorObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - staleAt = staleAt, - errorUpdatedAt = errorUpdatedAt, - fetchStatus = fetchStatus, - isInvalidated = isInvalidated, - refresh = query::invalidate - ) - } + return with(config.mapper) { + config.strategy.collectAsState(query).toObject(query = query, select = select) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt index 0693688..ad05b5a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt @@ -15,15 +15,18 @@ import soil.query.core.Marker @Immutable data class QueryConfig internal constructor( val strategy: QueryStrategy, + val mapper: QueryObjectMapper, val marker: Marker ) { class Builder { var strategy: QueryStrategy = Default.strategy + var mapper: QueryObjectMapper = Default.mapper var marker: Marker = Default.marker fun build() = QueryConfig( strategy = strategy, + mapper = mapper, marker = marker ) } @@ -31,6 +34,7 @@ data class QueryConfig internal constructor( companion object { val Default = QueryConfig( strategy = QueryStrategy.Default, + mapper = QueryObjectMapper.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt index dc5c7b7..df2e828 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObject.kt @@ -38,7 +38,7 @@ sealed interface QueryObject : QueryModel { * @param T Type of data to retrieve. */ @Immutable -data class QueryLoadingObject internal constructor( +data class QueryLoadingObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -58,7 +58,7 @@ data class QueryLoadingObject internal constructor( * @param T Type of data to retrieve. */ @Immutable -data class QueryLoadingErrorObject internal constructor( +data class QueryLoadingErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, @@ -78,7 +78,7 @@ data class QueryLoadingErrorObject internal constructor( * @param T Type of data to retrieve. */ @Immutable -data class QuerySuccessObject internal constructor( +data class QuerySuccessObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -101,7 +101,7 @@ data class QuerySuccessObject internal constructor( * @constructor Creates a [QueryRefreshErrorObject]. */ @Immutable -data class QueryRefreshErrorObject internal constructor( +data class QueryRefreshErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObjectMapper.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObjectMapper.kt new file mode 100644 index 0000000..c6bbd77 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryObjectMapper.kt @@ -0,0 +1,89 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.QueryStatus +import soil.query.core.isNone +import soil.query.core.map + +/** + * A mapper that converts [QueryState] to [QueryObject]. + */ +interface QueryObjectMapper { + + /** + * Converts the given [QueryState] to [QueryObject]. + * + * @param query The query reference. + * @param select A function that selects the object from the reply. + * @return The converted object. + */ + fun QueryState.toObject( + query: QueryRef, + select: (T) -> U + ): QueryObject + + companion object +} + +/** + * The default [QueryObjectMapper]. + */ +val QueryObjectMapper.Companion.Default: QueryObjectMapper + get() = DefaultQueryObjectMapper + +private object DefaultQueryObjectMapper : QueryObjectMapper { + override fun QueryState.toObject( + query: QueryRef, + select: (T) -> U + ): QueryObject = when (status) { + QueryStatus.Pending -> QueryLoadingObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate + ) + + QueryStatus.Success -> QuerySuccessObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate + ) + + QueryStatus.Failure -> if (reply.isNone) { + QueryLoadingErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate + ) + } else { + QueryRefreshErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + staleAt = staleAt, + errorUpdatedAt = errorUpdatedAt, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated, + refresh = query::invalidate + ) + } + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt index 48da03e..1f3687c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt @@ -32,9 +32,9 @@ interface QueryStrategy { * The default built-in strategy for Query built into the library. */ val QueryStrategy.Companion.Default: QueryStrategy - get() = QueryStrategyDefault + get() = DefaultQueryStrategy -private object QueryStrategyDefault : QueryStrategy { +private object DefaultQueryStrategy : QueryStrategy { @Composable override fun collectAsState(query: QueryRef): QueryState { val state by query.state.collectAsState() diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt index 1f88b28..3d6e314 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt @@ -6,14 +6,9 @@ package soil.query.compose import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import soil.query.SubscriberStatus import soil.query.SubscriptionClient import soil.query.SubscriptionKey -import soil.query.SubscriptionRef -import soil.query.SubscriptionState -import soil.query.SubscriptionStatus import soil.query.annotation.ExperimentalSoilQueryApi -import soil.query.core.map /** * Remember a [SubscriptionObject] and subscribes to the subscription state of [key]. @@ -33,7 +28,9 @@ fun rememberSubscription( ): SubscriptionObject { val scope = rememberCoroutineScope() val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = { it }) + return with(config.mapper) { + config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = { it }) + } } /** @@ -57,56 +54,7 @@ fun rememberSubscription( ): SubscriptionObject { val scope = rememberCoroutineScope() val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } - return config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = select) -} - -private fun SubscriptionState.toObject( - subscription: SubscriptionRef, - select: (T) -> U -): SubscriptionObject { - return when (status) { - SubscriptionStatus.Pending -> if (subscriberStatus == SubscriberStatus.NoSubscribers) { - SubscriptionIdleObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - subscribe = subscription::resume, - unsubscribe = subscription::cancel, - reset = subscription::reset - ) - } else { - SubscriptionLoadingObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - subscribe = subscription::resume, - unsubscribe = subscription::cancel, - reset = subscription::reset - ) - } - - SubscriptionStatus.Success -> SubscriptionSuccessObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = error, - errorUpdatedAt = errorUpdatedAt, - subscriberStatus = subscriberStatus, - subscribe = subscription::resume, - unsubscribe = subscription::cancel, - reset = subscription::reset - ) - - SubscriptionStatus.Failure -> SubscriptionErrorObject( - reply = reply.map(select), - replyUpdatedAt = replyUpdatedAt, - error = checkNotNull(error), - errorUpdatedAt = errorUpdatedAt, - subscriberStatus = subscriberStatus, - subscribe = subscription::resume, - unsubscribe = subscription::cancel, - reset = subscription::reset - ) + return with(config.mapper) { + config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = select) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt index c7d7375..960fc57 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt @@ -15,15 +15,18 @@ import soil.query.core.Marker @Immutable data class SubscriptionConfig internal constructor( val strategy: SubscriptionStrategy, + val mapper: SubscriptionObjectMapper, val marker: Marker ) { class Builder { var strategy: SubscriptionStrategy = SubscriptionStrategy.Default + var mapper: SubscriptionObjectMapper = SubscriptionObjectMapper.Default var marker: Marker = Default.marker fun build() = SubscriptionConfig( strategy = strategy, + mapper = mapper, marker = marker ) } @@ -31,6 +34,7 @@ data class SubscriptionConfig internal constructor( companion object { val Default = SubscriptionConfig( strategy = SubscriptionStrategy.Default, + mapper = SubscriptionObjectMapper.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt index 01623dc..12ea428 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObject.kt @@ -50,7 +50,7 @@ sealed interface SubscriptionObject : SubscriptionModel { * @param T Type of data to receive. */ @Immutable -data class SubscriptionIdleObject internal constructor( +data class SubscriptionIdleObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -72,7 +72,7 @@ data class SubscriptionIdleObject internal constructor( * @param T Type of data to receive. */ @Immutable -data class SubscriptionLoadingObject internal constructor( +data class SubscriptionLoadingObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, @@ -95,7 +95,7 @@ data class SubscriptionLoadingObject internal constructor( * @param T Type of data to receive. */ @Immutable -data class SubscriptionErrorObject internal constructor( +data class SubscriptionErrorObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable, @@ -115,7 +115,7 @@ data class SubscriptionErrorObject internal constructor( * @param T Type of data to receive. */ @Immutable -data class SubscriptionSuccessObject internal constructor( +data class SubscriptionSuccessObject( override val reply: Reply, override val replyUpdatedAt: Long, override val error: Throwable?, diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObjectMapper.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObjectMapper.kt new file mode 100644 index 0000000..38a90f9 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionObjectMapper.kt @@ -0,0 +1,87 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.SubscriberStatus +import soil.query.SubscriptionRef +import soil.query.SubscriptionState +import soil.query.SubscriptionStatus +import soil.query.core.map + +/** + * A mapper that converts [SubscriptionState] to [SubscriptionObject]. + */ +interface SubscriptionObjectMapper { + + /** + * Converts the given [SubscriptionState] to [SubscriptionObject]. + * + * @param subscription The subscription reference. + * @param select A function that selects the object from the reply. + * @return The converted object. + */ + fun SubscriptionState.toObject( + subscription: SubscriptionRef, + select: (T) -> U + ): SubscriptionObject + + companion object +} + +/** + * The default [SubscriptionObjectMapper]. + */ +val SubscriptionObjectMapper.Companion.Default: SubscriptionObjectMapper + get() = DefaultSubscriptionObjectMapper + +private object DefaultSubscriptionObjectMapper : SubscriptionObjectMapper { + override fun SubscriptionState.toObject( + subscription: SubscriptionRef, + select: (T) -> U + ): SubscriptionObject = when (status) { + SubscriptionStatus.Pending -> if (subscriberStatus == SubscriberStatus.NoSubscribers) { + SubscriptionIdleObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } else { + SubscriptionLoadingObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } + + SubscriptionStatus.Success -> SubscriptionSuccessObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + subscriberStatus = subscriberStatus, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + + SubscriptionStatus.Failure -> SubscriptionErrorObject( + reply = reply.map(select), + replyUpdatedAt = replyUpdatedAt, + error = checkNotNull(error), + errorUpdatedAt = errorUpdatedAt, + subscriberStatus = subscriberStatus, + subscribe = subscription::resume, + unsubscribe = subscription::cancel, + reset = subscription::reset + ) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt index d2ee495..823894a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt @@ -30,9 +30,9 @@ interface SubscriptionStrategy { * The default built-in strategy for Subscription built into the library. */ val SubscriptionStrategy.Companion.Default: SubscriptionStrategy - get() = SubscriptionStrategyDefault + get() = DefaultSubscriptionStrategy -private object SubscriptionStrategyDefault : SubscriptionStrategy { +private object DefaultSubscriptionStrategy : SubscriptionStrategy { @Composable override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { val state by subscription.state.collectAsState() @@ -49,9 +49,9 @@ private object SubscriptionStrategyDefault : SubscriptionStrategy { // Android, it works only with Compose UI 1.7.0-alpha05 or above. // Therefore, we will postpone adding this code until a future release. //val SubscriptionStrategy.Companion.Lifecycle: SubscriptionStrategy -// get() = SubscriptionStrategyLifecycle +// get() = LifecycleSubscriptionStrategy // -//private object SubscriptionStrategyLifecycle : SubscriptionStrategy { +//private object LifecycleSubscriptionStrategy : SubscriptionStrategy { // @Composable // override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { // val state by subscription.state.collectAsStateWithLifecycle() diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index 5515c88..e42e69b 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -45,6 +45,7 @@ class InfiniteQueryComposableTest : UnitTest() { SwrClientProvider(client) { val query = rememberInfiniteQuery(key, config = InfiniteQueryConfig { strategy = InfiniteQueryStrategy.Default + mapper = InfiniteQueryObjectMapper.Default marker = Marker.None }) when (val reply = query.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt new file mode 100644 index 0000000..ee58dad --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt @@ -0,0 +1,180 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.QueryChunk +import soil.query.QueryFetchStatus +import soil.query.QueryState +import soil.query.QueryStatus +import soil.query.buildInfiniteQueryKey +import soil.query.chunkedData +import soil.query.compose.tooling.QueryPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class InfiniteQueryObjectMapperTest : UnitTest() { + + @Test + fun testToObject_loading() { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { QueryState.initial() } + } + ) + val query = client.getInfiniteQuery(key) + val actual = with(InfiniteQueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it.chunkedData }) + } + assertTrue(actual is InfiniteQueryLoadingObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(0, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(query::loadMore, actual.loadMore) + assertNull(actual.loadMoreParam) + assertEquals(QueryStatus.Pending, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_success() { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.success(buildList { + add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) + add(QueryChunk((10 until 20).map { "Item $it" }, PageParam(1, 10))) + add(QueryChunk((20 until 30).map { "Item $it" }, PageParam(2, 10))) + }, dataUpdatedAt = 300, dataStaleAt = 400) + } + } + ) + val query = client.getInfiniteQuery(key) + val actual = with(InfiniteQueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it.chunkedData }) + } + assertTrue(actual is InfiniteQuerySuccessObject) + assertEquals(30, actual.reply.getOrThrow().size) + assertEquals(300, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(400, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(query::loadMore, actual.loadMore) + assertEquals(PageParam(3, 10), actual.loadMoreParam) + assertEquals(QueryStatus.Success, actual.status) + assertNotNull(actual.data) + } + + @Test + fun testToObject_loadingError() { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 200 + ) + } + } + ) + val query = client.getInfiniteQuery(key) + val actual = with(InfiniteQueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it.chunkedData }) + } + assertTrue(actual is InfiniteQueryLoadingErrorObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(200, actual.errorUpdatedAt) + assertEquals(0, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(query::loadMore, actual.loadMore) + assertNull(actual.loadMoreParam) + assertEquals(QueryStatus.Failure, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_refreshError() { + val key = TestInfiniteQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 600, + data = buildList { + add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) + add(QueryChunk((10 until 20).map { "Item $it" }, PageParam(1, 10))) + add(QueryChunk((20 until 30).map { "Item $it" }, PageParam(2, 10))) + }, + dataUpdatedAt = 300, + dataStaleAt = 400 + ) + } + } + ) + val query = client.getInfiniteQuery(key) + val actual = with(InfiniteQueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it.chunkedData }) + } + assertTrue(actual is InfiniteQueryRefreshErrorObject) + assertEquals(30, actual.reply.getOrThrow().size) + assertEquals(300, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(600, actual.errorUpdatedAt) + assertEquals(400, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(query::loadMore, actual.loadMore) + assertEquals(PageParam(3, 10), actual.loadMoreParam) + assertEquals(QueryStatus.Failure, actual.status) + assertNotNull(actual.data) + } + + private class TestInfiniteQueryKey : InfiniteQueryKey, PageParam> by buildInfiniteQueryKey( + id = Id, + fetch = { param -> + val startPosition = param.page * param.size + (startPosition.., PageParam>("test/infinite-query") + } + + private data class PageParam( + val page: Int, + val size: Int + ) +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt index 9cfd475..497ca82 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -40,6 +40,7 @@ class MutationComposableTest : UnitTest() { SwrClientProvider(client) { val mutation = rememberMutation(key, config = MutationConfig { strategy = MutationStrategy.Default + mapper = MutationObjectMapper.Default marker = Marker.None }) when (val reply = mutation.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationObjectMapperTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationObjectMapperTest.kt new file mode 100644 index 0000000..aa0accc --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationObjectMapperTest.kt @@ -0,0 +1,177 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.MutationKey +import soil.query.MutationState +import soil.query.MutationStatus +import soil.query.buildMutationKey +import soil.query.compose.tooling.MutationPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MutationObjectMapperTest : UnitTest() { + + @Test + fun testToObject_idle() { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutation = MutationPreviewClient { + on(key.id) { MutationState.initial() } + } + ) + val mutation = client.getMutation(key) + val actual = with(MutationObjectMapper.Default) { + mutation.state.value.toObject(mutation = mutation) + } + assertTrue(actual is MutationIdleObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(0, actual.mutatedCount) + assertEquals(mutation::mutate, actual.mutate) + assertEquals(mutation::mutateAsync, actual.mutateAsync) + assertEquals(mutation::reset, actual.reset) + assertEquals(MutationStatus.Idle, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_pending() { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutation = MutationPreviewClient { + on(key.id) { MutationState.pending() } + } + ) + val mutation = client.getMutation(key) + val actual = with(MutationObjectMapper.Default) { + mutation.state.value.toObject(mutation = mutation) + } + assertTrue(actual is MutationLoadingObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(0, actual.mutatedCount) + assertEquals(mutation::mutate, actual.mutate) + assertEquals(mutation::mutateAsync, actual.mutateAsync) + assertEquals(mutation::reset, actual.reset) + assertEquals(MutationStatus.Pending, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_success() { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutation = MutationPreviewClient { + on(key.id) { + MutationState.success( + data = "Hello, Mutation!", + dataUpdatedAt = 100, + mutatedCount = 1 + ) + } + } + ) + val mutation = client.getMutation(key) + val actual = with(MutationObjectMapper.Default) { + mutation.state.value.toObject(mutation = mutation) + } + assertTrue(actual is MutationSuccessObject) + assertEquals("Hello, Mutation!", actual.reply.getOrThrow()) + assertEquals(100, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(1, actual.mutatedCount) + assertEquals(mutation::mutate, actual.mutate) + assertEquals(mutation::mutateAsync, actual.mutateAsync) + assertEquals(mutation::reset, actual.reset) + assertEquals(MutationStatus.Success, actual.status) + assertNotNull(actual.data) + } + + @Test + fun testToObject_error() { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutation = MutationPreviewClient { + on(key.id) { + MutationState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 200 + ) + } + } + ) + val mutation = client.getMutation(key) + val actual = with(MutationObjectMapper.Default) { + mutation.state.value.toObject(mutation = mutation) + } + assertTrue(actual is MutationErrorObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(200, actual.errorUpdatedAt) + assertEquals(0, actual.mutatedCount) + assertEquals(mutation::mutate, actual.mutate) + assertEquals(mutation::mutateAsync, actual.mutateAsync) + assertEquals(mutation::reset, actual.reset) + assertEquals(MutationStatus.Failure, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_errorWithData() { + val key = TestMutationKey() + val client = SwrPreviewClient( + mutation = MutationPreviewClient { + on(key.id) { + MutationState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 200, + data = "Hello, Mutation!", + dataUpdatedAt = 100, + mutatedCount = 1 + ) + } + } + ) + val mutation = client.getMutation(key) + val actual = with(MutationObjectMapper.Default) { + mutation.state.value.toObject(mutation = mutation) + } + assertTrue(actual is MutationErrorObject) + assertEquals("Hello, Mutation!", actual.reply.getOrThrow()) + assertEquals(100, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(200, actual.errorUpdatedAt) + assertEquals(1, actual.mutatedCount) + assertEquals(mutation::mutate, actual.mutate) + assertEquals(mutation::mutateAsync, actual.mutateAsync) + assertEquals(mutation::reset, actual.reset) + assertEquals(MutationStatus.Failure, actual.status) + assertNotNull(actual.data) + } + + private class TestMutationKey : MutationKey by buildMutationKey( + mutate = { form -> + "${form.name} - ${form.age}" + } + ) + + private data class TestForm( + val name: String, + val age: Int + ) +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index 85682cb..fdce02d 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -37,6 +37,7 @@ class QueryComposableTest : UnitTest() { SwrClientProvider(client) { val query = rememberQuery(key, config = QueryConfig { strategy = QueryStrategy.Default + mapper = QueryObjectMapper.Default marker = Marker.None }) when (val reply = query.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryObjectMapperTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryObjectMapperTest.kt new file mode 100644 index 0000000..ae06a3f --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryObjectMapperTest.kt @@ -0,0 +1,151 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.QueryFetchStatus +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryState +import soil.query.QueryStatus +import soil.query.buildQueryKey +import soil.query.compose.tooling.QueryPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class QueryObjectMapperTest : UnitTest() { + + @Test + fun testToObject_loading() { + val key = TestQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { QueryState.initial() } + } + ) + val query = client.getQuery(key) + val actual = with(QueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it }) + } + assertTrue(actual is QueryLoadingObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(0, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(QueryStatus.Pending, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_success() { + val key = TestQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.success( + data = "Hello, Soil!", + dataUpdatedAt = 400, + dataStaleAt = 500 + ) + } + } + ) + val query = client.getQuery(key) + val actual = with(QueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it }) + } + assertTrue(actual is QuerySuccessObject) + assertEquals("Hello, Soil!", actual.reply.getOrThrow()) + assertEquals(400, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(500, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(QueryStatus.Success, actual.status) + assertNotNull(actual.data) + } + + @Test + fun testToObject_loadingError() { + val key = TestQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.failure( + error = IllegalStateException("Test"), + errorUpdatedAt = 300 + ) + } + } + ) + val query = client.getQuery(key) + val actual = with(QueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it }) + } + assertTrue(actual is QueryLoadingErrorObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(300, actual.errorUpdatedAt) + assertEquals(0, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(QueryStatus.Failure, actual.status) + assertNull(actual.data) + } + + @Test + fun testToObject_refreshError() { + val key = TestQueryKey() + val client = SwrPreviewClient( + query = QueryPreviewClient { + on(key.id) { + QueryState.failure( + error = IllegalStateException("Test"), + errorUpdatedAt = 600, + data = "Hello, Soil!", + dataUpdatedAt = 400, + dataStaleAt = 500 + ) + } + } + ) + val query = client.getQuery(key) + val actual = with(QueryObjectMapper.Default) { + query.state.value.toObject(query = query, select = { it }) + } + assertTrue(actual is QueryRefreshErrorObject) + assertEquals("Hello, Soil!", actual.reply.getOrThrow()) + assertEquals(400, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(600, actual.errorUpdatedAt) + assertEquals(500, actual.staleAt) + assertEquals(QueryFetchStatus.Idle, actual.fetchStatus) + assertFalse(actual.isInvalidated) + assertEquals(query::invalidate, actual.refresh) + assertEquals(QueryStatus.Failure, actual.status) + assertNotNull(actual.data) + } + + private class TestQueryKey : QueryKey by buildQueryKey( + id = Id, + fetch = { "Hello, Soil!" } + ) { + object Id : QueryId("test/query") + } +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt index ccff34e..2e02f95 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt @@ -41,6 +41,7 @@ class SubscriptionComposableTest : UnitTest() { SwrClientProvider(client) { val subscription = rememberSubscription(key, config = SubscriptionConfig { strategy = SubscriptionStrategy.Default + mapper = SubscriptionObjectMapper.Default marker = Marker.None }) when (val reply = subscription.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionObjectMapperTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionObjectMapperTest.kt new file mode 100644 index 0000000..74bc772 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionObjectMapperTest.kt @@ -0,0 +1,179 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import kotlinx.coroutines.flow.MutableStateFlow +import soil.query.SubscriberStatus +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionState +import soil.query.SubscriptionStatus +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.buildSubscriptionKey +import soil.query.compose.tooling.SubscriptionPreviewClient +import soil.query.compose.tooling.SwrPreviewClient +import soil.query.core.getOrThrow +import soil.query.core.isNone +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalSoilQueryApi::class) +class SubscriptionObjectMapperTest : UnitTest() { + + @Test + fun testToObject_idle() { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { SubscriptionState.initial() } + } + ) + val subscription = client.getSubscription(key) + val actual = with(SubscriptionObjectMapper.Default) { + subscription.state.value.toObject(subscription = subscription, select = { it }) + } + assertTrue(actual is SubscriptionIdleObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(subscription::resume, actual.subscribe) + assertEquals(subscription::cancel, actual.unsubscribe) + assertEquals(subscription::reset, actual.reset) + assertEquals(SubscriptionStatus.Pending, actual.status) + assertEquals(SubscriberStatus.NoSubscribers, actual.subscriberStatus) + assertNull(actual.data) + } + + @Test + fun testToObject_loading() { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { SubscriptionState.initial(subscriberStatus = SubscriberStatus.Active) } + } + ) + val subscription = client.getSubscription(key) + val actual = with(SubscriptionObjectMapper.Default) { + subscription.state.value.toObject(subscription = subscription, select = { it }) + } + assertTrue(actual is SubscriptionLoadingObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(subscription::resume, actual.subscribe) + assertEquals(subscription::cancel, actual.unsubscribe) + assertEquals(subscription::reset, actual.reset) + assertEquals(SubscriptionStatus.Pending, actual.status) + assertEquals(SubscriberStatus.Active, actual.subscriberStatus) + assertNull(actual.data) + } + + @Test + fun testToObject_success() { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { + SubscriptionState.success( + data = "Hello, Subscription!", + dataUpdatedAt = 400, + subscriberStatus = SubscriberStatus.Active + ) + } + } + ) + val subscription = client.getSubscription(key) + val actual = with(SubscriptionObjectMapper.Default) { + subscription.state.value.toObject(subscription = subscription, select = { it }) + } + assertTrue(actual is SubscriptionSuccessObject) + assertEquals("Hello, Subscription!", actual.reply.getOrThrow()) + assertEquals(400, actual.replyUpdatedAt) + assertNull(actual.error) + assertEquals(0, actual.errorUpdatedAt) + assertEquals(subscription::resume, actual.subscribe) + assertEquals(subscription::cancel, actual.unsubscribe) + assertEquals(subscription::reset, actual.reset) + assertEquals(SubscriptionStatus.Success, actual.status) + assertEquals(SubscriberStatus.Active, actual.subscriberStatus) + assertNotNull(actual.data) + } + + @Test + fun testToObject_error() { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { + SubscriptionState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 500, + subscriberStatus = SubscriberStatus.Active + ) + } + } + ) + val subscription = client.getSubscription(key) + val actual = with(SubscriptionObjectMapper.Default) { + subscription.state.value.toObject(subscription = subscription, select = { it }) + } + assertTrue(actual is SubscriptionErrorObject) + assertTrue(actual.reply.isNone) + assertEquals(0, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(500, actual.errorUpdatedAt) + assertEquals(subscription::resume, actual.subscribe) + assertEquals(subscription::cancel, actual.unsubscribe) + assertEquals(subscription::reset, actual.reset) + assertEquals(SubscriptionStatus.Failure, actual.status) + assertEquals(SubscriberStatus.Active, actual.subscriberStatus) + assertNull(actual.data) + } + + @Test + fun testToObject_errorWithData() { + val key = TestSubscriptionKey() + val client = SwrPreviewClient( + subscription = SubscriptionPreviewClient { + on(key.id) { + SubscriptionState.failure( + error = RuntimeException("Error"), + errorUpdatedAt = 500, + data = "Hello, Subscription!", + dataUpdatedAt = 400, + subscriberStatus = SubscriberStatus.Active + ) + } + } + ) + val subscription = client.getSubscription(key) + val actual = with(SubscriptionObjectMapper.Default) { + subscription.state.value.toObject(subscription = subscription, select = { it }) + } + assertTrue(actual is SubscriptionErrorObject) + assertEquals("Hello, Subscription!", actual.reply.getOrThrow()) + assertEquals(400, actual.replyUpdatedAt) + assertNotNull(actual.error) + assertEquals(500, actual.errorUpdatedAt) + assertEquals(subscription::resume, actual.subscribe) + assertEquals(subscription::cancel, actual.unsubscribe) + assertEquals(subscription::reset, actual.reset) + assertEquals(SubscriptionStatus.Failure, actual.status) + assertEquals(SubscriberStatus.Active, actual.subscriberStatus) + assertNotNull(actual.data) + } + + private class TestSubscriptionKey : SubscriptionKey by buildSubscriptionKey( + id = Id, + subscribe = { MutableStateFlow("Hello, Soil!") } + ) { + object Id : SubscriptionId("test/subscription") + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index 3dd1462..f70a006 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -71,5 +71,31 @@ data class MutationState internal constructor( status = MutationStatus.Failure ) } + + /** + * Creates a new [MutationState] with the [MutationStatus.Failure] status. + * + * @param error The error that occurred. + * @param errorUpdatedAt The timestamp when the error occurred. Default is the current epoch. + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param mutatedCount The number of times the data was mutated. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch(), + data: T, + dataUpdatedAt: Long = epoch(), + mutatedCount: Int = 1 + ): MutationState { + return MutationState( + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + status = MutationStatus.Failure, + mutatedCount = mutatedCount + ) + } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt index ac707a3..2191931 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt @@ -72,5 +72,31 @@ data class SubscriptionState internal constructor( subscriberStatus = subscriberStatus ) } + + /** + * Creates a new [SubscriptionState] with the [SubscriptionStatus.Failure] status. + * + * @param error The error to be stored in the state. + * @param errorUpdatedAt The timestamp when the error was updated. Default is the current epoch. + * @param data The data to be stored in the state. + * @param dataUpdatedAt The timestamp when the data was updated. Default is the current epoch. + * @param subscriberStatus The status of the subscriber. + */ + fun failure( + error: Throwable, + errorUpdatedAt: Long = epoch(), + data: T, + dataUpdatedAt: Long = epoch(), + subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + return SubscriptionState( + reply = Reply(data), + replyUpdatedAt = dataUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + status = SubscriptionStatus.Failure, + subscriberStatus = subscriberStatus + ) + } } } From 4dbf6191a8b1d8e462a82708a81cc81521d46ce5 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 8 Sep 2024 09:40:41 +0900 Subject: [PATCH 143/155] Update workflow for build job --- .github/ci-gradle.properties | 6 +++ .github/workflows/check-pr.yml | 83 ++++++++++++++++++++++++---------- gradle.properties | 2 +- 3 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 .github/ci-gradle.properties diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 0000000..ea093bd --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,6 @@ +# Linux +# - CPU: 4 cores +# - Memory: 16 GB +# https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories +# +org.gradle.jvmargs=-Xmx12g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml index 6117f2a..02bdbf1 100644 --- a/.github/workflows/check-pr.yml +++ b/.github/workflows/check-pr.yml @@ -2,9 +2,11 @@ name: Check PR on: pull_request: - branches: [ "main" ] + branches: ["main"] paths-ignore: - "docs/**" + - "art/**" + - "*.md" concurrency: group: pull_request-${{ github.ref }} @@ -19,25 +21,60 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: zulu - java-version: 17 - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} - - - name: Apply Spotless rules - run: ./gradlew spotlessApply - - - name: Commit newly formatted files - uses: stefanzweifel/git-auto-commit-action@v5 - with: - file_pattern: "**/*.kt **/*.gradle.kts **/*.yml" + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Setup java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Apply spotless rules + run: ./gradlew spotlessApply + + - name: Commit newly formatted files + uses: stefanzweifel/git-auto-commit-action@v5 + with: + file_pattern: "**/*.kt **/*.gradle.kts **/*.yml" + + build: + strategy: + matrix: + os: [ macos-latest, ubuntu-latest ] + job: [ test ] + + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Copy CI gradle.properties + if: matrix.os == 'ubuntu-latest' && matrix.job == 'test' + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Setup java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup gradle + uses: gradle/actions/setup-gradle@v3 + + # macos-latest + - name: Run tests on macos + if: matrix.os == 'macos-latest' && matrix.job == 'test' + run: ./gradlew iosX64Test + + # ubuntu-latest + - name: Run tests on linux + if: matrix.os == 'ubuntu-latest' && matrix.job == 'test' + run: | + ./gradlew testDebugUnitTest desktopTest wasmJsTest diff --git a/gradle.properties b/gradle.properties index 54f8b28..1cb3436 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official #Gradle -org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 org.gradle.caching=true org.gradle.parallel=true #Android From b985c9c7d235dd048bde8748940126018cc91e3d Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 8 Sep 2024 11:05:28 +0900 Subject: [PATCH 144/155] Create release check github action workflow --- .github/workflows/check-release.yml | 30 +++++++++++++++++++++++++++++ codecov.yml | 5 +++++ 2 files changed, 35 insertions(+) create mode 100644 .github/workflows/check-release.yml create mode 100644 codecov.yml diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml new file mode 100644 index 0000000..c7ccef7 --- /dev/null +++ b/.github/workflows/check-release.yml @@ -0,0 +1,30 @@ +name: Check Release PR +on: + workflow_dispatch: + +jobs: + check: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Run tests with code coverage + run: ./gradlew koverXmlReport + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./build/reports/kover/report.xml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f698adf --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +coverage: + status: + project: off + patch: off + \ No newline at end of file From 3c03f6aaa96a5c62368bcef2ca8357369dbe430d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 8 Sep 2024 02:20:39 +0000 Subject: [PATCH 145/155] Bump up version to 1.0.0-alpha05 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1cb3436..e173d92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ kotlin.mpp.stability.nowarn=true development=true #Product group=com.soil-kt.soil -version=1.0.0-alpha04 +version=1.0.0-alpha05 androidCompileSdk=34 androidTargetSdk=34 androidMinSdk=23 From e56df2a66f737923f3232d69ce6ac184af9e0551 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 8 Sep 2024 12:29:57 +0900 Subject: [PATCH 146/155] Update README --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6118849..f4ec500 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![Soil](art/Logo.svg) [![Release](https://img.shields.io/maven-central/v/com.soil-kt.soil/query-core?style=for-the-badge&color=62CC6A)](https://github.com/soil-kt/soil) -[![Kotlin](https://img.shields.io/badge/Kotlin-1.9.23-blue.svg?style=for-the-badge&logo=kotlin)](https://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.0.20-blue.svg?style=for-the-badge&logo=kotlin)](https://kotlinlang.org) # Compose-First Power Packs @@ -35,11 +35,23 @@ Soil is available on `mavenCentral()`. ```kts dependencies { - val soil = "1.0.0-alpha04" + val soil = "1.0.0-alpha05" + + // Query implementation("com.soil-kt.soil:query-core:$soil") + // Query utilities for Compose implementation("com.soil-kt.soil:query-compose:$soil") + // optional - helpers for Compose implementation("com.soil-kt.soil:query-compose-runtime:$soil") + // optional - receivers for Ktor (3.0.0-beta-2) + implementation("com.soil-kt.soil:query-receivers-ktor:$soil") + // optional - Test helpers + testImplementation("com.soil-kt.soil:query-test:$soil") + + // Form implementation("com.soil-kt.soil:form:$soil") + + // Space implementation("com.soil-kt.soil:space:$soil") } ``` From 0a9a3a457e997e48ec82eb52e9a20b582ac8cc16 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 15 Sep 2024 15:53:46 +0900 Subject: [PATCH 147/155] Introducing Combined Query To improve the efficiency of Composable functions that define multiple keys in parallel, we have implemented a new feature called Combined Query. This feature optimizes the recomposition process by treating multiple query keys as a single query, but only when a composite type can be defined from these keys. By using the Combined Query class, the number of recompositions can be significantly reduced to the same level as a single query. This ensures that the performance of Composable functions remains high, even when working with multiple keys simultaneously. --- .../soil/query/compose/CachingStrategy.kt | 8 +- .../query/compose/InfiniteQueryComposable.kt | 4 +- .../compose/InfiniteQueryObjectMapper.kt | 7 +- .../query/compose/InfiniteQueryStrategy.kt | 2 +- .../soil/query/compose/MutationComposable.kt | 2 +- .../soil/query/compose/QueryComposable.kt | 112 +++++++++++ .../soil/query/compose/QueryStrategy.kt | 2 +- .../query/compose/SubscriptionComposable.kt | 4 +- .../query/compose/SubscriptionStrategy.kt | 25 ++- .../query/compose/internal/CombinedQuery2.kt | 67 +++++++ .../query/compose/internal/CombinedQuery3.kt | 71 +++++++ .../query/compose/internal/CombinedQueryN.kt | 61 ++++++ .../compose/tooling/MutationPreviewClient.kt | 13 +- .../compose/tooling/QueryPreviewClient.kt | 20 +- .../tooling/SubscriptionPreviewClient.kt | 10 +- .../compose/InfiniteQueryComposableTest.kt | 3 +- .../compose/InfiniteQueryObjectMapperTest.kt | 3 +- .../soil/query/compose/QueryComposableTest.kt | 79 +++++++- .../kotlin/soil/query/InfiniteQueryRef.kt | 74 ++++---- .../commonMain/kotlin/soil/query/Mutation.kt | 5 - .../kotlin/soil/query/MutationRef.kt | 57 +++--- .../src/commonMain/kotlin/soil/query/Query.kt | 5 - .../kotlin/soil/query/QueryChunk.kt | 12 ++ .../commonMain/kotlin/soil/query/QueryRef.kt | 54 +++--- .../kotlin/soil/query/QueryStateMerger.kt | 106 +++++++++++ .../kotlin/soil/query/Subscription.kt | 5 - .../kotlin/soil/query/SubscriptionOptions.kt | 11 -- .../kotlin/soil/query/SubscriptionRef.kt | 45 ++--- .../commonMain/kotlin/soil/query/SwrCache.kt | 10 +- .../kotlin/soil/query/SwrCachePlus.kt | 2 +- .../kotlin/soil/query/QueryStateMergerTest.kt | 177 ++++++++++++++++++ .../soil/query/SubscriptionOptionsTest.kt | 6 - .../soil/query/test/TestSwrClientTest.kt | 24 +-- 33 files changed, 844 insertions(+), 242 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt create mode 100644 soil-query-core/src/commonMain/kotlin/soil/query/QueryStateMerger.kt create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/QueryStateMergerTest.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt index bb61533..65c6a2e 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/CachingStrategy.kt @@ -51,12 +51,12 @@ sealed interface CachingStrategy { data object CacheFirst : CachingStrategy, QueryStrategy, InfiniteQueryStrategy { @Composable override fun collectAsState(query: QueryRef): QueryState { - return collectAsState(query.key.id, query.state, query::resume) + return collectAsState(query.id, query.state, query::resume) } @Composable override fun collectAsState(query: InfiniteQueryRef): QueryState> { - return collectAsState(query.key.id, query.state, query::resume) + return collectAsState(query.id, query.state, query::resume) } @Composable @@ -81,12 +81,12 @@ sealed interface CachingStrategy { data object NetworkFirst : CachingStrategy, QueryStrategy, InfiniteQueryStrategy { @Composable override fun collectAsState(query: QueryRef): QueryState { - return collectAsState(query.key.id, query.state, query::resume) + return collectAsState(query.id, query.state, query::resume) } @Composable override fun collectAsState(query: InfiniteQueryRef): QueryState> { - return collectAsState(query.key.id, query.state, query::resume) + return collectAsState(query.id, query.state, query::resume) } @Composable diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 919cdd7..16574b5 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -27,7 +27,7 @@ fun rememberInfiniteQuery( client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() - val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = { it }) } @@ -52,7 +52,7 @@ fun rememberInfiniteQuery( client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = select) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt index bd75e78..feb6cf6 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryObjectMapper.kt @@ -7,9 +7,10 @@ import soil.query.InfiniteQueryRef import soil.query.QueryChunks import soil.query.QueryState import soil.query.QueryStatus -import soil.query.core.getOrThrow +import soil.query.core.getOrElse import soil.query.core.isNone import soil.query.core.map +import soil.query.emptyChunks /** * A mapper that converts [QueryState] to [InfiniteQueryObject]. @@ -65,7 +66,7 @@ private object DefaultInfiniteQueryObjectMapper : InfiniteQueryObjectMapper { isInvalidated = isInvalidated, refresh = query::invalidate, loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) + loadMoreParam = query.nextParam(reply.getOrElse { emptyChunks() }) ) QueryStatus.Failure -> if (reply.isNone) { @@ -92,7 +93,7 @@ private object DefaultInfiniteQueryObjectMapper : InfiniteQueryObjectMapper { isInvalidated = isInvalidated, refresh = query::invalidate, loadMore = query::loadMore, - loadMoreParam = query.key.loadMoreParam(reply.getOrThrow()) + loadMoreParam = query.nextParam(reply.getOrElse { emptyChunks() }) ) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt index 822616e..cf36324 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryStrategy.kt @@ -39,7 +39,7 @@ private object DefaultInfiniteQueryStrategy : InfiniteQueryStrategy { @Composable override fun collectAsState(query: InfiniteQueryRef): QueryState> { val state by query.state.collectAsState() - LaunchedEffect(query.key.id) { + LaunchedEffect(query.id) { query.resume() } return state diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index b452f6d..e55ae0f 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -26,7 +26,7 @@ fun rememberMutation( client: MutationClient = LocalMutationClient.current ): MutationObject { val scope = rememberCoroutineScope() - val mutation = remember(key) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } + val mutation = remember(key.id) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } return with(config.mapper) { config.strategy.collectAsState(mutation).toObject(mutation = mutation) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index cba2210..d686033 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -4,10 +4,12 @@ package soil.query.compose import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.QueryClient import soil.query.QueryKey +import soil.query.compose.internal.combineQuery /** * Remember a [QueryObject] and subscribes to the query state of [key]. @@ -55,3 +57,113 @@ fun rememberQuery( config.strategy.collectAsState(query).toObject(query = query, select = select) } } + +/** + * Remember a [QueryObject] and subscribes to the query state of [key1] and [key2]. + * + * @param T1 Type of data to retrieve from [key1]. + * @param T2 Type of data to retrieve from [key2]. + * @param R Type of data to transform. + * @param key1 The [QueryKey] for managing [query][soil.query.Query]. + * @param key2 The [QueryKey] for managing [query][soil.query.Query]. + * @param transform A function to transform [T1] and [T2] into [R]. + * @param config The configuration for the query. By default, it uses the [QueryConfig.Default]. + * @param client The [QueryClient] to resolve [key1] and [key2]. By default, it uses the [LocalQueryClient]. + * @return A [QueryObject] with transformed data each the query state changed. + */ +@Composable +fun rememberQuery( + key1: QueryKey, + key2: QueryKey, + transform: (T1, T2) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current, +): QueryObject { + val scope = rememberCoroutineScope() + val query1 = remember(key1.id) { client.getQuery(key1, config.marker).also { it.launchIn(scope) } } + val query2 = remember(key2.id) { client.getQuery(key2, config.marker).also { it.launchIn(scope) } } + val combinedQuery = remember(query1, query2) { + combineQuery(query1, query2, transform) + } + DisposableEffect(combinedQuery.id) { + val job = combinedQuery.launchIn(scope) + onDispose { job.cancel() } + } + return with(config.mapper) { + config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + } +} + +/** + * Remember a [QueryObject] and subscribes to the query state of [key1], [key2], and [key3]. + * + * @param T1 Type of data to retrieve from [key1]. + * @param T2 Type of data to retrieve from [key2]. + * @param T3 Type of data to retrieve from [key3]. + * @param R Type of data to transform. + * @param key1 The [QueryKey] for managing [query][soil.query.Query]. + * @param key2 The [QueryKey] for managing [query][soil.query.Query]. + * @param key3 The [QueryKey] for managing [query][soil.query.Query]. + * @param transform A function to transform [T1], [T2], and [T3] into [R]. + * @param config The configuration for the query. By default, it uses the [QueryConfig.Default]. + * @param client The [QueryClient] to resolve [key1], [key2], and [key3]. By default, it uses the [LocalQueryClient]. + * @return A [QueryObject] with transformed data each the query state changed. + */ +@Composable +fun rememberQuery( + key1: QueryKey, + key2: QueryKey, + key3: QueryKey, + transform: (T1, T2, T3) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current, +): QueryObject { + val scope = rememberCoroutineScope() + val query1 = remember(key1.id) { client.getQuery(key1, config.marker).also { it.launchIn(scope) } } + val query2 = remember(key2.id) { client.getQuery(key2, config.marker).also { it.launchIn(scope) } } + val query3 = remember(key3.id) { client.getQuery(key3, config.marker).also { it.launchIn(scope) } } + val combinedQuery = remember(query1, query2, query3) { + combineQuery(query1, query2, query3, transform) + } + DisposableEffect(combinedQuery.id) { + val job = combinedQuery.launchIn(scope) + onDispose { job.cancel() } + } + return with(config.mapper) { + config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + } +} + +/** + * Remember a [QueryObject] and subscribes to the query state of [keys]. + * + * @param T Type of data to retrieve. + * @param R Type of data to transform. + * @param keys The list of [QueryKey] for managing [query][soil.query.Query]. + * @param transform A function to transform [T] into [R]. + * @param config The configuration for the query. By default, it uses the [QueryConfig.Default]. + * @param client The [QueryClient] to resolve [keys]. By default, it uses the [LocalQueryClient]. + * @return A [QueryObject] with transformed data each the query state changed. + */ +@Composable +fun rememberQuery( + keys: List>, + transform: (List) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject { + val scope = rememberCoroutineScope() + val queries = remember(keys) { + keys.map { key -> client.getQuery(key, config.marker).also { it.launchIn(scope) } } + } + val combinedQuery = remember(queries) { + combineQuery(queries.toTypedArray(), transform) + } + DisposableEffect(combinedQuery.id) { + val job = combinedQuery.launchIn(scope) + onDispose { job.cancel() } + } + return with(config.mapper) { + config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt index 1f3687c..044edbf 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryStrategy.kt @@ -38,7 +38,7 @@ private object DefaultQueryStrategy : QueryStrategy { @Composable override fun collectAsState(query: QueryRef): QueryState { val state by query.state.collectAsState() - LaunchedEffect(query.key.id) { + LaunchedEffect(query.id) { query.resume() } return state diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt index 3d6e314..45588fc 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt @@ -27,7 +27,7 @@ fun rememberSubscription( client: SubscriptionClient = LocalSubscriptionClient.current ): SubscriptionObject { val scope = rememberCoroutineScope() - val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + val subscription = remember(key.id) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } return with(config.mapper) { config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = { it }) } @@ -53,7 +53,7 @@ fun rememberSubscription( client: SubscriptionClient = LocalSubscriptionClient.current ): SubscriptionObject { val scope = rememberCoroutineScope() - val subscription = remember(key) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + val subscription = remember(key.id) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } return with(config.mapper) { config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = select) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt index 823894a..82c4bfe 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionStrategy.kt @@ -36,15 +36,26 @@ private object DefaultSubscriptionStrategy : SubscriptionStrategy { @Composable override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { val state by subscription.state.collectAsState() - LaunchedEffect(subscription.key.id) { - if (subscription.options.subscribeOnMount) { - subscription.resume() - } + LaunchedEffect(subscription.id) { + subscription.resume() } return state } } +/** + * Strategy for manually starting the Subscription without automatic subscription. + */ +val SubscriptionStrategy.Companion.Lazy: SubscriptionStrategy + get() = LazySubscriptionStrategy + +private object LazySubscriptionStrategy : SubscriptionStrategy { + @Composable + override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { + return subscription.state.collectAsState().value + } +} + // FIXME: CompositionLocal LocalLifecycleOwner not present // Android, it works only with Compose UI 1.7.0-alpha05 or above. // Therefore, we will postpone adding this code until a future release. @@ -55,10 +66,8 @@ private object DefaultSubscriptionStrategy : SubscriptionStrategy { // @Composable // override fun collectAsState(subscription: SubscriptionRef): SubscriptionState { // val state by subscription.state.collectAsStateWithLifecycle() -// LifecycleStartEffect(subscription.key.id) { -// if (subscription.options.subscribeOnMount) { -// lifecycleScope.launch { subscription.resume() } -// } +// LifecycleStartEffect(subscription.id) { +// lifecycleScope.launch { subscription.resume() } // onStopOrDispose { subscription.cancel() } // } // return state diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt new file mode 100644 index 0000000..df7d822 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt @@ -0,0 +1,67 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import soil.query.QueryId +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.core.uuid +import soil.query.merge + + +internal fun combineQuery( + query1: QueryRef, + query2: QueryRef, + transform: (T1, T2) -> R +): QueryRef = CombinedQuery2(query1, query2, transform) + +private class CombinedQuery2( + private val query1: QueryRef, + private val query2: QueryRef, + private val transform: (T1, T2) -> R +) : QueryRef { + + override val id: QueryId = QueryId("auto/${uuid()}") + + // FIXME: Switch to K2 mode when it becomes stable. + private val _state: MutableStateFlow> = MutableStateFlow( + value = merge(query1.state.value, query2.state.value) + ) + override val state: StateFlow> = _state + + override suspend fun resume() { + coroutineScope { + val deferred1 = async { query1.resume() } + val deferred2 = async { query2.resume() } + awaitAll(deferred1, deferred2) + } + } + + override suspend fun invalidate() { + coroutineScope { + val deferred1 = async { query1.invalidate() } + val deferred2 = async { query2.invalidate() } + awaitAll(deferred1, deferred2) + } + } + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + combine(query1.state, query2.state, ::merge).collect { _state.value = it } + } + } + + private fun merge(state1: QueryState, state2: QueryState): QueryState { + return QueryState.merge(state1, state2, transform) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt new file mode 100644 index 0000000..2de4825 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt @@ -0,0 +1,71 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import soil.query.QueryId +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.core.uuid +import soil.query.merge + + +internal fun combineQuery( + query1: QueryRef, + query2: QueryRef, + query3: QueryRef, + transform: (T1, T2, T3) -> R +): QueryRef = CombinedQuery3(query1, query2, query3, transform) + +private class CombinedQuery3( + private val query1: QueryRef, + private val query2: QueryRef, + private val query3: QueryRef, + private val transform: (T1, T2, T3) -> R +) : QueryRef { + + override val id: QueryId = QueryId("auto/${uuid()}") + + // FIXME: Switch to K2 mode when it becomes stable. + private val _state: MutableStateFlow> = MutableStateFlow( + value = merge(query1.state.value, query2.state.value, query3.state.value) + ) + override val state: StateFlow> = _state + + override suspend fun resume() { + coroutineScope { + val deferred1 = async { query1.resume() } + val deferred2 = async { query2.resume() } + val deferred3 = async { query3.resume() } + awaitAll(deferred1, deferred2, deferred3) + } + } + + override suspend fun invalidate() { + coroutineScope { + val deferred1 = async { query1.invalidate() } + val deferred2 = async { query2.invalidate() } + val deferred3 = async { query3.invalidate() } + awaitAll(deferred1, deferred2, deferred3) + } + } + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + combine(query1.state, query2.state, query3.state, ::merge).collect { _state.value = it } + } + } + + private fun merge(state1: QueryState, state2: QueryState, state3: QueryState): QueryState { + return QueryState.merge(state1, state2, state3, transform) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt new file mode 100644 index 0000000..a66484e --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt @@ -0,0 +1,61 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import soil.query.QueryId +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.core.uuid +import soil.query.merge + + +internal fun combineQuery( + queries: Array>, + transform: (List) -> R +): QueryRef = CombinedQueryN(queries, transform) + +private class CombinedQueryN( + private val queries: Array>, + private val transform: (List) -> R +) : QueryRef { + + override val id: QueryId = QueryId("auto/${uuid()}") + + // FIXME: Switch to K2 mode when it becomes stable. + private val _state: MutableStateFlow> = MutableStateFlow( + value = merge(queries.map { it.state.value }.toTypedArray()) + ) + override val state: StateFlow> = _state + + override suspend fun resume() { + coroutineScope { + queries.map { query -> async { query.resume() } }.awaitAll() + } + } + + override suspend fun invalidate() { + coroutineScope { + queries.map { query -> async { query.invalidate() } }.awaitAll() + } + } + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + combine(queries.map { it.state }, ::merge).collect { _state.value = it } + } + } + + private fun merge(states: Array>): QueryState { + return QueryState.merge(states, transform) + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt index 18eeab7..cee803c 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/MutationPreviewClient.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import soil.query.MutationClient -import soil.query.MutationCommand import soil.query.MutationId import soil.query.MutationKey import soil.query.MutationOptions @@ -17,6 +16,7 @@ import soil.query.MutationRef import soil.query.MutationState import soil.query.core.Marker import soil.query.core.UniqueId +import soil.query.core.getOrThrow /** * Usage: @@ -39,18 +39,17 @@ class MutationPreviewClient( marker: Marker ): MutationRef { val state = previewData[key.id] as? MutationState ?: MutationState.initial() - val options = key.onConfigureOptions()?.invoke(defaultMutationOptions) ?: defaultMutationOptions - return SnapshotMutation(key, options, marker, MutableStateFlow(state)) + return SnapshotMutation(key.id, MutableStateFlow(state)) } private class SnapshotMutation( - override val key: MutationKey, - override val options: MutationOptions, - override val marker: Marker, + override val id: MutationId, override val state: StateFlow> ) : MutationRef { override fun launchIn(scope: CoroutineScope): Job = Job() - override suspend fun send(command: MutationCommand) = Unit + override suspend fun reset() = Unit + override suspend fun mutate(variable: S): T = state.value.reply.getOrThrow() + override suspend fun mutateAsync(variable: S) = Unit } /** diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt index a261bdc..65fc8f5 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/QueryPreviewClient.kt @@ -8,13 +8,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import soil.query.InfiniteQueryCommand import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey import soil.query.InfiniteQueryRef import soil.query.QueryChunks import soil.query.QueryClient -import soil.query.QueryCommand import soil.query.QueryId import soil.query.QueryKey import soil.query.QueryOptions @@ -44,8 +42,7 @@ class QueryPreviewClient( marker: Marker ): QueryRef { val state = previewData[key.id] as? QueryState ?: QueryState.initial() - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions - return SnapshotQuery(key, options, marker, MutableStateFlow(state)) + return SnapshotQuery(key.id, MutableStateFlow(state)) } @Suppress("UNCHECKED_CAST") @@ -54,8 +51,7 @@ class QueryPreviewClient( marker: Marker ): InfiniteQueryRef { val state = previewData[key.id] as? QueryState> ?: QueryState.initial() - val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions - return SnapshotInfiniteQuery(key, options, marker, MutableStateFlow(state)) + return SnapshotInfiniteQuery(key, MutableStateFlow(state)) } override fun prefetchQuery( @@ -69,25 +65,21 @@ class QueryPreviewClient( ): Job = Job() private class SnapshotQuery( - override val key: QueryKey, - override val options: QueryOptions, - override val marker: Marker, + override val id: QueryId, override val state: StateFlow> ) : QueryRef { override fun launchIn(scope: CoroutineScope): Job = Job() - override suspend fun send(command: QueryCommand) = Unit override suspend fun resume() = Unit override suspend fun invalidate() = Unit } private class SnapshotInfiniteQuery( - override val key: InfiniteQueryKey, - override val options: QueryOptions, - override val marker: Marker, + private val key: InfiniteQueryKey, override val state: StateFlow>> ) : InfiniteQueryRef { + override val id: InfiniteQueryId = key.id override fun launchIn(scope: CoroutineScope): Job = Job() - override suspend fun send(command: InfiniteQueryCommand) = Unit + override fun nextParam(data: QueryChunks): S? = key.loadMoreParam(data) override suspend fun resume() = Unit override suspend fun loadMore(param: S) = Unit override suspend fun invalidate() = Unit diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt index 8973343..19701e5 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/tooling/SubscriptionPreviewClient.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import soil.query.SubscriptionClient -import soil.query.SubscriptionCommand import soil.query.SubscriptionId import soil.query.SubscriptionKey import soil.query.SubscriptionOptions @@ -41,18 +40,15 @@ class SubscriptionPreviewClient( marker: Marker ): SubscriptionRef { val state = previewData[key.id] as? SubscriptionState ?: SubscriptionState.initial() - val options = key.onConfigureOptions()?.invoke(defaultSubscriptionOptions) ?: defaultSubscriptionOptions - return SnapshotSubscription(key, options, marker, MutableStateFlow(state)) + return SnapshotSubscription(key.id, MutableStateFlow(state)) } private class SnapshotSubscription( - override val key: SubscriptionKey, - override val options: SubscriptionOptions, - override val marker: Marker, + override val id: SubscriptionId, override val state: StateFlow> ) : SubscriptionRef { override fun launchIn(scope: CoroutineScope): Job = Job() - override suspend fun send(command: SubscriptionCommand) = Unit + override suspend fun reset() = Unit override suspend fun resume() = Unit override fun cancel() = Unit } diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index e42e69b..384a8cd 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -25,6 +25,7 @@ import soil.query.QueryChunk import soil.query.QueryState import soil.query.SwrCache import soil.query.SwrCacheScope +import soil.query.buildChunks import soil.query.buildInfiniteQueryKey import soil.query.chunkedData import soil.query.compose.tooling.QueryPreviewClient @@ -166,7 +167,7 @@ class InfiniteQueryComposableTest : UnitTest() { val client = SwrPreviewClient( query = QueryPreviewClient { on(key.id) { - QueryState.success(buildList { + QueryState.success(buildChunks { add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) add(QueryChunk((10 until 20).map { "Item $it" }, PageParam(1, 10))) add(QueryChunk((20 until 30).map { "Item $it" }, PageParam(2, 10))) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt index ee58dad..449b34a 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryObjectMapperTest.kt @@ -9,6 +9,7 @@ import soil.query.QueryChunk import soil.query.QueryFetchStatus import soil.query.QueryState import soil.query.QueryStatus +import soil.query.buildChunks import soil.query.buildInfiniteQueryKey import soil.query.chunkedData import soil.query.compose.tooling.QueryPreviewClient @@ -58,7 +59,7 @@ class InfiniteQueryObjectMapperTest : UnitTest() { val client = SwrPreviewClient( query = QueryPreviewClient { on(key.id) { - QueryState.success(buildList { + QueryState.success(buildChunks { add(QueryChunk((0 until 10).map { "Item $it" }, PageParam(0, 10))) add(QueryChunk((10 until 20).map { "Item $it" }, PageParam(1, 10))) add(QueryChunk((20 until 30).map { "Item $it" }, PageParam(2, 10))) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index fdce02d..784587b 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -94,6 +94,77 @@ class QueryComposableTest : UnitTest() { onNodeWithTag("query").assertTextEquals("error") } + @Test + fun testRememberQuery_combineTwo() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key1 = key1, key2 = key2, transform = { a, b -> a + b }) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!Hello, Soil!") + } + + @Test + fun testRememberQuery_combineThree() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val key3 = TestQueryKey(number = 3) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + on(key3.id) { "Hello, Kotlin!" } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key1 = key1, key2 = key2, key3 = key3, transform = { a, b, c -> a + b + c }) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!Hello, Soil!Hello, Kotlin!") + } + + @Test + fun testRememberQuery_combineN() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val key3 = TestQueryKey(number = 3) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + on(key3.id) { "Hello, Kotlin!" } + } + setContent { + SwrClientProvider(client) { + val query = rememberQuery(listOf(key1, key2, key3), transform = { it.joinToString("|") }) + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!|Hello, Soil!|Hello, Kotlin!") + } + + @Test fun testRememberQuery_loadingPreview() = runComposeUiTest { val key = TestQueryKey() @@ -178,10 +249,12 @@ class QueryComposableTest : UnitTest() { onNodeWithTag("query").assertTextEquals("Hello, Query!") } - private class TestQueryKey : QueryKey by buildQueryKey( - id = Id, + private class TestQueryKey( + number: Int = 1 + ) : QueryKey by buildQueryKey( + id = Id(number), fetch = { "Hello, Soil!" } ) { - object Id : QueryId("test/query") + class Id(number: Int) : QueryId("test/query/$number") } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt index 244c7a5..6d66286 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryRef.kt @@ -23,19 +23,9 @@ import soil.query.core.awaitOrNull interface InfiniteQueryRef : Actor { /** - * The [InfiniteQueryKey] for the Query. + * A unique identifier used for managing [InfiniteQueryKey]. */ - val key: InfiniteQueryKey - - /** - * The QueryOptions configured for the query. - */ - val options: QueryOptions - - /** - * The Marker specified in [QueryClient.getInfiniteQuery]. - */ - val marker: Marker + val id: InfiniteQueryId /** * [State Flow][StateFlow] to receive the current state of the query. @@ -43,27 +33,25 @@ interface InfiniteQueryRef : Actor { val state: StateFlow>> /** - * Sends a [QueryCommand] to the Actor. + * Function returning the parameter for additional fetching. + * + * @param data The data to be used to determine the next parameter. + * @return `null` if there is no more data to fetch. + * @see loadMore */ - suspend fun send(command: InfiniteQueryCommand) + fun nextParam(data: QueryChunks): S? /** * Resumes the Query. */ - suspend fun resume() { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) - deferred.awaitOrNull() - } + suspend fun resume() /** * Fetches data for the [InfiniteQueryKey] using the value of [param]. + * + * @see nextParam */ - suspend fun loadMore(param: S) { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.LoadMore(key, param, marker, deferred::completeWith)) - deferred.awaitOrNull() - } + suspend fun loadMore(param: S) /** * Invalidates the Query. @@ -71,11 +59,7 @@ interface InfiniteQueryRef : Actor { * Calling this function will invalidate the retrieved data of the Query, * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. */ - suspend fun invalidate() { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) - deferred.awaitOrNull() - } + suspend fun invalidate() } /** @@ -94,13 +78,13 @@ fun InfiniteQueryRef( } private class InfiniteQueryRefImpl( - override val key: InfiniteQueryKey, - override val marker: Marker, + private val key: InfiniteQueryKey, + private val marker: Marker, private val query: Query> ) : InfiniteQueryRef { - override val options: QueryOptions - get() = query.options + override val id: InfiniteQueryId + get() = key.id override val state: StateFlow>> get() = query.state @@ -112,7 +96,29 @@ private class InfiniteQueryRefImpl( } } - override suspend fun send(command: InfiniteQueryCommand) { + override fun nextParam(data: QueryChunks): S? { + return key.loadMoreParam(data) + } + + override suspend fun resume() { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) + deferred.awaitOrNull() + } + + override suspend fun loadMore(param: S) { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.LoadMore(key, param, marker, deferred::completeWith)) + deferred.awaitOrNull() + } + + override suspend fun invalidate() { + val deferred = CompletableDeferred>() + send(InfiniteQueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) + deferred.awaitOrNull() + } + + private suspend fun send(command: InfiniteQueryCommand) { query.command.send(command) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt index b2e53e0..9a8266e 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Mutation.kt @@ -14,11 +14,6 @@ import soil.query.core.Actor */ interface Mutation : Actor { - /** - * The MutationOptions configured for the mutation. - */ - val options: MutationOptions - /** * [State Flow][StateFlow] to receive the current state of the mutation. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt index 8dc60fd..5108d9b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationRef.kt @@ -21,57 +21,34 @@ import soil.query.core.Marker interface MutationRef : Actor { /** - * The [MutationKey] for the Mutation. + * A unique identifier used for managing [MutationKey]. */ - val key: MutationKey - - /** - * The MutationOptions configured for the mutation. - */ - val options: MutationOptions - - /** - * The Marker specified in [MutationClient.getMutation]. - */ - val marker: Marker + val id: MutationId /** * [State Flow][StateFlow] to receive the current state of the mutation. */ val state: StateFlow> - /** - * Sends a [MutationCommand] to the Actor. - */ - suspend fun send(command: MutationCommand) - /** * Mutates the variable. * * @param variable The variable to be mutated. * @return The result of the mutation. */ - suspend fun mutate(variable: S): T { - val deferred = CompletableDeferred() - send(MutationCommands.Mutate(key, variable, state.value.revision, marker, deferred::completeWith)) - return deferred.await() - } + suspend fun mutate(variable: S): T /** * Mutates the variable asynchronously. * * @param variable The variable to be mutated. */ - suspend fun mutateAsync(variable: S) { - send(MutationCommands.Mutate(key, variable, state.value.revision, marker)) - } + suspend fun mutateAsync(variable: S) /** * Resets the mutation state. */ - suspend fun reset() { - send(MutationCommands.Reset()) - } + suspend fun reset() } /** @@ -90,13 +67,13 @@ fun MutationRef( } private class MutationRefImpl( - override val key: MutationKey, - override val marker: Marker, + private val key: MutationKey, + private val marker: Marker, private val mutation: Mutation ) : MutationRef { - override val options: MutationOptions - get() = mutation.options + override val id: MutationId + get() = key.id override val state: StateFlow> get() = mutation.state @@ -105,7 +82,21 @@ private class MutationRefImpl( return mutation.launchIn(scope) } - override suspend fun send(command: MutationCommand) { + override suspend fun mutate(variable: S): T { + val deferred = CompletableDeferred() + send(MutationCommands.Mutate(key, variable, state.value.revision, marker, deferred::completeWith)) + return deferred.await() + } + + override suspend fun mutateAsync(variable: S) { + send(MutationCommands.Mutate(key, variable, state.value.revision, marker)) + } + + override suspend fun reset() { + send(MutationCommands.Reset()) + } + + private suspend fun send(command: MutationCommand) { mutation.command.send(command) } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt index 80c54bc..ef467c1 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Query.kt @@ -15,11 +15,6 @@ import soil.query.core.Actor */ interface Query : Actor { - /** - * The QueryOptions configured for the query. - */ - val options: QueryOptions - /** * [Shared Flow][SharedFlow] to receive query [events][QueryEvent]. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt index 9fd4ed4..5a18d24 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryChunk.kt @@ -16,6 +16,18 @@ data class QueryChunk( typealias QueryChunks = List> +/** + * Returns an empty list of chunks. + */ +fun emptyChunks(): QueryChunks = emptyList() + +/** + * Builds a list of chunks using the provided [builderAction]. + */ +inline fun buildChunks( + builderAction: MutableList>.() -> Unit +): QueryChunks = buildList(builderAction) + /** * Returns the data of all chunks. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt index 47516eb..2f574c9 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryRef.kt @@ -21,38 +21,19 @@ import soil.query.core.awaitOrNull interface QueryRef : Actor { /** - * The [QueryKey] for the Query. + * A unique identifier used for managing [QueryKey]. */ - val key: QueryKey - - /** - * The QueryOptions configured for the query. - */ - val options: QueryOptions - - /** - * The Marker specified in [QueryClient.getQuery]. - */ - val marker: Marker + val id: QueryId /** * [State Flow][StateFlow] to receive the current state of the query. */ val state: StateFlow> - /** - * Sends a [QueryCommand] to the Actor. - */ - suspend fun send(command: QueryCommand) - /** * Resumes the Query. */ - suspend fun resume() { - val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) - deferred.awaitOrNull() - } + suspend fun resume() /** * Invalidates the Query. @@ -60,11 +41,7 @@ interface QueryRef : Actor { * Calling this function will invalidate the retrieved data of the Query, * setting [QueryModel.isInvalidated] to `true` until revalidation is completed. */ - suspend fun invalidate() { - val deferred = CompletableDeferred() - send(QueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) - deferred.awaitOrNull() - } + suspend fun invalidate() } /** @@ -83,13 +60,13 @@ fun QueryRef( } private class QueryRefImpl( - override val key: QueryKey, - override val marker: Marker, + private val key: QueryKey, + private val marker: Marker, private val query: Query ) : QueryRef { - override val options: QueryOptions - get() = query.options + override val id: QueryId + get() = key.id override val state: StateFlow> get() = query.state @@ -101,10 +78,23 @@ private class QueryRefImpl( } } - override suspend fun send(command: QueryCommand) { + override suspend fun resume() { + val deferred = CompletableDeferred() + send(QueryCommands.Connect(key, state.value.revision, marker, deferred::completeWith)) + deferred.awaitOrNull() + } + + override suspend fun invalidate() { + val deferred = CompletableDeferred() + send(QueryCommands.Invalidate(key, state.value.revision, marker, deferred::completeWith)) + deferred.awaitOrNull() + } + + private suspend fun send(command: QueryCommand) { query.command.send(command) } + private suspend fun handleEvent(e: QueryEvent) { when (e) { QueryEvent.Invalidate -> invalidate() diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryStateMerger.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryStateMerger.kt new file mode 100644 index 0000000..abf98a7 --- /dev/null +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryStateMerger.kt @@ -0,0 +1,106 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply +import soil.query.core.combine +import soil.query.core.getOrThrow +import soil.query.core.isNone + +/** + * Merges multiple [QueryState] instances into a single [QueryState] instance. + * + * ## Merge specification details: + * [QueryState.reply] is combined by invoking [transform] only when all [QueryState] instances have [Reply.Some]. + * [QueryState.status] is selected based on the following priority order: + * 1. [QueryStatus.Failure]: If priorities are the same, the one with the oldest [QueryState.errorUpdatedAt] is selected. + * 2. [QueryStatus.Pending]: If priorities are the same, the left-hand side is selected. + * 3. [QueryStatus.Success]: If priorities are the same, the left-hand side is selected. + * + * For most other fields, the data associated with the [QueryState] selected by [QueryState.status] will be used as is. + * However, [QueryState.replyUpdatedAt] is set to the initial value `0` only when [QueryState.reply] is [Reply.None]. + */ +fun QueryState.Companion.merge( + state1: QueryState, + state2: QueryState, + transform: (T1, T2) -> R +): QueryState { + val reply = Reply.combine(state1.reply, state2.reply, transform) + return merge(reply, pick(state1, state2)) +} + +/** + * Merges multiple [QueryState] instances into a single [QueryState] instance. + * + * ## Merge specification details: + * [QueryState.reply] is combined by invoking [transform] only when all [QueryState] instances have [Reply.Some]. + * [QueryState.status] is selected based on the following priority order: + * 1. [QueryStatus.Failure]: If priorities are the same, the one with the oldest [QueryState.errorUpdatedAt] is selected. + * 2. [QueryStatus.Pending]: If priorities are the same, the left-hand side is selected. + * 3. [QueryStatus.Success]: If priorities are the same, the left-hand side is selected. + * + * For most other fields, the data associated with the [QueryState] selected by [QueryState.status] will be used as is. + * However, [QueryState.replyUpdatedAt] is set to the initial value `0` only when [QueryState.reply] is [Reply.None]. + */ +fun QueryState.Companion.merge( + state1: QueryState, + state2: QueryState, + state3: QueryState, + transform: (T1, T2, T3) -> R +): QueryState { + val reply = Reply.combine(state1.reply, state2.reply, state3.reply, transform) + return merge(reply, pick(state1, state2, state3)) +} + +/** + * Merges multiple [QueryState] instances into a single [QueryState] instance. + * + * ## Merge specification details: + * [QueryState.reply] is combined by invoking [transform] only when all [QueryState] instances have [Reply.Some]. + * [QueryState.status] is selected based on the following priority order: + * 1. [QueryStatus.Failure]: If priorities are the same, the one with the oldest [QueryState.errorUpdatedAt] is selected. + * 2. [QueryStatus.Pending]: If priorities are the same, the left-hand side is selected. + * 3. [QueryStatus.Success]: If priorities are the same, the left-hand side is selected. + * + * For most other fields, the data associated with the [QueryState] selected by [QueryState.status] will be used as is. + * However, [QueryState.replyUpdatedAt] is set to the initial value `0` only when [QueryState.reply] is [Reply.None]. + */ +fun QueryState.Companion.merge( + states: Array>, + transform: (List) -> R +): QueryState { + val values = states.filter { !it.reply.isNone }.map { it.reply.getOrThrow() } + val reply = if (values.size == states.size) Reply.some(transform(values)) else Reply.none() + return merge(reply, pick(*states)) +} + +private fun merge( + reply: Reply, + base: QueryState<*> +): QueryState = QueryState( + reply = reply, + replyUpdatedAt = if (reply.isNone) 0 else base.replyUpdatedAt, + error = base.error, + errorUpdatedAt = base.errorUpdatedAt, + staleAt = if (reply.isNone) 0 else base.staleAt, + status = base.status, + fetchStatus = base.fetchStatus, + isInvalidated = base.isInvalidated +) + +private fun pick( + vararg states: QueryState<*> +): QueryState<*> = states.reduce { acc, st -> + when { + acc.isFailure && st.isFailure -> { + if (acc.errorUpdatedAt <= st.errorUpdatedAt) acc else st + } + + acc.isFailure -> acc + st.isFailure -> st + acc.isPending -> acc + st.isPending -> st + else -> acc + } +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt b/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt index 274d18a..037a31c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/Subscription.kt @@ -15,11 +15,6 @@ import soil.query.core.Actor */ interface Subscription : Actor { - /** - * The SubscriptionOptions configured for the subscription. - */ - val options: SubscriptionOptions - /** * [Shared Flow][SharedFlow] to receive subscription result. */ diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt index c79c597..1244cab 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt @@ -25,11 +25,6 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { */ val gcTime: Duration - /** - * Determines whether to subscribe automatically to the subscription when mounted. - */ - val subscribeOnMount: Boolean - /** * This callback function will be called if some mutation encounters an error. */ @@ -43,7 +38,6 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { companion object Default : SubscriptionOptions { override val gcTime: Duration = 5.minutes - override val subscribeOnMount: Boolean = true override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = null override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = null @@ -70,7 +64,6 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { * Creates a new [SubscriptionOptions] with the specified settings. * * @param gcTime The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. - * @param subscribeOnMount Determines whether to subscribe automatically to the subscription when mounted. * @param onError This callback function will be called if some subscription encounters an error. * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. * @param keepAliveTime The duration to keep the actor alive after the last command is executed. @@ -85,7 +78,6 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { */ fun SubscriptionOptions( gcTime: Duration = SubscriptionOptions.gcTime, - subscribeOnMount: Boolean = SubscriptionOptions.subscribeOnMount, onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = SubscriptionOptions.onError, shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = SubscriptionOptions.shouldSuppressErrorRelay, keepAliveTime: Duration = SubscriptionOptions.keepAliveTime, @@ -100,7 +92,6 @@ fun SubscriptionOptions( ): SubscriptionOptions { return object : SubscriptionOptions { override val gcTime: Duration = gcTime - override val subscribeOnMount: Boolean = subscribeOnMount override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = onError override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = shouldSuppressErrorRelay @@ -121,7 +112,6 @@ fun SubscriptionOptions( */ fun SubscriptionOptions.copy( gcTime: Duration = this.gcTime, - subscribeOnMount: Boolean = this.subscribeOnMount, onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = this.onError, shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, keepAliveTime: Duration = this.keepAliveTime, @@ -136,7 +126,6 @@ fun SubscriptionOptions.copy( ): SubscriptionOptions { return SubscriptionOptions( gcTime = gcTime, - subscribeOnMount = subscribeOnMount, onError = onError, shouldSuppressErrorRelay = shouldSuppressErrorRelay, keepAliveTime = keepAliveTime, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt index ffbf20c..ea90c7a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionRef.kt @@ -19,19 +19,9 @@ import soil.query.core.Marker interface SubscriptionRef : Actor { /** - * The [SubscriptionKey] for the Subscription. + * A unique identifier used for managing [SubscriptionKey]. */ - val key: SubscriptionKey - - /** - * The SubscriptionOptions configured for the subscription. - */ - val options: SubscriptionOptions - - /** - * The Marker specified in [SubscriptionClient.getSubscription]. - */ - val marker: Marker + val id: SubscriptionId /** * [State Flow][StateFlow] to receive the current state of the subscription. @@ -39,9 +29,9 @@ interface SubscriptionRef : Actor { val state: StateFlow> /** - * Sends a [SubscriptionCommand] to the Actor. + * Resets the Subscription. */ - suspend fun send(command: SubscriptionCommand) + suspend fun reset() /** * Resumes the Subscription. @@ -52,13 +42,6 @@ interface SubscriptionRef : Actor { * Cancels the Subscription. */ fun cancel() - - /** - * Resets the Subscription. - */ - suspend fun reset() { - send(SubscriptionCommands.Reset(key, state.value.revision)) - } } /** @@ -77,15 +60,15 @@ fun SubscriptionRef( } private class SubscriptionRefImpl( - override val key: SubscriptionKey, - override val marker: Marker, + private val key: SubscriptionKey, + private val marker: Marker, private val subscription: Subscription ) : SubscriptionRef { private var job: Job? = null - override val options: SubscriptionOptions - get() = subscription.options + override val id: SubscriptionId + get() = key.id override val state: StateFlow> get() = subscription.state @@ -94,10 +77,6 @@ private class SubscriptionRefImpl( return subscription.launchIn(scope) } - override suspend fun send(command: SubscriptionCommand) { - subscription.command.send(command) - } - override suspend fun resume() { if (job?.isActive == true) return coroutineScope { @@ -112,6 +91,14 @@ private class SubscriptionRefImpl( job = null } + override suspend fun reset() { + send(SubscriptionCommands.Reset(key, state.value.revision)) + } + + private suspend fun send(command: SubscriptionCommand) { + subscription.command.send(command) + } + private suspend fun receive(result: Result) { send(SubscriptionCommands.Receive(key, result, state.value.revision, marker)) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt index bfc41fb..dca41bc 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCache.kt @@ -378,7 +378,8 @@ open class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutabl val query = getQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - withTimeoutOrNull(query.options.prefetchWindowTime) { + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + withTimeoutOrNull(options.prefetchWindowTime) { query.resume() } } finally { @@ -395,7 +396,8 @@ open class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutabl val query = getInfiniteQuery(key, marker).also { it.launchIn(scope) } return coroutineScope.launch { try { - withTimeoutOrNull(query.options.prefetchWindowTime) { + val options = key.onConfigureOptions()?.invoke(defaultQueryOptions) ?: defaultQueryOptions + withTimeoutOrNull(options.prefetchWindowTime) { query.resume() } } finally { @@ -578,7 +580,7 @@ open class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutabl internal class ManagedMutation( val scope: CoroutineScope, val id: UniqueId, - override val options: MutationOptions, + val options: MutationOptions, override val state: StateFlow>, override val command: SendChannel>, internal val actor: ActorBlockRunner, @@ -606,7 +608,7 @@ open class SwrCache(private val policy: SwrCachePolicy) : SwrClient, QueryMutabl internal class ManagedQuery( val scope: CoroutineScope, val id: UniqueId, - override val options: QueryOptions, + val options: QueryOptions, override val event: MutableSharedFlow, override val state: StateFlow>, override val command: SendChannel>, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt index dbedd72..f73f21f 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SwrCachePlus.kt @@ -170,7 +170,7 @@ class SwrCachePlus(private val policy: SwrCachePlusPolicy) : SwrCache(policy), S internal class ManagedSubscription( val scope: CoroutineScope, val id: UniqueId, - override val options: SubscriptionOptions, + val options: SubscriptionOptions, override val source: SharedFlow>, override val state: StateFlow>, override val command: SendChannel>, diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateMergerTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateMergerTest.kt new file mode 100644 index 0000000..ecbe82d --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateMergerTest.kt @@ -0,0 +1,177 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class QueryStateMergerTest : UnitTest() { + + @Test + fun testMergeForTwo() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.success(2, dataUpdatedAt = 300, dataStaleAt = 350) + val merged1 = QueryState.merge(state1, state2) { a, b -> a + b } + val merged2 = QueryState.merge(state2, state1) { a, b -> a + b } + assertEquals(QueryState.success(3, 200, 250), merged1) + assertEquals(QueryState.success(3, 300, 350), merged2) + } + + @Test + fun testMergeForTwo_withFailure() { + val error = RuntimeException("error") + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val merged1 = QueryState.merge(state1, state2) { a, b -> a + b } + val merged2 = QueryState.merge(state2, state1) { a, b -> a + b } + assertEquals(QueryState.failure(error, 999), merged1) + assertEquals(QueryState.failure(error, 999), merged2) + } + + @Test + fun testMergeForTwo_allFailure() { + val error = RuntimeException("error") + val state1 = QueryState.failure(error, errorUpdatedAt = 555) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val merged1 = QueryState.merge(state1, state2) { a, b -> a + b } + val merged2 = QueryState.merge(state2, state1) { a, b -> a + b } + assertEquals(QueryState.failure(error, 555), merged1) + assertEquals(QueryState.failure(error, 555), merged2) + } + + @Test + fun testMergeForTwo_withPending() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.initial() + val merged1 = QueryState.merge(state1, state2) { a, b -> a + b } + val merged2 = QueryState.merge(state2, state1) { a, b -> a + b } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } + + @Test + fun testMergeForTwo_allPending() { + val state1 = QueryState.initial() + val state2 = QueryState.initial() + val merged1 = QueryState.merge(state1, state2) { a, b -> a + b } + val merged2 = QueryState.merge(state2, state1) { a, b -> a + b } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } + + @Test + fun testMergeForThree() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.success(2, dataUpdatedAt = 300, dataStaleAt = 350) + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(state1, state2, state3) { a, b, c -> a + b + c } + val merged2 = QueryState.merge(state2, state1, state3) { a, b, c -> a + b + c } + assertEquals(QueryState.success(6, 200, 250), merged1) + assertEquals(QueryState.success(6, 300, 350), merged2) + } + + @Test + fun testMergeForThree_withFailure() { + val error = RuntimeException("error") + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(state1, state2, state3) { a, b, c -> a + b + c } + val merged2 = QueryState.merge(state2, state1, state3) { a, b, c -> a + b + c } + assertEquals(QueryState.failure(error, 999), merged1) + assertEquals(QueryState.failure(error, 999), merged2) + } + + @Test + fun testMergeForThree_allFailure() { + val error = RuntimeException("error") + val state1 = QueryState.failure(error, errorUpdatedAt = 555) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val state3 = QueryState.failure(error, errorUpdatedAt = 777) + val merged1 = QueryState.merge(state1, state2, state3) { a, b, c -> a + b + c } + val merged2 = QueryState.merge(state2, state1, state3) { a, b, c -> a + b + c } + assertEquals(QueryState.failure(error, 555), merged1) + assertEquals(QueryState.failure(error, 555), merged2) + } + + @Test + fun testMergeForThree_withPending() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.initial() + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(state1, state2, state3) { a, b, c -> a + b + c } + val merged2 = QueryState.merge(state2, state1, state3) { a, b, c -> a + b + c } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } + + @Test + fun testMergeForThree_allPending() { + val state1 = QueryState.initial() + val state2 = QueryState.initial() + val state3 = QueryState.initial() + val merged1 = QueryState.merge(state1, state2, state3) { a, b, c -> a + b + c } + val merged2 = QueryState.merge(state2, state1, state3) { a, b, c -> a + b + c } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } + + @Test + fun testMergeForN() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.success(2, dataUpdatedAt = 300, dataStaleAt = 350) + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(arrayOf(state1, state2, state3)) { it.reduce { acc, i -> acc + i } } + val merged2 = QueryState.merge(arrayOf(state2, state1, state3)) { it.reduce { acc, i -> acc + i } } + assertEquals(QueryState.success(6, 200, 250), merged1) + assertEquals(QueryState.success(6, 300, 350), merged2) + } + + @Test + fun testMergeForN_withFailure() { + val error = RuntimeException("error") + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(arrayOf(state1, state2, state3)) { it.reduce { acc, i -> acc + i } } + val merged2 = QueryState.merge(arrayOf(state2, state1, state3)) { it.reduce { acc, i -> acc + i } } + assertEquals(QueryState.failure(error, 999), merged1) + assertEquals(QueryState.failure(error, 999), merged2) + } + + @Test + fun testMergeForN_allFailure() { + val error = RuntimeException("error") + val state1 = QueryState.failure(error, errorUpdatedAt = 555) + val state2 = QueryState.failure(error, errorUpdatedAt = 999) + val state3 = QueryState.failure(error, errorUpdatedAt = 777) + val merged1 = QueryState.merge(arrayOf(state1, state2, state3)) { it.reduce { acc, i -> acc + i } } + val merged2 = QueryState.merge(arrayOf(state2, state1, state3)) { it.reduce { acc, i -> acc + i } } + assertEquals(QueryState.failure(error, 555), merged1) + assertEquals(QueryState.failure(error, 555), merged2) + } + + @Test + fun testMergeForN_withPending() { + val state1 = QueryState.success(1, dataUpdatedAt = 200, dataStaleAt = 250) + val state2 = QueryState.initial() + val state3 = QueryState.success(3, dataUpdatedAt = 400, dataStaleAt = 450) + val merged1 = QueryState.merge(arrayOf(state1, state2, state3)) { it.reduce { acc, i -> acc + i } } + val merged2 = QueryState.merge(arrayOf(state2, state1, state3)) { it.reduce { acc, i -> acc + i } } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } + + @Test + fun testMergeForN_allPending() { + val state1 = QueryState.initial() + val state2 = QueryState.initial() + val state3 = QueryState.initial() + val merged1 = QueryState.merge(arrayOf(state1, state2, state3)) { it.reduce { acc, i -> acc + i } } + val merged2 = QueryState.merge(arrayOf(state2, state1, state3)) { it.reduce { acc, i -> acc + i } } + assertEquals(QueryState.initial(), merged1) + assertEquals(QueryState.initial(), merged2) + } +} diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt index fe12cf5..1d7c35d 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt @@ -16,7 +16,6 @@ class SubscriptionOptionsTest : UnitTest() { fun factory_default() { val actual = SubscriptionOptions() assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) - assertEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) assertEquals(SubscriptionOptions.Default.onError, actual.onError) assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -34,7 +33,6 @@ class SubscriptionOptionsTest : UnitTest() { fun factory_factory_specifyingArguments() { val actual = SubscriptionOptions( gcTime = 1000.seconds, - subscribeOnMount = false, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, @@ -48,7 +46,6 @@ class SubscriptionOptionsTest : UnitTest() { retryRandomizer = Random(999) ) assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) - assertNotEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -66,7 +63,6 @@ class SubscriptionOptionsTest : UnitTest() { fun copy_default() { val actual = SubscriptionOptions.copy() assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) - assertEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) assertEquals(SubscriptionOptions.Default.onError, actual.onError) assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -84,7 +80,6 @@ class SubscriptionOptionsTest : UnitTest() { fun copy_override() { val actual = SubscriptionOptions.copy( gcTime = 1000.seconds, - subscribeOnMount = false, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, @@ -99,7 +94,6 @@ class SubscriptionOptionsTest : UnitTest() { ) assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) - assertNotEquals(SubscriptionOptions.Default.subscribeOnMount, actual.subscribeOnMount) assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) diff --git a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt index 6994c75..ce557bc 100644 --- a/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt +++ b/soil-query-test/src/commonTest/kotlin/soil/query/test/TestSwrClientTest.kt @@ -3,28 +3,20 @@ package soil.query.test -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.completeWith import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import soil.query.InfiniteQueryCommands import soil.query.InfiniteQueryId import soil.query.InfiniteQueryKey -import soil.query.InfiniteQueryRef import soil.query.MutationId import soil.query.MutationKey -import soil.query.QueryChunks -import soil.query.QueryCommands import soil.query.QueryId import soil.query.QueryKey -import soil.query.QueryRef import soil.query.SwrCache import soil.query.SwrCachePolicy import soil.query.buildInfiniteQueryKey import soil.query.buildMutationKey import soil.query.buildQueryKey -import soil.query.core.Marker import soil.query.core.getOrThrow import soil.testing.UnitTest import kotlin.test.Test @@ -63,7 +55,7 @@ class TestSwrClientTest : UnitTest() { } val key = ExampleQueryKey() val query = testClient.getQuery(key).also { it.launchIn(backgroundScope) } - query.test() + query.resume() assertEquals("Hello, World!", query.state.value.reply.getOrThrow()) } @@ -80,7 +72,7 @@ class TestSwrClientTest : UnitTest() { } val key = ExampleInfiniteQueryKey() val query = testClient.getInfiniteQuery(key).also { it.launchIn(backgroundScope) } - query.test() + query.resume() assertEquals("Hello, World!", query.state.value.reply.getOrThrow().first().data) } } @@ -119,15 +111,3 @@ private class ExampleInfiniteQueryKey : InfiniteQueryKey by buildIn namespace = "infinite-query/example" ) } - -private suspend fun QueryRef.test(): T { - val deferred = CompletableDeferred() - send(QueryCommands.Connect(key, marker = Marker.None, callback = deferred::completeWith)) - return deferred.await() -} - -private suspend fun InfiniteQueryRef.test(): QueryChunks { - val deferred = CompletableDeferred>() - send(InfiniteQueryCommands.Connect(key, marker = Marker.None, callback = deferred::completeWith)) - return deferred.await() -} From adbd2fb51a2bd13e9c2c7e1d0960b5ca1ee3f124 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sat, 7 Sep 2024 19:26:13 +0900 Subject: [PATCH 148/155] Introduce Optional Query The popular TanStack Query library in the React ecosystem has a feature known as [Dependent Query](https://tanstack.com/query/v5/docs/framework/react/guides/dependent-queries). Currently, Soil Query does not have an API for conditionally executing queries. Therefore, we have implemented the Optional Query feature to enable queries based on certain conditions, such as the results of other queries. Previously, developers could achieve similar functionality by manually implementing conditional branching, but this new feature can replace that approach. --- .../query/compose/InfiniteQueryComposables.kt | 47 +++++ .../soil/query/compose/MutationComposables.kt | 27 +++ .../soil/query/compose/QueryComposables.kt | 107 +++++++++++ .../query/compose/SubscriptionComposables.kt | 49 +++++ .../compose/InfiniteQueryComposableTest.kt | 78 ++++++++ .../query/compose/MutationComposableTest.kt | 57 +++++- .../soil/query/compose/QueryComposableTest.kt | 180 +++++++++++++++++- .../compose/SubscriptionComposableTest.kt | 67 +++++++ .../kotlin/soil/query/core/Reply.kt | 5 + 9 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposables.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposables.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposables.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposables.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposables.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposables.kt new file mode 100644 index 0000000..5850e5d --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposables.kt @@ -0,0 +1,47 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import soil.query.InfiniteQueryKey +import soil.query.QueryChunks +import soil.query.QueryClient + +/** + * Provides a conditional [rememberInfiniteQuery]. + * + * Calls [rememberInfiniteQuery] only if [keyFactory] returns a [InfiniteQueryKey] from [value]. + * + * @see rememberInfiniteQuery + */ +@Composable +fun rememberInfiniteQueryIf( + value: V, + keyFactory: (value: V) -> InfiniteQueryKey?, + config: InfiniteQueryConfig = InfiniteQueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): InfiniteQueryObject, S>? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberInfiniteQuery(key, config, client) +} + +/** + * Provides a conditional [rememberInfiniteQuery]. + * + * Calls [rememberInfiniteQuery] only if [keyFactory] returns a [InfiniteQueryKey] from [value]. + * + * @see rememberInfiniteQuery + */ +@Composable +fun rememberInfiniteQueryIf( + value: V, + keyFactory: (value: V) -> InfiniteQueryKey?, + select: (chunks: QueryChunks) -> U, + config: InfiniteQueryConfig = InfiniteQueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): InfiniteQueryObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberInfiniteQuery(key, select, config, client) +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposables.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposables.kt new file mode 100644 index 0000000..3cc4c3a --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposables.kt @@ -0,0 +1,27 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import soil.query.MutationClient +import soil.query.MutationKey + +/** + * Provides a conditional [rememberMutation]. + * + * Calls [rememberMutation] only if [keyFactory] returns a [MutationKey] from [value]. + * + * @see rememberMutation + */ +@Composable +fun rememberMutationIf( + value: V, + keyFactory: (value: V) -> MutationKey?, + config: MutationConfig = MutationConfig.Default, + client: MutationClient = LocalMutationClient.current +): MutationObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberMutation(key, config, client) +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposables.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposables.kt new file mode 100644 index 0000000..ce5300e --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposables.kt @@ -0,0 +1,107 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import soil.query.QueryClient +import soil.query.QueryKey +import kotlin.jvm.JvmName + +/** + * Provides a conditional [rememberQuery]. + * + * Calls [rememberQuery] only if [keyFactory] returns a [QueryKey] from [value]. + * + * @see rememberQuery + */ +@Composable +fun rememberQueryIf( + value: V, + keyFactory: (value: V) -> QueryKey?, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberQuery(key, config, client) +} + +/** + * Provides a conditional [rememberQuery]. + * + * Calls [rememberQuery] only if [keyFactory] returns a [QueryKey] from [value]. + * + * @see rememberQuery + */ +@Composable +fun rememberQueryIf( + value: V, + keyFactory: (value: V) -> QueryKey?, + select: (T) -> U, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberQuery(key, select, config, client) +} + +/** + * Provides a conditional [rememberQuery]. + * + * Calls [rememberQuery] only if [keyPairFactory] returns a [Pair] of [QueryKey]s from [value]. + * + * @see rememberQuery + */ +@JvmName("rememberQueryIfWithPair") +@Composable +fun rememberQueryIf( + value: V, + keyPairFactory: (value: V) -> Pair, QueryKey>?, + transform: (T1, T2) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject? { + val keyPair = remember(value) { keyPairFactory(value) } ?: return null + return rememberQuery(keyPair.first, keyPair.second, transform, config, client) +} + +/** + * Provides a conditional [rememberQuery]. + * + * Calls [rememberQuery] only if [keyTripleFactory] returns a [Triple] of [QueryKey]s from [value]. + * + * @see rememberQuery + */ +@JvmName("rememberQueryIfWithTriple") +@Composable +fun rememberQueryIf( + value: V, + keyTripleFactory: (value: V) -> Triple, QueryKey, QueryKey>?, + transform: (T1, T2, T3) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject? { + val keyTriple = remember(value) { keyTripleFactory(value) } ?: return null + return rememberQuery(keyTriple.first, keyTriple.second, keyTriple.third, transform, config, client) +} + +/** + * Provides a conditional [rememberQuery]. + * + * Calls [rememberQuery] only if [keyListFactory] returns a [List] of [QueryKey]s from [value]. + * + * @see rememberQuery + */ +@JvmName("rememberQueryIfWithList") +@Composable +fun rememberQueryIf( + value: V, + keyListFactory: (value: V) -> List>?, + transform: (List) -> R, + config: QueryConfig = QueryConfig.Default, + client: QueryClient = LocalQueryClient.current +): QueryObject? { + val keys = remember(value) { keyListFactory(value) } ?: return null + return rememberQuery(keys, transform, config, client) +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposables.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposables.kt new file mode 100644 index 0000000..6080daa --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposables.kt @@ -0,0 +1,49 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import soil.query.SubscriptionClient +import soil.query.SubscriptionKey +import soil.query.annotation.ExperimentalSoilQueryApi + +/** + * Provides a conditional [rememberSubscription]. + * + * Calls [rememberSubscription] only if [keyFactory] returns a [SubscriptionKey] from [value]. + * + * @see rememberSubscription + */ +@ExperimentalSoilQueryApi +@Composable +fun rememberSubscriptionIf( + value: V, + keyFactory: (value: V) -> SubscriptionKey?, + config: SubscriptionConfig = SubscriptionConfig.Default, + client: SubscriptionClient = LocalSubscriptionClient.current +): SubscriptionObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberSubscription(key, config, client) +} + +/** + * Provides a conditional [rememberSubscription]. + * + * Calls [rememberSubscription] only if [keyFactory] returns a [SubscriptionKey] from [value]. + * + * @see rememberSubscription + */ +@ExperimentalSoilQueryApi +@Composable +fun rememberSubscriptionIf( + value: V, + keyFactory: (value: V) -> SubscriptionKey?, + select: (T) -> U, + config: SubscriptionConfig = SubscriptionConfig.Default, + client: SubscriptionClient = LocalSubscriptionClient.current +): SubscriptionObject? { + val key = remember(value) { keyFactory(value) } ?: return null + return rememberSubscription(key, select, config, client) +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index 384a8cd..fce05ba 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -6,7 +6,11 @@ package soil.query.compose import androidx.compose.foundation.layout.Column import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi @@ -32,6 +36,7 @@ import soil.query.compose.tooling.QueryPreviewClient import soil.query.compose.tooling.SwrPreviewClient import soil.query.core.Marker import soil.query.core.Reply +import soil.query.core.orNone import soil.testing.UnitTest import kotlin.test.Test @@ -249,6 +254,79 @@ class InfiniteQueryComposableTest : UnitTest() { onNodeWithTag("query").assertTextEquals("ChunkSize: 1") } + @Test + fun testRememberInfiniteQueryIf() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberInfiniteQueryIf(enabled, keyFactory = { if (it) key else null }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> { + reply.value.forEach { chunk -> + Text( + "Size: ${chunk.data.size} - Page: ${chunk.param.page}", + modifier = Modifier.testTag("query") + ) + } + } + + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilAtLeastOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Size: 10 - Page: 0") + } + + @Test + fun testRememberInfiniteQueryIf_select() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberInfiniteQueryIf( + value = enabled, + keyFactory = { if (it) key else null }, + select = { it.chunkedData }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> { + reply.value.forEach { data -> + Text(data, modifier = Modifier.testTag("query")) + } + } + + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilAtLeastOneExists(hasTestTag("query")) + onAllNodes(hasTestTag("query")).assertCountEquals(10) + } + + private class TestInfiniteQueryKey : InfiniteQueryKey, PageParam> by buildInfiniteQueryKey( id = Id, fetch = { param -> diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt index 497ca82..e6da13c 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationComposableTest.kt @@ -3,9 +3,14 @@ package soil.query.compose +import androidx.compose.foundation.layout.Column import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi @@ -25,6 +30,7 @@ import soil.query.compose.tooling.MutationPreviewClient import soil.query.compose.tooling.SwrPreviewClient import soil.query.core.Marker import soil.query.core.Reply +import soil.query.core.orNone import soil.query.test.test import soil.testing.UnitTest import kotlin.test.Test @@ -61,10 +67,10 @@ class MutationComposableTest : UnitTest() { } } - // TODO: I don't know why but it's broken. - // Related issue: https://github.com/JetBrains/compose-multiplatform-core/blob/46232e6533a71625f7599c206594fca5e2e28e09/compose/ui/ui-test/src/skikoMain/kotlin/androidx/compose/ui/test/Assertions.skikoMain.kt#L26 - // onNodeWithTag("result").assertIsNotDisplayed() + waitForIdle() + onNodeWithTag("result").assertDoesNotExist() onNodeWithTag("mutation").performClick() + waitUntilExactlyOneExists(hasTestTag("result")) onNodeWithTag("result").assertTextEquals("Soil - 1") } @@ -227,6 +233,51 @@ class MutationComposableTest : UnitTest() { onNodeWithTag("mutation").assertTextEquals("Error") } + @Test + fun testRememberMutationIf() = runComposeUiTest { + val key = TestMutationKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val mutation = rememberMutationIf(enabled, { if (it) key else null }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = mutation?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("result")) + is Reply.None -> Unit + } + val scope = rememberCoroutineScope() + if (mutation != null) { + Button( + onClick = { + scope.launch { + mutation.mutate(TestForm("Soil", 1)) + } + }, + modifier = Modifier.testTag("mutation") + ) { + Text("Mutate") + } + } + } + } + } + + waitForIdle() + onNodeWithTag("mutation").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitForIdle() + onNodeWithTag("result").assertDoesNotExist() + onNodeWithTag("mutation").performClick() + + waitUntilExactlyOneExists(hasTestTag("result")) + onNodeWithTag("result").assertTextEquals("Soil - 1") + } + private class TestMutationKey : MutationKey by buildMutationKey( mutate = { form -> "${form.name} - ${form.age}" diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt index 784587b..9121155 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryComposableTest.kt @@ -3,13 +3,20 @@ package soil.query.compose +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.waitUntilExactlyOneExists import soil.query.QueryId @@ -22,6 +29,7 @@ import soil.query.compose.tooling.QueryPreviewClient import soil.query.compose.tooling.SwrPreviewClient import soil.query.core.Marker import soil.query.core.Reply +import soil.query.core.orNone import soil.query.test.test import soil.testing.UnitTest import kotlin.test.Test @@ -164,7 +172,6 @@ class QueryComposableTest : UnitTest() { onNodeWithTag("query").assertTextEquals("Hello, Compose!|Hello, Soil!|Hello, Kotlin!") } - @Test fun testRememberQuery_loadingPreview() = runComposeUiTest { val key = TestQueryKey() @@ -249,6 +256,177 @@ class QueryComposableTest : UnitTest() { onNodeWithTag("query").assertTextEquals("Hello, Query!") } + @Test + fun testRememberQueryIf() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberQueryIf(enabled, keyFactory = { if (it) key else null }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Soil!") + } + + @Test + fun testRememberQueryIf_select() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key.id) { "Hello, Compose!" } + } + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberQueryIf( + value = enabled, + keyFactory = { if (it) key else null }, + select = { it.uppercase() } + ) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("HELLO, COMPOSE!") + } + + @Test + fun testRememberQueryIf_combineTwo() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + } + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberQueryIf( + value = enabled, + keyPairFactory = { if (it) key1 to key2 else null }, + transform = { a, b -> a + b }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!Hello, Soil!") + } + + @Test + fun testRememberQueryIf_combineThree() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val key3 = TestQueryKey(number = 3) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + on(key3.id) { "Hello, Kotlin!" } + } + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberQueryIf( + value = enabled, + keyTripleFactory = { if (it) Triple(key1, key2, key3) else null }, + transform = { a, b, c -> a + b + c }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!Hello, Soil!Hello, Kotlin!") + } + + @Test + fun testRememberQueryIf_combineN() = runComposeUiTest { + val key1 = TestQueryKey(number = 1) + val key2 = TestQueryKey(number = 2) + val key3 = TestQueryKey(number = 3) + val client = SwrCache(coroutineScope = SwrCacheScope()).test { + on(key1.id) { "Hello, Compose!" } + on(key2.id) { "Hello, Soil!" } + on(key3.id) { "Hello, Kotlin!" } + } + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val query = rememberQueryIf( + value = enabled, + keyListFactory = { if (it) listOf(key1, key2, key3) else null }, + transform = { it.joinToString("|") }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = query?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("query").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("query")) + onNodeWithTag("query").assertTextEquals("Hello, Compose!|Hello, Soil!|Hello, Kotlin!") + } + private class TestQueryKey( number: Int = 1 ) : QueryKey by buildQueryKey( diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt index 2e02f95..492b75c 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt @@ -3,13 +3,20 @@ package soil.query.compose +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.waitUntilExactlyOneExists import kotlinx.coroutines.flow.MutableStateFlow @@ -26,6 +33,7 @@ import soil.query.compose.tooling.SubscriptionPreviewClient import soil.query.compose.tooling.SwrPreviewClient import soil.query.core.Marker import soil.query.core.Reply +import soil.query.core.orNone import soil.query.test.testPlus import soil.testing.UnitTest import kotlin.test.Test @@ -196,6 +204,65 @@ class SubscriptionComposableTest : UnitTest() { onNodeWithTag("subscription").assertTextEquals("Error") } + @Test + fun testRememberSubscriptionIf() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()) + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val subscription = rememberSubscriptionIf(enabled, keyFactory = { if (it) key else null }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = subscription?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("subscription")) + onNodeWithTag("subscription").assertTextEquals("Hello, Soil!") + } + + @Test + fun testRememberSubscriptionIf_select() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()).testPlus { + on(key.id) { MutableStateFlow("Hello, Compose!") } + } + setContent { + SwrClientProvider(client) { + var enabled by remember { mutableStateOf(false) } + val subscription = + rememberSubscriptionIf(enabled, keyFactory = { if (it) key else null }, select = { it.uppercase() }) + Column { + Button(onClick = { enabled = !enabled }, modifier = Modifier.testTag("toggle")) { + Text("Toggle") + } + when (val reply = subscription?.reply.orNone()) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + } + + waitForIdle() + onNodeWithTag("subscription").assertDoesNotExist() + onNodeWithTag("toggle").performClick() + + waitUntilExactlyOneExists(hasTestTag("subscription")) + onNodeWithTag("subscription").assertTextEquals("HELLO, COMPOSE!") + } + private class TestSubscriptionKey : SubscriptionKey by buildSubscriptionKey( id = Id, subscribe = { MutableStateFlow("Hello, Soil!") } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt index f018b86..a017abb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt @@ -50,6 +50,11 @@ fun Reply.getOrElse(default: () -> T): T = when (this) { is Reply.Some -> value } +/** + * Returns the value of the [Reply.Some] instance, or the [default] value if there is no reply yet ([Reply.None]). + */ +inline fun Reply?.orNone(): Reply = this ?: Reply.none() + /** * Transforms the value of the [Reply.Some] instance using the provided [transform] function, * or returns [Reply.None] if there is no reply yet ([Reply.None]). From 44bb6fbd0e8a9ae33502a1069bb4f29feecdc813 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 16 Sep 2024 15:11:56 +0900 Subject: [PATCH 149/155] Implement the `Symbol` class to represent null The Symbol class has been implemented to represent null values for `DataModel`. With the recent addition of Optional Query, there have been instances where a `QueryObject` can be nullable. To address this, a fallback function has been included in the runtime package module. refs: #95 --- .../kotlin/soil/query/compose/runtime/Util.kt | 38 +++++++++++++++++++ .../soil/query/compose/runtime/AwaitTest.kt | 37 ++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Util.kt diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Util.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Util.kt new file mode 100644 index 0000000..b0c0606 --- /dev/null +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/Util.kt @@ -0,0 +1,38 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.runtime + +import androidx.compose.runtime.Immutable +import soil.query.core.DataModel +import soil.query.core.Reply + +@Immutable +private sealed class Symbol : DataModel { + data object None : Symbol() { + override val reply: Reply = Reply.None + override val replyUpdatedAt: Long = 0 + override val error: Throwable? = null + override val errorUpdatedAt: Long = 0 + override fun isAwaited(): Boolean = false + } + + data object Pending : Symbol() { + override val reply: Reply = Reply.None + override val replyUpdatedAt: Long = 0 + override val error: Throwable? = null + override val errorUpdatedAt: Long = 0 + override fun isAwaited(): Boolean = true + } + +} + +/** + * Returns a [DataModel] that represents no data. + */ +fun DataModel?.orNone(): DataModel = this ?: Symbol.None + +/** + * Returns a [DataModel] that represents pending data. + */ +fun DataModel?.orPending(): DataModel = this ?: Symbol.Pending diff --git a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt index 6abcae3..2402846 100644 --- a/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt +++ b/soil-query-compose-runtime/src/commonTest/kotlin/soil/query/compose/runtime/AwaitTest.kt @@ -26,6 +26,7 @@ import soil.query.SwrCache import soil.query.SwrCacheScope import soil.query.buildInfiniteQueryKey import soil.query.buildQueryKey +import soil.query.compose.QueryObject import soil.query.compose.SwrClientProvider import soil.query.compose.rememberInfiniteQuery import soil.query.compose.rememberQuery @@ -227,6 +228,42 @@ class AwaitTest : UnitTest() { onNodeWithTag("await").assertTextEquals("Hello, Compose!") } + @Test + fun testAwait_orNone() = runComposeUiTest { + setContent { + val query: QueryObject? = null /* rememberQueryIf(..) */ + Suspense( + fallback = { Text("Loading...", modifier = Modifier.testTag("fallback")) }, + contentThreshold = Duration.ZERO + ) { + Await(query.orNone()) { data -> + Text(data, modifier = Modifier.testTag("await")) + } + } + } + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + onNodeWithTag("fallback").assertDoesNotExist() + } + + @Test + fun testAwait_orPending() = runComposeUiTest { + setContent { + val query: QueryObject? = null /* rememberQueryIf(..) */ + Suspense( + fallback = { Text("Loading...", modifier = Modifier.testTag("fallback")) }, + contentThreshold = Duration.ZERO + ) { + Await(query.orPending()) { data -> + Text(data, modifier = Modifier.testTag("await")) + } + } + } + waitForIdle() + onNodeWithTag("await").assertDoesNotExist() + onNodeWithTag("fallback").assertExists() + } + private class TestQueryKey(val variant: String) : QueryKey by buildQueryKey( id = Id(variant), fetch = { "Hello, Soil!" } From e8dc1878dc00d765e31e070bbc732cca07dda505 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 16 Sep 2024 17:08:14 +0900 Subject: [PATCH 150/155] Fix a flaky test --- .../kotlin/soil/query/compose/InfiniteQueryComposableTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index fce05ba..98cc165 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -286,6 +286,7 @@ class InfiniteQueryComposableTest : UnitTest() { onNodeWithTag("query").assertDoesNotExist() onNodeWithTag("toggle").performClick() + waitForIdle() waitUntilAtLeastOneExists(hasTestTag("query")) onNodeWithTag("query").assertTextEquals("Size: 10 - Page: 0") } @@ -322,6 +323,7 @@ class InfiniteQueryComposableTest : UnitTest() { onNodeWithTag("query").assertDoesNotExist() onNodeWithTag("toggle").performClick() + waitForIdle() waitUntilAtLeastOneExists(hasTestTag("query")) onAllNodes(hasTestTag("query")).assertCountEquals(10) } From 7b0734682cd698865f5660e631a07be8e9cedb72 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 22 Sep 2024 08:46:12 +0900 Subject: [PATCH 151/155] Optimization of Recomposition Within the Composable functions provided by the Query library, recomposition occurs due to changes in the internal state related to data loading. To reduce unnecessary state updates in the UI, we have implemented a RecompositionOptimizer that omits certain state changes based on conditions. This RecompositionOptimizer can be configured on a per-Composable function basis, and the optimization is enabled by default. If you need to reference the omitted state items in the UI, you can disable the optimization through the configuration settings. --- .../query/compose/InfiniteQueryComposable.kt | 5 +- .../soil/query/compose/InfiniteQueryConfig.kt | 23 +- .../InfiniteQueryRecompositionOptimizer.kt | 71 +++++ .../soil/query/compose/MutationComposable.kt | 3 +- .../soil/query/compose/MutationConfig.kt | 26 +- .../compose/MutationRecompositionOptimizer.kt | 68 ++++ .../soil/query/compose/QueryComposable.kt | 46 +-- .../kotlin/soil/query/compose/QueryConfig.kt | 25 +- .../compose/QueryRecompositionOptimizer.kt | 70 +++++ .../query/compose/SubscriptionComposable.kt | 5 +- .../soil/query/compose/SubscriptionConfig.kt | 30 +- .../SubscriptionRecompositionOptimizer.kt | 79 +++++ .../query/compose/internal/CombinedQuery2.kt | 58 +++- .../query/compose/internal/CombinedQuery3.kt | 64 +++- .../query/compose/internal/CombinedQueryN.kt | 51 ++- .../query/compose/internal/InfiniteQuery.kt | 80 +++++ .../soil/query/compose/internal/Mutation.kt | 78 +++++ .../soil/query/compose/internal/Query.kt | 76 +++++ .../query/compose/internal/Subscription.kt | 80 +++++ .../compose/InfiniteQueryComposableTest.kt | 3 +- ...InfiniteQueryRecompositionOptimizerTest.kt | 297 ++++++++++++++++++ .../query/compose/MutationComposableTest.kt | 3 +- .../MutationRecompositionOptimizerTest.kt | 160 ++++++++++ .../soil/query/compose/QueryComposableTest.kt | 3 +- .../QueryRecompositionOptimizerTest.kt | 262 +++++++++++++++ .../compose/SubscriptionComposableTest.kt | 5 +- .../SubscriptionRecompositionOptimizerTest.kt | 259 +++++++++++++++ .../kotlin/soil/query/MutationState.kt | 48 +++ .../kotlin/soil/query/QueryState.kt | 53 ++++ .../kotlin/soil/query/SubscriptionState.kt | 50 +++ .../kotlin/soil/query/MutationStateTest.kt | 47 +++ .../kotlin/soil/query/QueryStateTest.kt | 48 +++ .../soil/query/SubscriptionStateTest.kt | 77 +++++ 33 files changed, 2153 insertions(+), 100 deletions(-) create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizer.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationRecompositionOptimizer.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryRecompositionOptimizer.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionRecompositionOptimizer.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/InfiniteQuery.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Mutation.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Query.kt create mode 100644 soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Subscription.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizerTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/MutationRecompositionOptimizerTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/QueryRecompositionOptimizerTest.kt create mode 100644 soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionRecompositionOptimizerTest.kt create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/MutationStateTest.kt create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/QueryStateTest.kt create mode 100644 soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionStateTest.kt diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt index 16574b5..08c7af4 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryComposable.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.rememberCoroutineScope import soil.query.InfiniteQueryKey import soil.query.QueryChunks import soil.query.QueryClient +import soil.query.compose.internal.newInfiniteQuery /** * Remember a [InfiniteQueryObject] and subscribes to the query state of [key]. @@ -27,7 +28,7 @@ fun rememberInfiniteQuery( client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject, S> { val scope = rememberCoroutineScope() - val query = remember(key.id) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { newInfiniteQuery(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = { it }) } @@ -52,7 +53,7 @@ fun rememberInfiniteQuery( client: QueryClient = LocalQueryClient.current ): InfiniteQueryObject { val scope = rememberCoroutineScope() - val query = remember(key.id) { client.getInfiniteQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { newInfiniteQuery(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = select) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt index 9873b80..3d061f0 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryConfig.kt @@ -9,23 +9,33 @@ import soil.query.core.Marker /** * Configuration for the infinite query. * + * @property mapper The mapper for converting query data. + * @property optimizer The optimizer for recomposing the query data. * @property strategy The strategy for caching query data. * @property marker The marker with additional information based on the caller of a query. */ @Immutable data class InfiniteQueryConfig internal constructor( - val strategy: InfiniteQueryStrategy, val mapper: InfiniteQueryObjectMapper, + val optimizer: InfiniteQueryRecompositionOptimizer, + val strategy: InfiniteQueryStrategy, val marker: Marker ) { - class Builder { - var strategy: InfiniteQueryStrategy = Default.strategy - var mapper: InfiniteQueryObjectMapper = Default.mapper - var marker: Marker = Default.marker + /** + * Creates a new [InfiniteQueryConfig] with the provided [block]. + */ + fun builder(block: Builder.() -> Unit) = Builder(this).apply(block).build() + + class Builder(config: InfiniteQueryConfig = Default) { + var mapper: InfiniteQueryObjectMapper = config.mapper + var optimizer: InfiniteQueryRecompositionOptimizer = config.optimizer + var strategy: InfiniteQueryStrategy = config.strategy + var marker: Marker = config.marker fun build() = InfiniteQueryConfig( strategy = strategy, + optimizer = optimizer, mapper = mapper, marker = marker ) @@ -33,8 +43,9 @@ data class InfiniteQueryConfig internal constructor( companion object { val Default = InfiniteQueryConfig( - strategy = InfiniteQueryStrategy.Default, mapper = InfiniteQueryObjectMapper.Default, + optimizer = InfiniteQueryRecompositionOptimizer.Default, + strategy = InfiniteQueryStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizer.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizer.kt new file mode 100644 index 0000000..96a9501 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizer.kt @@ -0,0 +1,71 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.QueryChunks +import soil.query.QueryState +import soil.query.QueryStatus + +/** + * A recomposition optimizer for [QueryState] with [QueryChunks]. + */ +interface InfiniteQueryRecompositionOptimizer { + + /** + * Omit the specified keys from the [QueryState] with [QueryChunks]. + * + * @param state The infinite query state. + * @return The optimized infinite query state. + */ + fun omit(state: QueryState>): QueryState> + + companion object +} + +/** + * Optimizer implementation for [InfiniteQueryStrategy.Companion.Default]. + */ +val InfiniteQueryRecompositionOptimizer.Companion.Default: InfiniteQueryRecompositionOptimizer + get() = DefaultInfiniteQueryRecompositionOptimizer + +private object DefaultInfiniteQueryRecompositionOptimizer : InfiniteQueryRecompositionOptimizer { + override fun omit(state: QueryState>): QueryState> { + val keys = buildSet { + add(QueryState.OmitKey.replyUpdatedAt) + add(QueryState.OmitKey.staleAt) + when (state.status) { + QueryStatus.Pending -> { + add(QueryState.OmitKey.errorUpdatedAt) + add(QueryState.OmitKey.fetchStatus) + } + + QueryStatus.Success -> { + add(QueryState.OmitKey.errorUpdatedAt) + if (!state.isInvalidated) { + add(QueryState.OmitKey.fetchStatus) + } + } + + QueryStatus.Failure -> { + if (!state.isInvalidated) { + add(QueryState.OmitKey.fetchStatus) + } + } + } + } + return state.omit(keys) + } +} + +/** + * Option that performs no optimization. + */ +val InfiniteQueryRecompositionOptimizer.Companion.Disabled: InfiniteQueryRecompositionOptimizer + get() = DisabledInfiniteQueryRecompositionOptimizer + +private object DisabledInfiniteQueryRecompositionOptimizer : InfiniteQueryRecompositionOptimizer { + override fun omit(state: QueryState>): QueryState> { + return state + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt index e55ae0f..930f4c7 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationComposable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.MutationClient import soil.query.MutationKey +import soil.query.compose.internal.newMutation /** * Remember a [MutationObject] and subscribes to the mutation state of [key]. @@ -26,7 +27,7 @@ fun rememberMutation( client: MutationClient = LocalMutationClient.current ): MutationObject { val scope = rememberCoroutineScope() - val mutation = remember(key.id) { client.getMutation(key, config.marker).also { it.launchIn(scope) } } + val mutation = remember(key.id) { newMutation(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(mutation).toObject(mutation = mutation) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt index f350b36..75e27b9 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationConfig.kt @@ -9,31 +9,43 @@ import soil.query.core.Marker /** * Configuration for the mutation. * + * @property mapper The mapper for converting mutation data. + * @property optimizer The optimizer for recomposing the mutation data. + * @property strategy The strategy for caching mutation data. * @property marker The marker with additional information based on the caller of a mutation. */ @Immutable data class MutationConfig internal constructor( - val strategy: MutationStrategy, val mapper: MutationObjectMapper, + val optimizer: MutationRecompositionOptimizer, + val strategy: MutationStrategy, val marker: Marker ) { - class Builder { - var strategy: MutationStrategy = Default.strategy - var mapper: MutationObjectMapper = Default.mapper - var marker: Marker = Default.marker + /** + * Creates a new [MutationConfig] with the provided [block]. + */ + fun builder(block: Builder.() -> Unit) = Builder(this).apply(block).build() + + class Builder(config: MutationConfig = Default) { + var mapper: MutationObjectMapper = config.mapper + var optimizer: MutationRecompositionOptimizer = config.optimizer + var strategy: MutationStrategy = config.strategy + var marker: Marker = config.marker fun build() = MutationConfig( - strategy = strategy, mapper = mapper, + optimizer = optimizer, + strategy = strategy, marker = marker ) } companion object { val Default = MutationConfig( - strategy = MutationStrategy.Default, mapper = MutationObjectMapper.Default, + optimizer = MutationRecompositionOptimizer.Default, + strategy = MutationStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationRecompositionOptimizer.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationRecompositionOptimizer.kt new file mode 100644 index 0000000..ca136c1 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/MutationRecompositionOptimizer.kt @@ -0,0 +1,68 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.MutationState +import soil.query.MutationStatus + +/** + * A recomposition optimizer for [MutationState]. + */ +interface MutationRecompositionOptimizer { + + /** + * Omit the specified keys from the [MutationState]. + * + * @param state The mutation state. + * @return The optimized mutation state. + */ + fun omit(state: MutationState): MutationState + + companion object +} + +/** + * Optimizer implementation for [MutationStrategy.Companion.Default]. + */ +val MutationRecompositionOptimizer.Companion.Default: MutationRecompositionOptimizer + get() = DefaultMutationRecompositionOptimizer + +private object DefaultMutationRecompositionOptimizer : MutationRecompositionOptimizer { + override fun omit(state: MutationState): MutationState { + val keys = buildSet { + add(MutationState.OmitKey.replyUpdatedAt) + add(MutationState.OmitKey.mutatedCount) + when (state.status) { + MutationStatus.Idle -> { + add(MutationState.OmitKey.errorUpdatedAt) + } + + MutationStatus.Pending -> { + if (state.error == null) { + add(MutationState.OmitKey.errorUpdatedAt) + } + } + + MutationStatus.Success -> { + add(MutationState.OmitKey.errorUpdatedAt) + } + + MutationStatus.Failure -> Unit + } + } + return state.omit(keys) + } +} + +/** + * Option that performs no optimization. + */ +val MutationRecompositionOptimizer.Companion.Disabled: MutationRecompositionOptimizer + get() = DisabledMutationRecompositionOptimizer + +private object DisabledMutationRecompositionOptimizer : MutationRecompositionOptimizer { + override fun omit(state: MutationState): MutationState { + return state + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt index d686033..5a98fc3 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryComposable.kt @@ -4,12 +4,12 @@ package soil.query.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import soil.query.QueryClient import soil.query.QueryKey -import soil.query.compose.internal.combineQuery +import soil.query.compose.internal.newCombinedQuery +import soil.query.compose.internal.newQuery /** * Remember a [QueryObject] and subscribes to the query state of [key]. @@ -27,7 +27,7 @@ fun rememberQuery( client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { newQuery(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = { it }) } @@ -52,7 +52,7 @@ fun rememberQuery( client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() - val query = remember(key) { client.getQuery(key, config.marker).also { it.launchIn(scope) } } + val query = remember(key.id) { newQuery(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(query).toObject(query = query, select = select) } @@ -80,17 +80,11 @@ fun rememberQuery( client: QueryClient = LocalQueryClient.current, ): QueryObject { val scope = rememberCoroutineScope() - val query1 = remember(key1.id) { client.getQuery(key1, config.marker).also { it.launchIn(scope) } } - val query2 = remember(key2.id) { client.getQuery(key2, config.marker).also { it.launchIn(scope) } } - val combinedQuery = remember(query1, query2) { - combineQuery(query1, query2, transform) - } - DisposableEffect(combinedQuery.id) { - val job = combinedQuery.launchIn(scope) - onDispose { job.cancel() } + val query = remember(key1.id, key2.id) { + newCombinedQuery(key1, key2, transform, config, client, scope) } return with(config.mapper) { - config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + config.strategy.collectAsState(query).toObject(query = query, select = { it }) } } @@ -119,18 +113,11 @@ fun rememberQuery( client: QueryClient = LocalQueryClient.current, ): QueryObject { val scope = rememberCoroutineScope() - val query1 = remember(key1.id) { client.getQuery(key1, config.marker).also { it.launchIn(scope) } } - val query2 = remember(key2.id) { client.getQuery(key2, config.marker).also { it.launchIn(scope) } } - val query3 = remember(key3.id) { client.getQuery(key3, config.marker).also { it.launchIn(scope) } } - val combinedQuery = remember(query1, query2, query3) { - combineQuery(query1, query2, query3, transform) - } - DisposableEffect(combinedQuery.id) { - val job = combinedQuery.launchIn(scope) - onDispose { job.cancel() } + val query = remember(key1.id, key2.id, key3.id) { + newCombinedQuery(key1, key2, key3, transform, config, client, scope) } return with(config.mapper) { - config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + config.strategy.collectAsState(query).toObject(query = query, select = { it }) } } @@ -153,17 +140,10 @@ fun rememberQuery( client: QueryClient = LocalQueryClient.current ): QueryObject { val scope = rememberCoroutineScope() - val queries = remember(keys) { - keys.map { key -> client.getQuery(key, config.marker).also { it.launchIn(scope) } } - } - val combinedQuery = remember(queries) { - combineQuery(queries.toTypedArray(), transform) - } - DisposableEffect(combinedQuery.id) { - val job = combinedQuery.launchIn(scope) - onDispose { job.cancel() } + val query = remember(*keys.map { it.id }.toTypedArray()) { + newCombinedQuery(keys, transform, config, client, scope) } return with(config.mapper) { - config.strategy.collectAsState(combinedQuery).toObject(query = combinedQuery, select = { it }) + config.strategy.collectAsState(query).toObject(query = query, select = { it }) } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt index ad05b5a..a31d4da 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryConfig.kt @@ -9,32 +9,43 @@ import soil.query.core.Marker /** * Configuration for the query. * + * @property mapper The mapper for converting query data. + * @property optimizer The optimizer for recomposing the query data. * @property strategy The strategy for caching query data. * @property marker The marker with additional information based on the caller of a query. */ @Immutable data class QueryConfig internal constructor( - val strategy: QueryStrategy, val mapper: QueryObjectMapper, + val optimizer: QueryRecompositionOptimizer, + val strategy: QueryStrategy, val marker: Marker ) { - class Builder { - var strategy: QueryStrategy = Default.strategy - var mapper: QueryObjectMapper = Default.mapper - var marker: Marker = Default.marker + /** + * Creates a new [QueryConfig] with the provided [block]. + */ + fun builder(block: Builder.() -> Unit) = Builder(this).apply(block).build() + + class Builder(config: QueryConfig = Default) { + var mapper: QueryObjectMapper = config.mapper + var optimizer: QueryRecompositionOptimizer = config.optimizer + var strategy: QueryStrategy = config.strategy + var marker: Marker = config.marker fun build() = QueryConfig( - strategy = strategy, mapper = mapper, + optimizer = optimizer, + strategy = strategy, marker = marker ) } companion object { val Default = QueryConfig( - strategy = QueryStrategy.Default, mapper = QueryObjectMapper.Default, + optimizer = QueryRecompositionOptimizer.Default, + strategy = QueryStrategy.Default, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryRecompositionOptimizer.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryRecompositionOptimizer.kt new file mode 100644 index 0000000..881dbb4 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/QueryRecompositionOptimizer.kt @@ -0,0 +1,70 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.QueryState +import soil.query.QueryStatus + +/** + * A recomposition optimizer for [QueryState]. + */ +interface QueryRecompositionOptimizer { + + /** + * Omit the specified keys from the [QueryState]. + * + * @param state The query state. + * @return The optimized query state. + */ + fun omit(state: QueryState): QueryState + + companion object +} + +/** + * Optimizer implementation for [QueryStrategy.Companion.Default]. + */ +val QueryRecompositionOptimizer.Companion.Default: QueryRecompositionOptimizer + get() = DefaultQueryRecompositionOptimizer + +private object DefaultQueryRecompositionOptimizer : QueryRecompositionOptimizer { + override fun omit(state: QueryState): QueryState { + val keys = buildSet { + add(QueryState.OmitKey.replyUpdatedAt) + add(QueryState.OmitKey.staleAt) + when (state.status) { + QueryStatus.Pending -> { + add(QueryState.OmitKey.errorUpdatedAt) + add(QueryState.OmitKey.fetchStatus) + } + + QueryStatus.Success -> { + add(QueryState.OmitKey.errorUpdatedAt) + if (!state.isInvalidated) { + add(QueryState.OmitKey.fetchStatus) + } + } + + QueryStatus.Failure -> { + if (!state.isInvalidated) { + add(QueryState.OmitKey.fetchStatus) + } + } + } + } + return state.omit(keys) + } +} + +/** + * Option that performs no optimization. + */ +val QueryRecompositionOptimizer.Companion.Disabled: QueryRecompositionOptimizer + get() = DisabledQueryRecompositionOptimizer + +private object DisabledQueryRecompositionOptimizer : QueryRecompositionOptimizer { + override fun omit(state: QueryState): QueryState { + return state + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt index 45588fc..4127500 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionComposable.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.rememberCoroutineScope import soil.query.SubscriptionClient import soil.query.SubscriptionKey import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.compose.internal.newSubscription /** * Remember a [SubscriptionObject] and subscribes to the subscription state of [key]. @@ -27,7 +28,7 @@ fun rememberSubscription( client: SubscriptionClient = LocalSubscriptionClient.current ): SubscriptionObject { val scope = rememberCoroutineScope() - val subscription = remember(key.id) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + val subscription = remember(key.id) { newSubscription(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = { it }) } @@ -53,7 +54,7 @@ fun rememberSubscription( client: SubscriptionClient = LocalSubscriptionClient.current ): SubscriptionObject { val scope = rememberCoroutineScope() - val subscription = remember(key.id) { client.getSubscription(key, config.marker).also { it.launchIn(scope) } } + val subscription = remember(key.id) { newSubscription(key, config, client, scope) } return with(config.mapper) { config.strategy.collectAsState(subscription).toObject(subscription = subscription, select = select) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt index 960fc57..3d40c6a 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionConfig.kt @@ -9,32 +9,50 @@ import soil.query.core.Marker /** * Configuration for the subscription. * + * @property mapper The mapper for converting subscription data. + * @property optimizer The optimizer for recomposing the subscription data. * @property strategy The strategy for caching subscription data. * @property marker The marker with additional information based on the caller of a subscription. */ @Immutable data class SubscriptionConfig internal constructor( - val strategy: SubscriptionStrategy, val mapper: SubscriptionObjectMapper, + val optimizer: SubscriptionRecompositionOptimizer, + val strategy: SubscriptionStrategy, val marker: Marker ) { - class Builder { - var strategy: SubscriptionStrategy = SubscriptionStrategy.Default - var mapper: SubscriptionObjectMapper = SubscriptionObjectMapper.Default - var marker: Marker = Default.marker + /** + * Creates a new [SubscriptionConfig] with the provided [block]. + */ + fun builder(block: Builder.() -> Unit) = Builder(this).apply(block).build() + + class Builder(config: SubscriptionConfig = Default) { + var mapper: SubscriptionObjectMapper = config.mapper + var optimizer: SubscriptionRecompositionOptimizer = config.optimizer + var strategy: SubscriptionStrategy = config.strategy + var marker: Marker = config.marker fun build() = SubscriptionConfig( - strategy = strategy, mapper = mapper, + optimizer = optimizer, + strategy = strategy, marker = marker ) } companion object { val Default = SubscriptionConfig( + mapper = SubscriptionObjectMapper.Default, + optimizer = SubscriptionRecompositionOptimizer.Default, strategy = SubscriptionStrategy.Default, + marker = Marker.None + ) + + val Lazy = SubscriptionConfig( mapper = SubscriptionObjectMapper.Default, + optimizer = SubscriptionRecompositionOptimizer.Lazy, + strategy = SubscriptionStrategy.Lazy, marker = Marker.None ) } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionRecompositionOptimizer.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionRecompositionOptimizer.kt new file mode 100644 index 0000000..7b96784 --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/SubscriptionRecompositionOptimizer.kt @@ -0,0 +1,79 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import soil.query.SubscriberStatus +import soil.query.SubscriptionState +import soil.query.SubscriptionStatus + +/** + * A recomposition optimizer for [SubscriptionState]. + */ +interface SubscriptionRecompositionOptimizer { + + /** + * Omit the specified keys from the [SubscriptionState]. + * + * @param state The subscription state. + * @return The optimized subscription state. + */ + fun omit(state: SubscriptionState): SubscriptionState + + companion object +} + +/** + * Optimizer implementation for [SubscriptionStrategy.Companion.Default]. + */ +val SubscriptionRecompositionOptimizer.Companion.Default: SubscriptionRecompositionOptimizer + get() = DefaultSubscriptionRecompositionOptimizer + +private object DefaultSubscriptionRecompositionOptimizer : + AbstractSubscriptionRecompositionOptimizer(SubscriberStatus.Active) + +/** + * Optimizer implementation for [SubscriptionStrategy.Companion.Lazy]. + */ +val SubscriptionRecompositionOptimizer.Companion.Lazy: SubscriptionRecompositionOptimizer + get() = LazySubscriptionRecompositionOptimizer + +private object LazySubscriptionRecompositionOptimizer : + AbstractSubscriptionRecompositionOptimizer() + +private abstract class AbstractSubscriptionRecompositionOptimizer( + private val subscriberStatus: SubscriberStatus? = null +) : SubscriptionRecompositionOptimizer { + override fun omit(state: SubscriptionState): SubscriptionState { + val keys = buildSet { + add(SubscriptionState.OmitKey.replyUpdatedAt) + if (subscriberStatus != null) { + add(SubscriptionState.OmitKey.subscriberStatus) + } + when (state.status) { + SubscriptionStatus.Pending -> { + add(SubscriptionState.OmitKey.errorUpdatedAt) + } + + SubscriptionStatus.Success -> { + add(SubscriptionState.OmitKey.errorUpdatedAt) + } + + SubscriptionStatus.Failure -> Unit + } + } + return state.omit(keys, subscriberStatus ?: SubscriberStatus.NoSubscribers) + } +} + +/** + * Option that performs no optimization. + */ +val SubscriptionRecompositionOptimizer.Companion.Disabled: SubscriptionRecompositionOptimizer + get() = DisabledSubscriptionRecompositionOptimizer + +private object DisabledSubscriptionRecompositionOptimizer : SubscriptionRecompositionOptimizer { + override fun omit(state: SubscriptionState): SubscriptionState { + return state + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt index df7d822..863cf68 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery2.kt @@ -3,6 +3,7 @@ package soil.query.compose.internal +import androidx.compose.runtime.RememberObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -12,24 +13,37 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import soil.query.QueryClient import soil.query.QueryId +import soil.query.QueryKey import soil.query.QueryRef import soil.query.QueryState +import soil.query.compose.QueryConfig import soil.query.core.uuid import soil.query.merge -internal fun combineQuery( - query1: QueryRef, - query2: QueryRef, - transform: (T1, T2) -> R -): QueryRef = CombinedQuery2(query1, query2, transform) +internal fun newCombinedQuery( + key1: QueryKey, + key2: QueryKey, + transform: (T1, T2) -> R, + config: QueryConfig, + client: QueryClient, + scope: CoroutineScope +): QueryRef = CombinedQuery2(key1, key2, transform, config, client, scope) private class CombinedQuery2( - private val query1: QueryRef, - private val query2: QueryRef, - private val transform: (T1, T2) -> R -) : QueryRef { + key1: QueryKey, + key2: QueryKey, + private val transform: (T1, T2) -> R, + config: QueryConfig, + client: QueryClient, + private val scope: CoroutineScope +) : QueryRef, RememberObserver { + + private val query1: QueryRef = client.getQuery(key1, config.marker) + private val query2: QueryRef = client.getQuery(key2, config.marker) + private val optimize: (QueryState) -> QueryState = config.optimizer::omit override val id: QueryId = QueryId("auto/${uuid()}") @@ -62,6 +76,30 @@ private class CombinedQuery2( } private fun merge(state1: QueryState, state2: QueryState): QueryState { - return QueryState.merge(state1, state2, transform) + return optimize(QueryState.merge(state1, state2, transform)) + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = query1.launchIn(scope) + val job2 = query2.launchIn(scope) + val job3 = launchIn(scope) + jobs = listOf(job1, job2, job3) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt index 2de4825..e06a1d5 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQuery3.kt @@ -3,6 +3,7 @@ package soil.query.compose.internal +import androidx.compose.runtime.RememberObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -12,26 +13,40 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import soil.query.QueryClient import soil.query.QueryId +import soil.query.QueryKey import soil.query.QueryRef import soil.query.QueryState +import soil.query.compose.QueryConfig import soil.query.core.uuid import soil.query.merge -internal fun combineQuery( - query1: QueryRef, - query2: QueryRef, - query3: QueryRef, - transform: (T1, T2, T3) -> R -): QueryRef = CombinedQuery3(query1, query2, query3, transform) +internal fun newCombinedQuery( + key1: QueryKey, + key2: QueryKey, + key3: QueryKey, + transform: (T1, T2, T3) -> R, + config: QueryConfig, + client: QueryClient, + scope: CoroutineScope +): QueryRef = CombinedQuery3(key1, key2, key3, transform, config, client, scope) private class CombinedQuery3( - private val query1: QueryRef, - private val query2: QueryRef, - private val query3: QueryRef, - private val transform: (T1, T2, T3) -> R -) : QueryRef { + key1: QueryKey, + key2: QueryKey, + key3: QueryKey, + private val transform: (T1, T2, T3) -> R, + config: QueryConfig, + client: QueryClient, + private val scope: CoroutineScope +) : QueryRef, RememberObserver { + + private val query1: QueryRef = client.getQuery(key1, config.marker) + private val query2: QueryRef = client.getQuery(key2, config.marker) + private val query3: QueryRef = client.getQuery(key3, config.marker) + private val optimize: (QueryState) -> QueryState = config.optimizer::omit override val id: QueryId = QueryId("auto/${uuid()}") @@ -66,6 +81,31 @@ private class CombinedQuery3( } private fun merge(state1: QueryState, state2: QueryState, state3: QueryState): QueryState { - return QueryState.merge(state1, state2, state3, transform) + return optimize(QueryState.merge(state1, state2, state3, transform)) + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = query1.launchIn(scope) + val job2 = query2.launchIn(scope) + val job3 = query3.launchIn(scope) + val job4 = launchIn(scope) + jobs = listOf(job1, job2, job3, job4) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt index a66484e..b28d4bb 100644 --- a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/CombinedQueryN.kt @@ -3,6 +3,7 @@ package soil.query.compose.internal +import androidx.compose.runtime.RememberObserver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -12,22 +13,33 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import soil.query.QueryClient import soil.query.QueryId +import soil.query.QueryKey import soil.query.QueryRef import soil.query.QueryState +import soil.query.compose.QueryConfig import soil.query.core.uuid import soil.query.merge - -internal fun combineQuery( - queries: Array>, - transform: (List) -> R -): QueryRef = CombinedQueryN(queries, transform) +internal fun newCombinedQuery( + keys: List>, + transform: (List) -> R, + config: QueryConfig, + client: QueryClient, + scope: CoroutineScope +): QueryRef = CombinedQueryN(keys, transform, config, client, scope) private class CombinedQueryN( - private val queries: Array>, - private val transform: (List) -> R -) : QueryRef { + keys: List>, + private val transform: (List) -> R, + config: QueryConfig, + client: QueryClient, + private val scope: CoroutineScope +) : QueryRef, RememberObserver { + + private val queries: List> = keys.map { key -> client.getQuery(key, config.marker) } + private val optimize: (QueryState) -> QueryState = config.optimizer::omit override val id: QueryId = QueryId("auto/${uuid()}") @@ -56,6 +68,27 @@ private class CombinedQueryN( } private fun merge(states: Array>): QueryState { - return QueryState.merge(states, transform) + return optimize(QueryState.merge(states, transform)) + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + jobs = queries.map { it.launchIn(scope) } + launchIn(scope) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null } } diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/InfiniteQuery.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/InfiniteQuery.kt new file mode 100644 index 0000000..edbf65e --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/InfiniteQuery.kt @@ -0,0 +1,80 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import androidx.compose.runtime.RememberObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.InfiniteQueryRef +import soil.query.QueryChunks +import soil.query.QueryClient +import soil.query.QueryState +import soil.query.compose.InfiniteQueryConfig + +internal fun newInfiniteQuery( + key: InfiniteQueryKey, + config: InfiniteQueryConfig, + client: QueryClient, + scope: CoroutineScope +): InfiniteQueryRef = InfiniteQuery(key, config, client, scope) + +private class InfiniteQuery( + key: InfiniteQueryKey, + config: InfiniteQueryConfig, + client: QueryClient, + private val scope: CoroutineScope +) : InfiniteQueryRef, RememberObserver { + + private val query: InfiniteQueryRef = client.getInfiniteQuery(key, config.marker) + private val optimize: (QueryState>) -> QueryState> = config.optimizer::omit + + override val id: InfiniteQueryId = query.id + + private val _state: MutableStateFlow>> = MutableStateFlow( + value = optimize(query.state.value) + ) + override val state: StateFlow>> = _state + + override fun nextParam(data: QueryChunks): S? = query.nextParam(data) + + override suspend fun resume() = query.resume() + + override suspend fun loadMore(param: S) = query.loadMore(param) + + override suspend fun invalidate() = query.invalidate() + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.state.collect { _state.value = optimize(it) } + } + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = query.launchIn(scope) + val job2 = launchIn(scope) + jobs = listOf(job1, job2) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Mutation.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Mutation.kt new file mode 100644 index 0000000..052877d --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Mutation.kt @@ -0,0 +1,78 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import androidx.compose.runtime.RememberObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.MutationClient +import soil.query.MutationId +import soil.query.MutationKey +import soil.query.MutationRef +import soil.query.MutationState +import soil.query.compose.MutationConfig + +internal fun newMutation( + key: MutationKey, + config: MutationConfig, + client: MutationClient, + scope: CoroutineScope +): MutationRef = Mutation(key, config, client, scope) + +private class Mutation( + key: MutationKey, + config: MutationConfig, + client: MutationClient, + private val scope: CoroutineScope +) : MutationRef, RememberObserver { + + private val mutation: MutationRef = client.getMutation(key, config.marker) + private val optimize: (MutationState) -> MutationState = config.optimizer::omit + + override val id: MutationId = mutation.id + + private val _state: MutableStateFlow> = MutableStateFlow( + value = optimize(mutation.state.value) + ) + + override val state: StateFlow> = _state + + override suspend fun mutate(variable: S): T = mutation.mutate(variable) + + override suspend fun mutateAsync(variable: S) = mutation.mutateAsync(variable) + + override suspend fun reset() = mutation.reset() + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + mutation.state.collect { _state.value = optimize(it) } + } + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = mutation.launchIn(scope) + val job2 = launchIn(scope) + jobs = listOf(job1, job2) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Query.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Query.kt new file mode 100644 index 0000000..7943b9f --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Query.kt @@ -0,0 +1,76 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import androidx.compose.runtime.RememberObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.QueryClient +import soil.query.QueryId +import soil.query.QueryKey +import soil.query.QueryRef +import soil.query.QueryState +import soil.query.compose.QueryConfig + +internal fun newQuery( + key: QueryKey, + config: QueryConfig, + client: QueryClient, + scope: CoroutineScope +): QueryRef = Query(key, config, client, scope) + +private class Query( + key: QueryKey, + config: QueryConfig, + client: QueryClient, + private val scope: CoroutineScope, +) : QueryRef, RememberObserver { + + private val query: QueryRef = client.getQuery(key, config.marker) + private val optimize: (QueryState) -> QueryState = config.optimizer::omit + + override val id: QueryId = query.id + + // FIXME: Switch to K2 mode when it becomes stable. + private val _state: MutableStateFlow> = MutableStateFlow( + value = optimize(query.state.value) + ) + override val state: StateFlow> = _state + + override suspend fun resume() = query.resume() + + override suspend fun invalidate() = query.invalidate() + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + query.state.collect { _state.value = optimize(it) } + } + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = query.launchIn(scope) + val job2 = launchIn(scope) + jobs = listOf(job1, job2) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null + } +} diff --git a/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Subscription.kt b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Subscription.kt new file mode 100644 index 0000000..3f92e9f --- /dev/null +++ b/soil-query-compose/src/commonMain/kotlin/soil/query/compose/internal/Subscription.kt @@ -0,0 +1,80 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose.internal + +import androidx.compose.runtime.RememberObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import soil.query.SubscriptionClient +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionRef +import soil.query.SubscriptionState +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.compose.SubscriptionConfig + +@ExperimentalSoilQueryApi +internal fun newSubscription( + key: SubscriptionKey, + config: SubscriptionConfig, + client: SubscriptionClient, + scope: CoroutineScope +): SubscriptionRef = Subscription(key, config, client, scope) + +@ExperimentalSoilQueryApi +private class Subscription( + key: SubscriptionKey, + config: SubscriptionConfig, + client: SubscriptionClient, + private val scope: CoroutineScope +) : SubscriptionRef, RememberObserver { + + private val subscription: SubscriptionRef = client.getSubscription(key, config.marker) + private val optimize: (SubscriptionState) -> SubscriptionState = config.optimizer::omit + + override val id: SubscriptionId = subscription.id + + private val _state: MutableStateFlow> = MutableStateFlow( + value = optimize(subscription.state.value) + ) + override val state: StateFlow> = _state + + override suspend fun reset() = subscription.reset() + + override suspend fun resume() = subscription.resume() + + override fun cancel() = subscription.cancel() + + override fun launchIn(scope: CoroutineScope): Job { + return scope.launch { + subscription.state.collect { _state.value = optimize(it) } + } + } + + // ----- RememberObserver -----// + private var jobs: List? = null + + override fun onAbandoned() = stop() + + override fun onForgotten() = stop() + + override fun onRemembered() { + stop() + start() + } + + private fun start() { + val job1 = subscription.launchIn(scope) + val job2 = launchIn(scope) + jobs = listOf(job1, job2) + } + + private fun stop() { + jobs?.forEach { it.cancel() } + jobs = null + } +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt index 98cc165..5c18eec 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryComposableTest.kt @@ -50,8 +50,9 @@ class InfiniteQueryComposableTest : UnitTest() { setContent { SwrClientProvider(client) { val query = rememberInfiniteQuery(key, config = InfiniteQueryConfig { - strategy = InfiniteQueryStrategy.Default mapper = InfiniteQueryObjectMapper.Default + optimizer = InfiniteQueryRecompositionOptimizer.Default + strategy = InfiniteQueryStrategy.Default marker = Marker.None }) when (val reply = query.reply) { diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizerTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizerTest.kt new file mode 100644 index 0000000..f028c65 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/InfiniteQueryRecompositionOptimizerTest.kt @@ -0,0 +1,297 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import kotlinx.coroutines.delay +import soil.query.InfiniteQueryId +import soil.query.InfiniteQueryKey +import soil.query.QueryChunks +import soil.query.QueryFetchStatus +import soil.query.QueryState +import soil.query.QueryStatus +import soil.query.SwrCache +import soil.query.SwrCacheScope +import soil.query.buildInfiniteQueryKey +import soil.query.core.Reply +import soil.query.emptyChunks +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalTestApi::class) +class InfiniteQueryRecompositionOptimizerTest : UnitTest() { + + @Test + fun testRecompositionCount_default() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + var recompositionCount = 0 + setContent { + SwrClientProvider(client) { + val query = rememberInfiniteQuery(key) + SideEffect { recompositionCount++ } + when (val reply = query.reply) { + is Reply.Some -> { + Column { + reply.value.forEach { chunk -> + Text( + "Size: ${chunk.data.size} - Page: ${chunk.param.page}", + modifier = Modifier.testTag("query") + ) + } + } + } + + is Reply.None -> Unit + } + } + } + + waitUntilAtLeastOneExists(hasTestTag("query")) + + // pending -> success + assertEquals(2, recompositionCount) + } + + @Test + fun testRecompositionCount_disabled() = runComposeUiTest { + val key = TestInfiniteQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + var recompositionCount = 0 + setContent { + SwrClientProvider(client) { + val query = rememberInfiniteQuery(key, config = InfiniteQueryConfig { + optimizer = InfiniteQueryRecompositionOptimizer.Disabled + }) + SideEffect { recompositionCount++ } + when (val reply = query.reply) { + is Reply.Some -> { + Column { + reply.value.forEach { chunk -> + Text( + "Size: ${chunk.data.size} - Page: ${chunk.param.page}", + modifier = Modifier.testTag("query") + ) + } + } + } + + is Reply.None -> Unit + } + } + } + + waitUntilAtLeastOneExists(hasTestTag("query")) + + // pending -> pending(fetching) -> success + assertEquals(3, recompositionCount) + } + + @Test + fun testOmit_pending() { + val state = QueryState.test>( + reply = Reply.None, + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test>( + reply = Reply.None, + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_success() { + val state = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_success_isInvalidated() { + val state = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + val actual = InfiniteQueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_failure() { + val error = RuntimeException("error") + val state = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + staleAt = 0, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_failure_isInvalidated() { + val error = RuntimeException("error") + val state = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + val actual = InfiniteQueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + staleAt = 0, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_pending() { + val expected = QueryState.test>( + reply = Reply.None, + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_success() { + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_failure() { + val error = RuntimeException("error") + val expected = QueryState.test>( + reply = Reply.some(emptyChunks()), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = InfiniteQueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + private class TestInfiniteQueryKey( + private val delayTime: Duration = 100.milliseconds + ) : InfiniteQueryKey, PageParam> by buildInfiniteQueryKey( + id = InfiniteQueryId("test/infinite-query"), + fetch = { param -> + delay(delayTime) + val startPosition = param.page * param.size + (startPosition.. Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + + // pending -> success + assertEquals(2, recompositionCount) + } + + @Test + fun testRecompositionCount_disabled() = runComposeUiTest { + val key = TestQueryKey() + val client = SwrCache(coroutineScope = SwrCacheScope()) + var recompositionCount = 0 + setContent { + SwrClientProvider(client) { + val query = rememberQuery(key, config = QueryConfig { + optimizer = QueryRecompositionOptimizer.Disabled + }) + SideEffect { recompositionCount++ } + when (val reply = query.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("query")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("query")) + + // pending -> pending(fetching) -> success + assertEquals(3, recompositionCount) + } + + @Test + fun testOmit_pending() { + val state = QueryState.test( + reply = Reply.None, + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test( + reply = Reply.None, + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_success() { + val state = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_success_isInvalidated() { + val state = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + val actual = QueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + staleAt = 0, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_failure() { + val error = RuntimeException("error") + val state = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + staleAt = 0, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Idle, + isInvalidated = false + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_failure_isInvalidated() { + val error = RuntimeException("error") + val state = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + val actual = QueryRecompositionOptimizer.Default.omit(state) + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + staleAt = 0, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_pending() { + val expected = QueryState.test( + reply = Reply.None, + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Pending, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_success() { + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_failure() { + val error = RuntimeException("error") + val expected = QueryState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + staleAt = 400, + status = QueryStatus.Failure, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = false + ) + val actual = QueryRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + private class TestQueryKey( + number: Int = 1, + private val delayTime: Duration = 100.milliseconds + ) : QueryKey by buildQueryKey( + id = QueryId("test/query/$number"), + fetch = { + delay(delayTime) + "Hello, Soil!" + } + ) +} diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt index 492b75c..40fe7d8 100644 --- a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionComposableTest.kt @@ -48,8 +48,9 @@ class SubscriptionComposableTest : UnitTest() { setContent { SwrClientProvider(client) { val subscription = rememberSubscription(key, config = SubscriptionConfig { - strategy = SubscriptionStrategy.Default mapper = SubscriptionObjectMapper.Default + optimizer = SubscriptionRecompositionOptimizer.Default + strategy = SubscriptionStrategy.Default marker = Marker.None }) when (val reply = subscription.reply) { @@ -116,7 +117,7 @@ class SubscriptionComposableTest : UnitTest() { ) setContent { SwrClientProvider(client) { - when (rememberSubscription(key)) { + when (rememberSubscription(key, config = SubscriptionConfig.Lazy)) { is SubscriptionIdleObject -> Text("idle", modifier = Modifier.testTag("subscription")) else -> Unit } diff --git a/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionRecompositionOptimizerTest.kt b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionRecompositionOptimizerTest.kt new file mode 100644 index 0000000..6df47a1 --- /dev/null +++ b/soil-query-compose/src/commonTest/kotlin/soil/query/compose/SubscriptionRecompositionOptimizerTest.kt @@ -0,0 +1,259 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query.compose + +import androidx.compose.material.Text +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilExactlyOneExists +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import soil.query.SubscriberStatus +import soil.query.SubscriptionId +import soil.query.SubscriptionKey +import soil.query.SubscriptionState +import soil.query.SubscriptionStatus +import soil.query.SwrCachePlus +import soil.query.SwrCacheScope +import soil.query.annotation.ExperimentalSoilQueryApi +import soil.query.buildSubscriptionKey +import soil.query.core.Reply +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalTestApi::class, ExperimentalSoilQueryApi::class) +class SubscriptionRecompositionOptimizerTest : UnitTest() { + + @Test + fun testRecomposition_default() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()) + var recompositionCount = 0 + setContent { + SwrClientProvider(client) { + val subscription = rememberSubscription(key) + SideEffect { recompositionCount++ } + when (val reply = subscription.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("subscription")) + + // pending(active) -> success + assertEquals(2, recompositionCount) + } + + @Test + fun testRecomposition_disabled() = runComposeUiTest { + val key = TestSubscriptionKey() + val client = SwrCachePlus(coroutineScope = SwrCacheScope()) + var recompositionCount = 0 + setContent { + SwrClientProvider(client) { + val subscription = rememberSubscription(key, config = SubscriptionConfig { + optimizer = SubscriptionRecompositionOptimizer.Disabled + }) + SideEffect { recompositionCount++ } + when (val reply = subscription.reply) { + is Reply.Some -> Text(reply.value, modifier = Modifier.testTag("subscription")) + is Reply.None -> Unit + } + } + } + + waitUntilExactlyOneExists(hasTestTag("subscription")) + + // pending(no-subscribers) -> pending(active) -> success + assertEquals(3, recompositionCount) + } + + @Test + fun testOmit_default_pending() { + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Pending, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Default.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + status = SubscriptionStatus.Pending, + subscriberStatus = SubscriberStatus.Active + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_success() { + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Default.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.Active + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_default_failure() { + val error = RuntimeException("error") + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + status = SubscriptionStatus.Failure, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Default.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + status = SubscriptionStatus.Failure, + subscriberStatus = SubscriberStatus.Active + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_lazy_pending() { + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Pending, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Lazy.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + status = SubscriptionStatus.Pending, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_lazy_success() { + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Lazy.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + errorUpdatedAt = 0, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_lazy_failure() { + val error = RuntimeException("error") + val state = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + status = SubscriptionStatus.Failure, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Lazy.omit(state) + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 0, + error = error, + errorUpdatedAt = 200, + status = SubscriptionStatus.Failure, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_pending() { + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Pending, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_success() { + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + errorUpdatedAt = 200, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + @Test + fun testOmit_disabled_failure() { + val error = RuntimeException("error") + val expected = SubscriptionState.test( + reply = Reply.some(1), + replyUpdatedAt = 300, + error = error, + errorUpdatedAt = 200, + status = SubscriptionStatus.Failure, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = SubscriptionRecompositionOptimizer.Disabled.omit(expected) + assertEquals(expected, actual) + } + + private class TestSubscriptionKey( + private val delayTime: Duration = 100.milliseconds + ) : SubscriptionKey by buildSubscriptionKey( + id = SubscriptionId("test/subscription"), + subscribe = { + flow { + delay(delayTime) + emit("Hello, Soil!") + } + } + ) +} diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt index f70a006..e9b4aea 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationState.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.Reply import soil.query.core.epoch +import kotlin.jvm.JvmInline /** * State for managing the execution result of [Mutation]. @@ -17,6 +18,30 @@ data class MutationState internal constructor( override val status: MutationStatus = MutationStatus.Idle, override val mutatedCount: Int = 0 ) : MutationModel { + + /** + * Returns a new [MutationState] with the items included in [keys] omitted from the current [MutationState]. + * + * NOTE: This function is provided to optimize recomposition for Compose APIs. + */ + fun omit(keys: Set): MutationState { + if (keys.isEmpty()) return this + return copy( + replyUpdatedAt = if (keys.contains(OmitKey.replyUpdatedAt)) 0 else replyUpdatedAt, + errorUpdatedAt = if (keys.contains(OmitKey.errorUpdatedAt)) 0 else errorUpdatedAt, + mutatedCount = if (keys.contains(OmitKey.mutatedCount)) 0 else mutatedCount + ) + } + + @JvmInline + value class OmitKey(val name: String) { + companion object { + val replyUpdatedAt = OmitKey("replyUpdatedAt") + val errorUpdatedAt = OmitKey("errorUpdatedAt") + val mutatedCount = OmitKey("mutatedCount") + } + } + companion object { /** @@ -97,5 +122,28 @@ data class MutationState internal constructor( mutatedCount = mutatedCount ) } + + /** + * Creates a new [MutationState] for Testing. + * + * NOTE: **This method is for testing purposes only.** + */ + fun test( + reply: Reply = Reply.None, + replyUpdatedAt: Long = 0, + error: Throwable? = null, + errorUpdatedAt: Long = 0, + status: MutationStatus = MutationStatus.Idle, + mutatedCount: Int = 0 + ): MutationState { + return MutationState( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + status = status, + mutatedCount = mutatedCount + ) + } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt index a1c3fbf..e86afd2 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryState.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.Reply import soil.query.core.epoch +import kotlin.jvm.JvmInline /** * State for managing the execution result of [Query]. @@ -34,6 +35,31 @@ data class QueryState internal constructor( replyUpdatedAt = dataUpdatedAt ) + /** + * Returns a new [QueryState] with the items included in [keys] omitted from the current [QueryState]. + * + * NOTE: This function is provided to optimize recomposition for Compose APIs. + */ + fun omit(keys: Set): QueryState { + if (keys.isEmpty()) return this + return copy( + replyUpdatedAt = if (keys.contains(OmitKey.replyUpdatedAt)) 0 else replyUpdatedAt, + errorUpdatedAt = if (keys.contains(OmitKey.errorUpdatedAt)) 0 else errorUpdatedAt, + staleAt = if (keys.contains(OmitKey.staleAt)) 0 else staleAt, + fetchStatus = if (keys.contains(OmitKey.fetchStatus)) QueryFetchStatus.Idle else fetchStatus + ) + } + + @JvmInline + value class OmitKey(val name: String) { + companion object { + val replyUpdatedAt = OmitKey("replyUpdatedAt") + val errorUpdatedAt = OmitKey("errorUpdatedAt") + val staleAt = OmitKey("staleAt") + val fetchStatus = OmitKey("fetchStatus") + } + } + companion object { /** @@ -105,5 +131,32 @@ data class QueryState internal constructor( staleAt = dataStaleAt ) } + + /** + * Creates a new [QueryState] for Testing. + * + * NOTE: **This method is for testing purposes only.** + */ + fun test( + reply: Reply = Reply.None, + replyUpdatedAt: Long = 0, + error: Throwable? = null, + errorUpdatedAt: Long = 0, + staleAt: Long = 0, + status: QueryStatus = QueryStatus.Pending, + fetchStatus: QueryFetchStatus = QueryFetchStatus.Idle, + isInvalidated: Boolean = false + ): QueryState { + return QueryState( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + staleAt = staleAt, + status = status, + fetchStatus = fetchStatus, + isInvalidated = isInvalidated + ) + } } } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt index 2191931..d13a705 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionState.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.Reply import soil.query.core.epoch +import kotlin.jvm.JvmInline /** * State for managing the execution result of [Subscription]. @@ -18,6 +19,32 @@ data class SubscriptionState internal constructor( override val subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers ) : SubscriptionModel { + /** + * Returns a new [SubscriptionState] with the items included in [keys] omitted from the current [SubscriptionState]. + * + * NOTE: This function is provided to optimize recomposition for Compose APIs. + */ + fun omit( + keys: Set, + defaultSubscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + if (keys.isEmpty()) return this + return copy( + replyUpdatedAt = if (keys.contains(OmitKey.replyUpdatedAt)) 0 else replyUpdatedAt, + errorUpdatedAt = if (keys.contains(OmitKey.errorUpdatedAt)) 0 else errorUpdatedAt, + subscriberStatus = if (keys.contains(OmitKey.subscriberStatus)) defaultSubscriberStatus else subscriberStatus + ) + } + + @JvmInline + value class OmitKey(val name: String) { + companion object { + val replyUpdatedAt = OmitKey("replyUpdatedAt") + val errorUpdatedAt = OmitKey("errorUpdatedAt") + val subscriberStatus = OmitKey("subscriberStatus") + } + } + companion object { /** @@ -98,5 +125,28 @@ data class SubscriptionState internal constructor( subscriberStatus = subscriberStatus ) } + + /** + * Creates a new [SubscriptionState] for Testing. + * + * NOTE: **This method is for testing purposes only.** + */ + fun test( + reply: Reply = Reply.None, + replyUpdatedAt: Long = 0, + error: Throwable? = null, + errorUpdatedAt: Long = 0, + status: SubscriptionStatus = SubscriptionStatus.Pending, + subscriberStatus: SubscriberStatus = SubscriberStatus.NoSubscribers + ): SubscriptionState { + return SubscriptionState( + reply = reply, + replyUpdatedAt = replyUpdatedAt, + error = error, + errorUpdatedAt = errorUpdatedAt, + status = status, + subscriberStatus = subscriberStatus + ) + } } } diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationStateTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationStateTest.kt new file mode 100644 index 0000000..2cf3512 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationStateTest.kt @@ -0,0 +1,47 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class MutationStateTest : UnitTest() { + + @Test + fun testOmit() { + val state = MutationState( + reply = Reply(1), + replyUpdatedAt = 300, + error = null, + errorUpdatedAt = 400, + status = MutationStatus.Success, + mutatedCount = 1 + ) + val actual = state.omit( + keys = setOf( + MutationState.OmitKey.replyUpdatedAt, + MutationState.OmitKey.errorUpdatedAt, + MutationState.OmitKey.mutatedCount + ) + ) + val expected = MutationState( + reply = Reply(1), + replyUpdatedAt = 0, + error = null, + errorUpdatedAt = 0, + status = MutationStatus.Success, + mutatedCount = 0 + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_empty() { + val expectedState = MutationState.success(1, dataUpdatedAt = 300, mutatedCount = 1) + val actualState = expectedState.omit(emptySet()) + assertEquals(expectedState, actualState) + } +} diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateTest.kt new file mode 100644 index 0000000..dc2ff44 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryStateTest.kt @@ -0,0 +1,48 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class QueryStateTest : UnitTest() { + + @Test + fun testOmit() { + val state = QueryState( + reply = Reply(1), + replyUpdatedAt = 100, + error = null, + errorUpdatedAt = 200, + staleAt = 300, + status = QueryStatus.Success, + fetchStatus = QueryFetchStatus.Fetching, + isInvalidated = true + ) + val actual = state.omit( + keys = setOf( + QueryState.OmitKey.replyUpdatedAt, + QueryState.OmitKey.staleAt, + QueryState.OmitKey.errorUpdatedAt, + QueryState.OmitKey.fetchStatus + ) + ) + val expected = QueryState( + reply = Reply(1), + error = null, + status = QueryStatus.Success, + isInvalidated = true + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_emptyKeys() { + val expectedState = QueryState.success(1, dataUpdatedAt = 300, dataStaleAt = 400) + val actualState = expectedState.omit(emptySet()) + assertEquals(expectedState, actualState) + } +} diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionStateTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionStateTest.kt new file mode 100644 index 0000000..a5f0044 --- /dev/null +++ b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionStateTest.kt @@ -0,0 +1,77 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +package soil.query + +import soil.query.core.Reply +import soil.testing.UnitTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SubscriptionStateTest : UnitTest() { + + @Test + fun testOmit() { + val state = SubscriptionState( + reply = Reply(1), + replyUpdatedAt = 300, + error = null, + errorUpdatedAt = 400, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.Active + ) + val actual = state.omit( + keys = setOf( + SubscriptionState.OmitKey.replyUpdatedAt, + SubscriptionState.OmitKey.errorUpdatedAt, + SubscriptionState.OmitKey.subscriberStatus + ) + ) + val expected = SubscriptionState( + reply = Reply(1), + replyUpdatedAt = 0, + error = null, + errorUpdatedAt = 0, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_defaultSubscriberStatus() { + val state = SubscriptionState( + reply = Reply(1), + replyUpdatedAt = 300, + error = null, + errorUpdatedAt = 400, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.NoSubscribers + ) + val actual = state.omit( + keys = setOf( + SubscriptionState.OmitKey.replyUpdatedAt, + SubscriptionState.OmitKey.errorUpdatedAt, + SubscriptionState.OmitKey.subscriberStatus + ), + defaultSubscriberStatus = SubscriberStatus.Active + ) + val expected = SubscriptionState( + reply = Reply(1), + replyUpdatedAt = 0, + error = null, + errorUpdatedAt = 0, + status = SubscriptionStatus.Success, + subscriberStatus = SubscriberStatus.Active + ) + assertEquals(expected, actual) + } + + @Test + fun testOmit_empty() { + val expected = SubscriptionState.success(1, dataUpdatedAt = 300) + val actual = expected.omit(emptySet()) + assertEquals(expected, actual) + } + +} From e11fcb5fed3b8cdb09d5865108153dc94fa554d5 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 22 Sep 2024 09:38:12 +0900 Subject: [PATCH 152/155] Bump Ktor to 3.0.0-rc-1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 93036cb..4585042 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ kotlin = "2.0.20" kotlinx-coroutines = "1.8.1" kotlinx-serialization = "1.7.0" kover = "0.8.3" -ktor = "3.0.0-beta-2" +ktor = "3.0.0-rc-1" maven-publish = "0.28.0" robolectric = "4.12.2" spotless = "6.25.0" From 10a6a3c7346068f271dfe0e905f61d774755f33b Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Sun, 22 Sep 2024 13:06:42 +0900 Subject: [PATCH 153/155] Introduce Comparison Functions To suppress updates of data and exceptions considered identical, and to reduce recompositions, I have implemented the following two functions: - contentEquals - errorEquals These are intended to be used similarly to the [compare](https://swr.vercel.app/docs/advanced/performance#deep-comparison) function in the [SWR](https://github.com/vercel/swr) library. --- .../kotlin/soil/query/InfiniteQueryCommand.kt | 4 +- .../kotlin/soil/query/InfiniteQueryKey.kt | 16 ++++++- .../kotlin/soil/query/MutationAction.kt | 2 +- .../kotlin/soil/query/MutationClient.kt | 1 + .../kotlin/soil/query/MutationCommand.kt | 40 ++++++++++++---- .../kotlin/soil/query/MutationKey.kt | 14 ++++++ .../kotlin/soil/query/MutationOptions.kt | 14 ++++++ .../kotlin/soil/query/QueryAction.kt | 2 +- .../kotlin/soil/query/QueryClient.kt | 1 + .../kotlin/soil/query/QueryCommand.kt | 46 ++++++++++++++----- .../commonMain/kotlin/soil/query/QueryKey.kt | 14 ++++++ .../kotlin/soil/query/QueryOptions.kt | 14 ++++++ .../kotlin/soil/query/SubscriptionAction.kt | 2 +- .../kotlin/soil/query/SubscriptionClient.kt | 1 + .../kotlin/soil/query/SubscriptionCommand.kt | 40 ++++++++++++---- .../kotlin/soil/query/SubscriptionKey.kt | 14 ++++++ .../kotlin/soil/query/SubscriptionOptions.kt | 14 ++++++ .../kotlin/soil/query/MutationOptionsTest.kt | 6 +++ .../kotlin/soil/query/QueryOptionsTest.kt | 6 +++ .../soil/query/SubscriptionOptionsTest.kt | 6 +++ 20 files changed, 219 insertions(+), 38 deletions(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt index f1c6bf7..14bbb38 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryCommand.kt @@ -97,7 +97,7 @@ suspend inline fun QueryCommand.Context>.dispatchFetchC .map { QueryChunk(it, variable) } .map { chunk -> state.reply.getOrElse { emptyList() } + chunk } .run { key.onRecoverData()?.let(::recoverCatching) ?: this } - .onSuccess(::dispatchFetchSuccess) + .onSuccess { dispatchFetchSuccess(it, key.contentEquals) } .onFailure(::dispatchFetchFailure) .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } @@ -122,7 +122,7 @@ suspend inline fun QueryCommand.Context>.dispatchRevali ) { revalidate(key, chunks) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } - .onSuccess(::dispatchFetchSuccess) + .onSuccess { dispatchFetchSuccess(it, key.contentEquals) } .onFailure(::dispatchFetchFailure) .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt index ccd9424..12efbfb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/InfiniteQueryKey.kt @@ -40,10 +40,24 @@ interface InfiniteQueryKey { */ val loadMoreParam: (chunks: QueryChunks) -> S? + /** + * Function to compare the content of the data. + * + * This function is used to determine whether the data is identical to the previous data via [InfiniteQueryCommand]. + * If the data is considered the same, only [QueryState.staleAt] is updated. + * This can be useful when strict update management is needed, such as when special comparison is necessary, + * although it is generally not that important. + * + * @see QueryKey.contentEquals + */ + val contentEquals: QueryContentEquals>? get() = null + /** * Function to configure the [QueryOptions]. * * If unspecified, the default value of [SwrCachePolicy] is used. + * + * @see QueryKey.onConfigureOptions */ fun onConfigureOptions(): QueryOptionsOverride? = null @@ -52,7 +66,7 @@ interface InfiniteQueryKey { * * Depending on the type of exception that occurred during data retrieval, it is possible to recover it as normal data. * - * @see QueryRecoverData + * @see QueryKey.onRecoverData */ fun onRecoverData(): QueryRecoverData>? = null } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt index 7695d09..2982adb 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationAction.kt @@ -76,7 +76,7 @@ fun createMutationReducer(): MutationReducer = { state, action -> reply = Reply(action.data), replyUpdatedAt = action.dataUpdatedAt, error = null, - errorUpdatedAt = action.dataUpdatedAt, + errorUpdatedAt = if (state.error != null) action.dataUpdatedAt else state.errorUpdatedAt, mutatedCount = state.mutatedCount + 1 ) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt index 82b32af..c727c24 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationClient.kt @@ -24,5 +24,6 @@ interface MutationClient { ): MutationRef } +typealias MutationContentEquals = (oldData: T, newData: T) -> Boolean typealias MutationOptionsOverride = (MutationOptions) -> MutationOptions typealias MutationCallback = (Result) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt index 6e4f6a7..2d6c224 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationCommand.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import soil.query.core.ErrorRecord import soil.query.core.Marker +import soil.query.core.Reply import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -103,7 +104,7 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( if (job != null && options.shouldExecuteEffectSynchronously) { job.join() } - dispatchMutateSuccess(data) + dispatchMutateSuccess(data, key.contentEquals) } } .onFailure(::dispatchMutateFailure) @@ -116,12 +117,23 @@ suspend inline fun MutationCommand.Context.dispatchMutateResult( * * @param data The mutation returned data. */ -fun MutationCommand.Context.dispatchMutateSuccess(data: T) { +fun MutationCommand.Context.dispatchMutateSuccess( + data: T, + contentEquals: MutationContentEquals? = null +) { val currentAt = epoch() - val action = MutationAction.MutateSuccess( - data = data, - dataUpdatedAt = currentAt - ) + val currentReply = state.reply + val action = if (currentReply is Reply.Some && contentEquals?.invoke(currentReply.value, data) == true) { + MutationAction.MutateSuccess( + data = currentReply.value, + dataUpdatedAt = state.replyUpdatedAt + ) + } else { + MutationAction.MutateSuccess( + data = data, + dataUpdatedAt = currentAt + ) + } dispatch(action) } @@ -132,10 +144,18 @@ fun MutationCommand.Context.dispatchMutateSuccess(data: T) { */ fun MutationCommand.Context.dispatchMutateFailure(error: Throwable) { val currentAt = epoch() - val action = MutationAction.MutateFailure( - error = error, - errorUpdatedAt = currentAt - ) + val currentError = state.error + val action = if (currentError != null && options.errorEquals?.invoke(currentError, error) == true) { + MutationAction.MutateFailure( + error = currentError, + errorUpdatedAt = state.errorUpdatedAt + ) + } else { + MutationAction.MutateFailure( + error = error, + errorUpdatedAt = currentAt + ) + } dispatch(action) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt index 240528d..bde025a 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationKey.kt @@ -29,6 +29,20 @@ interface MutationKey { */ val mutate: suspend MutationReceiver.(variable: S) -> T + /** + * Function to compare the content of the data. + * + * This function is used to determine whether the data is identical to the previous data via [MutationCommand]. + * If the data is considered the same, [MutationState.replyUpdatedAt] is not updated, and the existing reply state is maintained. + * This can be useful when strict update management is needed, such as when special comparison is necessary, + * although it is generally not that important. + * + * ```kotlin + * override val contentEquals: MutationContentEquals = { a, b -> a.xx == b.xx } + * ``` + */ + val contentEquals: MutationContentEquals? get() = null + /** * Function to configure the [MutationOptions]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt index 23d2fd7..77960be 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/MutationOptions.kt @@ -28,6 +28,14 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { */ val isStrictMode: Boolean + /** + * Determines whether two errors are equal. + * + * This function is used to determine whether a new error is identical to an existing error via [MutationCommand]. + * If the errors are considered identical, [MutationState.errorUpdatedAt] is not updated, and the existing error state is maintained. + */ + val errorEquals: ((Throwable, Throwable) -> Boolean)? + /** * This callback function will be called if some mutation encounters an error. */ @@ -46,6 +54,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { companion object Default : MutationOptions { override val isOneShot: Boolean = false override val isStrictMode: Boolean = false + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = null override val onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = null override val shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = null override val shouldExecuteEffectSynchronously: Boolean = false @@ -72,6 +81,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { * * @param isOneShot Only allows mutate to execute once while active (until reset). * @param isStrictMode Requires revision match as a precondition for executing mutate. + * @param errorEquals Determines whether two errors are equal. * @param onError This callback function will be called if some mutation encounters an error. * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. * @param shouldExecuteEffectSynchronously Whether the query side effect should be synchronous. @@ -88,6 +98,7 @@ interface MutationOptions : ActorOptions, LoggingOptions, RetryOptions { fun MutationOptions( isOneShot: Boolean = MutationOptions.isOneShot, isStrictMode: Boolean = MutationOptions.isStrictMode, + errorEquals: ((Throwable, Throwable) -> Boolean)? = MutationOptions.errorEquals, onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = MutationOptions.onError, shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = MutationOptions.shouldSuppressErrorRelay, shouldExecuteEffectSynchronously: Boolean = MutationOptions.shouldExecuteEffectSynchronously, @@ -104,6 +115,7 @@ fun MutationOptions( return object : MutationOptions { override val isOneShot: Boolean = isOneShot override val isStrictMode: Boolean = isStrictMode + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = errorEquals override val onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = onError override val shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = shouldSuppressErrorRelay override val shouldExecuteEffectSynchronously: Boolean = shouldExecuteEffectSynchronously @@ -125,6 +137,7 @@ fun MutationOptions( fun MutationOptions.copy( isOneShot: Boolean = this.isOneShot, isStrictMode: Boolean = this.isStrictMode, + errorEquals: ((Throwable, Throwable) -> Boolean)? = this.errorEquals, onError: ((ErrorRecord, MutationModel<*>) -> Unit)? = this.onError, shouldSuppressErrorRelay: ((ErrorRecord, MutationModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, shouldExecuteEffectSynchronously: Boolean = this.shouldExecuteEffectSynchronously, @@ -141,6 +154,7 @@ fun MutationOptions.copy( return MutationOptions( isOneShot = isOneShot, isStrictMode = isStrictMode, + errorEquals = errorEquals, onError = onError, shouldSuppressErrorRelay = shouldSuppressErrorRelay, shouldExecuteEffectSynchronously = shouldExecuteEffectSynchronously, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt index 605a667..c38aba8 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryAction.kt @@ -84,7 +84,7 @@ fun createQueryReducer(): QueryReducer = { state, action -> reply = Reply(action.data), replyUpdatedAt = action.dataUpdatedAt, error = null, - errorUpdatedAt = action.dataUpdatedAt, + errorUpdatedAt = if (state.error != null) action.dataUpdatedAt else state.errorUpdatedAt, staleAt = action.dataStaleAt, status = QueryStatus.Success, fetchStatus = QueryFetchStatus.Idle, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt index b1b8ad9..4bbeb42 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryClient.kt @@ -139,6 +139,7 @@ interface QueryMutableClient : QueryReadonlyClient { typealias QueryInitialData = QueryReadonlyClient.() -> T? typealias QueryEffect = QueryMutableClient.() -> Unit +typealias QueryContentEquals = (oldData: T, newData: T) -> Boolean typealias QueryRecoverData = (error: Throwable) -> T typealias QueryOptionsOverride = (QueryOptions) -> QueryOptions typealias QueryCallback = (Result) -> Unit diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt index 9f43d4e..d2bc4c0 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryCommand.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.ErrorRecord import soil.query.core.Marker +import soil.query.core.Reply import soil.query.core.RetryCallback import soil.query.core.RetryFn import soil.query.core.UniqueId @@ -111,7 +112,7 @@ suspend inline fun QueryCommand.Context.dispatchFetchResult( ) { fetch(key) .run { key.onRecoverData()?.let(::recoverCatching) ?: this } - .onSuccess(::dispatchFetchSuccess) + .onSuccess { dispatchFetchSuccess(it, key.contentEquals) } .onFailure(::dispatchFetchFailure) .onFailure { reportQueryError(it, key.id, marker) } .also { callback?.invoke(it) } @@ -122,13 +123,25 @@ suspend inline fun QueryCommand.Context.dispatchFetchResult( * * @param data The fetched data. */ -fun QueryCommand.Context.dispatchFetchSuccess(data: T) { +fun QueryCommand.Context.dispatchFetchSuccess( + data: T, + contentEquals: QueryContentEquals? = null +) { val currentAt = epoch() - val action = QueryAction.FetchSuccess( - data = data, - dataUpdatedAt = currentAt, - dataStaleAt = options.staleTime.toEpoch(currentAt) - ) + val currentReply = state.reply + val action = if (currentReply is Reply.Some && contentEquals?.invoke(currentReply.value, data) == true) { + QueryAction.FetchSuccess( + data = currentReply.value, + dataUpdatedAt = state.replyUpdatedAt, + dataStaleAt = options.staleTime.toEpoch(currentAt) + ) + } else { + QueryAction.FetchSuccess( + data = data, + dataUpdatedAt = currentAt, + dataStaleAt = options.staleTime.toEpoch(currentAt) + ) + } dispatch(action) } @@ -139,11 +152,20 @@ fun QueryCommand.Context.dispatchFetchSuccess(data: T) { */ fun QueryCommand.Context.dispatchFetchFailure(error: Throwable) { val currentAt = epoch() - val action = QueryAction.FetchFailure( - error = error, - errorUpdatedAt = currentAt, - paused = shouldPause(error) - ) + val currentError = state.error + val action = if (currentError != null && options.errorEquals?.invoke(currentError, error) == true) { + QueryAction.FetchFailure( + error = currentError, + errorUpdatedAt = state.errorUpdatedAt, + paused = shouldPause(currentError) + ) + } else { + QueryAction.FetchFailure( + error = error, + errorUpdatedAt = currentAt, + paused = shouldPause(error) + ) + } dispatch(action) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt index d168ce5..516ab7c 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryKey.kt @@ -25,6 +25,20 @@ interface QueryKey { */ val fetch: suspend QueryReceiver.() -> T + /** + * Function to compare the content of the data. + * + * This function is used to determine whether the data is identical to the previous data via [QueryCommand]. + * If the data is considered the same, only [QueryState.staleAt] is updated. + * This can be useful when strict update management is needed, such as when special comparison is necessary, + * although it is generally not that important. + * + * ```kotlin + * override val contentEquals: QueryContentEquals = { a, b -> a.xx == b.xx } + * ``` + */ + val contentEquals: QueryContentEquals? get() = null + /** * Function to configure the [QueryOptions]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt index 5849c46..d45fab3 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/QueryOptions.kt @@ -38,6 +38,14 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { */ val prefetchWindowTime: Duration + /** + * Determines whether two errors are equal. + * + * This function is used to determine whether a new error is identical to an existing error via [QueryCommand]. + * If the errors are considered identical, [QueryState.errorUpdatedAt] is not updated, and the existing error state is maintained. + */ + val errorEquals: ((Throwable, Throwable) -> Boolean)? + /** * Determines whether query processing needs to be paused based on error. * @@ -75,6 +83,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { override val staleTime: Duration = Duration.ZERO override val gcTime: Duration = 5.minutes override val prefetchWindowTime: Duration = 1.seconds + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = null override val pauseDurationAfter: ((Throwable) -> Duration?)? = null override val revalidateOnReconnect: Boolean = true override val revalidateOnFocus: Boolean = true @@ -106,6 +115,7 @@ interface QueryOptions : ActorOptions, LoggingOptions, RetryOptions { * @param staleTime The duration after which the returned value of the fetch function block is considered stale. * @param gcTime The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. * @param prefetchWindowTime Maximum window time on prefetch processing. + * @param errorEquals Determines whether two errors are equal. * @param pauseDurationAfter Determines whether query processing needs to be paused based on error. * @param revalidateOnReconnect Automatically revalidate active [Query] when the network reconnects. * @param revalidateOnFocus Automatically revalidate active [Query] when the window is refocused. @@ -125,6 +135,7 @@ fun QueryOptions( staleTime: Duration = QueryOptions.staleTime, gcTime: Duration = QueryOptions.gcTime, prefetchWindowTime: Duration = QueryOptions.prefetchWindowTime, + errorEquals: ((Throwable, Throwable) -> Boolean)? = QueryOptions.errorEquals, pauseDurationAfter: ((Throwable) -> Duration?)? = QueryOptions.pauseDurationAfter, revalidateOnReconnect: Boolean = QueryOptions.revalidateOnReconnect, revalidateOnFocus: Boolean = QueryOptions.revalidateOnFocus, @@ -144,6 +155,7 @@ fun QueryOptions( override val staleTime: Duration = staleTime override val gcTime: Duration = gcTime override val prefetchWindowTime: Duration = prefetchWindowTime + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = errorEquals override val pauseDurationAfter: ((Throwable) -> Duration?)? = pauseDurationAfter override val revalidateOnReconnect: Boolean = revalidateOnReconnect override val revalidateOnFocus: Boolean = revalidateOnFocus @@ -168,6 +180,7 @@ fun QueryOptions.copy( staleTime: Duration = this.staleTime, gcTime: Duration = this.gcTime, prefetchWindowTime: Duration = this.prefetchWindowTime, + errorEquals: ((Throwable, Throwable) -> Boolean)? = this.errorEquals, pauseDurationAfter: ((Throwable) -> Duration?)? = this.pauseDurationAfter, revalidateOnReconnect: Boolean = this.revalidateOnReconnect, revalidateOnFocus: Boolean = this.revalidateOnFocus, @@ -187,6 +200,7 @@ fun QueryOptions.copy( staleTime = staleTime, gcTime = gcTime, prefetchWindowTime = prefetchWindowTime, + errorEquals = errorEquals, pauseDurationAfter = pauseDurationAfter, revalidateOnReconnect = revalidateOnReconnect, revalidateOnFocus = revalidateOnFocus, diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt index 9ef6504..2d4a5fc 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionAction.kt @@ -73,7 +73,7 @@ fun createSubscriptionReducer(): SubscriptionReducer = { state, action -> reply = Reply(action.data), replyUpdatedAt = action.dataUpdatedAt, error = null, - errorUpdatedAt = action.dataUpdatedAt + errorUpdatedAt = if (state.error != null) action.dataUpdatedAt else state.errorUpdatedAt ) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt index 1f17ec3..0d75659 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionClient.kt @@ -26,5 +26,6 @@ interface SubscriptionClient { ): SubscriptionRef } +typealias SubscriptionContentEquals = (oldData: T, newData: T) -> Boolean typealias SubscriptionRecoverData = (error: Throwable) -> T typealias SubscriptionOptionsOverride = (SubscriptionOptions) -> SubscriptionOptions diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt index 6e14807..9146893 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionCommand.kt @@ -5,6 +5,7 @@ package soil.query import soil.query.core.ErrorRecord import soil.query.core.Marker +import soil.query.core.Reply import soil.query.core.UniqueId import soil.query.core.epoch @@ -51,7 +52,7 @@ fun SubscriptionCommand.Context.dispatchResult( ) { result .run { key.onRecoverData()?.let(::recoverCatching) ?: this } - .onSuccess(::dispatchReceiveSuccess) + .onSuccess { dispatchReceiveSuccess(it, key.contentEquals) } .onFailure(::dispatchReceiveFailure) .onFailure { reportSubscriptionError(it, key.id, marker) } } @@ -61,12 +62,23 @@ fun SubscriptionCommand.Context.dispatchResult( * * @param data The data of the subscription. */ -fun SubscriptionCommand.Context.dispatchReceiveSuccess(data: T) { +fun SubscriptionCommand.Context.dispatchReceiveSuccess( + data: T, + contentEquals: SubscriptionContentEquals? = null +) { val currentAt = epoch() - val action = SubscriptionAction.ReceiveSuccess( - data = data, - dataUpdatedAt = currentAt - ) + val currentReply = state.reply + val action = if (currentReply is Reply.Some && contentEquals?.invoke(currentReply.value, data) == true) { + SubscriptionAction.ReceiveSuccess( + data = currentReply.value, + dataUpdatedAt = state.replyUpdatedAt + ) + } else { + SubscriptionAction.ReceiveSuccess( + data = data, + dataUpdatedAt = currentAt + ) + } dispatch(action) } @@ -77,10 +89,18 @@ fun SubscriptionCommand.Context.dispatchReceiveSuccess(data: T) { */ fun SubscriptionCommand.Context.dispatchReceiveFailure(error: Throwable) { val currentAt = epoch() - val action = SubscriptionAction.ReceiveFailure( - error = error, - errorUpdatedAt = currentAt - ) + val currentError = state.error + val action = if (currentError != null && options.errorEquals?.invoke(currentError, error) == true) { + SubscriptionAction.ReceiveFailure( + error = currentError, + errorUpdatedAt = state.errorUpdatedAt + ) + } else { + SubscriptionAction.ReceiveFailure( + error = error, + errorUpdatedAt = currentAt + ) + } dispatch(action) } diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt index f97c0f3..c1f2b52 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionKey.kt @@ -27,6 +27,20 @@ interface SubscriptionKey { */ val subscribe: SubscriptionReceiver.() -> Flow + /** + * Function to compare the content of the data. + * + * This function is used to determine whether the data is identical to the previous data via [SubscriptionCommand]. + * If the data is considered the same, [SubscriptionState.replyUpdatedAt] is not updated, and the existing reply state is maintained. + * This can be useful when strict update management is needed, such as when special comparison is necessary, + * although it is generally not that important. + * + * ```kotlin + * override val contentEquals: SubscriptionContentEquals = { a, b -> a.xx == b.xx } + * ``` + */ + val contentEquals: SubscriptionContentEquals? get() = null + /** * Function to configure the [SubscriptionOptions]. * diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt index 1244cab..cc0370b 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/SubscriptionOptions.kt @@ -25,6 +25,14 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { */ val gcTime: Duration + /** + * Determines whether two errors are equal. + * + * This function is used to determine whether a new error is identical to an existing error via [SubscriptionCommand]. + * If the errors are considered identical, [SubscriptionState.errorUpdatedAt] is not updated, and the existing error state is maintained. + */ + val errorEquals: ((Throwable, Throwable) -> Boolean)? + /** * This callback function will be called if some mutation encounters an error. */ @@ -38,6 +46,7 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { companion object Default : SubscriptionOptions { override val gcTime: Duration = 5.minutes + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = null override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = null override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = null @@ -64,6 +73,7 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { * Creates a new [SubscriptionOptions] with the specified settings. * * @param gcTime The period during which the Key's return value, if not referenced anywhere, is temporarily cached in memory. + * @param errorEquals Determines whether two errors are equal. * @param onError This callback function will be called if some subscription encounters an error. * @param shouldSuppressErrorRelay Determines whether to suppress error information when relaying it using [soil.query.core.ErrorRelay]. * @param keepAliveTime The duration to keep the actor alive after the last command is executed. @@ -78,6 +88,7 @@ interface SubscriptionOptions : ActorOptions, LoggingOptions, RetryOptions { */ fun SubscriptionOptions( gcTime: Duration = SubscriptionOptions.gcTime, + errorEquals: ((Throwable, Throwable) -> Boolean)? = SubscriptionOptions.errorEquals, onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = SubscriptionOptions.onError, shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = SubscriptionOptions.shouldSuppressErrorRelay, keepAliveTime: Duration = SubscriptionOptions.keepAliveTime, @@ -92,6 +103,7 @@ fun SubscriptionOptions( ): SubscriptionOptions { return object : SubscriptionOptions { override val gcTime: Duration = gcTime + override val errorEquals: ((Throwable, Throwable) -> Boolean)? = errorEquals override val onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = onError override val shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = shouldSuppressErrorRelay @@ -112,6 +124,7 @@ fun SubscriptionOptions( */ fun SubscriptionOptions.copy( gcTime: Duration = this.gcTime, + errorEquals: ((Throwable, Throwable) -> Boolean)? = this.errorEquals, onError: ((ErrorRecord, SubscriptionModel<*>) -> Unit)? = this.onError, shouldSuppressErrorRelay: ((ErrorRecord, SubscriptionModel<*>) -> Boolean)? = this.shouldSuppressErrorRelay, keepAliveTime: Duration = this.keepAliveTime, @@ -126,6 +139,7 @@ fun SubscriptionOptions.copy( ): SubscriptionOptions { return SubscriptionOptions( gcTime = gcTime, + errorEquals = errorEquals, onError = onError, shouldSuppressErrorRelay = shouldSuppressErrorRelay, keepAliveTime = keepAliveTime, diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt index 2ddf1e3..164e58f 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/MutationOptionsTest.kt @@ -17,6 +17,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions() assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertEquals(MutationOptions.Default.errorEquals, actual.errorEquals) assertEquals(MutationOptions.Default.onError, actual.onError) assertEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) @@ -36,6 +37,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions( isOneShot = true, isStrictMode = true, + errorEquals = { _, _ -> true }, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, shouldExecuteEffectSynchronously = true, @@ -51,6 +53,7 @@ class MutationOptionsTest : UnitTest() { ) assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertNotEquals(MutationOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(MutationOptions.Default.onError, actual.onError) assertNotEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) @@ -70,6 +73,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions.Default.copy() assertEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertEquals(MutationOptions.Default.errorEquals, actual.errorEquals) assertEquals(MutationOptions.Default.onError, actual.onError) assertEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) @@ -89,6 +93,7 @@ class MutationOptionsTest : UnitTest() { val actual = MutationOptions.Default.copy( isOneShot = true, isStrictMode = true, + errorEquals = { _, _ -> true }, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, shouldExecuteEffectSynchronously = true, @@ -104,6 +109,7 @@ class MutationOptionsTest : UnitTest() { ) assertNotEquals(MutationOptions.Default.isOneShot, actual.isOneShot) assertNotEquals(MutationOptions.Default.isStrictMode, actual.isStrictMode) + assertNotEquals(MutationOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(MutationOptions.Default.onError, actual.onError) assertNotEquals(MutationOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(MutationOptions.Default.shouldExecuteEffectSynchronously, actual.shouldExecuteEffectSynchronously) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt index 69b7121..f64a8ab 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/QueryOptionsTest.kt @@ -18,6 +18,7 @@ class QueryOptionsTest : UnitTest() { assertEquals(QueryOptions.Default.staleTime, actual.staleTime) assertEquals(QueryOptions.Default.gcTime, actual.gcTime) assertEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertEquals(QueryOptions.Default.errorEquals, actual.errorEquals) assertEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) @@ -40,6 +41,7 @@ class QueryOptionsTest : UnitTest() { staleTime = 1000.seconds, gcTime = 2000.seconds, prefetchWindowTime = 3000.seconds, + errorEquals = { _, _ -> true }, pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, @@ -58,6 +60,7 @@ class QueryOptionsTest : UnitTest() { assertNotEquals(QueryOptions.Default.staleTime, actual.staleTime) assertNotEquals(QueryOptions.Default.gcTime, actual.gcTime) assertNotEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertNotEquals(QueryOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) @@ -80,6 +83,7 @@ class QueryOptionsTest : UnitTest() { assertEquals(QueryOptions.Default.staleTime, actual.staleTime) assertEquals(QueryOptions.Default.gcTime, actual.gcTime) assertEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertEquals(QueryOptions.Default.errorEquals, actual.errorEquals) assertEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) assertEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) @@ -102,6 +106,7 @@ class QueryOptionsTest : UnitTest() { staleTime = 1000.seconds, gcTime = 2000.seconds, prefetchWindowTime = 3000.seconds, + errorEquals = { _, _ -> true }, pauseDurationAfter = { null }, revalidateOnReconnect = false, revalidateOnFocus = false, @@ -120,6 +125,7 @@ class QueryOptionsTest : UnitTest() { assertNotEquals(QueryOptions.Default.staleTime, actual.staleTime) assertNotEquals(QueryOptions.Default.gcTime, actual.gcTime) assertNotEquals(QueryOptions.Default.prefetchWindowTime, actual.prefetchWindowTime) + assertNotEquals(QueryOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(QueryOptions.Default.pauseDurationAfter, actual.pauseDurationAfter) assertNotEquals(QueryOptions.Default.revalidateOnReconnect, actual.revalidateOnReconnect) assertNotEquals(QueryOptions.Default.revalidateOnFocus, actual.revalidateOnFocus) diff --git a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt index 1d7c35d..3f44bf7 100644 --- a/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt +++ b/soil-query-core/src/commonTest/kotlin/soil/query/SubscriptionOptionsTest.kt @@ -16,6 +16,7 @@ class SubscriptionOptionsTest : UnitTest() { fun factory_default() { val actual = SubscriptionOptions() assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertEquals(SubscriptionOptions.Default.errorEquals, actual.errorEquals) assertEquals(SubscriptionOptions.Default.onError, actual.onError) assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -33,6 +34,7 @@ class SubscriptionOptionsTest : UnitTest() { fun factory_factory_specifyingArguments() { val actual = SubscriptionOptions( gcTime = 1000.seconds, + errorEquals = { _, _ -> true }, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, @@ -46,6 +48,7 @@ class SubscriptionOptionsTest : UnitTest() { retryRandomizer = Random(999) ) assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertNotEquals(SubscriptionOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -63,6 +66,7 @@ class SubscriptionOptionsTest : UnitTest() { fun copy_default() { val actual = SubscriptionOptions.copy() assertEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertEquals(SubscriptionOptions.Default.errorEquals, actual.errorEquals) assertEquals(SubscriptionOptions.Default.onError, actual.onError) assertEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) @@ -80,6 +84,7 @@ class SubscriptionOptionsTest : UnitTest() { fun copy_override() { val actual = SubscriptionOptions.copy( gcTime = 1000.seconds, + errorEquals = { _, _ -> true }, onError = { _, _ -> }, shouldSuppressErrorRelay = { _, _ -> true }, keepAliveTime = 4000.seconds, @@ -94,6 +99,7 @@ class SubscriptionOptionsTest : UnitTest() { ) assertNotEquals(SubscriptionOptions.Default.gcTime, actual.gcTime) + assertNotEquals(SubscriptionOptions.Default.errorEquals, actual.errorEquals) assertNotEquals(SubscriptionOptions.Default.onError, actual.onError) assertNotEquals(SubscriptionOptions.Default.shouldSuppressErrorRelay, actual.shouldSuppressErrorRelay) assertNotEquals(SubscriptionOptions.Default.keepAliveTime, actual.keepAliveTime) From f23e8625f7f0c0b9cf6d60764cb8d4642b242c55 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 23 Sep 2024 14:09:57 +0900 Subject: [PATCH 154/155] Support for `LazyLoadEffect` Component The `LoadMoreEffect` component, which was temporarily implemented in the `playground` module, has been migrated to the `soil.query.compose.runtime` library as `LazyLoadEffect`. From now on, it will be possible to handle additional data fetching for InfiniteQuery simply by using the `LazyLoadEffect` component within the library. Currently, we provide Effect components corresponding to the following three states: - LazyListState - LazyGridState - States that inherit ScrollableState --- .../query/compose/LoadMoreEffect.kt | 35 ---- .../soil/kmp/screen/HelloQueryScreen.kt | 9 +- .../query/compose/runtime/LazyLoadEffect.kt | 161 ++++++++++++++++++ 3 files changed, 166 insertions(+), 39 deletions(-) delete mode 100644 internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt create mode 100644 soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt diff --git a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt b/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt deleted file mode 100644 index e040722..0000000 --- a/internal/playground/src/commonMain/kotlin/soil/playground/query/compose/LoadMoreEffect.kt +++ /dev/null @@ -1,35 +0,0 @@ -package soil.playground.query.compose - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter -import kotlin.time.Duration.Companion.milliseconds - -@OptIn(FlowPreview::class) -@Composable -inline fun LoadMoreEffect( - state: LazyListState, - noinline loadMore: suspend (T) -> Unit, - loadMoreParam: T?, - crossinline predicate: (totalCount: Int, lastIndex: Int) -> Boolean = { totalCount, lastIndex -> - totalCount > 0 && lastIndex > totalCount - 5 - } -) { - LaunchedEffect(state, loadMore, loadMoreParam) { - if (loadMoreParam == null) return@LaunchedEffect - snapshotFlow { - val totalCount = state.layoutInfo.totalItemsCount - val lastIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - predicate(totalCount, lastIndex) - } - .debounce(250.milliseconds) - .filter { it } - .collect { - loadMore(loadMoreParam) - } - } -} diff --git a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt index ff9c556..35be77e 100644 --- a/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/soil/kmp/screen/HelloQueryScreen.kt @@ -17,7 +17,6 @@ import io.ktor.client.plugins.ResponseException import soil.playground.Alert import soil.playground.query.compose.ContentLoading import soil.playground.query.compose.ContentUnavailable -import soil.playground.query.compose.LoadMoreEffect import soil.playground.query.compose.PostListItem import soil.playground.query.compose.rememberGetPostsQuery import soil.playground.query.data.PageParam @@ -28,6 +27,7 @@ import soil.query.compose.rememberQueriesErrorReset import soil.query.compose.runtime.Await import soil.query.compose.runtime.Catch import soil.query.compose.runtime.ErrorBoundary +import soil.query.compose.runtime.LazyLoadEffect import soil.query.compose.runtime.Suspense @Composable @@ -84,7 +84,7 @@ private fun HelloQueryContent( } val pageParam = state.loadMoreParam if (state.posts.isNotEmpty() && pageParam != null) { - item(pageParam, contentType = "loading") { + item(true, contentType = "loading") { ContentLoading( modifier = Modifier.fillMaxWidth(), size = 20.dp @@ -92,10 +92,11 @@ private fun HelloQueryContent( } } } - LoadMoreEffect( + LazyLoadEffect( state = lazyListState, loadMore = state.loadMore, - loadMoreParam = state.loadMoreParam + loadMoreParam = state.loadMoreParam, + totalItemsCount = state.posts.size ) } } diff --git a/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt new file mode 100644 index 0000000..d85a929 --- /dev/null +++ b/soil-query-compose-runtime/src/commonMain/kotlin/soil/query/compose/runtime/LazyLoadEffect.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Soil Contributors +// SPDX-License-Identifier: Apache-2.0 + +@file:Suppress("unused") + +package soil.query.compose.runtime + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import kotlin.jvm.JvmInline + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state of a [androidx.compose.foundation.lazy.LazyColumn] or [androidx.compose.foundation.lazy.LazyRow]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadEffect( + state: LazyListState, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: LazyListState) -> Int = { + it.firstVisibleItemIndex + it.layoutInfo.visibleItemsInfo.size / 2 + } +) { + LazyLoadCustomEffect( + state = state, + loadMore = loadMore, + loadMoreParam = loadMoreParam, + totalItemsCount = totalItemsCount, + threshold = threshold, + direction = direction, + computeVisibleItemIndex = computeVisibleItemIndex + ) +} + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state of a [androidx.compose.foundation.lazy.grid.LazyGrid]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadEffect( + state: LazyGridState, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: LazyGridState) -> Int = { + it.firstVisibleItemIndex + it.layoutInfo.visibleItemsInfo.size / 2 + } +) { + LazyLoadCustomEffect( + state = state, + loadMore = loadMore, + loadMoreParam = loadMoreParam, + totalItemsCount = totalItemsCount, + threshold = threshold, + direction = direction, + computeVisibleItemIndex = computeVisibleItemIndex + ) +} + +/** + * Provides a [LaunchedEffect] to perform additional loading for [soil.query.compose.InfiniteQueryObject]. + * + * The percentage is calculated from the values of [computeVisibleItemIndex] and [totalItemsCount]. + * It only triggers when the [threshold] is reached. + * + * @param state The scroll state inherited from [ScrollableState]. + * @param loadMore Specifies the function to call for loading more items, typically [soil.query.compose.InfiniteQueryObject.loadMore]. + * @param loadMoreParam The parameter passed to [soil.query.compose.InfiniteQueryObject.loadMore]. If `null`, the effect is not triggered. + * @param totalItemsCount The total number of items related to [state]. + * @param threshold The threshold scroll position at which to trigger additional loading. + * @param direction The direction for loading more items. + * @param computeVisibleItemIndex A function that calculates the index of the visible item used as the reference for additional loading. + */ +@Composable +inline fun LazyLoadCustomEffect( + state: S, + noinline loadMore: suspend (T) -> Unit, + loadMoreParam: T?, + totalItemsCount: Int, + threshold: LazyLoadThreshold = LazyLoadThreshold.Lazily, + direction: LazyLoadDirection = LazyLoadDirection.Forward, + crossinline computeVisibleItemIndex: (state: S) -> Int +) { + LaunchedEffect(state, totalItemsCount, loadMore, loadMoreParam) { + if (totalItemsCount == 0 || loadMoreParam == null) return@LaunchedEffect + snapshotFlow { + val itemIndex = computeVisibleItemIndex(state).coerceIn(0, totalItemsCount - 1) + itemIndex to direction.canScrollMore(state) + } + .filter { (itemIndex, canScrollMore) -> + !canScrollMore || direction.getScrollPositionRatio(itemIndex, totalItemsCount) >= threshold.value + } + .take(1) + .collect { + loadMore(loadMoreParam) + } + } +} + +@JvmInline +value class LazyLoadThreshold(val value: Float) { + init { + require(value in 0.0f..1.0f) { "Threshold value must be in the range [0.0, 1.0]" } + } + + companion object { + val Eagerly = LazyLoadThreshold(0.5f) + val Lazily = LazyLoadThreshold(0.75f) + } +} + +sealed interface LazyLoadDirection { + fun canScrollMore(state: ScrollableState): Boolean + fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float + + data object Backward : LazyLoadDirection { + override fun canScrollMore(state: ScrollableState): Boolean = state.canScrollBackward + override fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float { + return 1f - (1f * itemIndex / totalItemsCount) + } + } + + data object Forward : LazyLoadDirection { + override fun canScrollMore(state: ScrollableState): Boolean = state.canScrollForward + override fun getScrollPositionRatio(itemIndex: Int, totalItemsCount: Int): Float { + return 1f * (itemIndex + 1) / totalItemsCount + } + } +} From 09aa2660721fd438b0e472b943b70de8fdf913b3 Mon Sep 17 00:00:00 2001 From: ogaclejapan Date: Mon, 23 Sep 2024 14:42:05 +0900 Subject: [PATCH 155/155] Tweak a suppress annotation A warning was issued regarding the `Reply` class, so the scope of the suppression annotation changes to file-wide. --- soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt index a017abb..07f3663 100644 --- a/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt +++ b/soil-query-core/src/commonMain/kotlin/soil/query/core/Reply.kt @@ -1,6 +1,8 @@ // Copyright 2024 Soil Contributors // SPDX-License-Identifier: Apache-2.0 +@file:Suppress("NOTHING_TO_INLINE", "KotlinRedundantDiagnosticSuppress") + package soil.query.core /** @@ -13,7 +15,6 @@ sealed interface Reply { data class Some internal constructor(val value: T) : Reply companion object { - @Suppress("NOTHING_TO_INLINE") internal inline operator fun invoke(value: T): Reply = Some(value) fun none(): Reply = None