diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..eeeced6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,320 @@ +name: Release + +on: + workflow_dispatch: + inputs: + mode: + description: "Prepare a release PR or publish the current main version." + required: true + default: prepare-release-pr + type: choice + options: + - prepare-release-pr + - publish-current + version: + description: "Release version as x.y.z. Leave empty in prepare mode to bump the root Gradle patch version." + required: false + type: string + pull_request: + types: + - closed + branches: + - main + +permissions: + contents: read + +concurrency: + group: release-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + prepare-release-pr: + name: Prepare release PR + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'prepare-release-pr' }} + permissions: + contents: read + pull-requests: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve release version + id: version + shell: bash + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + current_version="$(sed -nE 's/^val projectVersion = providers\.gradleProperty\("releaseVersion"\)\.orElse\("([^"]+)"\)\.get\(\)$/\1/p' build.gradle.kts)" + if [[ -z "$current_version" ]]; then + echo "Could not read root Gradle version from build.gradle.kts" >&2 + exit 1 + fi + + if [[ -n "$REQUESTED_VERSION" ]]; then + if [[ ! "$REQUESTED_VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "Release version must use x.y.z format, found: $REQUESTED_VERSION" >&2 + exit 1 + fi + release_version="$REQUESTED_VERSION" + else + if [[ ! "$current_version" =~ ^([0-9]+)[.]([0-9]+)[.]([0-9]+)$ ]]; then + echo "Automatic version bump requires current root version to use x.y.z, found: $current_version" >&2 + exit 1 + fi + release_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" + fi + + if git rev-parse --verify --quiet "refs/tags/v${release_version}" >/dev/null; then + echo "Release tag already exists: v${release_version}" >&2 + exit 1 + fi + + sed -i -E "s/orElse\\(\"[^\"]+\"\\)/orElse(\"${release_version}\")/" build.gradle.kts + + echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" + echo "branch=release/v${release_version}" >> "$GITHUB_OUTPUT" + + - name: Build release notes + id: notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + run: | + set -euo pipefail + + notes_file="$(mktemp)" + latest_tag="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1)" + + { + echo "## Release v${RELEASE_VERSION}" + echo + echo "This PR updates the root Gradle release version to ${RELEASE_VERSION}." + echo + echo "## Changes since previous release" + echo + } > "$notes_file" + + if [[ -n "$latest_tag" ]]; then + previous_release_date="$(git log -1 --format=%cI "$latest_tag")" + gh pr list \ + --state merged \ + --base main \ + --limit 100 \ + --search "merged:>${previous_release_date}" \ + --json number,title,url,author,mergedAt \ + --jq '. | sort_by(.mergedAt) | reverse | .[] | "- #\(.number) \(.title) by @\(.author.login) (\(.url))"' \ + >> "$notes_file" + else + gh pr list \ + --state merged \ + --base main \ + --limit 100 \ + --json number,title,url,author,mergedAt \ + --jq '. | sort_by(.mergedAt) | reverse | .[] | "- #\(.number) \(.title) by @\(.author.login) (\(.url))"' \ + >> "$notes_file" + fi + + if ! grep -q '^- #' "$notes_file"; then + echo "- No merged pull requests found since the previous release." >> "$notes_file" + fi + + { + echo + echo "## Publish" + echo + echo "After this PR is merged, this workflow publishes dev.voir:optional:${RELEASE_VERSION}, pushes tag v${RELEASE_VERSION}, and creates a GitHub release using these notes." + } >> "$notes_file" + + echo "path=${notes_file}" >> "$GITHUB_OUTPUT" + + - name: Create release pull request + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + RELEASE_BRANCH: ${{ steps.version.outputs.branch }} + BODY_FILE: ${{ steps.notes.outputs.path }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "RELEASE_BOT_TOKEN is required so the release PR can trigger validation workflows." >&2 + exit 1 + fi + + gh repo view "$GITHUB_REPOSITORY" >/dev/null + + git config user.name "voir-release-bot" + git config user.email "voir-release-bot@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + + if git ls-remote --exit-code --heads origin "$RELEASE_BRANCH" >/dev/null 2>&1; then + git fetch origin "$RELEASE_BRANCH" + git checkout "$RELEASE_BRANCH" + else + git checkout -b "$RELEASE_BRANCH" + fi + + git add build.gradle.kts + if git diff --cached --quiet; then + echo "Release branch already contains version ${RELEASE_VERSION}." + else + git commit -m "Release v${RELEASE_VERSION}" + fi + git push --set-upstream origin "$RELEASE_BRANCH" + + existing_pr="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$RELEASE_BRANCH" --base main --json url --jq '.[0].url')" + if [[ -n "$existing_pr" ]]; then + echo "Release PR already exists: $existing_pr" + gh pr edit "$existing_pr" --title "Release v${RELEASE_VERSION}" --body-file "$BODY_FILE" + exit 0 + fi + + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "Release v${RELEASE_VERSION}" \ + --body-file "$BODY_FILE" + + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + if: >- + ${{ + (github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v')) || + (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-current') + }} + permissions: + contents: write + pull-requests: read + steps: + - name: Check out repository + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Resolve published version + id: version + shell: bash + run: | + set -euo pipefail + + release_version="$(sed -nE 's/^val projectVersion = providers\.gradleProperty\("releaseVersion"\)\.orElse\("([^"]+)"\)\.get\(\)$/\1/p' build.gradle.kts)" + if [[ ! "$release_version" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]]; then + echo "Root Gradle release version must use x.y.z format, found: ${release_version:-}" >&2 + exit 1 + fi + + release_tag="v${release_version}" + if git ls-remote --tags origin "refs/tags/${release_tag}" | grep -q "refs/tags/${release_tag}$"; then + echo "Release tag already exists on origin: ${release_tag}" >&2 + exit 1 + fi + + echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + + - name: Build release notes + id: notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} + RELEASE_TAG: ${{ steps.version.outputs.release_tag }} + run: | + set -euo pipefail + + notes_file="$(mktemp)" + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" > "$notes_file" + else + previous_tag="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1)" + { + echo "## Release ${RELEASE_TAG}" + echo + echo "Published from the current main branch." + echo + echo "## Changes since previous release" + echo + } > "$notes_file" + + if [[ -n "$previous_tag" ]]; then + previous_release_date="$(git log -1 --format=%cI "$previous_tag")" + gh pr list \ + --state merged \ + --base main \ + --limit 100 \ + --search "merged:>${previous_release_date}" \ + --json number,title,url,author,mergedAt \ + --jq '. | sort_by(.mergedAt) | reverse | .[] | "- #\(.number) \(.title) by @\(.author.login) (\(.url))"' \ + >> "$notes_file" + else + gh pr list \ + --state merged \ + --base main \ + --limit 100 \ + --json number,title,url,author,mergedAt \ + --jq '. | sort_by(.mergedAt) | reverse | .[] | "- #\(.number) \(.title) by @\(.author.login) (\(.url))"' \ + >> "$notes_file" + fi + + if ! grep -q '^- #' "$notes_file"; then + echo "- No merged pull requests found since the previous release." >> "$notes_file" + fi + fi + + echo "path=${notes_file}" >> "$GITHUB_OUTPUT" + + - name: Publish to Maven Central + run: ./gradlew publishToMavenCentral -PreleaseVersion=${{ steps.version.outputs.release_version }} --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Create and push git tag + shell: bash + run: | + set -euo pipefail + + release_tag="${{ steps.version.outputs.release_tag }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git tag -a "$release_tag" -m "Release $release_tag" + git push origin "$release_tag" + + - name: Create GitHub release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.version.outputs.release_tag }} + NOTES_FILE: ${{ steps.notes.outputs.path }} + run: | + set -euo pipefail + + gh release create "$RELEASE_TAG" \ + --title "$RELEASE_TAG" \ + --notes-file "$NOTES_FILE" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..c10926e --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,51 @@ +name: Verify the library + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Run tests + runs-on: macos-latest + if: ${{ !contains(github.event.pull_request.body || '', '[skip verify]') }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Build (all modules) with tests + run: ./gradlew --no-daemon clean build + + - name: Publish test reports (always) + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-reports + path: | + **/build/test-results/** + **/build/reports/tests/** + if-no-files-found: ignore + + - name: Publish build scans / reports (optional) + if: failure() + run: | + echo "Gradle build failed. See artifacts for reports." diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0dd7fb --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Optional + +A tiny Kotlin Multiplatform library for representing fields that can be **absent**, **explicitly +null**, or **present with a value**. + +`Optional` is useful for PATCH requests, partial updates, form edits, and any DTO where `null` has a +real meaning: + +- `Optional.Absent` means "this field was not provided; leave the current value unchanged". +- `Optional.PresentNull` means "this field was provided as null; clear the current value". +- `Optional.Present(value)` means "this field was provided with a non-null value; update it". + +The library integrates with `kotlinx.serialization`, so JSON can keep the natural wire shape: + +```json +{} +{ + "name": "Ada" +} +{ + "name": null +} +``` + +## Installation + +Add the dependency from Maven Central: + +```kotlin +dependencies { + implementation("dev.voir:optional:1.0.1") +} +``` + +The artifact is Kotlin Multiplatform and currently publishes JVM and iOS targets. + +## Basic Usage + +```kotlin +import dev.voir.optional.Optional + +val unchanged: Optional = Optional.Absent +val renamed: Optional = Optional.of("Ada") +val cleared: Optional = Optional.ofNullable(null) +``` + +Use callbacks when you only care about specific states: + +```kotlin +patch.name.ifPresent { name -> + user.name = name +} + +patch.name.ifNull { + user.name = null +} + +patch.name.ifProvided { nameOrNull -> + user.name = nameOrNull +} +``` + +Or use the helper extensions: + +```kotlin +import dev.voir.optional.isPresent +import dev.voir.optional.isProvided +import dev.voir.optional.orElse +import dev.voir.optional.toOptional +import dev.voir.optional.toOptionalOrAbsent + +val displayName = patch.name.orElse(current.name) + +val explicitNull = nullableName.toOptional() +val absentWhenNull = nullableName.toOptionalOrAbsent() + +if (patch.name.isProvided) { + // Field was sent by the client, including explicit null. +} + +if (patch.name.isPresent) { + // Field contains a non-null value. +} +``` + +## Serialization + +`Optional` is serializable with `kotlinx.serialization`. + +For patch DTOs, give each optional field an `Optional.Absent` default and configure JSON with +`encodeDefaults = false`. This lets absent fields be omitted from the JSON object. + +```kotlin +import dev.voir.optional.Optional +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class UserPatch( + val name: Optional = Optional.Absent, + val age: Optional = Optional.Absent, +) + +val json = Json { + encodeDefaults = false +} + +json.encodeToString(UserPatch()) +// {} + +json.encodeToString(UserPatch(name = Optional.Present("Ada"))) +// {"name":"Ada"} + +json.encodeToString(UserPatch(name = Optional.PresentNull)) +// {"name":null} +``` + +Decoding works the same way: + +```kotlin +val omitted = json.decodeFromString("{}") +// omitted.name == Optional.Absent + +val present = json.decodeFromString("""{"name":"Ada"}""") +// present.name == Optional.Present("Ada") + +val cleared = json.decodeFromString("""{"name":null}""") +// cleared.name == Optional.PresentNull +``` + +> Important: `Optional.Absent` represents a missing surrounding property. It cannot be serialized +> directly as a standalone value. Use it as a default property value with +`Json { encodeDefaults = false }`. + +## Applying a Patch + +```kotlin +import dev.voir.optional.Optional +import dev.voir.optional.orElse +import kotlinx.serialization.Serializable + +data class User( + val name: String?, + val age: Int?, +) + +@Serializable +data class UserPatch( + val name: Optional = Optional.Absent, + val age: Optional = Optional.Absent, +) + +fun User.apply(patch: UserPatch): User = + copy( + name = patch.name.orElse(name), + age = patch.age.orElse(age), + ) +``` + +In this example: + +- Missing fields keep the current value. +- `null` fields clear the current value. +- Non-null fields replace the current value. + +## API Overview + +Core type: + +```kotlin +sealed class Optional { + data object Absent : Optional() + data class Present(val value: T) : Optional() + data object PresentNull : Optional() +} +``` + +Factory functions: + +```kotlin +Optional.of("value") // Optional.Present("value") +Optional.ofNullable(value) // Present(value) or PresentNull +``` + +State helpers: + +```kotlin +optional.isPresent +optional.isProvided +optional.isNullOrAbsent +``` + +Value helpers: + +```kotlin +optional.ifPresent { value -> } +optional.ifNull { } +optional.ifProvided { valueOrNull -> } +optional.ifNullOrAbsent { } + +optional.orElse(currentValue) +optional.orElse { fallbackValue } +optional.getOrError { "Missing value" } +``` + +Conversion helpers: + +```kotlin +nullableValue.toOptional() // null becomes PresentNull +nullableValue.toOptionalOrAbsent() // null becomes Absent +``` + +## Requirements + +- Kotlin Multiplatform +- `kotlinx.serialization` + +This project is built with Kotlin `2.3.20` and `kotlinx.serialization-json` `1.10.0`. + +## Contributing + +Contributions are welcome. Please keep changes small, tested, and focused on the library's core +goal: making tri-state optional values easy to use in Kotlin Multiplatform projects. + +To run the tests: + +```bash +./gradlew check +``` + +## License + +Optional is open source under the [GNU Lesser General Public License v3.0](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..91481b2 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.serialization) apply false +} + +val projectVersion = providers.gradleProperty("releaseVersion").orElse("1.0.0").get() + +allprojects { + group = "dev.voir" + version = projectVersion +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6b6e846 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,8 @@ +kotlin.code.style=official +#Gradle +org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true +#Kotlin Multiplatform +kotlin.mpp.enableCInteropCommonization=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8633463 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +[versions] +kotlin = "2.3.20" # https://kotlinlang.org/docs/releases.html +kotlinx-serialization-json = "1.10.0" # https://github.com/Kotlin/kotlinx.serialization + +[libraries] +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } + +[plugins] +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# 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 +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/optional/build.gradle.kts b/optional/build.gradle.kts new file mode 100644 index 0000000..260c101 --- /dev/null +++ b/optional/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.serialization) + id("com.vanniktech.maven.publish") version "0.36.0" +} + +kotlin { + jvmToolchain(21) + + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + api(libs.kotlinx.serialization.json) + } + commonTest.dependencies { + implementation(kotlin("test")) + } + jvmMain.dependencies { } + iosMain.dependencies { } + } +} + +val isLocalPublish = gradle.startParameter.taskNames.any { + it.contains("publishToMavenLocal", ignoreCase = true) +} + +mavenPublishing { + publishToMavenCentral() + if (!isLocalPublish) { + signAllPublications() + } + + coordinates( + groupId = "dev.voir", + artifactId = "optional", + version = project.version.toString() + ) + + pom { + name.set("Optional - Kotlin Multiplatform tri-state optional type") + description.set("A Kotlin Multiplatform tri-state optional type for partial updates and patch DTOs, distinguishing absent, explicit null, and non-null values.") + url.set("https://github.com/VoirDev/optional/") + + licenses { + license { + name.set("GNU Lesser General Public License, Version 3") + url.set("https://www.gnu.org/licenses/lgpl-3.0.txt") + } + } + + developers { + developer { + id.set("checksanity") + name.set("Gary Bezruchko") + email.set("hello@voir.dev") + organization.set("VOIR") + organizationUrl.set("https://voir.dev") + } + } + + scm { + url.set("https://github.com/VoirDev/optional/") + connection.set("scm:git:git://github.com/VoirDev/optional.git") + developerConnection.set("scm:git:ssh://git@github.com/VoirDev/optional.git") + } + } +} diff --git a/optional/src/commonMain/kotlin/dev/voir/optional/Helpers.kt b/optional/src/commonMain/kotlin/dev/voir/optional/Helpers.kt new file mode 100644 index 0000000..e1c3385 --- /dev/null +++ b/optional/src/commonMain/kotlin/dev/voir/optional/Helpers.kt @@ -0,0 +1,100 @@ +package dev.voir.optional + +/** + * Returns `true` when this optional value is either [Optional.Absent] or + * [Optional.PresentNull]. + */ +val Optional<*>.isNullOrAbsent: Boolean + get() = when (this) { + Optional.Absent -> true + Optional.PresentNull -> true + is Optional.Present -> false + } + +/** + * Returns `true` when this optional value contains a non-null value. + */ +val Optional<*>.isPresent: Boolean + get() = this is Optional.Present + +/** + * Returns `true` when this optional value was provided, including explicit null. + */ +val Optional<*>.isProvided: Boolean + get() = this !is Optional.Absent + +/** + * Runs [block] when this optional value is absent or explicitly null. + * + * @param block callback invoked for [Optional.Absent] and [Optional.PresentNull]. + */ +inline fun Optional<*>.ifNullOrAbsent(block: () -> Unit) { + if (this is Optional.Absent || this is Optional.PresentNull) { + block() + } +} + +/** + * Returns the provided value, explicit null, or a lazy fallback when absent. + * + * @param T the wrapped value type. + * @param fallback value producer used only for [Optional.Absent]. + * @return the wrapped value for [Optional.Present], `null` for + * [Optional.PresentNull], or [fallback] for [Optional.Absent]. + */ +inline fun Optional.orElse(fallback: () -> T): T? = + when (this) { + is Optional.Present -> value + Optional.PresentNull -> null + Optional.Absent -> fallback() + } + +/** + * Returns the provided value, explicit null, or [current] when absent. + * + * @param T the wrapped value type. + * @param current fallback value returned for [Optional.Absent]. + * @return the wrapped value for [Optional.Present], `null` for + * [Optional.PresentNull], or [current] for [Optional.Absent]. + */ +inline fun Optional.orElse(current: T?): T? = + when (this) { + is Optional.Present -> value + Optional.PresentNull -> null + Optional.Absent -> current + } + +/** + * Returns the wrapped non-null value or throws an error for null-like states. + * + * @param T the wrapped value type. + * @param message lazily creates the error message used when this value is + * [Optional.Absent] or [Optional.PresentNull]. + * @return the wrapped value from [Optional.Present]. + */ +inline fun Optional.getOrError(message: () -> String): T = + when (this) { + is Optional.Present -> value + Optional.Absent -> error(message()) + Optional.PresentNull -> error(message()) + } + +/** + * Converts a nullable receiver to [Optional.Present] or [Optional.PresentNull]. + * + * @param T the nullable receiver type. + * @return [Optional.Present] when the receiver is non-null, otherwise + * [Optional.PresentNull]. + */ +fun T?.toOptional(): Optional = + if (this == null) Optional.PresentNull else Optional.Present(this) + +/** + * Converts a nullable receiver to [Optional.Present] or [Optional.Absent]. + * + * @param T the non-null receiver type. + * @return [Optional.Present] when the receiver is non-null, otherwise + * [Optional.Absent]. + */ +fun T?.toOptionalOrAbsent(): Optional = + if (this == null) Optional.Absent else Optional.Present(this) diff --git a/optional/src/commonMain/kotlin/dev/voir/optional/Optional.kt b/optional/src/commonMain/kotlin/dev/voir/optional/Optional.kt new file mode 100644 index 0000000..e8d7c89 --- /dev/null +++ b/optional/src/commonMain/kotlin/dev/voir/optional/Optional.kt @@ -0,0 +1,99 @@ +package dev.voir.optional + +import kotlinx.serialization.Serializable + +/** + * Represents a value that can be present, explicitly null, or absent from input. + * + * This is useful for partial updates and patch-like DTOs where `null` means + * "clear this value" while [Absent] means "leave the current value unchanged". + * + * @param T the non-null value type carried by [Present]. + */ +@Serializable(with = OptionalSerializer::class) +sealed class Optional { + + /** + * Marks a value as not provided. + * + * During serialization this state is intended to be skipped by using it as a + * default property value with `Json { encodeDefaults = false }`. + */ + @Serializable + data object Absent : Optional() + + /** + * Wraps a non-null value that was provided. + * + * @param T the non-null wrapped value type. + * @property value the provided value. + */ + @Serializable + data class Present(val value: T) : Optional() + + /** + * Marks a value as explicitly provided as `null`. + */ + @Serializable + data object PresentNull : Optional() + + /** + * Factory functions for creating [Optional] values without referencing the + * concrete sealed subclasses at call sites. + */ + companion object { + /** + * Wraps a non-null [value] as [Present]. + * + * @param T the non-null value type. + * @param value the value to wrap. + * @return an [Optional] containing [value]. + */ + fun of(value: T): Optional = Present(value) + + /** + * Wraps [value] as [Present] when it is non-null, or [PresentNull] when + * it is null. + * + * @param T the nullable source type. + * @param value the value to wrap. + * @return [Present] for non-null values, otherwise [PresentNull]. + */ + fun ofNullable(value: T?): Optional = + if (value == null) PresentNull else Present(value) + } + + /** + * Runs [block] only when this value is [Present]. + * + * @param block receives the wrapped non-null value. + */ + inline fun ifPresent(block: (T) -> Unit) { + if (this is Present) block(value) + } + + /** + * Runs [block] only when this value is [PresentNull]. + * + * @param block callback invoked for an explicit null. + */ + inline fun ifNull(block: () -> Unit) { + if (this is PresentNull) block() + } + + /** + * Runs [block] when this value was provided, passing either the non-null + * value from [Present] or `null` from [PresentNull]. + * + * [Absent] intentionally does nothing. + * + * @param block receives the provided value, or `null` for [PresentNull]. + */ + inline fun ifProvided(block: (T?) -> Unit) { + when (this) { + is Present -> block(value) + PresentNull -> block(null) + Absent -> {} + } + } +} diff --git a/optional/src/commonMain/kotlin/dev/voir/optional/OptionalSerializer.kt b/optional/src/commonMain/kotlin/dev/voir/optional/OptionalSerializer.kt new file mode 100644 index 0000000..c9b74cc --- /dev/null +++ b/optional/src/commonMain/kotlin/dev/voir/optional/OptionalSerializer.kt @@ -0,0 +1,73 @@ +package dev.voir.optional + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Serializes [Optional] using the same wire representation as the wrapped value. + * + * [Optional.Present] is encoded with [valueSerializer], [Optional.PresentNull] + * is encoded as `null`, and [Optional.Absent] is rejected because absence must + * be represented by omitting the surrounding property. + * + * @param T the non-null value type handled by [valueSerializer]. + * @property valueSerializer serializer for the wrapped non-null value. + */ +class OptionalSerializer( + private val valueSerializer: KSerializer +) : KSerializer> { + + /** + * Reuses the wrapped value descriptor so schema tools see the field as the + * underlying nullable value type rather than as a sealed wrapper. + */ + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = valueSerializer.descriptor.nullable + + /** + * Encodes [value] as its underlying value or as `null`. + * + * @param encoder serialization sink provided by kotlinx.serialization. + * @param value optional value to encode. + * @throws SerializationException when [value] is [Optional.Absent]. + */ + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: Optional) { + when (value) { + is Optional.Present -> encoder.encodeSerializableValue(valueSerializer, value.value) + Optional.PresentNull -> encoder.encodeNull() + Optional.Absent -> { + // A serializer only sees a value once the property is being + // written, so Absent cannot omit itself from here. + throw SerializationException( + "Optional.Absent must not be serialized directly. " + + "Use it only as a default property value with Json { encodeDefaults = false }." + ) + } + } + } + + /** + * Decodes a present field as [Optional.Present] or [Optional.PresentNull]. + * + * Missing fields are handled by Kotlin default property values before this + * serializer is called, so this function never returns [Optional.Absent]. + * + * @param decoder serialization source provided by kotlinx.serialization. + * @return [Optional.Present] for non-null payloads, otherwise [Optional.PresentNull]. + */ + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): Optional { + return if (decoder.decodeNotNullMark()) { + Optional.Present(decoder.decodeSerializableValue(valueSerializer)) + } else { + decoder.decodeNull() + Optional.PresentNull + } + } +} diff --git a/optional/src/commonTest/kotlin/dev/voir/optional/HelpersTest.kt b/optional/src/commonTest/kotlin/dev/voir/optional/HelpersTest.kt new file mode 100644 index 0000000..6a7ac6b --- /dev/null +++ b/optional/src/commonTest/kotlin/dev/voir/optional/HelpersTest.kt @@ -0,0 +1,126 @@ +package dev.voir.optional + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class HelpersTest { + + @Test + fun `predicates describe each state`() { + val present = Optional.Present("value") + val presentNull = Optional.PresentNull + val absent = Optional.Absent + + assertFalse(present.isNullOrAbsent) + assertTrue(present.isPresent) + assertTrue(present.isProvided) + + assertTrue(presentNull.isNullOrAbsent) + assertFalse(presentNull.isPresent) + assertTrue(presentNull.isProvided) + + assertTrue(absent.isNullOrAbsent) + assertFalse(absent.isPresent) + assertFalse(absent.isProvided) + } + + @Test + fun `if null or absent runs for absent and present null only`() { + var calls = 0 + + Optional.Present("value").ifNullOrAbsent { calls += 1 } + Optional.PresentNull.ifNullOrAbsent { calls += 1 } + Optional.Absent.ifNullOrAbsent { calls += 1 } + + assertEquals(2, calls) + } + + @Test + fun `lazy or else returns value null or fallback`() { + val present: Optional = Optional.Present("value") + val presentNull: Optional = Optional.PresentNull + val absent: Optional = Optional.Absent + var fallbackCalls = 0 + val fallback = { + fallbackCalls += 1 + "fallback" + } + + assertEquals("value", present.orElse(fallback)) + assertNull(presentNull.orElse(fallback)) + assertEquals("fallback", absent.orElse(fallback)) + assertEquals(1, fallbackCalls) + } + + @Test + fun `value or else returns value null or current`() { + val present: Optional = Optional.Present("value") + val presentNull: Optional = Optional.PresentNull + val absent: Optional = Optional.Absent + + assertEquals("value", present.orElse("current")) + assertNull(presentNull.orElse("current")) + assertEquals("current", absent.orElse("current")) + assertNull(absent.orElse(null)) + } + + @Test + fun `get or error returns present value without creating message`() { + var messageCalls = 0 + + val value = Optional.Present("value").getOrError { + messageCalls += 1 + "missing" + } + + assertEquals("value", value) + assertEquals(0, messageCalls) + } + + @Test + fun `get or error throws for null-like states with lazy message`() { + val presentNull: Optional = Optional.PresentNull + val absent: Optional = Optional.Absent + var messageCalls = 0 + val message = { + messageCalls += 1 + "missing value" + } + + assertEquals( + "missing value", + assertFailsWith { absent.getOrError(message) }.message + ) + assertEquals( + "missing value", + assertFailsWith { presentNull.getOrError(message) }.message + ) + assertEquals(2, messageCalls) + } + + @Test + fun `nullable value converts to optional`() { + val nullValue: String? = null + val present = "value".toOptional() + val presentNull = nullValue.toOptional() + + assertEquals("value", assertIs>(present).value) + assertSame(Optional.PresentNull, presentNull) + } + + @Test + fun `nullable value converts to optional or absent`() { + val nullValue: String? = null + val present = "value".toOptionalOrAbsent() + val absent = nullValue.toOptionalOrAbsent() + + assertEquals("value", assertIs>(present).value) + assertSame(Optional.Absent, absent) + } +} diff --git a/optional/src/commonTest/kotlin/dev/voir/optional/OptionalSerializerTest.kt b/optional/src/commonTest/kotlin/dev/voir/optional/OptionalSerializerTest.kt new file mode 100644 index 0000000..e914369 --- /dev/null +++ b/optional/src/commonTest/kotlin/dev/voir/optional/OptionalSerializerTest.kt @@ -0,0 +1,112 @@ +package dev.voir.optional + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class OptionalSerializerTest { + + private val json = Json { + encodeDefaults = false + } + + @Serializable + private data class Patch( + val name: Optional = Optional.Absent, + val age: Optional = Optional.Absent, + ) + + @Test + fun `absent default properties are omitted when defaults are not encoded`() { + assertEquals("{}", json.encodeToString(Patch())) + assertEquals("""{"age":30}""", json.encodeToString(Patch(age = Optional.Present(30)))) + } + + @Test + fun `present properties encode as wrapped value`() { + val encoded = json.encodeToString(Patch(name = Optional.Present("Ada"), age = Optional.Present(30))) + + assertEquals("""{"name":"Ada","age":30}""", encoded) + } + + @Test + fun `present null properties encode as json null`() { + val encoded = json.encodeToString(Patch(name = Optional.PresentNull, age = Optional.PresentNull)) + + assertEquals("""{"name":null,"age":null}""", encoded) + } + + @Test + fun `missing properties decode as absent defaults`() { + val patch = json.decodeFromString("{}") + + assertSame(Optional.Absent, patch.name) + assertSame(Optional.Absent, patch.age) + } + + @Test + fun `non-null properties decode as present values`() { + val patch = json.decodeFromString("""{"name":"Ada","age":30}""") + + assertEquals("Ada", assertIs>(patch.name).value) + assertEquals(30, assertIs>(patch.age).value) + } + + @Test + fun `null properties decode as present null`() { + val patch = json.decodeFromString("""{"name":null,"age":null}""") + + assertSame(Optional.PresentNull, patch.name) + assertSame(Optional.PresentNull, patch.age) + } + + @Test + fun `direct absent serialization fails because absent must be a property default`() { + val error = assertFailsWith { + json.encodeToString(OptionalSerializer(String.serializer()), Optional.Absent) + } + + assertTrue(error.message.orEmpty().contains("Optional.Absent must not be serialized directly")) + } + + @Test + fun `direct present serialization uses wrapped value shape`() { + val encoded = json.encodeToString(OptionalSerializer(String.serializer()), Optional.Present("Ada")) + + assertEquals(""""Ada"""", encoded) + } + + @Test + fun `direct present null serialization uses json null`() { + val encoded = json.encodeToString(OptionalSerializer(String.serializer()), Optional.PresentNull) + + assertEquals("null", encoded) + } + + @Test + fun `direct deserialization never produces absent`() { + val present = json.decodeFromString(OptionalSerializer(String.serializer()), """"Ada"""") + val presentNull = json.decodeFromString(OptionalSerializer(String.serializer()), "null") + + assertEquals("Ada", assertIs>(present).value) + assertSame(Optional.PresentNull, presentNull) + } + + @OptIn(ExperimentalSerializationApi::class) + @Test + fun `descriptor is nullable wrapped value descriptor`() { + val descriptor = OptionalSerializer(String.serializer()).descriptor + + assertTrue(descriptor.isNullable) + } +} diff --git a/optional/src/commonTest/kotlin/dev/voir/optional/OptionalTest.kt b/optional/src/commonTest/kotlin/dev/voir/optional/OptionalTest.kt new file mode 100644 index 0000000..e080988 --- /dev/null +++ b/optional/src/commonTest/kotlin/dev/voir/optional/OptionalTest.kt @@ -0,0 +1,78 @@ +package dev.voir.optional + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame + +class OptionalTest { + + @Test + fun `of wraps non-null value as present`() { + val optional = Optional.of("value") + + val present = assertIs>(optional) + assertEquals("value", present.value) + } + + @Test + fun `of nullable wraps non-null value as present`() { + val optional = Optional.ofNullable("value") + + val present = assertIs>(optional) + assertEquals("value", present.value) + } + + @Test + fun `of nullable wraps null as present null`() { + val optional = Optional.ofNullable(null) + + assertSame(Optional.PresentNull, optional) + } + + @Test + fun `if present runs only for present value`() { + val seen = mutableListOf() + + Optional.Present("value").ifPresent { seen += "present:$it" } + Optional.PresentNull.ifPresent { seen += "null:$it" } + Optional.Absent.ifPresent { seen += "absent:$it" } + + assertEquals(listOf("present:value"), seen) + } + + @Test + fun `if null runs only for present null`() { + var calls = 0 + + Optional.Present("value").ifNull { calls += 1 } + Optional.PresentNull.ifNull { calls += 1 } + Optional.Absent.ifNull { calls += 1 } + + assertEquals(1, calls) + } + + @Test + fun `if provided runs for present and present null only`() { + val seen = mutableListOf() + + Optional.Present("value").ifProvided { seen += it } + Optional.PresentNull.ifProvided { seen += it } + Optional.Absent.ifProvided { seen += it } + + assertEquals(listOf("value", null), seen) + } + + @Test + fun `state objects are singletons`() { + assertSame(Optional.Absent, Optional.Absent) + assertSame(Optional.PresentNull, Optional.PresentNull) + } + + @Test + fun `present uses value equality`() { + assertEquals(Optional.Present("value"), Optional.Present("value")) + assertFalse(Optional.Present("value") == Optional.Present("other")) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a64593f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,32 @@ +rootProject.name = "OptionalKMP" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +include(":optional") +